developer #3

Merged
gabrielb merged 12 commits from developer into main 2026-05-19 13:42:38 +00:00
82 changed files with 13912 additions and 1443 deletions

View File

@ -1,4 +1,3 @@
VITE_KEYCLOAK_URL= # Arquivo template, mantido no repositório vazio por convenção.
VITE_KEYCLOAK_REALM= # Para rodar o portal em dev, copie .env.example para .env e preencha os valores reais.
VITE_KEYCLOAK_CLIENT_ID= # .env é ignorado pelo git.
VITE_API_URL=

26
.env.example Normal file
View File

@ -0,0 +1,26 @@
# Copie este arquivo para .env e preencha os valores.
# .env é ignorado pelo git — nunca commitar credenciais reais neste arquivo de exemplo.
#
# Em produção, popule essas variáveis via secret manager do orquestrador (Docker, K8s, etc.).
# ─── Core API ────────────────────────────────────────────────────────────────
# URL base da API REST consumida pelo BFF (server-side apenas).
NUXT_CORE_API_URL=https://sistema.modumfiscal.com.br
# ─── Keycloak (realm dedicado do portal público) ─────────────────────────────
NUXT_KEYCLOAK_URL=https://keycloakprod.modumfiscal.com.br
NUXT_KEYCLOAK_REALM=modumfiscal-portal-dev
NUXT_KEYCLOAK_CLIENT_ID=portal-modumfiscal-bff
# Client confidential — gere no Keycloak (Clients → Credentials → Client secret).
NUXT_KEYCLOAK_CLIENT_SECRET=SUBSTITUIR_PELO_VALOR_REAL_NO_ENV_LOCAL
# ─── Sessão (Token-handler pattern) ──────────────────────────────────────────
# Redis para armazenar tokens server-side e PKCE state.
NUXT_REDIS_URL=redis://localhost:6379
# Segredo para derivar o cookie de sessão (mín. 32 chars; rotacionar em incidente).
# Gere com: openssl rand -base64 32 OU node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"
NUXT_COOKIE_SECRET=GERE_LOCALMENTE_NUNCA_COMMITAR_SECRET_REAL
# TTL da sessão em segundos (default: 8h = 28800).
NUXT_SESSION_TTL_SECONDS=28800
# TTL do PKCE state em segundos (default: 5min = 300).
NUXT_PKCE_TTL_SECONDS=300

View File

@ -0,0 +1,86 @@
name: Dev Build & Deploy Portal
on:
push:
branches:
- developer
# Variáveis necessárias no Gitea (Settings → Variables):
# DEV_NUXT_KEYCLOAK_URL ex: https://keycloakprod.modumfiscal.com.br
# DEV_NUXT_KEYCLOAK_REALM ex: modumfiscal-portal-dev
# DEV_NUXT_KEYCLOAK_CLIENT_ID ex: portal-modumfiscal-bff
# DEV_NUXT_CORE_API_URL ex: https://sistema.modumfiscal.com.br
# DEV_NUXT_REDIS_URL ex: redis://portal-redis:6379
#
# Secrets necessários (Settings → Secrets):
# REGISTRY_USER
# REGISTRY_PASSWORD
# NUXT_KEYCLOAK_CLIENT_SECRET
# NUXT_COOKIE_SECRET gere com: node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"
jobs:
build-deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Define version
id: version
run: |
VERSION=$(date +'%Y.%m.%d.%H%M')
echo "version=$VERSION" >> $GITHUB_OUTPUT
- name: Login registry
env:
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
run: |
echo "$REGISTRY_PASSWORD" | docker login git.modumsolucao.com.br \
-u "$REGISTRY_USER" \
--password-stdin
- name: Build Docker image
run: |
docker build \
--memory=3g \
-t git.modumsolucao.com.br/modumsolucao/portal-modumfiscal-web:latest \
-t git.modumsolucao.com.br/modumsolucao/portal-modumfiscal-web:${{ steps.version.outputs.version }} \
.
- name: Push image
run: |
docker push git.modumsolucao.com.br/modumsolucao/portal-modumfiscal-web:latest
docker push git.modumsolucao.com.br/modumsolucao/portal-modumfiscal-web:${{ steps.version.outputs.version }}
- name: Deploy DEV
env:
NUXT_KEYCLOAK_URL: ${{ vars.DEV_NUXT_KEYCLOAK_URL }}
NUXT_KEYCLOAK_REALM: ${{ vars.DEV_NUXT_KEYCLOAK_REALM }}
NUXT_KEYCLOAK_CLIENT_ID: ${{ vars.DEV_NUXT_KEYCLOAK_CLIENT_ID }}
NUXT_KEYCLOAK_CLIENT_SECRET: ${{ secrets.NUXT_KEYCLOAK_CLIENT_SECRET }}
NUXT_CORE_API_URL: ${{ vars.DEV_NUXT_CORE_API_URL }}
NUXT_REDIS_URL: ${{ vars.DEV_NUXT_REDIS_URL }}
NUXT_COOKIE_SECRET: ${{ secrets.NUXT_COOKIE_SECRET }}
IMAGE_VERSION: ${{ steps.version.outputs.version }}
run: |
docker service update \
--image git.modumsolucao.com.br/modumsolucao/portal-modumfiscal-web:$IMAGE_VERSION \
--env-add NUXT_KEYCLOAK_URL="$NUXT_KEYCLOAK_URL" \
--env-add NUXT_KEYCLOAK_REALM="$NUXT_KEYCLOAK_REALM" \
--env-add NUXT_KEYCLOAK_CLIENT_ID="$NUXT_KEYCLOAK_CLIENT_ID" \
--env-add NUXT_KEYCLOAK_CLIENT_SECRET="$NUXT_KEYCLOAK_CLIENT_SECRET" \
--env-add NUXT_CORE_API_URL="$NUXT_CORE_API_URL" \
--env-add NUXT_REDIS_URL="$NUXT_REDIS_URL" \
--env-add NUXT_COOKIE_SECRET="$NUXT_COOKIE_SECRET" \
--with-registry-auth \
app_portal-modumfiscal-web
- name: Cleanup old images
run: |
IMAGES_TO_DELETE=$(docker images "git.modumsolucao.com.br/modumsolucao/portal-modumfiscal-web" --format "{{.ID}}" | tail -n +3)
if [ -n "$IMAGES_TO_DELETE" ]; then
echo "$IMAGES_TO_DELETE" | xargs -I {} docker rmi -f {} || true
fi
docker image prune -f

View File

@ -0,0 +1,102 @@
name: Prod Build & Deploy Portal
on:
push:
branches:
- main
# Variáveis necessárias no Gitea (Settings → Variables):
# PROD_NUXT_KEYCLOAK_URL ex: https://keycloakprod.modumfiscal.com.br
# PROD_NUXT_KEYCLOAK_REALM ex: modumfiscal-portal-prod
# PROD_NUXT_KEYCLOAK_CLIENT_ID ex: portal-modumfiscal-bff
# PROD_NUXT_CORE_API_URL ex: https://sistema.modumfiscal.com.br
# PROD_NUXT_REDIS_URL ex: redis://portal-redis:6379
#
# Secrets necessários (Settings → Secrets):
# REGISTRY_USER
# REGISTRY_PASSWORD
# PROD_NUXT_KEYCLOAK_CLIENT_SECRET
# PROD_NUXT_COOKIE_SECRET
jobs:
build:
runs-on: ubuntu-latest
outputs:
version: ${{ steps.version.outputs.version }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Define version
id: version
run: |
VERSION=$(date +'%Y.%m.%d.%H%M')
echo "version=$VERSION" >> $GITHUB_OUTPUT
- name: Login registry
env:
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
run: |
echo "$REGISTRY_PASSWORD" | docker login git.modumsolucao.com.br \
-u "$REGISTRY_USER" \
--password-stdin
- name: Build Docker image
run: |
docker build \
--memory=3g \
-t git.modumsolucao.com.br/modumsolucao/portal-modumfiscal-web:prod-latest \
-t git.modumsolucao.com.br/modumsolucao/portal-modumfiscal-web:prod-${{ steps.version.outputs.version }} \
.
- name: Push image
run: |
docker push git.modumsolucao.com.br/modumsolucao/portal-modumfiscal-web:prod-latest
docker push git.modumsolucao.com.br/modumsolucao/portal-modumfiscal-web:prod-${{ steps.version.outputs.version }}
- name: Cleanup old images
run: |
IMAGES_TO_DELETE=$(docker images "git.modumsolucao.com.br/modumsolucao/portal-modumfiscal-web" --format "{{.ID}}" | tail -n +3)
if [ -n "$IMAGES_TO_DELETE" ]; then
echo "$IMAGES_TO_DELETE" | xargs -I {} docker rmi -f {} || true
fi
docker image prune -f
deploy:
runs-on: prod
needs: build
steps:
- name: Login registry
env:
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
run: |
echo "$REGISTRY_PASSWORD" | docker login git.modumsolucao.com.br \
-u "$REGISTRY_USER" \
--password-stdin
- name: Deploy PROD
env:
NUXT_KEYCLOAK_URL: ${{ vars.PROD_NUXT_KEYCLOAK_URL }}
NUXT_KEYCLOAK_REALM: ${{ vars.PROD_NUXT_KEYCLOAK_REALM }}
NUXT_KEYCLOAK_CLIENT_ID: ${{ vars.PROD_NUXT_KEYCLOAK_CLIENT_ID }}
NUXT_KEYCLOAK_CLIENT_SECRET: ${{ secrets.PROD_NUXT_KEYCLOAK_CLIENT_SECRET }}
NUXT_CORE_API_URL: ${{ vars.PROD_NUXT_CORE_API_URL }}
NUXT_REDIS_URL: ${{ vars.PROD_NUXT_REDIS_URL }}
NUXT_COOKIE_SECRET: ${{ secrets.PROD_NUXT_COOKIE_SECRET }}
IMAGE_VERSION: ${{ needs.build.outputs.version }}
run: |
docker service update \
--image git.modumsolucao.com.br/modumsolucao/portal-modumfiscal-web:prod-$IMAGE_VERSION \
--env-add NUXT_KEYCLOAK_URL="$NUXT_KEYCLOAK_URL" \
--env-add NUXT_KEYCLOAK_REALM="$NUXT_KEYCLOAK_REALM" \
--env-add NUXT_KEYCLOAK_CLIENT_ID="$NUXT_KEYCLOAK_CLIENT_ID" \
--env-add NUXT_KEYCLOAK_CLIENT_SECRET="$NUXT_KEYCLOAK_CLIENT_SECRET" \
--env-add NUXT_CORE_API_URL="$NUXT_CORE_API_URL" \
--env-add NUXT_REDIS_URL="$NUXT_REDIS_URL" \
--env-add NUXT_COOKIE_SECRET="$NUXT_COOKIE_SECRET" \
--with-registry-auth \
app_portal-modumfiscal-web

14
.gitignore vendored
View File

@ -7,16 +7,30 @@ yarn-error.log*
pnpm-debug.log* pnpm-debug.log*
lerna-debug.log* lerna-debug.log*
# Dependencies
node_modules node_modules
# Build artifacts (Vite legado — pode ser removido após migração)
dist dist
dist-ssr dist-ssr
# Nuxt
.nuxt
.output
.nitro
.cache
# Local env files
*.local *.local
.env
.env.*.local
# Editor directories and files # Editor directories and files
.vscode/* .vscode/*
!.vscode/extensions.json !.vscode/extensions.json
.idea .idea
.DS_Store .DS_Store
.claude
*.suo *.suo
*.ntvs* *.ntvs*
*.njsproj *.njsproj

20
Dockerfile Normal file
View File

@ -0,0 +1,20 @@
# Build stage
FROM node:22-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Production stage — apenas o .output do Nuxt (SSR via Node.js)
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NUXT_HOST=0.0.0.0
ENV NUXT_PORT=3000
COPY --from=builder /app/.output ./
EXPOSE 3000
CMD ["node", "server/index.mjs"]

18
docker-compose.yml Normal file
View File

@ -0,0 +1,18 @@
services:
redis:
image: redis:7-alpine
container_name: portal-redis
ports:
- "6379:6379"
restart: unless-stopped
volumes:
- portal_redis_data:/data
command: redis-server --appendonly yes
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 5
volumes:
portal_redis_data:

699
docs/api-backend.md Normal file
View File

@ -0,0 +1,699 @@
# API Backend — portal-modumfiscal-web
Documentação dos endpoints que o portal consome. Base URL: `https://sistema.modumfiscal.com.br/api/v1`.
**Envelope padrão de resposta (sucesso):**
```json
{
"timestamp": "2025-05-18T10:00:00Z",
"statusCode": 200,
"responseType": "SUCCESS",
"message": "OK",
"data": { }
}
```
**Envelope de erro:**
```json
{
"statusCode": 422,
"message": "Unprocessable Entity",
"internalCode": "119",
"description": "Registro não foi encontrado."
}
```
Respostas binárias (PDF) retornam `Content-Type: application/pdf` direto — sem envelope.
---
## Módulo Público — sem autenticação
Headers obrigatórios em toda requisição:
- `X-Municipio: <id_municipio>`
- `X-Dominio: <subdominio>`
---
### Prefeitura (bootstrap)
#### `GET /publico/prefeitura/{dominio}`
Retorna a configuração visual e institucional da prefeitura.
**Response `data`:**
```json
{
"idMunicipio": 1,
"nomePrefeitura": "Prefeitura Municipal de Tutóia",
"nomeMunicipio": "Tutóia",
"uf": "MA",
"template": "tutoia",
"pathLogo": "/logos/tutoia.png",
"pathBackground": "/backgrounds/tutoia.jpeg",
"corPrimaria": "#F59E0B",
"cnpj": "12.345.678/0001-90",
"site": "https://tutoia.ma.gov.br",
"email": "atendimento@tutoia.ma.gov.br",
"telefone": "(98) 3478-1234"
}
```
> `pathLogo` e `pathBackground` são paths relativos — o frontend resolve para URL absoluta via `resolverUrl()`.
---
### Certidão Pública
#### `GET /publico/certidao/consultar`
Consulta a situação fiscal do contribuinte.
**Query params:**
| Param | Tipo | Obrigatório | Descrição |
|---|---|---|---|
| `documento` | string | sim | CPF (11 dígitos) ou CNPJ (14 dígitos) sem formatação |
**Response `data`:**
```json
{
"situacao": "NEGATIVA",
"nomeContribuinte": "João da Silva Santos"
}
```
`situacao` possíveis: `NEGATIVA` · `POSITIVA` · `POSITIVA_EFEITOS_NEGATIVA`
---
#### `GET /publico/certidao/emitir`
Emite a certidão em PDF.
**Query params:**
| Param | Tipo | Obrigatório | Descrição |
|---|---|---|---|
| `documento` | string | sim | CPF/CNPJ sem formatação |
| `tipoCertidao` | string | sim | `NEGATIVA` · `POSITIVA` · `POSITIVA_EFEITOS_NEGATIVA` |
**Response:** `application/pdf` (binário direto, sem envelope)
---
### IPTU Público
#### `GET /publico/iptu/consultar`
Retorna todos os imóveis e débitos de IPTU do contribuinte ou de uma inscrição específica.
**Query params:**
| Param | Tipo | Obrigatório | Descrição |
|---|---|---|---|
| `documento` | string | condicional | CPF/CNPJ sem formatação (informar `documento` **ou** `inscricao`) |
| `inscricao` | string | condicional | Inscrição imobiliária (ex: `0001.001.0001.001`) |
**Response `data`:**
```json
{
"content": [
{
"inscricaoImobiliaria": "0001.001.0001.001",
"enderecoCompleto": "Rua das Flores, 100 — Centro",
"debitos": [
{
"id": "d1",
"descricao": "IPTU 2025 — Cota 4/10",
"vencimento": "30/04/2025",
"valor": 125.90,
"valorAtualizado": 138.49,
"status": "VENCIDO"
}
]
}
]
}
```
`status` possíveis: `VENCIDO` · `A_VENCER` · `PAGO` · `PARCELADO`
---
#### `GET /publico/iptu/carne`
Emite o carnê completo de IPTU em PDF.
**Query params:**
| Param | Tipo | Obrigatório | Descrição |
|---|---|---|---|
| `inscricao` | string | sim | Inscrição imobiliária |
**Response:** `application/pdf`
---
#### `GET /publico/iptu/boleto`
Emite o boleto avulso de uma parcela.
**Query params:**
| Param | Tipo | Obrigatório | Descrição |
|---|---|---|---|
| `idDebito` | string | sim | ID do débito retornado por `/consultar` |
**Response:** `application/pdf`
---
### Primeiro Acesso
Fluxo de 4 etapas para o contribuinte criar senha de acesso ao portal.
#### `GET /publico/primeiro-acesso/verificar`
Valida se o documento tem cadastro e retorna os canais disponíveis para envio do código.
**Query params:**
| Param | Tipo | Obrigatório |
|---|---|---|
| `documento` | string | sim |
**Response `data`:**
```json
{
"nome": "Maria Aparecida Santos",
"canais": [
{ "tipo": "EMAIL", "valor": "ma***@gmail.com" },
{ "tipo": "WHATSAPP", "valor": "(98) *****-8901" },
{ "tipo": "SMS", "valor": "(98) *****-8901" }
]
}
```
`tipo` possíveis: `EMAIL` · `WHATSAPP` · `SMS`
---
#### `POST /publico/primeiro-acesso/codigo`
Envia o código de 6 dígitos para o canal escolhido.
**Request body:**
```json
{
"documento": "12345678900",
"canal": "EMAIL"
}
```
**Response `data`:**
```json
{ "enviado": true }
```
---
#### `POST /publico/primeiro-acesso/validar`
Valida o código digitado e retorna um token temporário para definição de senha.
**Request body:**
```json
{
"documento": "12345678900",
"codigo": "123456"
}
```
**Response `data`:**
```json
{ "token": "eyJhb..." }
```
Erros: `422` se código inválido ou expirado.
---
#### `POST /publico/primeiro-acesso/senha`
Define a nova senha usando o token temporário.
**Request body:**
```json
{
"token": "eyJhb...",
"senha": "MinhaS3nha!"
}
```
**Response `data`:**
```json
{ "sucesso": true }
```
---
### Credenciamento
Fluxo para novo contribuinte solicitar cadastro no sistema.
#### `GET /publico/credenciamento/verificar`
Verifica se o documento já tem cadastro ou pode ser credenciado.
**Query params:**
| Param | Tipo | Obrigatório |
|---|---|---|
| `documento` | string | sim |
**Response `data`:**
```json
{ "situacao": "APTO" }
```
`situacao` possíveis:
- `APTO` — pode prosseguir com o credenciamento
- `JA_CREDENCIADO` — já tem acesso ao sistema
- `BLOQUEADO` — documento bloqueado (contatar prefeitura)
---
#### `GET /publico/cep/{cep}`
Busca endereço por CEP para auto-preenchimento no formulário de credenciamento.
**Path param:** `cep` — 8 dígitos sem hífen
**Response `data`:**
```json
{
"logradouro": "Rua das Acácias",
"bairro": "Centro",
"localidade": "Tutóia",
"uf": "MA"
}
```
---
#### `POST /publico/credenciamento/solicitar`
Envia a solicitação de credenciamento para análise da prefeitura.
**Request body (Pessoa Física):**
```json
{
"documento": "12345678900",
"tipoPessoa": "FISICA",
"nomeCompleto": "João da Silva Santos",
"dataNascimento": "1990-05-15",
"email": "joao@email.com",
"emailConfirmacao": "joao@email.com",
"telefone": "98991234567",
"whatsapp": true,
"endereco": {
"cep": "65900000",
"logradouro": "Rua das Flores",
"numero": "100",
"complemento": "Apto 201",
"bairro": "Centro",
"cidade": "Tutóia",
"uf": "MA"
}
}
```
**Request body adicional (Pessoa Jurídica):**
```json
{
"tipoPessoa": "JURIDICA",
"razaoSocial": "Empresa LTDA",
"nomeFantasia": "Empresa",
"inscricaoEstadual": "12345678",
"representanteLegal": {
"nomeCompleto": "João da Silva",
"cpf": "12345678900",
"cargo": "Diretor"
}
}
```
**Response `data`:**
```json
{ "protocolo": "CRED-2025-00123" }
```
---
## Módulo Portal — requer autenticação
Header obrigatório além dos de tenant:
- `Authorization: Bearer <jwt_keycloak>`
---
### Painel
#### `GET /contribuinte/painel/resumo`
Dados do card de resumo do dashboard.
**Response `data`:**
```json
{
"totalDebitos": 1250.90,
"certidoesAtivas": 2,
"alvarasAndamento": 2,
"ultimoPagamento": 430.00,
"debitosVencidos": 1
}
```
---
#### `GET /contribuinte/painel/atividades`
Lista das últimas atividades do contribuinte.
**Response `data`:**
```json
{
"content": [
{
"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` possíveis: `PAGAMENTO` · `CERTIDAO` · `DEBITO` · `ALVARA` · `CADASTRO`
---
### Débitos
#### `GET /contribuinte/debitos`
Lista os débitos do contribuinte com filtros opcionais.
**Query params:**
| Param | Tipo | Obrigatório | Descrição |
|---|---|---|---|
| `tipo` | string | não | `IPTU` · `ISS` · `TAXA` · `MULTA` |
| `status` | string | não | `VENCIDO` · `A_VENCER` · `PARCELADO` |
**Response `data`:**
```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`
Emite a guia de pagamento (boleto/DAM) de um débito específico em PDF.
**Path param:** `id` — ID do débito
**Response:** `application/pdf`
---
### Certidões
#### `GET /contribuinte/certidoes`
Lista todas as certidões emitidas pelo contribuinte.
**Response `data`:**
```json
{
"content": [
{
"id": "cert1",
"tipo": "Certidão Negativa de Débitos",
"numero": "CN-2025-00481",
"dataEmissao": "10/05/2025",
"dataValidade": "07/11/2025",
"status": "ATIVA"
}
]
}
```
`status` possíveis: `ATIVA` · `VENCIDA` · `CANCELADA`
---
#### `GET /contribuinte/certidoes/{id}/pdf`
Reemite uma certidão em PDF.
**Path param:** `id` — ID da certidão
**Response:** `application/pdf`
> Retorna `422` se a certidão estiver com status `CANCELADA`.
---
### Alvarás
#### `GET /contribuinte/alvaras`
Lista os processos de alvará do contribuinte.
**Query params:**
| Param | Tipo | Obrigatório | Descrição |
|---|---|---|---|
| `status` | string | não | Filtro por status do processo |
**Response `data`:**
```json
{
"content": [
{
"id": "alv1",
"tipo": "Alvará de Funcionamento — Comércio Varejista",
"numeroProcesso": "ALV-2025-00342",
"status": "EM_ANALISE",
"ultimaAtualizacao": "14/05/2025",
"etapas": [
{ "nome": "Protocolo", "concluida": true, "atual": false },
{ "nome": "Análise", "concluida": false, "atual": true },
{ "nome": "Vistoria", "concluida": false, "atual": false },
{ "nome": "Emissão", "concluida": false, "atual": false }
]
}
]
}
```
`status` possíveis: `EM_ANALISE` · `AGUARDANDO_DOCUMENTOS` · `DEFERIDO` · `INDEFERIDO` · `CANCELADO`
---
### Pagamentos
#### `GET /contribuinte/pagamentos`
Histórico de pagamentos do contribuinte, filtrável por ano.
**Query params:**
| Param | Tipo | Obrigatório | Descrição |
|---|---|---|---|
| `ano` | integer | não | Ano de referência (padrão: ano atual) |
**Response `data`:**
```json
{
"content": [
{
"id": "pag1",
"descricao": "IPTU 2025 — Cota 3/10",
"referencia": "MAR/2025",
"dataPagamento": "28/03/2025",
"formaPagamento": "PIX",
"valor": 125.90
}
]
}
```
`formaPagamento` possíveis: `BOLETO` · `PIX` · `CARTAO` · `TRANSFERENCIA` · `ESPECIE`
---
#### `GET /contribuinte/pagamentos/{id}/comprovante`
Baixa o comprovante de um pagamento em PDF.
**Path param:** `id` — ID do pagamento
**Response:** `application/pdf`
---
### Dados Cadastrais
#### `GET /contribuinte/dados`
Retorna os dados cadastrais completos do contribuinte autenticado.
**Response `data` (Pessoa Física):**
```json
{
"tipoPessoa": "FISICA",
"documento": "123.456.789-00",
"nomeCompleto": "João da Silva Santos",
"dataNascimento": "15/06/1975",
"email": "joao.santos@email.com",
"telefone": "(98) 99123-4567",
"whatsapp": true,
"endereco": {
"cep": "65900-000",
"logradouro": "Rua das Flores",
"numero": "100",
"complemento": "Apto 201",
"bairro": "Centro",
"cidade": "Tutóia",
"uf": "MA"
}
}
```
**Campos adicionais (Pessoa Jurídica):**
```json
{
"tipoPessoa": "JURIDICA",
"documento": "12.345.678/0001-90",
"razaoSocial": "Empresa LTDA",
"nomeFantasia": "Empresa",
"inscricaoEstadual": "12345678"
}
```
---
#### `PUT /contribuinte/dados/contato`
Atualiza os dados de contato editáveis (email, telefone, WhatsApp).
**Request body:**
```json
{
"email": "novo@email.com",
"telefone": "98991234567",
"whatsapp": true
}
```
`telefone` deve ser enviado **sem formatação** (apenas dígitos).
**Response `data`:**
```json
{ "atualizado": true }
```
---
## Avisos (pendente no backend)
#### `GET /publico/avisos/{dominio}`
Retorna os avisos/banners exibidos no carousel da Home.
> **Status:** endpoint ainda não implementado no backend. O frontend usa dados estáticos hardcoded em `HomeView.vue`.
**Response `data` esperada:**
```json
{
"content": [
{
"id": "av1",
"titulo": "IPTU 2025 — Prazo final",
"descricao": "Últimas parcelas com desconto até 30 de junho.",
"tipo": "ALERTA",
"ativo": true
}
]
}
```
`tipo` sugeridos: `INFO` · `ALERTA` · `URGENTE`
---
## Fluxo de Autenticação
O portal usa **Keycloak PKCE** — nenhuma credencial trafega pelo frontend.
```
1. Usuário informa CPF/CNPJ na Home
2. LoginView monta — exibe doc mascarado + campo senha
3. entrar() → redireciona para Keycloak (PKCE code challenge)
4. Keycloak autentica e redireciona de volta com ?code=
5. authService troca code por access_token + refresh_token
6. authStore.setSession(token, userInfo) persiste sessão
7. router.push({ name: 'painel' })
```
**Realm:** `modumfiscal-dev`
**Client ID:** `portal-modumfiscal-web`
**Grant type:** `authorization_code` com PKCE (S256)
**Token endpoint:** `https://keycloakprod.modumfiscal.com.br/realms/modumfiscal-dev/protocol/openid-connect/token`
O `preferred_username` do JWT é o CPF/CNPJ sem formatação — usado como identificador do contribuinte nas chamadas ao backend.
---
## Status de Implementação dos Endpoints
| Endpoint | Frontend pronto | Backend pronto | Mock |
|---|---|---|---|
| `GET /publico/prefeitura/{dominio}` | ✓ | ✓ | — |
| `GET /publico/certidao/consultar` | ✓ | pendente | ✓ |
| `GET /publico/certidao/emitir` | ✓ | pendente | ✓ |
| `GET /publico/iptu/consultar` | ✓ | pendente | ✓ |
| `GET /publico/iptu/carne` | ✓ | pendente | ✓ |
| `GET /publico/iptu/boleto` | ✓ | pendente | ✓ |
| `GET /publico/primeiro-acesso/verificar` | ✓ | pendente | ✓ |
| `POST /publico/primeiro-acesso/codigo` | ✓ | pendente | ✓ |
| `POST /publico/primeiro-acesso/validar` | ✓ | pendente | ✓ |
| `POST /publico/primeiro-acesso/senha` | ✓ | pendente | ✓ |
| `GET /publico/credenciamento/verificar` | ✓ | pendente | ✓ |
| `GET /publico/cep/{cep}` | ✓ | pendente | ✓ |
| `POST /publico/credenciamento/solicitar` | ✓ | pendente | ✓ |
| `GET /contribuinte/painel/resumo` | ✓ | pendente | ✓ |
| `GET /contribuinte/painel/atividades` | ✓ | pendente | ✓ |
| `GET /contribuinte/debitos` | ✓ | pendente | ✓ |
| `GET /contribuinte/debitos/{id}/guia` | ✓ | pendente | ✓ |
| `GET /contribuinte/certidoes` | ✓ | pendente | ✓ |
| `GET /contribuinte/certidoes/{id}/pdf` | ✓ | pendente | ✓ |
| `GET /contribuinte/alvaras` | ✓ | pendente | ✓ |
| `GET /contribuinte/pagamentos` | ✓ | pendente | ✓ |
| `GET /contribuinte/pagamentos/{id}/comprovante` | ✓ | pendente | ✓ |
| `GET /contribuinte/dados` | ✓ | pendente | ✓ |
| `PUT /contribuinte/dados/contato` | ✓ | pendente | ✓ |
| `GET /publico/avisos/{dominio}` | parcial | pendente | — |

View File

@ -1,16 +0,0 @@
<!doctype html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Portal do Contribuinte</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,300;0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700;1,9..40,400&display=swap" rel="stylesheet" />
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

100
nuxt.config.ts Normal file
View File

@ -0,0 +1,100 @@
import { defineNuxtConfig } from 'nuxt/config'
import tailwindcss from '@tailwindcss/vite'
import Aura from '@primeuix/themes/aura'
import { definePreset } from '@primeuix/themes'
// Preset inicial com paleta `blue` — evita flash verde (Aura default = emerald) no SSR/boot.
// O tema dinâmico da prefeitura (via theme.config.js / applyTemplate) sobrescreve isso
// no client-side assim que o store é hidratado.
const InitialPreset = definePreset(Aura, {
semantic: {
primary: {
50: '#eff6ff',
100: '#dbeafe',
200: '#bfdbfe',
300: '#93c5fd',
400: '#60a5fa',
500: '#3b82f6',
600: '#2563eb',
700: '#1d4ed8',
800: '#1e40af',
900: '#1e3a8a',
950: '#172554',
},
},
})
export default defineNuxtConfig({
compatibilityDate: '2025-10-01',
ssr: true,
srcDir: 'src',
serverDir: 'server',
devtools: { enabled: true },
modules: [
'@pinia/nuxt',
'pinia-plugin-persistedstate/nuxt',
'@primevue/nuxt-module',
],
// Auto-import de components sem prefix de diretório (AccessibilityWidget em vez de CommonAccessibilityWidget).
components: [
{ path: '~/components', pathPrefix: false },
],
css: [
'~/assets/main.css',
'~/assets/layout/layout.scss',
],
vite: {
plugins: [tailwindcss()],
},
primevue: {
options: {
theme: {
preset: InitialPreset,
options: { darkModeSelector: '.app-dark' },
},
},
autoImport: true,
},
runtimeConfig: {
// Servidor only — disponível em useRuntimeConfig() no server/
keycloakUrl: '',
keycloakRealm: '',
keycloakClientId: '',
keycloakClientSecret: '',
coreApiUrl: '',
redisUrl: '',
cookieSecret: '',
sessionTtlSeconds: 28800,
pkceTtlSeconds: 300,
public: {
// Disponível no cliente — sem segredos
appName: 'Portal Modum Fiscal',
},
},
app: {
head: {
htmlAttrs: { lang: 'pt-BR' },
title: 'Portal do Contribuinte',
meta: [
{ charset: 'utf-8' },
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
],
link: [
{ rel: 'icon', type: 'image/svg+xml', href: '/favicon.svg' },
],
},
pageTransition: { name: 'page', mode: 'out-in' },
},
typescript: {
strict: false,
},
})

9716
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,18 +1,24 @@
{ {
"name": "portal-modumfiscal-web", "name": "portal-modumfiscal-web",
"private": true, "private": true,
"version": "0.0.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "build": "nuxt build",
"build": "vite build", "dev": "nuxt dev",
"preview": "vite preview", "generate": "nuxt generate",
"lint": "eslint --fix . --ext .vue,.js --ignore-path .gitignore" "preview": "nuxt preview",
"postinstall": "nuxt prepare",
"lint": "eslint --fix . --ext .vue,.js,.ts --ignore-path .gitignore"
}, },
"dependencies": { "dependencies": {
"@pinia/nuxt": "^0.11.2",
"@primeuix/themes": "^2.0.3", "@primeuix/themes": "^2.0.3",
"@primevue/nuxt-module": "^4.5.5",
"axios": "^1.16.1", "axios": "^1.16.1",
"ioredis": "^5.4.1",
"jose": "^5.9.6",
"jwt-decode": "^4.0.0", "jwt-decode": "^4.0.0",
"nuxt": "^3.14.0",
"pinia": "^3.0.4", "pinia": "^3.0.4",
"pinia-plugin-persistedstate": "^4.7.1", "pinia-plugin-persistedstate": "^4.7.1",
"pkce-challenge": "^6.0.0", "pkce-challenge": "^6.0.0",
@ -20,21 +26,14 @@
"primevue": "^4.5.5", "primevue": "^4.5.5",
"tailwindcss-primeui": "^0.6.1", "tailwindcss-primeui": "^0.6.1",
"vue": "^3.5.34", "vue": "^3.5.34",
"vue-router": "^5.0.7",
"zod": "^4.4.3" "zod": "^4.4.3"
}, },
"devDependencies": { "devDependencies": {
"@primevue/auto-import-resolver": "^4.5.5",
"@rushstack/eslint-patch": "^1.16.1",
"@tailwindcss/vite": "^4.3.0", "@tailwindcss/vite": "^4.3.0",
"@vitejs/plugin-vue": "^6.0.6",
"@vue/eslint-config-prettier": "^10.2.0", "@vue/eslint-config-prettier": "^10.2.0",
"autoprefixer": "^10.5.0",
"eslint-plugin-vue": "^10.9.1", "eslint-plugin-vue": "^10.9.1",
"postcss": "^8.5.14",
"sass": "^1.99.0", "sass": "^1.99.0",
"tailwindcss": "^4.3.0", "tailwindcss": "^4.3.0",
"unplugin-vue-components": "^32.0.0", "typescript": "^5.6.3"
"vite": "^8.0.12"
} }
} }

View File

@ -0,0 +1,40 @@
export default defineEventHandler(async (event) => {
const { code, state, error, error_description } = getQuery(event) as Record<string, string | undefined>
if (error) {
console.warn('[auth/callback] Keycloak retornou erro:', error, error_description)
return sendRedirect(event, `/?auth_error=${encodeURIComponent(error)}`, 302)
}
if (!code || !state) {
return sendRedirect(event, '/?auth_error=missing_params', 302)
}
const pkceState = await consumePkceState(state)
if (!pkceState) {
return sendRedirect(event, '/?auth_error=invalid_state', 302)
}
try {
const tokens = await exchangeCodeForTokens({
code,
codeVerifier: pkceState.codeVerifier,
redirectUri: callbackUrlFromEvent(event),
})
const userInfo = userInfoFromAccessToken(tokens.access_token)
const sid = await createSession({
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
idToken: tokens.id_token,
accessTokenExpiresAt: Date.now() + tokens.expires_in * 1000,
userInfo,
})
setSessionCookie(event, sid)
return sendRedirect(event, pkceState.returnTo, 302)
} catch (err) {
console.error('[auth/callback] exchange falhou:', (err as Error).message)
return sendRedirect(event, '/?auth_error=exchange_failed', 302)
}
})

View File

@ -0,0 +1,32 @@
import { z } from 'zod'
const bodySchema = z.object({
documento: z.string().trim().min(11).max(20).optional(),
returnTo: z.string().startsWith('/').max(200).optional(),
})
export default defineEventHandler(async (event) => {
const body = await readValidatedBody(event, bodySchema.safeParse)
if (!body.success) {
throw createError({ statusCode: 400, statusMessage: 'Body inválido' })
}
const { codeVerifier, codeChallenge, state } = await generatePkce()
const returnTo = body.data.returnTo ?? '/portal/painel'
await savePkceState(state, {
codeVerifier,
returnTo,
createdAt: Date.now(),
})
const redirectUri = callbackUrlFromEvent(event)
const authUrl = buildAuthUrl({
codeChallenge,
state,
redirectUri,
loginHint: body.data.documento?.replace(/\D/g, ''),
})
return { authUrl }
})

View File

@ -0,0 +1,22 @@
export default defineEventHandler(async (event) => {
const sid = readSessionCookie(event)
const session = await readSession(sid)
await deleteSession(sid)
clearSessionCookie(event)
if (!session) {
return { logoutUrl: '/' }
}
const host = getRequestHost(event, { xForwardedHost: true })
const proto = getRequestProtocol(event, { xForwardedProto: true })
const postLogoutRedirectUri = `${proto}://${host}/`
const logoutUrl = buildLogoutUrl({
idTokenHint: session.idToken,
postLogoutRedirectUri,
})
return { logoutUrl }
})

16
server/api/auth/me.get.ts Normal file
View File

@ -0,0 +1,16 @@
export default defineEventHandler(async (event) => {
const sid = readSessionCookie(event)
const session = await readSession(sid)
if (!session) {
throw createError({ statusCode: 401, statusMessage: 'Sem sessão' })
}
// Devolve só metadados — tokens nunca saem do servidor
return {
name: session.userInfo.name ?? '',
documento: session.userInfo.preferred_username ?? '',
email: session.userInfo.email ?? '',
roles: session.userInfo.realm_roles ?? [],
}
})

View File

@ -0,0 +1,14 @@
export default defineEventHandler(async (event) => {
const sid = readSessionCookie(event)
if (!sid) {
throw createError({ statusCode: 401, statusMessage: 'Sem sessão' })
}
const token = await getValidAccessToken(sid)
if (!token) {
clearSessionCookie(event)
throw createError({ statusCode: 401, statusMessage: 'Sessão expirada' })
}
return { ok: true }
})

View File

@ -0,0 +1,72 @@
/**
* Proxy genérico para o core-api.
*
* Fluxo:
* 1. cookie de sessão busca tokens em Redis (refresh transparente se expirado)
* 2. Resolve o tenant (dominio) a partir do hostname
* 3. Busca codigoMunicipio via /publico/prefeitura/{dominio} (cacheado em Redis)
* 4. Forward para core-api com Authorization + X-Municipio + X-Dominio
*
* Bypass: rotas /api/v1/publico/** podem ser acessadas sem sessão.
*/
export default defineEventHandler(async (event) => {
const path = getRouterParam(event, 'path') ?? ''
const isPublico = path.startsWith('publico/')
let accessToken: string | null = null
if (!isPublico) {
const sid = readSessionCookie(event)
if (!sid) {
throw createError({ statusCode: 401, statusMessage: 'Sem sessão' })
}
accessToken = await getValidAccessToken(sid)
if (!accessToken) {
clearSessionCookie(event)
throw createError({ statusCode: 401, statusMessage: 'Sessão expirada' })
}
}
const dominio = tenantFromEvent(event)
const prefeitura = await fetchPrefeituraInfo(dominio)
if (!prefeitura) {
throw createError({ statusCode: 400, statusMessage: `Tenant '${dominio}' não encontrado` })
}
const cfg = useRuntimeConfig()
const url = `${cfg.coreApiUrl}/api/v1/${path}`
const query = getQuery(event)
const method = event.method.toUpperCase()
const body = ['GET', 'HEAD'].includes(method) ? undefined : await readRawBody(event)
const headers: Record<string, string> = {
'X-Municipio': String(prefeitura.codigoMunicipio),
'X-Dominio': prefeitura.dominio,
}
if (accessToken) headers.Authorization = `Bearer ${accessToken}`
const contentType = getHeader(event, 'content-type')
if (contentType) headers['Content-Type'] = contentType
try {
const res = await $fetch.raw(url, {
method: method as 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE',
query,
body,
headers,
responseType: 'stream',
})
setResponseStatus(event, res.status)
for (const [name, value] of res.headers.entries()) {
if (name === 'transfer-encoding') continue
setResponseHeader(event, name, value)
}
return res._data
} catch (err: unknown) {
const fetchErr = err as { response?: { status?: number; _data?: unknown } }
if (fetchErr.response) {
setResponseStatus(event, fetchErr.response.status ?? 500)
return fetchErr.response._data
}
throw err
}
})

45
server/middleware/csrf.ts Normal file
View File

@ -0,0 +1,45 @@
/**
* CSRF defense para o BFF.
*
* Aplicada apenas em rotas `/api/**` com métodos mutating (POST/PUT/PATCH/DELETE).
*
* Dupla camada:
* 1. Origin/Referer check sender precisa bater com o host da requisição
* 2. Header custom `X-Requested-With: fetch` força preflight CORS em cross-origin,
* o que o browser bloqueia antes mesmo de chegar aqui (sem CORS permissivo configurado)
*
* Bypass: `/api/auth/callback` (GET externo do Keycloak não é mutating mesmo)
*/
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS'])
export default defineEventHandler((event) => {
const url = getRequestURL(event)
if (!url.pathname.startsWith('/api/')) return
const method = event.method.toUpperCase()
if (SAFE_METHODS.has(method)) return
// 1. Origin/Referer precisa casar com host
const host = getRequestHost(event, { xForwardedHost: true })
const origin = getRequestHeader(event, 'origin')
const referer = getRequestHeader(event, 'referer')
const sender = origin ?? referer
if (!sender) {
throw createError({ statusCode: 403, statusMessage: 'CSRF: Origin/Referer obrigatório' })
}
try {
const senderHost = new URL(sender).host
if (senderHost !== host) {
throw createError({ statusCode: 403, statusMessage: 'CSRF: Origin não confiável' })
}
} catch {
throw createError({ statusCode: 403, statusMessage: 'CSRF: Origin inválido' })
}
// 2. Header custom — sem CORS permissivo, browser bloqueia cross-origin
const requestedWith = getRequestHeader(event, 'x-requested-with')
if (requestedWith !== 'fetch') {
throw createError({ statusCode: 403, statusMessage: 'CSRF: header X-Requested-With ausente' })
}
})

103
server/utils/keycloak.ts Normal file
View File

@ -0,0 +1,103 @@
export interface TokenResponse {
access_token: string
refresh_token: string
id_token: string
expires_in: number
refresh_expires_in?: number
token_type: string
scope?: string
}
function realmBase(): string {
const cfg = useRuntimeConfig()
return `${cfg.keycloakUrl}/realms/${cfg.keycloakRealm}`
}
export function buildAuthUrl(opts: {
codeChallenge: string
state: string
redirectUri: string
loginHint?: string
}): string {
const cfg = useRuntimeConfig()
const params = new URLSearchParams({
client_id: cfg.keycloakClientId,
redirect_uri: opts.redirectUri,
response_type: 'code',
scope: 'openid profile email',
code_challenge: opts.codeChallenge,
code_challenge_method: 'S256',
state: opts.state,
})
if (opts.loginHint) params.set('login_hint', opts.loginHint)
return `${realmBase()}/protocol/openid-connect/auth?${params.toString()}`
}
export async function exchangeCodeForTokens(opts: {
code: string
codeVerifier: string
redirectUri: string
}): Promise<TokenResponse> {
const cfg = useRuntimeConfig()
const body = new URLSearchParams({
grant_type: 'authorization_code',
code: opts.code,
client_id: cfg.keycloakClientId,
client_secret: cfg.keycloakClientSecret,
redirect_uri: opts.redirectUri,
code_verifier: opts.codeVerifier,
})
return await $fetch<TokenResponse>(
`${realmBase()}/protocol/openid-connect/token`,
{
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(),
},
)
}
export async function refreshTokens(refreshToken: string): Promise<TokenResponse> {
const cfg = useRuntimeConfig()
const body = new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: refreshToken,
client_id: cfg.keycloakClientId,
client_secret: cfg.keycloakClientSecret,
})
return await $fetch<TokenResponse>(
`${realmBase()}/protocol/openid-connect/token`,
{
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(),
},
)
}
export function buildLogoutUrl(opts: {
idTokenHint: string
postLogoutRedirectUri: string
}): string {
const params = new URLSearchParams({
id_token_hint: opts.idTokenHint,
post_logout_redirect_uri: opts.postLogoutRedirectUri,
})
return `${realmBase()}/protocol/openid-connect/logout?${params.toString()}`
}
/**
* Decodifica payload de JWT sem validar assinatura uso server-only para
* extrair claims do id_token/access_token recém-emitido pelo Keycloak.
* NÃO USAR para validar tokens recebidos de terceiros.
*/
export function decodeJwtPayload(token: string): Record<string, unknown> {
const [, payload] = token.split('.')
if (!payload) return {}
try {
const json = Buffer.from(payload, 'base64url').toString('utf-8')
return JSON.parse(json) as Record<string, unknown>
} catch {
return {}
}
}

61
server/utils/pkce.ts Normal file
View File

@ -0,0 +1,61 @@
import pkceChallenge from 'pkce-challenge'
import { randomBytes } from 'node:crypto'
import type { H3Event } from 'h3'
export interface PkceArtifacts {
codeVerifier: string
codeChallenge: string
state: string
}
export async function generatePkce(): Promise<PkceArtifacts> {
const { code_verifier, code_challenge } = await pkceChallenge()
const state = randomBytes(16).toString('base64url')
return {
codeVerifier: code_verifier,
codeChallenge: code_challenge,
state,
}
}
// ─── PKCE state em Redis (single-use, TTL curto) ─────────────────────────────
export interface PkceStateData {
codeVerifier: string
returnTo: string
createdAt: number
}
export async function savePkceState(state: string, data: PkceStateData): Promise<void> {
const ttl = useRuntimeConfig().pkceTtlSeconds
await useRedis().set(`pkce:${state}`, JSON.stringify(data), 'EX', ttl)
}
/**
* e remove o state atomicamente single-use, previne replay.
*/
export async function consumePkceState(state: string): Promise<PkceStateData | null> {
if (!state) return null
const key = `pkce:${state}`
const pipe = useRedis().multi()
pipe.get(key)
pipe.del(key)
const results = await pipe.exec()
const raw = results?.[0]?.[1] as string | null | undefined
if (!raw) return null
try {
return JSON.parse(raw) as PkceStateData
} catch {
return null
}
}
/**
* Computa o redirect_uri do callback a partir do request coerente com o que foi enviado ao Keycloak.
*/
export function callbackUrlFromEvent(event: H3Event): string {
const host = getRequestHost(event, { xForwardedHost: true })
const proto = getRequestProtocol(event, { xForwardedProto: true })
return `${proto}://${host}/api/auth/callback`
}

View File

@ -0,0 +1,44 @@
export interface PrefeituraInfo {
codigoMunicipio: number
nomePrefeitura: string
dominio: string
template: string
pathLogo?: string
pathBackground?: string
}
interface ApiEnvelope<T> {
data: T
}
const CACHE_TTL_SECONDS = 300 // 5 min
export async function fetchPrefeituraInfo(dominio: string): Promise<PrefeituraInfo | null> {
if (!dominio) return null
const cacheKey = `prefeitura:${dominio}`
const cached = await useRedis().get(cacheKey)
if (cached) {
try {
return JSON.parse(cached) as PrefeituraInfo
} catch {
// cache corrompido — segue para refetch
}
}
const cfg = useRuntimeConfig()
try {
const res = await $fetch<ApiEnvelope<PrefeituraInfo>>(
`${cfg.coreApiUrl}/api/v1/publico/prefeitura/${encodeURIComponent(dominio)}`,
{ timeout: 8000 },
)
const info = res?.data
if (!info?.codigoMunicipio) return null
await useRedis().set(cacheKey, JSON.stringify(info), 'EX', CACHE_TTL_SECONDS)
return info
} catch (err) {
console.error(`[prefeitura] lookup falhou para '${dominio}':`, (err as Error).message)
return null
}
}

27
server/utils/redis.ts Normal file
View File

@ -0,0 +1,27 @@
import Redis from 'ioredis'
let _client: Redis | null = null
export function useRedis(): Redis {
if (_client) return _client
const url = useRuntimeConfig().redisUrl
if (!url) {
throw createError({
statusCode: 500,
statusMessage: 'REDIS_URL não configurada (definir NUXT_REDIS_URL).',
})
}
_client = new Redis(url, {
lazyConnect: false,
maxRetriesPerRequest: 3,
enableReadyCheck: true,
})
_client.on('error', (err) => {
console.error('[redis] error:', err.message)
})
return _client
}

126
server/utils/session.ts Normal file
View File

@ -0,0 +1,126 @@
import { randomBytes } from 'node:crypto'
import { decodeJwtPayload, refreshTokens } from './keycloak'
import type { H3Event } from 'h3'
const COOKIE_NAME = 'portal_sid'
const REFRESH_GUARD_MS = 30_000 // renova se restam <30s
export interface UserInfo {
sub: string
name?: string
preferred_username?: string
email?: string
realm_roles?: string[]
[key: string]: unknown
}
export interface SessionData {
accessToken: string
refreshToken: string
idToken: string
accessTokenExpiresAt: number
userInfo: UserInfo
createdAt: number
}
function genSid(): string {
return randomBytes(32).toString('base64url')
}
// ─── Operações de store ───────────────────────────────────────────────────────
export async function createSession(data: Omit<SessionData, 'createdAt'>): Promise<string> {
const sid = genSid()
const ttl = useRuntimeConfig().sessionTtlSeconds
const payload: SessionData = { ...data, createdAt: Date.now() }
await useRedis().set(`sess:${sid}`, JSON.stringify(payload), 'EX', ttl)
return sid
}
export async function readSession(sid: string | undefined): Promise<SessionData | null> {
if (!sid) return null
const raw = await useRedis().get(`sess:${sid}`)
if (!raw) return null
// sliding TTL — qualquer leitura estende a sessão
const ttl = useRuntimeConfig().sessionTtlSeconds
await useRedis().expire(`sess:${sid}`, ttl)
try {
return JSON.parse(raw) as SessionData
} catch {
return null
}
}
export async function updateSession(sid: string, patch: Partial<SessionData>): Promise<void> {
const current = await readSession(sid)
if (!current) return
const next: SessionData = { ...current, ...patch }
const ttl = useRuntimeConfig().sessionTtlSeconds
await useRedis().set(`sess:${sid}`, JSON.stringify(next), 'EX', ttl)
}
export async function deleteSession(sid: string | undefined): Promise<void> {
if (!sid) return
await useRedis().del(`sess:${sid}`)
}
// ─── Cookie helpers ───────────────────────────────────────────────────────────
export function setSessionCookie(event: H3Event, sid: string): void {
setCookie(event, COOKIE_NAME, sid, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/',
maxAge: useRuntimeConfig().sessionTtlSeconds,
})
}
export function readSessionCookie(event: H3Event): string | undefined {
return getCookie(event, COOKIE_NAME)
}
export function clearSessionCookie(event: H3Event): void {
deleteCookie(event, COOKIE_NAME, { path: '/' })
}
// ─── Orquestração: token válido + refresh transparente ────────────────────────
export async function getValidAccessToken(sid: string): Promise<string | null> {
const session = await readSession(sid)
if (!session) return null
const now = Date.now()
if (session.accessTokenExpiresAt - now > REFRESH_GUARD_MS) {
return session.accessToken
}
try {
const tokens = await refreshTokens(session.refreshToken)
await updateSession(sid, {
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
idToken: tokens.id_token ?? session.idToken,
accessTokenExpiresAt: Date.now() + tokens.expires_in * 1000,
})
return tokens.access_token
} catch {
await deleteSession(sid)
return null
}
}
// ─── Helper: monta UserInfo a partir do access_token ─────────────────────────
export function userInfoFromAccessToken(accessToken: string): UserInfo {
const claims = decodeJwtPayload(accessToken)
const realmAccess = claims.realm_access as { roles?: string[] } | undefined
return {
sub: String(claims.sub ?? ''),
name: claims.name as string | undefined,
preferred_username: claims.preferred_username as string | undefined,
email: claims.email as string | undefined,
realm_roles: realmAccess?.roles,
}
}

30
server/utils/tenant.ts Normal file
View File

@ -0,0 +1,30 @@
import type { H3Event } from 'h3'
const INVALID_SUBDOMAINS = new Set([
'0.0.0.0',
'www',
'api',
'admin',
'test',
'dev',
'development',
'staging',
'production',
'localhost',
])
const DEFAULT_TENANT = 'sistema'
export function tenantFromHost(host: string | undefined): string {
if (!host) return DEFAULT_TENANT
const sub = host.split(':')[0]?.split('.')[0]?.toLowerCase()
if (!sub || sub.length <= 1) return DEFAULT_TENANT
if (INVALID_SUBDOMAINS.has(sub)) return DEFAULT_TENANT
if (!/^[a-z0-9-]+$/.test(sub)) return DEFAULT_TENANT
return sub
}
export function tenantFromEvent(event: H3Event): string {
const host = getRequestHost(event, { xForwardedHost: true })
return tenantFromHost(host)
}

View File

@ -1,21 +0,0 @@
<script setup>
import { onMounted } from 'vue'
import { usePrefeituraStore } from '@/stores/prefeituraStore'
import { applyTemplate, applySurface } from '@/config/theme.config'
import AccessibilityWidget from '@/components/common/AccessibilityWidget.vue'
const prefeitura = usePrefeituraStore()
onMounted(() => {
applyTemplate(prefeitura.template ?? 'blue')
applySurface('slate')
})
</script>
<template>
<a href="#main-content" class="skip-link">Pular para o conteúdo principal</a>
<RouterView />
<Toast />
<AccessibilityWidget />
</template>

58
src/app.vue Normal file
View File

@ -0,0 +1,58 @@
<script setup>
import { ref, onMounted } from 'vue'
import { usePrefeituraStore } from '@/stores/prefeituraStore'
import { applyTemplate, applySurface } from '@/config/theme.config'
import logoModum from '@/assets/images/logo-modum-fiscal.png'
const prefeitura = usePrefeituraStore()
const themeReady = ref(false)
onMounted(() => {
applyTemplate(prefeitura.template ?? 'blue')
applySurface('slate')
requestAnimationFrame(() => {
themeReady.value = true
})
})
</script>
<template>
<!-- Splash de boot esconde o flash entre preset inicial e tema dinâmico da prefeitura -->
<Transition
enter-active-class="transition-opacity duration-150"
leave-active-class="transition-opacity duration-300"
enter-from-class="opacity-0"
leave-to-class="opacity-0"
>
<div
v-if="!themeReady"
class="fixed inset-0 z-[100] bg-slate-50 dark:bg-slate-950 flex items-center justify-center"
aria-hidden="true"
>
<div class="flex flex-col items-center gap-5">
<img
:src="prefeitura.pathLogo || logoModum"
alt=""
class="h-14 w-auto opacity-80 object-contain"
/>
<div class="w-7 h-7 border-2 border-slate-200 dark:border-slate-700 border-t-slate-500 dark:border-t-slate-400 rounded-full animate-spin" />
<p class="text-[11px] text-slate-400 dark:text-slate-500 uppercase tracking-[0.2em] font-medium">
Carregando
</p>
</div>
</div>
</Transition>
<a href="#main-content" class="skip-link">Pular para o conteúdo principal</a>
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
<Toast />
<LoginModal />
<ClientOnly>
<AccessibilityWidget />
</ClientOnly>
</template>

View File

@ -7,7 +7,7 @@ body {
font-size: 16px; /* base mínimo para leitura confortável */ font-size: 16px; /* base mínimo para leitura confortável */
} }
#app { #__nuxt {
min-height: 100vh; min-height: 100vh;
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View File

@ -1,37 +0,0 @@
import { prefeituraService } from '@/services/prefeituraService'
import { usePrefeituraStore } from '@/stores/prefeituraStore'
import { getTenant } from '@/utils/tenant'
const API_URL = import.meta.env.VITE_API_URL ?? ''
function resolverUrl(path) {
if (!path) return null
if (path.startsWith('http')) return path
return `${API_URL}${path}`
}
export async function bootstrapPrefeitura(pinia) {
const store = usePrefeituraStore(pinia)
const dominio = getTenant()
try {
const { data } = await prefeituraService.getPrefeituraInfo(dominio)
const info = data.data
store.$patch({
codigoMunicipio: info.codigoMunicipio,
nomePrefeitura: info.nomePrefeitura,
dominio: info.dominio,
template: info.template,
pathLogo: resolverUrl(info.pathLogo),
pathBackground: resolverUrl(info.pathBackground),
})
localStorage.setItem('current_municipio', info.codigoMunicipio)
localStorage.setItem('current_dominio', info.dominio)
return { success: true }
} catch {
return { success: false }
}
}

View File

@ -3,15 +3,16 @@ import { ref, computed, watch } from 'vue'
const props = defineProps({ const props = defineProps({
modelValue: { type: String, default: '' }, modelValue: { type: String, default: '' },
disabled: { type: Boolean, default: false },
}) })
const emit = defineEmits(['update:modelValue']) const emit = defineEmits(['update:modelValue'])
const wrapper = ref(null)
const input = ref(props.modelValue) const input = ref(props.modelValue)
watch(() => props.modelValue, (v) => { input.value = v }) watch(() => props.modelValue, (v) => { input.value = v })
const apenasDigitos = computed(() => input.value.replace(/\D/g, '')) const apenasDigitos = computed(() => input.value.replace(/\D/g, ''))
const isCnpj = computed(() => apenasDigitos.value.length > 11) const isCnpj = computed(() => apenasDigitos.value.length > 11)
const valorFormatado = computed(() => { const valorFormatado = computed(() => {
@ -36,13 +37,20 @@ function onInput(e) {
input.value = raw input.value = raw
emit('update:modelValue', raw) emit('update:modelValue', raw)
} }
function focus() {
wrapper.value?.querySelector('input')?.focus()
}
defineExpose({ focus })
</script> </script>
<template> <template>
<div class="relative"> <div ref="wrapper" class="relative">
<InputText <InputText
:value="valorFormatado" :value="valorFormatado"
:placeholder="placeholder" :placeholder="placeholder"
:disabled="disabled"
inputmode="numeric" inputmode="numeric"
autocomplete="username" autocomplete="username"
class="w-full text-lg tracking-wide" class="w-full text-lg tracking-wide"
@ -52,8 +60,15 @@ function onInput(e) {
@input="onInput" @input="onInput"
/> />
<span <span
v-if="apenasDigitos.length > 0" v-if="disabled"
class="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-slate-400 font-medium" class="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-slate-400 font-medium flex items-center gap-1 pointer-events-none"
>
<i class="pi pi-lock text-[10px]" aria-hidden="true" />
{{ isCnpj ? 'CNPJ' : 'CPF' }}
</span>
<span
v-else-if="apenasDigitos.length > 0"
class="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-slate-400 font-medium pointer-events-none"
> >
{{ isCnpj ? 'CNPJ' : 'CPF' }} {{ isCnpj ? 'CNPJ' : 'CPF' }}
</span> </span>

View File

@ -0,0 +1,110 @@
<script setup>
import { ref } from 'vue'
import { useLoginModal } from '@/composables/useLoginModal'
import { useAuth } from '@/composables/useAuth'
const { isOpen, returnTo, close } = useLoginModal()
const { login } = useAuth()
const documento = ref('')
const carregando = ref(false)
const erro = ref('')
async function entrar() {
const doc = documento.value.replace(/\D/g, '')
if (doc.length !== 11 && doc.length !== 14) {
erro.value = 'Informe um CPF (11 dígitos) ou CNPJ (14 dígitos) válido.'
return
}
erro.value = ''
carregando.value = true
try {
await login(doc, returnTo.value ?? '/portal/painel')
// login() faz window.location.href não retorna aqui
} catch (e) {
carregando.value = false
erro.value = e?.data?.statusMessage ?? 'Não foi possível iniciar o login.'
}
}
function onHide() {
documento.value = ''
erro.value = ''
carregando.value = false
close()
}
</script>
<template>
<Dialog
v-model:visible="isOpen"
modal
dismissable-mask
:draggable="false"
:closable="true"
:show-header="false"
:pt="{
root: { class: 'w-full max-w-sm mx-4 rounded-2xl overflow-hidden shadow-2xl' },
mask: { class: 'backdrop-blur-sm' },
}"
@hide="onHide"
>
<div class="bg-primary px-7 py-6 relative">
<button
class="absolute top-3 right-3 w-8 h-8 rounded-full flex items-center justify-center text-white/80 hover:text-white hover:bg-white/15 transition-colors"
aria-label="Fechar"
@click="onHide"
>
<i class="pi pi-times text-sm" aria-hidden="true" />
</button>
<div class="flex items-center gap-3 pr-10">
<div class="w-10 h-10 bg-white/20 rounded-xl flex items-center justify-center" aria-hidden="true">
<i class="pi pi-lock text-white text-lg" />
</div>
<div>
<h2 class="text-white font-bold text-base leading-tight">Faça login para continuar</h2>
<p class="text-white/80 text-xs mt-0.5">Acesso seguro ao Portal do Contribuinte</p>
</div>
</div>
</div>
<div class="px-7 py-6 space-y-5 bg-white dark:bg-slate-800">
<div>
<label class="block text-sm font-medium text-slate-700 dark:text-slate-200 mb-1.5">CPF ou CNPJ</label>
<DocumentoInput v-model="documento" @keyup.enter="entrar" />
<p v-if="erro" class="mt-1.5 text-xs text-red-600 dark:text-red-400 flex items-center gap-1">
<i class="pi pi-exclamation-circle text-xs" aria-hidden="true" />
{{ erro }}
</p>
</div>
<Button
label="Continuar"
icon="pi pi-arrow-right"
icon-pos="right"
class="w-full"
size="large"
:loading="carregando"
@click="entrar"
/>
<div class="text-center space-y-2 pt-1">
<NuxtLink
to="/primeiro-acesso"
class="block text-sm text-primary font-medium hover:underline"
@click="close"
>
Esqueci minha senha
</NuxtLink>
<NuxtLink
to="/credenciamento"
class="block text-xs text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-200 transition-colors"
@click="close"
>
Ainda não cadastrado? <span class="text-primary font-semibold">Credenciar-se</span>
</NuxtLink>
</div>
</div>
</Dialog>
</template>

View File

@ -2,9 +2,9 @@
import { ref, watch, onMounted } from 'vue' import { ref, watch, onMounted } from 'vue'
const aberto = ref(false) const aberto = ref(false)
const nivelFonte = ref(Number(localStorage.getItem('a11y-fonte') || 0)) const nivelFonte = ref(0)
const altoContraste = ref(localStorage.getItem('a11y-contraste') === '1') const altoContraste = ref(false)
const modoEscuro = ref(localStorage.getItem('a11y-escuro') === '1') const modoEscuro = ref(false)
const opcoesFonte = [ const opcoesFonte = [
{ nivel: 0, label: 'A', title: 'Texto normal' }, { nivel: 0, label: 'A', title: 'Texto normal' },
@ -18,23 +18,32 @@ function applyFonte(nivel) {
if (nivel === 2) document.documentElement.classList.add('a11y-font-xl') if (nivel === 2) document.documentElement.classList.add('a11y-font-xl')
} }
// Toda leitura de localStorage e DOM precisa estar dentro de onMounted
// caso contrário o componente quebra no SSR.
onMounted(() => { onMounted(() => {
nivelFonte.value = Number(localStorage.getItem('a11y-fonte') || 0)
altoContraste.value = localStorage.getItem('a11y-contraste') === '1'
modoEscuro.value = localStorage.getItem('a11y-escuro') === '1'
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)
}) })
watch(nivelFonte, (val) => { watch(nivelFonte, (val) => {
if (!import.meta.client) return
applyFonte(val) applyFonte(val)
localStorage.setItem('a11y-fonte', val) localStorage.setItem('a11y-fonte', val)
}) })
watch(altoContraste, (val) => { watch(altoContraste, (val) => {
if (!import.meta.client) return
document.documentElement.classList.toggle('a11y-contrast', val) document.documentElement.classList.toggle('a11y-contrast', val)
localStorage.setItem('a11y-contraste', val ? '1' : '0') localStorage.setItem('a11y-contraste', val ? '1' : '0')
}) })
watch(modoEscuro, (val) => { watch(modoEscuro, (val) => {
if (!import.meta.client) return
document.documentElement.classList.toggle('app-dark', val) document.documentElement.classList.toggle('app-dark', val)
localStorage.setItem('a11y-escuro', val ? '1' : '0') localStorage.setItem('a11y-escuro', val ? '1' : '0')
}) })

View File

@ -1,21 +1,32 @@
<script setup> <script setup>
import { computed } from 'vue' import { computed } from 'vue'
import { useAuthStore } from '@/stores/authStore'
import { usePrefeituraStore } from '@/stores/prefeituraStore' import { usePrefeituraStore } from '@/stores/prefeituraStore'
import { useAuth } from '@/composables/useAuth'
import { useFocusLoginInput } from '@/composables/useFocusLoginInput'
import logoFallback from '@/assets/images/logo-modum-fiscal.png' import logoFallback from '@/assets/images/logo-modum-fiscal.png'
const auth = useAuthStore() const { isAuthenticated } = useAuth()
const prefeitura = usePrefeituraStore() const prefeitura = usePrefeituraStore()
const route = useRoute()
const router = useRouter()
const { request: requestFocusLogin } = useFocusLoginInput()
const logoSrc = computed(() => prefeitura.pathLogo || logoFallback) const logoSrc = computed(() => prefeitura.pathLogo || logoFallback)
async function clicarEntrar() {
if (route.path !== '/') {
await router.push('/')
}
requestFocusLogin()
}
</script> </script>
<template> <template>
<header class="bg-white dark:bg-slate-900 border-b border-slate-200 dark:border-slate-700"> <header class="bg-white dark:bg-slate-900 border-b border-slate-200 dark:border-slate-700">
<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 justify-between">
<RouterLink <NuxtLink
:to="{ name: 'home' }" to="/"
class="flex items-center gap-3 min-w-0" class="flex items-center gap-3 min-w-0"
:aria-label="`Ir para a página inicial — ${prefeitura.nomePrefeitura || 'ModumFiscal'}`" :aria-label="`Ir para a página inicial — ${prefeitura.nomePrefeitura || 'ModumFiscal'}`"
> >
@ -31,28 +42,32 @@ const logoSrc = computed(() => prefeitura.pathLogo || logoFallback)
{{ prefeitura.nomePrefeitura || 'ModumFiscal' }} {{ prefeitura.nomePrefeitura || 'ModumFiscal' }}
</p> </p>
</div> </div>
</RouterLink> </NuxtLink>
<nav aria-label="Navegação principal" class="hidden md:flex items-center gap-4"> <nav aria-label="Navegação principal" class="hidden md:flex items-center gap-4">
<RouterLink <NuxtLink
:to="{ name: 'servicos' }" to="/servicos"
class="text-sm text-slate-600 dark:text-slate-300 hover:text-primary transition-colors" class="text-sm text-slate-600 dark:text-slate-300 hover:text-primary transition-colors"
:aria-current="$route.name === 'servicos' ? 'page' : undefined" :aria-current="route.path === '/servicos' ? 'page' : undefined"
> >
Serviços Serviços
</RouterLink> </NuxtLink>
</nav> </nav>
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<template v-if="auth.isAuthenticated"> <template v-if="isAuthenticated">
<RouterLink :to="{ name: 'painel' }"> <NuxtLink to="/portal/painel">
<Button label="Meu Painel" icon="pi pi-user" size="small" aria-label="Ir para o meu painel" /> <Button label="Meu Painel" icon="pi pi-user" size="small" aria-label="Ir para o meu painel" />
</RouterLink> </NuxtLink>
</template> </template>
<template v-else> <template v-else>
<RouterLink :to="{ name: 'home' }"> <Button
<Button label="Entrar" icon="pi pi-sign-in" size="small" aria-label="Entrar no portal" /> label="Entrar"
</RouterLink> icon="pi pi-sign-in"
size="small"
aria-label="Entrar no portal"
@click="clicarEntrar"
/>
</template> </template>
</div> </div>

View File

@ -6,14 +6,12 @@ defineProps({
to: { type: [String, Object], default: null }, to: { type: [String, Object], default: null },
requiresAuth: { type: Boolean, default: false }, requiresAuth: { type: Boolean, default: false },
}) })
const cardClasses = 'group flex flex-col gap-3 bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-5 hover:border-primary/40 dark:hover:border-primary/50 hover:shadow-md transition-all duration-200 cursor-pointer'
</script> </script>
<template> <template>
<component <NuxtLink v-if="to" :to="to" :class="cardClasses">
:is="to ? 'RouterLink' : 'div'"
:to="to ?? undefined"
class="group flex flex-col gap-3 bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-5 hover:border-primary/40 dark:hover:border-primary/50 hover:shadow-md transition-all duration-200 cursor-pointer"
>
<div class="flex items-start justify-between"> <div class="flex items-start justify-between">
<div class="w-10 h-10 bg-primary/8 dark:bg-primary/15 rounded-lg flex items-center justify-center group-hover:bg-primary/15 dark:group-hover:bg-primary/25 transition-colors"> <div class="w-10 h-10 bg-primary/8 dark:bg-primary/15 rounded-lg flex items-center justify-center group-hover:bg-primary/15 dark:group-hover:bg-primary/25 transition-colors">
<i :class="['pi', icon, 'text-primary text-lg']" /> <i :class="['pi', icon, 'text-primary text-lg']" />
@ -28,5 +26,18 @@ defineProps({
<span>Acessar</span> <span>Acessar</span>
<i class="pi pi-arrow-right text-xs" /> <i class="pi pi-arrow-right text-xs" />
</div> </div>
</component> </NuxtLink>
<div v-else :class="cardClasses">
<div class="flex items-start justify-between">
<div class="w-10 h-10 bg-primary/8 dark:bg-primary/15 rounded-lg flex items-center justify-center group-hover:bg-primary/15 dark:group-hover:bg-primary/25 transition-colors">
<i :class="['pi', icon, 'text-primary text-lg']" />
</div>
<i v-if="requiresAuth" class="pi pi-lock text-slate-300 dark:text-slate-600 text-xs" title="Requer login" />
</div>
<div>
<p class="font-semibold text-slate-800 dark:text-slate-100 text-sm">{{ titulo }}</p>
<p v-if="descricao" class="text-xs text-slate-500 dark:text-slate-400 mt-1 leading-relaxed">{{ descricao }}</p>
</div>
</div>
</template> </template>

43
src/composables/useApi.ts Normal file
View File

@ -0,0 +1,43 @@
import type { FetchOptions } from 'ofetch'
/**
* Wrapper para chamadas autenticadas ao core-api via BFF proxy.
*
* - Todas as requests passam por `/api/proxy/**` (que injeta Bearer + tenant headers no server)
* - Headers CSRF (`X-Requested-With: fetch`) sempre injetados exigido pelo middleware do BFF em mutating methods
*
* Uso típico:
* const api = useApi()
* const debitos = await api.get<DebitoDTO[]>('portal/contribuinte/debitos')
* const novo = await api.post<DebitoDTO>('portal/contribuinte/debitos', payload)
*/
export function useApi() {
function buildUrl(path: string): string {
const clean = path.startsWith('/') ? path : `/${path}`
return `/api/proxy${clean}`
}
async function request<T>(path: string, options: FetchOptions = {}): Promise<T> {
return await $fetch<T>(buildUrl(path), {
...options,
headers: {
'X-Requested-With': 'fetch',
...(options.headers ?? {}),
},
})
}
return {
request,
get: <T>(path: string, opts?: FetchOptions) =>
request<T>(path, { ...opts, method: 'GET' }),
post: <T>(path: string, body?: unknown, opts?: FetchOptions) =>
request<T>(path, { ...opts, method: 'POST', body }),
put: <T>(path: string, body?: unknown, opts?: FetchOptions) =>
request<T>(path, { ...opts, method: 'PUT', body }),
patch: <T>(path: string, body?: unknown, opts?: FetchOptions) =>
request<T>(path, { ...opts, method: 'PATCH', body }),
delete: <T>(path: string, opts?: FetchOptions) =>
request<T>(path, { ...opts, method: 'DELETE' }),
}
}

View File

@ -0,0 +1,65 @@
import { computed } from 'vue'
import { useAuthStore } from '@/stores/authStore'
interface MeResponse {
name: string
documento: string
email: string
roles: string[]
}
const FETCH_HEADERS = { 'X-Requested-With': 'fetch' }
export function useAuth() {
const store = useAuthStore()
const router = useRouter()
async function login(documento?: string, returnTo?: string) {
const res = await $fetch<{ authUrl: string }>('/api/auth/login', {
method: 'POST',
headers: FETCH_HEADERS,
body: { documento, returnTo },
})
if (import.meta.client) {
window.location.href = res.authUrl
}
}
async function fetchMe(): Promise<MeResponse | null> {
try {
const me = await $fetch<MeResponse>('/api/auth/me')
store.setUser(me)
return me
} catch {
store.clearUser()
return null
}
}
async function logout() {
try {
const res = await $fetch<{ logoutUrl: string }>('/api/auth/logout', {
method: 'POST',
headers: FETCH_HEADERS,
})
store.clearUser()
if (import.meta.client) {
window.location.href = res.logoutUrl
}
} catch {
store.clearUser()
await router.push('/')
}
}
return {
user: computed(() => store.user),
isAuthenticated: computed(() => store.isAuthenticated),
nomeUsuario: computed(() => store.nomeUsuario),
documento: computed(() => store.documento),
roles: computed(() => store.roles),
login,
logout,
fetchMe,
}
}

View File

@ -0,0 +1,20 @@
/**
* Sinalização cross-página: quando o usuário clica em "Entrar" no AppHeader
* mas a home ainda não está montada (estava em outra rota), o request fica
* armazenado no state. Ao montar, a home consome e focus no documento.
*
* Se a home está montada, o watch reage imediatamente.
*/
export function useFocusLoginInput() {
const requested = useState<boolean>('focusLoginInput', () => false)
function request() {
requested.value = true
}
function consume() {
requested.value = false
}
return { requested, request, consume }
}

View File

@ -0,0 +1,26 @@
/**
* Estado global do modal de login.
*
* Quando o middleware `auth` bloqueia uma rota, ele chama `open(toPath)`
* o modal abre e, ao submeter, redireciona o usuário pra `toPath` após o
* fluxo Keycloak completar.
*
* `useState` garante SSR-safety: state separado por request no server,
* singleton no client.
*/
export function useLoginModal() {
const isOpen = useState<boolean>('loginModal:isOpen', () => false)
const returnTo = useState<string | null>('loginModal:returnTo', () => null)
function open(path?: string | null) {
returnTo.value = path ?? null
isOpen.value = true
}
function close() {
isOpen.value = false
returnTo.value = null
}
return { isOpen, returnTo, open, close }
}

View File

@ -1,41 +0,0 @@
import axios from 'axios'
import { useAuthStore } from '@/stores/authStore'
import { usePrefeituraStore } from '@/stores/prefeituraStore'
const BASE_URL = `${import.meta.env.VITE_API_URL ?? ''}/api/v1`
function addTenantHeaders(config) {
const prefeitura = usePrefeituraStore()
if (prefeitura.codigoMunicipio) config.headers['X-Municipio'] = prefeitura.codigoMunicipio
if (prefeitura.dominio) config.headers['X-Dominio'] = prefeitura.dominio
return config
}
// ─── Cliente público (sem autenticação) ───────────────────────────────────────
// Usado no bootstrap de prefeitura e em serviços públicos (certidão, IPTU)
export const apiClientPublico = axios.create({ baseURL: BASE_URL, timeout: 10000 })
apiClientPublico.interceptors.request.use((config) => addTenantHeaders(config))
// ─── Cliente autenticado ───────────────────────────────────────────────────────
// Usado nas rotas /portal/* que exigem login
const apiClient = axios.create({ baseURL: BASE_URL, timeout: 10000 })
apiClient.interceptors.request.use((config) => {
const auth = useAuthStore()
if (auth.token) config.headers.Authorization = `Bearer ${auth.token}`
return addTenantHeaders(config)
})
apiClient.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
useAuthStore().clearSession()
window.location.href = '/'
}
return Promise.reject(error)
},
)
export default apiClient

View File

@ -7,11 +7,7 @@
<!-- tabindex="-1" permite que o skip link mova o foco para --> <!-- tabindex="-1" permite que o skip link mova o foco para -->
<main id="main-content" tabindex="-1" class="flex-1 outline-none"> <main id="main-content" tabindex="-1" class="flex-1 outline-none">
<RouterView v-slot="{ Component }"> <slot />
<Transition name="page" mode="out-in">
<component :is="Component" />
</Transition>
</RouterView>
</main> </main>
<AppFooter /> <AppFooter />

View File

@ -1,21 +1,20 @@
<script setup> <script setup>
import { useAuthStore } from '@/stores/authStore' import { useAuth } from '@/composables/useAuth'
import { authService } from '@/services/authService'
const auth = useAuthStore() const { nomeUsuario, logout } = useAuth()
const route = useRoute()
const navItems = [ const navItems = [
{ name: 'painel', label: 'Painel' }, { path: '/portal/painel', label: 'Painel' },
{ name: 'debitos', label: 'Débitos' }, { path: '/portal/debitos', label: 'Débitos' },
{ name: 'certidoes-portal', label: 'Certidões' }, { path: '/portal/certidoes', label: 'Certidões' },
{ name: 'alvaras', label: 'Alvarás' }, { path: '/portal/alvaras', label: 'Alvarás' },
{ name: 'pagamentos', label: 'Pagamentos' }, { path: '/portal/pagamentos', label: 'Pagamentos' },
{ name: 'dados', label: 'Dados Cadastrais' }, { path: '/portal/dados', label: 'Dados Cadastrais' },
] ]
function sair() { function sair() {
auth.clearSession() logout()
authService.logout()
} }
</script> </script>
@ -23,8 +22,8 @@ function sair() {
<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 justify-between">
<RouterLink <NuxtLink
:to="{ name: 'painel' }" to="/portal/painel"
class="flex items-center gap-3" class="flex items-center gap-3"
aria-label="Ir para o painel principal" aria-label="Ir para o painel principal"
> >
@ -32,24 +31,24 @@ function sair() {
<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> <span class="font-semibold text-slate-800 dark:text-slate-100">Portal do Contribuinte</span>
</RouterLink> </NuxtLink>
<nav aria-label="Menu do contribuinte" class="hidden md:flex items-center gap-1"> <nav aria-label="Menu do contribuinte" class="hidden md:flex items-center gap-1">
<RouterLink <NuxtLink
v-for="item in navItems" v-for="item in navItems"
:key="item.name" :key="item.path"
:to="{ name: item.name }" :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-4 py-2 rounded-lg text-sm text-slate-600 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800 hover:text-slate-900 dark:hover:text-slate-100 transition-colors"
active-class="bg-primary/10 text-primary font-semibold" active-class="bg-primary/10 text-primary font-semibold"
:aria-current="$route.name === item.name ? 'page' : undefined" :aria-current="route.path === item.path ? 'page' : undefined"
> >
{{ item.label }} {{ item.label }}
</RouterLink> </NuxtLink>
</nav> </nav>
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<span class="hidden sm:block text-sm text-slate-600 dark:text-slate-300" aria-live="polite"> <span class="hidden sm:block text-sm text-slate-600 dark:text-slate-300" aria-live="polite">
{{ auth.nomeUsuario }} {{ nomeUsuario }}
</span> </span>
<Button <Button
label="Sair" label="Sair"
@ -65,11 +64,7 @@ function sair() {
</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"> <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">
<RouterView v-slot="{ Component }"> <slot />
<Transition name="page" mode="out-in">
<component :is="Component" />
</Transition>
</RouterView>
</main> </main>
<AppFooter /> <AppFooter />

View File

@ -1,34 +0,0 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
import PrimeVue from 'primevue/config'
import ToastService from 'primevue/toastservice'
import ConfirmationService from 'primevue/confirmationservice'
import App from './App.vue'
import router from './router'
import { primeVueConfig } from './config/primevue.config'
import { bootstrapPrefeitura } from './bootstrap/prefeituraBoot'
import '@/assets/main.css'
import '@/assets/layout/layout.scss'
async function startApp() {
const app = createApp(App)
const pinia = createPinia()
pinia.use(piniaPluginPersistedstate)
app.use(pinia)
await bootstrapPrefeitura(pinia)
app.use(router)
app.use(PrimeVue, primeVueConfig)
app.use(ToastService)
app.use(ConfirmationService)
app.mount('#app')
}
startApp()

20
src/middleware/auth.ts Normal file
View File

@ -0,0 +1,20 @@
import { useAuthStore } from '@/stores/authStore'
import { useLoginModal } from '@/composables/useLoginModal'
/**
* Guarda rotas autenticadas (/portal/**).
*
* Quando bloqueia: abre o modal global de login (com a rota pretendida) e
* leva o usuário pra home, onde o modal fica visível.
*/
export default defineNuxtRouteMiddleware((to) => {
const auth = useAuthStore()
if (auth.isAuthenticated) return
if (import.meta.client) {
const { open } = useLoginModal()
open(to.fullPath)
}
return navigateTo('/')
})

View File

@ -0,0 +1,408 @@
<script setup>
import { ref, computed, watch } from 'vue'
import { credenciamentoService } from '@/services/credenciamentoService'
const router = useRouter()
const etapa = ref(0)
const carregando = ref(false)
const erro = ref('')
const form = ref({
documento: '',
tipoPessoa: '',
nomeCompleto: '',
nomeFantasia: '',
dataNascimento: '',
inscricaoEstadual: '',
cep: '',
logradouro: '',
numero: '',
complemento: '',
bairro: '',
cidade: '',
uf: '',
email: '',
emailConfirm: '',
telefone: '',
whatsapp: false,
representanteNome: '',
representanteCpf: '',
representanteCargo: '',
})
const docDigitos = computed(() => form.value.documento.replace(/\D/g, ''))
const isPJ = computed(() => form.value.tipoPessoa === 'JURIDICA')
const totalEtapas = computed(() => isPJ.value ? 6 : 5)
const etapaLabels = computed(() => {
const base = ['Documento', 'Dados', 'Endereço', 'Contato']
if (isPJ.value) base.push('Representante')
base.push('Revisão')
return base
})
const emailsIguais = computed(() => form.value.email === form.value.emailConfirm)
const emailValido = computed(() => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.value.email))
async function verificarDocumento() {
const d = docDigitos.value
if (d.length !== 11 && d.length !== 14) return
carregando.value = true
erro.value = ''
try {
const res = await credenciamentoService.verificarDocumento(d)
if (res.data.situacao === 'JA_CREDENCIADO') {
erro.value = 'Este documento já possui cadastro. Use "Esqueci minha senha" se precisar recuperar o acesso.'
return
}
form.value.tipoPessoa = d.length === 14 ? 'JURIDICA' : 'FISICA'
etapa.value = 1
} catch (e) {
erro.value = e?.data?.description ?? 'Não foi possível verificar o documento. Tente novamente.'
} finally {
carregando.value = false
}
}
const buscandoCep = ref(false)
async function buscarCep() {
const cep = form.value.cep.replace(/\D/g, '')
if (cep.length !== 8) return
buscandoCep.value = true
try {
const res = await credenciamentoService.buscarCep(cep)
const end = res.data
form.value.logradouro = end.logradouro ?? ''
form.value.bairro = end.bairro ?? ''
form.value.cidade = end.localidade ?? ''
form.value.uf = end.uf ?? ''
} catch {
// silencioso usuário preenche manualmente
} finally {
buscandoCep.value = false
}
}
watch(() => form.value.cep, (v) => {
if (v.replace(/\D/g, '').length === 8) buscarCep()
})
function avancar() {
erro.value = ''
etapa.value++
}
function voltar() {
erro.value = ''
etapa.value--
}
async function solicitar() {
carregando.value = true
erro.value = ''
try {
await credenciamentoService.solicitar({
documento: docDigitos.value,
tipoPessoa: form.value.tipoPessoa,
nomeCompleto: form.value.nomeCompleto,
nomeFantasia: form.value.nomeFantasia || undefined,
dataNascimento: form.value.dataNascimento || undefined,
inscricaoEstadual: form.value.inscricaoEstadual || undefined,
endereco: {
cep: form.value.cep.replace(/\D/g, ''),
logradouro: form.value.logradouro,
numero: form.value.numero,
complemento: form.value.complemento || undefined,
bairro: form.value.bairro,
cidade: form.value.cidade,
uf: form.value.uf,
},
contato: {
email: form.value.email,
telefone: form.value.telefone.replace(/\D/g, ''),
whatsapp: form.value.whatsapp,
},
representante: isPJ.value ? {
nome: form.value.representanteNome,
cpf: form.value.representanteCpf.replace(/\D/g, ''),
cargo: form.value.representanteCargo,
} : undefined,
})
etapa.value = totalEtapas.value
} catch (e) {
erro.value = e?.data?.description ?? 'Erro ao enviar solicitação. Tente novamente.'
} finally {
carregando.value = false
}
}
function formatarTelefone(e) {
const d = e.target.value.replace(/\D/g, '').slice(0, 11)
form.value.telefone = d
.replace(/(\d{2})(\d)/, '($1) $2')
.replace(/(\d{5})(\d{1,4})$/, '$1-$2')
}
function formatarCep(e) {
const d = e.target.value.replace(/\D/g, '').slice(0, 8)
form.value.cep = d.replace(/(\d{5})(\d{1,3})/, '$1-$2')
}
const estados = ['AC','AL','AP','AM','BA','CE','DF','ES','GO','MA','MT','MS','MG','PA','PB','PR','PE','PI','RJ','RN','RS','RO','RR','SC','SP','SE','TO']
</script>
<template>
<div class="min-h-[calc(100vh-8rem)] flex items-center justify-center px-4 py-12">
<div class="w-full max-w-lg">
<div v-if="etapa < totalEtapas" class="mb-8">
<div class="flex items-center justify-between mb-2">
<p class="text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wide">
Passo {{ etapa + 1 }} de {{ totalEtapas }}
</p>
<p class="text-xs text-slate-500 dark:text-slate-400">{{ etapaLabels[etapa] }}</p>
</div>
<div class="h-1.5 bg-slate-200 dark:bg-slate-700 rounded-full overflow-hidden">
<div
class="h-full bg-primary rounded-full transition-all duration-300"
:style="{ width: `${((etapa + 1) / totalEtapas) * 100}%` }"
/>
</div>
</div>
<div class="bg-white dark:bg-slate-800 rounded-2xl shadow-lg border border-slate-200 dark:border-slate-700 overflow-hidden">
<div class="bg-gradient-to-r from-primary to-primary-700 px-8 py-6">
<div class="flex items-center gap-3">
<div class="w-10 h-10 bg-white/20 rounded-xl flex items-center justify-center" aria-hidden="true">
<i class="pi pi-user-plus text-white text-lg" />
</div>
<div>
<h1 class="text-white font-bold text-base">Credenciamento</h1>
<p class="text-white/80 text-xs mt-0.5">
{{ etapa < totalEtapas ? etapaLabels[etapa] : 'Solicitação enviada!' }}
</p>
</div>
</div>
</div>
<div class="px-8 py-8 space-y-5">
<template v-if="etapa === 0">
<p class="text-sm text-slate-600 dark:text-slate-300">
Informe seu CPF (pessoa física) ou CNPJ (empresa) para iniciar o credenciamento.
</p>
<div>
<label class="block text-sm font-semibold text-slate-700 dark:text-slate-200 mb-1.5">CPF ou CNPJ</label>
<DocumentoInput v-model="form.documento" @keyup.enter="verificarDocumento" />
</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 documento"
icon="pi pi-arrow-right" icon-pos="right"
class="w-full" size="large"
:loading="carregando"
:disabled="docDigitos.length !== 11 && docDigitos.length !== 14"
@click="verificarDocumento"
/>
<p class="text-center text-sm text-slate-500 dark:text-slate-400">
tem cadastro?
<NuxtLink to="/" class="text-primary font-semibold hover:underline">Entrar</NuxtLink>
</p>
</template>
<template v-else-if="etapa === 1">
<div class="space-y-4">
<div>
<label class="block text-sm font-semibold text-slate-700 dark:text-slate-200 mb-1.5">
{{ isPJ ? 'Razão Social' : 'Nome completo' }}
</label>
<InputText v-model="form.nomeCompleto" :placeholder="isPJ ? 'Nome da empresa conforme CNPJ' : 'Seu nome completo'" class="w-full" size="large" />
</div>
<div v-if="isPJ">
<label class="block text-sm font-semibold text-slate-700 dark:text-slate-200 mb-1.5">Nome fantasia <span class="font-normal text-slate-400">(opcional)</span></label>
<InputText v-model="form.nomeFantasia" placeholder="Nome fantasia" class="w-full" size="large" />
</div>
<div v-if="!isPJ">
<label class="block text-sm font-semibold text-slate-700 dark:text-slate-200 mb-1.5">Data de nascimento</label>
<DatePicker v-model="form.dataNascimento" date-format="dd/mm/yy" placeholder="DD/MM/AAAA" class="w-full" size="large" show-icon />
</div>
<div v-if="isPJ">
<label class="block text-sm font-semibold text-slate-700 dark:text-slate-200 mb-1.5">Inscrição estadual <span class="font-normal text-slate-400">(opcional)</span></label>
<InputText v-model="form.inscricaoEstadual" placeholder="IE ou ISENTO" class="w-full" size="large" />
</div>
</div>
<div class="flex gap-3 pt-2">
<Button label="Voltar" severity="secondary" outlined class="flex-1" @click="voltar" />
<Button label="Continuar" icon="pi pi-arrow-right" icon-pos="right" class="flex-1" :disabled="!form.nomeCompleto.trim()" @click="avancar" />
</div>
</template>
<template v-else-if="etapa === 2">
<div class="space-y-4">
<div>
<label class="block text-sm font-semibold text-slate-700 dark:text-slate-200 mb-1.5">CEP</label>
<div class="relative">
<InputText :value="form.cep" placeholder="00000-000" class="w-full" size="large" @input="formatarCep" />
<i v-if="buscandoCep" class="pi pi-spin pi-spinner absolute right-3 top-1/2 -translate-y-1/2 text-slate-400" aria-hidden="true" />
</div>
</div>
<div class="grid grid-cols-3 gap-3">
<div class="col-span-2">
<label class="block text-sm font-semibold text-slate-700 dark:text-slate-200 mb-1.5">Logradouro</label>
<InputText v-model="form.logradouro" placeholder="Rua, Av., ..." class="w-full" size="large" />
</div>
<div>
<label class="block text-sm font-semibold text-slate-700 dark:text-slate-200 mb-1.5">Número</label>
<InputText v-model="form.numero" placeholder="Nº" class="w-full" size="large" />
</div>
</div>
<div>
<label class="block text-sm font-semibold text-slate-700 dark:text-slate-200 mb-1.5">Complemento <span class="font-normal text-slate-400">(opcional)</span></label>
<InputText v-model="form.complemento" placeholder="Apto, sala, bloco..." class="w-full" size="large" />
</div>
<div>
<label class="block text-sm font-semibold text-slate-700 dark:text-slate-200 mb-1.5">Bairro</label>
<InputText v-model="form.bairro" class="w-full" size="large" />
</div>
<div class="grid grid-cols-3 gap-3">
<div class="col-span-2">
<label class="block text-sm font-semibold text-slate-700 dark:text-slate-200 mb-1.5">Cidade</label>
<InputText v-model="form.cidade" class="w-full" size="large" />
</div>
<div>
<label class="block text-sm font-semibold text-slate-700 dark:text-slate-200 mb-1.5">UF</label>
<Select v-model="form.uf" :options="estados" placeholder="UF" class="w-full" size="large" />
</div>
</div>
</div>
<div class="flex gap-3 pt-2">
<Button label="Voltar" severity="secondary" outlined class="flex-1" @click="voltar" />
<Button label="Continuar" icon="pi pi-arrow-right" icon-pos="right" class="flex-1"
:disabled="!form.logradouro || !form.numero || !form.bairro || !form.cidade || !form.uf"
@click="avancar" />
</div>
</template>
<template v-else-if="etapa === 3">
<div class="space-y-4">
<div>
<label class="block text-sm font-semibold text-slate-700 dark:text-slate-200 mb-1.5">E-mail</label>
<InputText v-model="form.email" type="email" placeholder="seu@email.com" class="w-full" size="large" :invalid="form.email.length > 0 && !emailValido" />
</div>
<div>
<label class="block text-sm font-semibold text-slate-700 dark:text-slate-200 mb-1.5">Confirmar e-mail</label>
<InputText v-model="form.emailConfirm" type="email" placeholder="Repita o e-mail" class="w-full" size="large" :invalid="form.emailConfirm.length > 0 && !emailsIguais" />
<p v-if="form.emailConfirm.length > 0 && !emailsIguais" class="mt-1 text-xs text-red-600 dark:text-red-400">Os e-mails não coincidem.</p>
</div>
<div>
<label class="block text-sm font-semibold text-slate-700 dark:text-slate-200 mb-1.5">Telefone / Celular</label>
<InputText :value="form.telefone" placeholder="(00) 00000-0000" class="w-full" size="large" inputmode="numeric" @input="formatarTelefone" />
</div>
<div class="flex items-center gap-3 p-4 bg-slate-50 dark:bg-slate-700/50 rounded-xl">
<Checkbox v-model="form.whatsapp" :binary="true" input-id="whatsapp" />
<label for="whatsapp" class="text-sm text-slate-700 dark:text-slate-200 cursor-pointer">
Este número também recebe WhatsApp
</label>
</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>
<div class="flex gap-3 pt-2">
<Button label="Voltar" severity="secondary" outlined class="flex-1" @click="voltar" />
<Button label="Continuar" icon="pi pi-arrow-right" icon-pos="right" class="flex-1"
:disabled="!emailValido || !emailsIguais || form.telefone.replace(/\D/g,'').length < 10"
@click="avancar" />
</div>
</template>
<template v-else-if="etapa === 4 && isPJ">
<p class="text-sm text-slate-600 dark:text-slate-300">
Informe os dados do representante legal da empresa que será responsável pelo acesso ao portal.
</p>
<div class="space-y-4">
<div>
<label class="block text-sm font-semibold text-slate-700 dark:text-slate-200 mb-1.5">Nome completo</label>
<InputText v-model="form.representanteNome" placeholder="Nome do representante" class="w-full" size="large" />
</div>
<div>
<label class="block text-sm font-semibold text-slate-700 dark:text-slate-200 mb-1.5">CPF</label>
<DocumentoInput v-model="form.representanteCpf" />
</div>
<div>
<label class="block text-sm font-semibold text-slate-700 dark:text-slate-200 mb-1.5">Cargo / Função</label>
<InputText v-model="form.representanteCargo" placeholder="Ex.: Sócio-Administrador, Diretor..." class="w-full" size="large" />
</div>
</div>
<div class="flex gap-3 pt-2">
<Button label="Voltar" severity="secondary" outlined class="flex-1" @click="voltar" />
<Button label="Continuar" icon="pi pi-arrow-right" icon-pos="right" class="flex-1"
:disabled="!form.representanteNome || form.representanteCpf.replace(/\D/g,'').length !== 11 || !form.representanteCargo"
@click="avancar" />
</div>
</template>
<template v-else-if="(isPJ && etapa === 5) || (!isPJ && etapa === 4)">
<p class="text-sm text-slate-600 dark:text-slate-300 mb-2">Confira os dados antes de enviar a solicitação.</p>
<div class="space-y-3 text-sm">
<div class="bg-slate-50 dark:bg-slate-700/50 rounded-xl p-4 space-y-2">
<p class="font-semibold text-slate-500 dark:text-slate-400 text-xs uppercase tracking-wide">Identificação</p>
<p class="text-slate-800 dark:text-slate-100"><span class="text-slate-500 dark:text-slate-400">Documento:</span> {{ form.documento }}</p>
<p class="text-slate-800 dark:text-slate-100"><span class="text-slate-500 dark:text-slate-400">Nome:</span> {{ form.nomeCompleto }}</p>
</div>
<div class="bg-slate-50 dark:bg-slate-700/50 rounded-xl p-4 space-y-2">
<p class="font-semibold text-slate-500 dark:text-slate-400 text-xs uppercase tracking-wide">Endereço</p>
<p class="text-slate-800 dark:text-slate-100">{{ form.logradouro }}, {{ form.numero }}{{ form.complemento ? `${form.complemento}` : '' }}</p>
<p class="text-slate-800 dark:text-slate-100">{{ form.bairro }} {{ form.cidade }}/{{ form.uf }} CEP {{ form.cep }}</p>
</div>
<div class="bg-slate-50 dark:bg-slate-700/50 rounded-xl p-4 space-y-2">
<p class="font-semibold text-slate-500 dark:text-slate-400 text-xs uppercase tracking-wide">Contato</p>
<p class="text-slate-800 dark:text-slate-100">{{ form.email }}</p>
<p class="text-slate-800 dark:text-slate-100">{{ form.telefone }}{{ form.whatsapp ? ' (WhatsApp)' : '' }}</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>
<div class="flex gap-3 pt-2">
<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" />
</div>
</template>
<template v-else>
<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">
<i class="pi pi-check-circle text-emerald-600 dark:text-emerald-400 text-3xl" aria-hidden="true" />
</div>
<h2 class="text-lg font-bold text-slate-800 dark:text-slate-100 mb-2">Solicitação enviada!</h2>
<p class="text-sm text-slate-500 dark:text-slate-400">
Sua solicitação de credenciamento foi recebida e será analisada em até
<strong class="text-slate-700 dark:text-slate-300">2 dias úteis</strong>.
Você receberá um e-mail em <strong class="text-slate-700 dark:text-slate-300">{{ form.email }}</strong> com o resultado.
</p>
</div>
<Button label="Voltar à página inicial" icon="pi pi-home" class="w-full" size="large" @click="router.push('/')" />
</template>
</div>
</div>
<div v-if="etapa === 0" class="text-center mt-4">
<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"
@click="router.push('/')"
>
<i class="pi pi-arrow-left text-xs" aria-hidden="true" />
Voltar à página inicial
</button>
</div>
</div>
</div>
</template>

View File

@ -1,10 +1,9 @@
<script setup> <script setup>
import { ref, computed } from 'vue' import { ref, computed, watch, nextTick } from 'vue'
import { useRouter } from 'vue-router'
import { usePrefeituraStore } from '@/stores/prefeituraStore' import { usePrefeituraStore } from '@/stores/prefeituraStore'
import { useAuth } from '@/composables/useAuth'
import { useFocusLoginInput } from '@/composables/useFocusLoginInput'
import { useMotion } from '@/composables/useMotion' import { useMotion } from '@/composables/useMotion'
import DocumentoInput from '@/components/auth/DocumentoInput.vue'
import ServiceCard from '@/components/common/ServiceCard.vue'
import bgTutoia from '@/assets/images/bg-tutoia.jpeg' import bgTutoia from '@/assets/images/bg-tutoia.jpeg'
@ -12,13 +11,30 @@ const { prefersReducedMotion } = useMotion()
const router = useRouter() const router = useRouter()
const prefeitura = usePrefeituraStore() const prefeitura = usePrefeituraStore()
const { isAuthenticated, nomeUsuario, login } = useAuth()
const { requested: focusLoginRequested, consume: consumeFocusLogin } = useFocusLoginInput()
const documento = ref('') const documento = ref('')
const erro = ref('') const erro = ref('')
const carregando = ref(false)
// Ref ao DocumentoInput usado pelo botão "Entrar" do AppHeader pra focar o campo
const documentoRef = ref(null)
// Quando o AppHeader sinaliza intenção de login (clique no botão Entrar),
// foca o campo + scroll suave pra ele.
watch(focusLoginRequested, async (v) => {
if (!v || isAuthenticated.value) {
if (v) consumeFocusLogin()
return
}
await nextTick()
const el = documentoRef.value?.$el ?? documentoRef.value
el?.scrollIntoView?.({ behavior: 'smooth', block: 'center' })
documentoRef.value?.focus?.()
consumeFocusLogin()
}, { immediate: true })
// Hero background
// Mapa template imagem estática (Vite resolve na build).
// Para adicionar novo município: importar a foto e adicionar a chave abaixo.
const heroBgMap = { const heroBgMap = {
tutoia: bgTutoia, tutoia: bgTutoia,
} }
@ -37,8 +53,7 @@ const heroBgStyle = computed(() => {
const heroHasPhoto = computed(() => !!heroBgUrl.value) const heroHasPhoto = computed(() => !!heroBgUrl.value)
// Avisos (carousel) // Dados mockados conectar ao endpoint /publico/avisos/{dominio} futuramente
// Dados mockados conectar ao endpoint /api/v1/publico/avisos/{dominio} futuramente
const avisos = ref([ const avisos = ref([
{ {
id: 1, id: 1,
@ -47,7 +62,7 @@ const avisos = ref([
titulo: 'IPTU 2025 — Parcela única com desconto', titulo: 'IPTU 2025 — Parcela única com desconto',
descricao: 'Pague até 31/07 e ganhe 10% de desconto. Emita seu boleto agora mesmo.', descricao: 'Pague até 31/07 e ganhe 10% de desconto. Emita seu boleto agora mesmo.',
cor: 'amber', cor: 'amber',
acao: { label: 'Emitir boleto', to: { name: 'iptu' } }, acao: { label: 'Emitir boleto', to: '/servicos/iptu' },
}, },
{ {
id: 2, id: 2,
@ -56,7 +71,7 @@ const avisos = ref([
titulo: 'Novo serviço: Certidão Online Instantânea', titulo: 'Novo serviço: Certidão Online Instantânea',
descricao: 'Emita certidões negativas em segundos, sem sair de casa e com validade legal.', descricao: 'Emita certidões negativas em segundos, sem sair de casa e com validade legal.',
cor: 'green', cor: 'green',
acao: { label: 'Emitir agora', to: { name: 'certidao' } }, acao: { label: 'Emitir agora', to: '/servicos/certidao' },
}, },
{ {
id: 3, id: 3,
@ -75,28 +90,35 @@ const corAviso = {
blue: { bg: 'bg-blue-50 dark:bg-blue-900/20', borda: 'border-blue-200 dark:border-blue-700/40', icone: 'text-blue-600 dark:text-blue-400', tag: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300' }, blue: { bg: 'bg-blue-50 dark:bg-blue-900/20', borda: 'border-blue-200 dark:border-blue-700/40', icone: 'text-blue-600 dark:text-blue-400', tag: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300' },
} }
// Serviços
const servicosPublicos = [ const servicosPublicos = [
{ icon: 'pi-file-check', titulo: 'Emissão de Certidão', descricao: 'Certidão negativa, positiva e situação fiscal.', to: { name: 'certidao' } }, { icon: 'pi-file-check', titulo: 'Emissão de Certidão', descricao: 'Certidão negativa, positiva e situação fiscal.', to: '/servicos/certidao' },
{ icon: 'pi-home', titulo: 'IPTU — Débitos e Carnê', descricao: 'Consulte débitos e imprima o carnê de pagamento.', to: { name: 'iptu' } }, { icon: 'pi-home', titulo: 'IPTU — Débitos e Carnê', descricao: 'Consulte débitos e imprima o carnê de pagamento.', to: '/servicos/iptu' },
{ icon: 'pi-verified', titulo: 'Validar Documento', descricao: 'Verifique a autenticidade de certidões emitidas.', to: { name: 'servicos' } }, { icon: 'pi-verified', titulo: 'Validar Documento', descricao: 'Verifique a autenticidade de certidões emitidas.', to: '/servicos' },
] ]
const servicosAutenticados = [ const servicosAutenticados = [
{ icon: 'pi-receipt', titulo: 'Débitos e Guias', descricao: 'Emita guias de pagamento de todos os seus tributos.', to: { name: 'debitos' } }, { icon: 'pi-receipt', titulo: 'Débitos e Guias', descricao: 'Emita guias de pagamento de todos os seus tributos.', to: '/portal/debitos' },
{ icon: 'pi-file-edit', titulo: 'Certidões Ativas', descricao: 'Acesse e reemita certidões emitidas anteriormente.', to: { name: 'certidoes-portal' } }, { 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: { name: 'alvaras' } }, { 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: { name: '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: { name: 'dados' } }, { icon: 'pi-user', titulo: 'Dados Cadastrais', descricao: 'Visualize e mantenha seus dados sempre atualizados.', to: '/portal/dados' },
] ]
function continuar() { async function continuar() {
if (documento.value.replace(/\D/g, '').length < 11) { const doc = documento.value.replace(/\D/g, '')
if (doc.length < 11) {
erro.value = 'Informe um CPF (11 dígitos) ou CNPJ (14 dígitos) válido.' erro.value = 'Informe um CPF (11 dígitos) ou CNPJ (14 dígitos) válido.'
return return
} }
erro.value = '' erro.value = ''
router.push({ name: 'login', query: { doc: documento.value } }) carregando.value = true
try {
await login(doc, '/portal/painel')
// login() faz window.location não retorna aqui em condições normais
} catch (e) {
carregando.value = false
erro.value = e?.data?.statusMessage ?? 'Não foi possível iniciar o login.'
}
} }
</script> </script>
@ -109,15 +131,12 @@ function continuar() {
:class="heroHasPhoto ? '' : 'bg-gradient-to-br from-slate-900 via-primary-900 to-slate-800'" :class="heroHasPhoto ? '' : 'bg-gradient-to-br from-slate-900 via-primary-900 to-slate-800'"
:style="heroBgStyle" :style="heroBgStyle"
> >
<!-- Padrão geométrico sutil (visível sem foto) -->
<div <div
v-if="!heroHasPhoto" v-if="!heroHasPhoto"
class="absolute inset-0 opacity-5 pointer-events-none" class="absolute inset-0 opacity-5 pointer-events-none"
style="background-image: radial-gradient(circle at 25px 25px, white 2px, transparent 0); background-size: 50px 50px;" style="background-image: radial-gradient(circle at 25px 25px, white 2px, transparent 0); background-size: 50px 50px;"
/> />
<!-- Gradiente lateral esquerdo garante legibilidade dos textos
independente do conteúdo da foto de fundo -->
<div <div
v-if="heroHasPhoto" v-if="heroHasPhoto"
class="absolute inset-0 pointer-events-none" class="absolute inset-0 pointer-events-none"
@ -127,9 +146,7 @@ function continuar() {
<div class="relative max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-14 lg:py-20"> <div class="relative max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-14 lg:py-20">
<div class="grid grid-cols-1 lg:grid-cols-2 gap-12 lg:gap-20 items-center"> <div class="grid grid-cols-1 lg:grid-cols-2 gap-12 lg:gap-20 items-center">
<!-- Esquerda -->
<div> <div>
<!-- Identidade do município -->
<div class="flex items-center gap-3 mb-6"> <div class="flex items-center gap-3 mb-6">
<img <img
v-if="prefeitura.pathLogo" v-if="prefeitura.pathLogo"
@ -160,7 +177,6 @@ function continuar() {
da prefeitura sem precisar sair de casa. da prefeitura sem precisar sair de casa.
</p> </p>
<!-- Lista de serviços públicos -->
<ul class="space-y-2 mb-8"> <ul class="space-y-2 mb-8">
<li <li
v-for="s in servicosPublicos" v-for="s in servicosPublicos"
@ -177,20 +193,47 @@ function continuar() {
</li> </li>
</ul> </ul>
<RouterLink <NuxtLink
:to="{ name: 'servicos' }" to="/servicos"
class="inline-flex items-center gap-2 text-sm text-white/80 hover:text-white transition-colors font-medium" class="inline-flex items-center gap-2 text-sm text-white/80 hover:text-white transition-colors font-medium"
> >
Ver todos os serviços disponíveis Ver todos os serviços disponíveis
<i class="pi pi-arrow-right text-xs" /> <i class="pi pi-arrow-right text-xs" />
</RouterLink> </NuxtLink>
</div> </div>
<!-- Direita Card de acesso -->
<div class="flex justify-center lg:justify-end"> <div class="flex justify-center lg:justify-end">
<div class="bg-white dark:bg-slate-800 rounded-2xl shadow-2xl p-8 w-full max-w-sm backdrop-blur-sm"> <!-- Card de saudação (usuário autenticado) -->
<div v-if="isAuthenticated" class="bg-white dark:bg-slate-800 rounded-2xl shadow-2xl p-8 w-full max-w-sm backdrop-blur-sm">
<div class="flex items-center gap-3 mb-6">
<div class="w-11 h-11 bg-primary rounded-xl flex items-center justify-center shadow-sm">
<i class="pi pi-user text-white text-lg" />
</div>
<div class="min-w-0">
<p class="text-xs text-slate-500 dark:text-slate-400">Bem-vindo(a) de volta</p>
<p class="font-bold text-slate-800 dark:text-slate-100 text-base truncate">{{ nomeUsuario || 'Contribuinte' }}</p>
</div>
</div>
<p class="text-sm text-slate-600 dark:text-slate-300 mb-6 leading-relaxed">
Você está autenticado. Acesse seu painel para emitir guias,
consultar débitos e gerenciar seus serviços.
</p>
<NuxtLink to="/portal/painel" class="block">
<Button
label="Ir para o painel"
icon="pi pi-arrow-right"
icon-pos="right"
class="w-full"
size="large"
/>
</NuxtLink>
</div>
<!-- Card de acesso (visitante) -->
<div v-else class="bg-white dark:bg-slate-800 rounded-2xl shadow-2xl p-8 w-full max-w-sm backdrop-blur-sm">
<!-- Cabeçalho do card -->
<div class="flex items-center gap-3 mb-7"> <div class="flex items-center gap-3 mb-7">
<div class="w-11 h-11 bg-primary rounded-xl flex items-center justify-center shadow-sm"> <div class="w-11 h-11 bg-primary rounded-xl flex items-center justify-center shadow-sm">
<i class="pi pi-lock-open text-white text-lg" /> <i class="pi pi-lock-open text-white text-lg" />
@ -201,13 +244,13 @@ function continuar() {
</div> </div>
</div> </div>
<!-- Formulário -->
<div class="space-y-4"> <div class="space-y-4">
<div> <div>
<label class="block text-sm font-medium text-slate-700 dark:text-slate-200 mb-1.5"> <label class="block text-sm font-medium text-slate-700 dark:text-slate-200 mb-1.5">
CPF ou CNPJ CPF ou CNPJ
</label> </label>
<DocumentoInput <DocumentoInput
ref="documentoRef"
v-model="documento" v-model="documento"
@keyup.enter="continuar" @keyup.enter="continuar"
/> />
@ -223,6 +266,7 @@ function continuar() {
icon-pos="right" icon-pos="right"
class="w-full" class="w-full"
size="large" size="large"
:loading="carregando"
@click="continuar" @click="continuar"
/> />
</div> </div>
@ -232,19 +276,19 @@ function continuar() {
</Divider> </Divider>
<div class="space-y-2.5 text-center"> <div class="space-y-2.5 text-center">
<RouterLink <NuxtLink
:to="{ name: 'primeiro-acesso' }" to="/primeiro-acesso"
class="flex items-center justify-center gap-2 w-full px-4 py-2.5 rounded-lg border border-slate-200 dark:border-slate-600 text-sm text-slate-700 dark:text-slate-200 hover:bg-slate-50 dark:hover:bg-slate-700 hover:border-slate-300 dark:hover:border-slate-500 transition-colors font-medium" class="flex items-center justify-center gap-2 w-full px-4 py-2.5 rounded-lg border border-slate-200 dark:border-slate-600 text-sm text-slate-700 dark:text-slate-200 hover:bg-slate-50 dark:hover:bg-slate-700 hover:border-slate-300 dark:hover:border-slate-500 transition-colors font-medium"
> >
<i class="pi pi-key text-slate-500 dark:text-slate-400 text-sm" /> <i class="pi pi-key text-slate-500 dark:text-slate-400 text-sm" />
Criar minha senha Criar minha senha
</RouterLink> </NuxtLink>
<RouterLink <NuxtLink
:to="{ name: 'credenciamento' }" to="/credenciamento"
class="block text-xs text-slate-400 dark:text-slate-500 hover:text-slate-600 dark:hover:text-slate-300 transition-colors" class="block text-xs text-slate-400 dark:text-slate-500 hover:text-slate-600 dark:hover:text-slate-300 transition-colors"
> >
Ainda não cadastrado? <span class="text-primary font-semibold">Credenciar-se</span> Ainda não cadastrado? <span class="text-primary font-semibold">Credenciar-se</span>
</RouterLink> </NuxtLink>
</div> </div>
</div> </div>
@ -257,7 +301,6 @@ function continuar() {
<!-- CAROUSEL DE AVISOS --> <!-- CAROUSEL DE AVISOS -->
<section class="bg-white dark:bg-slate-900 border-b border-slate-100 dark:border-slate-800" aria-label="Avisos e comunicados"> <section class="bg-white dark:bg-slate-900 border-b border-slate-100 dark:border-slate-800" aria-label="Avisos e comunicados">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6"> <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<!-- autoplay desativado quando prefers-reduced-motion está ativo -->
<Carousel <Carousel
:value="avisos" :value="avisos"
:num-visible="1" :num-visible="1"
@ -275,17 +318,13 @@ function continuar() {
corAviso[aviso.cor].borda, corAviso[aviso.cor].borda,
]" ]"
> >
<div <div class="w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0 bg-white shadow-sm">
: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].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>
<p class="text-xs text-slate-600 dark:text-slate-300 mt-0.5 leading-relaxed">{{ aviso.descricao }}</p> <p class="text-xs text-slate-600 dark:text-slate-300 mt-0.5 leading-relaxed">{{ aviso.descricao }}</p>
<RouterLink <NuxtLink
v-if="aviso.acao" v-if="aviso.acao"
:to="aviso.acao.to" :to="aviso.acao.to"
class="inline-block mt-3" class="inline-block mt-3"
@ -296,7 +335,7 @@ function continuar() {
outlined outlined
class="whitespace-nowrap" class="whitespace-nowrap"
/> />
</RouterLink> </NuxtLink>
</div> </div>
</div> </div>
</template> </template>
@ -322,11 +361,10 @@ function continuar() {
v-for="s in servicosAutenticados" v-for="s in servicosAutenticados"
:key="s.titulo" :key="s.titulo"
v-bind="s" v-bind="s"
:require-auth="true" :requires-auth="true"
/> />
</div> </div>
<!-- CTA credenciamento -->
<div class="mt-10 bg-gradient-to-r from-primary/5 to-primary/10 dark:from-primary/10 dark:to-primary/15 border border-primary/15 dark:border-primary/20 rounded-2xl p-6 flex flex-col sm:flex-row items-center justify-between gap-5"> <div class="mt-10 bg-gradient-to-r from-primary/5 to-primary/10 dark:from-primary/10 dark:to-primary/15 border border-primary/15 dark:border-primary/20 rounded-2xl p-6 flex flex-col sm:flex-row items-center justify-between gap-5">
<div> <div>
<p class="font-bold text-slate-800 dark:text-slate-100 text-base">Ainda não tem acesso ao portal?</p> <p class="font-bold text-slate-800 dark:text-slate-100 text-base">Ainda não tem acesso ao portal?</p>
@ -334,9 +372,9 @@ function continuar() {
Solicite seu credenciamento e passe a gerenciar todos os seus tributos municipais de forma online. Solicite seu credenciamento e passe a gerenciar todos os seus tributos municipais de forma online.
</p> </p>
</div> </div>
<RouterLink :to="{ name: 'credenciamento' }"> <NuxtLink to="/credenciamento">
<Button label="Solicitar Credenciamento" icon="pi pi-user-plus" class="whitespace-nowrap" /> <Button label="Solicitar Credenciamento" icon="pi pi-user-plus" class="whitespace-nowrap" />
</RouterLink> </NuxtLink>
</div> </div>
</section> </section>
@ -344,7 +382,6 @@ function continuar() {
</template> </template>
<style scoped> <style scoped>
/* Remove as setas de navegação padrão do Carousel no contexto dos avisos */
.aviso-carousel :deep(.p-carousel-prev), .aviso-carousel :deep(.p-carousel-prev),
.aviso-carousel :deep(.p-carousel-next) { .aviso-carousel :deep(.p-carousel-next) {
width: 1.75rem; width: 1.75rem;

View File

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

View File

@ -0,0 +1,131 @@
<script setup>
import { ref, onMounted } from 'vue'
import { portalService } from '@/services/portalService'
definePageMeta({
layout: 'portal',
middleware: 'auth',
})
const router = useRouter()
const certidoes = ref([])
const carregando = ref(true)
const carregandoPdf = ref(null)
const mensagemErro = ref('')
onMounted(carregar)
async function carregar() {
carregando.value = true
mensagemErro.value = ''
try {
const res = await portalService.getCertidoes()
certidoes.value = res.data?.content ?? []
} catch (e) {
mensagemErro.value = e?.data?.description ?? 'Não foi possível carregar as certidões.'
} finally {
carregando.value = false
}
}
async function reemitir(cert) {
carregandoPdf.value = cert.id
try {
const buf = await portalService.reemitirCertidao(cert.id)
const url = URL.createObjectURL(new Blob([buf], { type: 'application/pdf' }))
const a = document.createElement('a')
a.href = url
a.download = `certidao-${cert.numero}.pdf`
a.click()
URL.revokeObjectURL(url)
} catch {
mensagemErro.value = 'Erro ao reemitir a certidão.'
} finally {
carregandoPdf.value = null
}
}
const statusMap = {
ATIVA: { label: 'Ativa', classe: 'bg-emerald-100 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-400' },
VENCIDA: { label: 'Vencida', classe: 'bg-slate-100 dark:bg-slate-700 text-slate-500 dark:text-slate-400' },
CANCELADA: { label: 'Cancelada', classe: 'bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400' },
}
</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">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>
</div>
<Button label="Nova certidão" icon="pi pi-plus" size="small" @click="router.push('/servicos/certidao')" />
</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-6 bg-slate-200 dark:bg-slate-700 rounded-full animate-pulse w-14" />
<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="certidoes.length === 0" class="p-12 text-center">
<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="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')" />
</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">Certidão</span>
<span class="hidden sm:block w-28 text-right">Emissão</span>
<span class="hidden sm:block w-28 text-right">Validade</span>
<span class="w-20 text-center">Status</span>
<span class="w-24" />
</div>
<div class="divide-y divide-slate-100 dark:divide-slate-700">
<div
v-for="cert in certidoes"
:key="cert.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">{{ cert.tipo }}</p>
<p class="text-xs text-slate-400 dark:text-slate-500 mt-0.5"> {{ cert.numero }}</p>
</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">{{ cert.dataValidade }}</p>
<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']">
{{ statusMap[cert.status]?.label ?? cert.status }}
</span>
</div>
<div class="w-24 flex justify-end">
<Button
icon="pi pi-download"
label="PDF"
size="small"
outlined
:loading="carregandoPdf === cert.id"
:disabled="cert.status === 'CANCELADA'"
@click="reemitir(cert)"
/>
</div>
</div>
</div>
</div>
</div>
</div>
</template>

191
src/pages/portal/dados.vue Normal file
View File

@ -0,0 +1,191 @@
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { portalService } from '@/services/portalService'
definePageMeta({
layout: 'portal',
middleware: 'auth',
})
const carregando = ref(true)
const salvando = ref(false)
const modoEditar = ref(false)
const mensagemErro = ref('')
const mensagemSucesso = ref('')
const dados = ref(null)
const contato = reactive({ email: '', telefone: '', whatsapp: false })
onMounted(async () => {
try {
const res = await portalService.getDadosCadastrais()
dados.value = res.data
contato.email = dados.value?.email ?? ''
contato.telefone = dados.value?.telefone ?? ''
contato.whatsapp = dados.value?.whatsapp ?? false
} catch (e) {
mensagemErro.value = e?.data?.description ?? 'Não foi possível carregar os dados cadastrais.'
} finally {
carregando.value = false
}
})
async function salvarContato() {
salvando.value = true
mensagemErro.value = ''
mensagemSucesso.value = ''
try {
await portalService.atualizarContato({
email: contato.email,
telefone: contato.telefone.replace(/\D/g, ''),
whatsapp: contato.whatsapp,
})
dados.value.email = contato.email
dados.value.telefone = contato.telefone
dados.value.whatsapp = contato.whatsapp
mensagemSucesso.value = 'Dados de contato atualizados com sucesso!'
modoEditar.value = false
} catch (e) {
mensagemErro.value = e?.data?.description ?? 'Erro ao salvar. Tente novamente.'
} finally {
salvando.value = false
}
}
function cancelarEdicao() {
contato.email = dados.value?.email ?? ''
contato.telefone = dados.value?.telefone ?? ''
contato.whatsapp = dados.value?.whatsapp ?? false
modoEditar.value = false
mensagemErro.value = ''
}
function formatarTelefone(e) {
const d = e.target.value.replace(/\D/g, '').slice(0, 11)
contato.telefone = d
.replace(/(\d{2})(\d)/, '($1) $2')
.replace(/(\d{5})(\d{1,4})$/, '$1-$2')
}
</script>
<template>
<div class="space-y-6 max-w-2xl">
<div>
<h1 class="text-2xl font-bold text-slate-800 dark:text-slate-100">Dados Cadastrais</h1>
<p class="text-sm text-slate-500 dark:text-slate-400 mt-0.5">Visualize seus dados e mantenha o contato atualizado.</p>
</div>
<div v-if="carregando" class="space-y-4">
<div v-for="i in 2" :key="i" class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-6 space-y-4">
<div class="h-4 bg-slate-200 dark:bg-slate-700 rounded animate-pulse w-1/4" />
<div class="space-y-3">
<div class="h-3.5 bg-slate-200 dark:bg-slate-700 rounded animate-pulse w-3/4" />
<div class="h-3.5 bg-slate-200 dark:bg-slate-700 rounded animate-pulse w-1/2" />
</div>
</div>
</div>
<template v-else-if="dados">
<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">Identificação</p>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<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>
</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-sm font-semibold text-slate-800 dark:text-slate-100 mt-0.5">{{ dados.nomeCompleto }}</p>
</div>
<div v-if="dados.nomeFantasia">
<p class="text-xs text-slate-400 dark:text-slate-500">Nome fantasia</p>
<p class="text-sm font-semibold text-slate-800 dark:text-slate-100 mt-0.5">{{ dados.nomeFantasia }}</p>
</div>
<div v-if="dados.dataNascimento">
<p class="text-xs text-slate-400 dark:text-slate-500">Data de nascimento</p>
<p class="text-sm font-semibold text-slate-800 dark:text-slate-100 mt-0.5">{{ dados.dataNascimento }}</p>
</div>
</div>
</div>
<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-sm text-slate-800 dark:text-slate-100">
{{ dados.endereco?.logradouro }}, {{ dados.endereco?.numero }}
<template v-if="dados.endereco?.complemento"> {{ dados.endereco.complemento }}</template>
</p>
<p class="text-sm text-slate-600 dark:text-slate-300 mt-0.5">
{{ dados.endereco?.bairro }} {{ dados.endereco?.cidade }}/{{ dados.endereco?.uf }}
</p>
<p class="text-xs text-slate-400 dark:text-slate-500 mt-1">CEP: {{ dados.endereco?.cep }}</p>
<p class="text-xs text-slate-400 dark:text-slate-500 mt-3">
Para alterar o endereço, compareça ao setor de atendimento da Prefeitura.
</p>
</div>
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-6">
<div class="flex items-center justify-between mb-4">
<p class="text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-wide">Contato</p>
<Button
v-if="!modoEditar"
label="Editar"
icon="pi pi-pencil"
size="small"
text
@click="modoEditar = true"
/>
</div>
<div v-if="!modoEditar" class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<p class="text-xs text-slate-400 dark:text-slate-500">E-mail</p>
<p class="text-sm font-semibold text-slate-800 dark:text-slate-100 mt-0.5">{{ dados.email }}</p>
</div>
<div>
<p class="text-xs text-slate-400 dark:text-slate-500">Telefone</p>
<p class="text-sm font-semibold text-slate-800 dark:text-slate-100 mt-0.5">
{{ dados.telefone }}
<span v-if="dados.whatsapp" class="ml-1.5 text-xs text-emerald-600 dark:text-emerald-400 font-normal">(WhatsApp)</span>
</p>
</div>
</div>
<div v-else class="space-y-4">
<div>
<label class="block text-sm font-semibold text-slate-700 dark:text-slate-200 mb-1.5">E-mail</label>
<InputText v-model="contato.email" type="email" class="w-full" size="large" />
</div>
<div>
<label class="block text-sm font-semibold text-slate-700 dark:text-slate-200 mb-1.5">Telefone / Celular</label>
<InputText :value="contato.telefone" class="w-full" size="large" inputmode="numeric" @input="formatarTelefone" />
</div>
<div class="flex items-center gap-3 p-4 bg-slate-50 dark:bg-slate-700/50 rounded-xl">
<Checkbox v-model="contato.whatsapp" :binary="true" input-id="whatsapp-dados" />
<label for="whatsapp-dados" class="text-sm text-slate-700 dark:text-slate-200 cursor-pointer">
Este número também recebe WhatsApp
</label>
</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="Cancelar" severity="secondary" outlined class="flex-1" @click="cancelarEdicao" />
<Button label="Salvar" icon="pi pi-check" class="flex-1" :loading="salvando" @click="salvarContato" />
</div>
</div>
<p v-if="mensagemSucesso && !modoEditar" role="status" class="mt-4 text-sm text-emerald-600 dark:text-emerald-400 flex items-center gap-1.5">
<i class="pi pi-check-circle" aria-hidden="true" /> {{ mensagemSucesso }}
</p>
</div>
</template>
<div v-else-if="mensagemErro" class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-8 text-center">
<p class="text-sm text-slate-600 dark:text-slate-300">{{ mensagemErro }}</p>
</div>
</div>
</template>

View File

@ -0,0 +1,233 @@
<script setup>
import { ref, computed, onMounted } from 'vue'
import { portalService } from '@/services/portalService'
definePageMeta({
layout: 'portal',
middleware: 'auth',
})
const debitos = ref([])
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 statusDisponiveis = [
{ value: 'VENCIDO', label: 'Vencido', classe: 'bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400' },
{ value: 'A_VENCER', label: 'A vencer', classe: 'bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400' },
{ value: 'PARCELADO', label: 'Parcelado', classe: 'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400' },
]
const statusClasse = Object.fromEntries(statusDisponiveis.map(s => [s.value, s.classe]))
const statusLabel = Object.fromEntries(statusDisponiveis.map(s => [s.value, s.label]))
onMounted(() => carregar())
async function carregar() {
carregando.value = true
mensagemErro.value = ''
try {
const params = {}
if (filtroTipo.value) params.tipo = filtroTipo.value
if (filtroStatus.value) params.status = filtroStatus.value
const res = await portalService.getDebitos(params)
debitos.value = res.data?.content ?? []
} catch (e) {
mensagemErro.value = e?.data?.description ?? 'Não foi possível carregar os débitos.'
} finally {
carregando.value = false
}
}
async function emitirGuia(debito) {
carregandoGuia.value = debito.id
try {
const buf = await portalService.emitirGuia(debito.id)
const url = URL.createObjectURL(new Blob([buf], { type: 'application/pdf' }))
const a = document.createElement('a')
a.href = url
a.download = `guia-${debito.id}.pdf`
a.click()
URL.revokeObjectURL(url)
} catch {
mensagemErro.value = 'Erro ao gerar a guia. Tente novamente.'
} finally {
carregandoGuia.value = null
}
}
const totalSelecionado = computed(() =>
debitos.value
.filter(d => d._selecionado)
.reduce((sum, d) => sum + (d.valorAtualizado ?? d.valor), 0)
)
const temSelecionados = computed(() => debitos.value.some(d => d._selecionado))
function toggleTodos(val) {
debitos.value.forEach(d => (d._selecionado = val))
}
function formatarMoeda(valor) {
return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(valor ?? 0)
}
function aplicarFiltro() {
carregar()
}
function limparFiltros() {
filtroTipo.value = null
filtroStatus.value = null
carregar()
}
</script>
<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">Débitos e Guias</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>
</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="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">Tipo</label>
<Select
v-model="filtroTipo"
:options="tiposDisponiveis"
placeholder="Todos"
show-clear
class="w-full"
size="small"
/>
</div>
<div class="flex-1 min-w-[160px]">
<label class="block text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wide mb-1.5">Status</label>
<Select
v-model="filtroStatus"
:options="statusDisponiveis"
option-label="label"
option-value="value"
placeholder="Todos"
show-clear
class="w-full"
size="small"
/>
</div>
<div class="flex gap-2">
<Button label="Filtrar" icon="pi pi-filter" size="small" @click="aplicarFiltro" />
<Button label="Limpar" severity="secondary" size="small" outlined @click="limparFiltros" />
</div>
</div>
<Transition
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="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 v-else-if="debitos.length === 0 && !mensagemErro" class="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" />
<p class="font-semibold text-slate-700 dark:text-slate-200">Nenhum débito encontrado</p>
<p class="text-sm text-slate-400 dark:text-slate-500 mt-1">Sua situação fiscal está regularizada.</p>
</div>
<div v-else-if="mensagemErro" class="p-8 text-center">
<i class="pi pi-exclamation-triangle text-amber-400 text-3xl mb-3 block" aria-hidden="true" />
<p class="text-sm text-slate-600 dark:text-slate-300">{{ mensagemErro }}</p>
<Button label="Tentar novamente" severity="secondary" size="small" class="mt-4" @click="carregar" />
</div>
<div v-else>
<div class="flex items-center gap-4 px-5 py-3 bg-slate-50 dark:bg-slate-700/50 border-b border-slate-200 dark:border-slate-700 text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wide">
<Checkbox :binary="true" @change="toggleTodos($event.target.checked)" />
<span class="flex-1">Descrição</span>
<span class="hidden sm:block w-28 text-right">Vencimento</span>
<span class="w-28 text-right">Valor</span>
<span class="w-20 text-center">Status</span>
<span class="w-28" />
</div>
<div class="divide-y divide-slate-100 dark:divide-slate-700">
<div
v-for="debito in debitos"
:key="debito.id"
class="flex items-center gap-4 px-5 py-4 hover:bg-slate-50 dark:hover:bg-slate-700/30 transition-colors"
>
<Checkbox v-model="debito._selecionado" :binary="true" />
<div class="flex-1 min-w-0">
<p class="text-sm font-semibold text-slate-800 dark:text-slate-100 truncate">{{ debito.descricao }}</p>
<p class="text-xs text-slate-400 dark:text-slate-500 mt-0.5">{{ debito.tipo }} · Ref: {{ debito.referencia }}</p>
</div>
<p class="hidden sm:block text-sm text-slate-500 dark:text-slate-400 w-28 text-right whitespace-nowrap">
{{ debito.vencimento }}
</p>
<div class="w-28 text-right">
<p class="text-sm font-bold text-slate-800 dark:text-slate-100">{{ formatarMoeda(debito.valorAtualizado ?? debito.valor) }}</p>
<p v-if="debito.valorAtualizado > debito.valor" class="text-xs text-slate-400 line-through">{{ formatarMoeda(debito.valor) }}</p>
</div>
<div class="w-20 flex justify-center">
<span
:class="['text-xs font-semibold px-2 py-1 rounded-full whitespace-nowrap', statusClasse[debito.status] ?? 'bg-slate-100 dark:bg-slate-700 text-slate-600 dark:text-slate-300']"
>
{{ statusLabel[debito.status] ?? debito.status }}
</span>
</div>
<div class="w-28 flex justify-end">
<Button
label="Emitir guia"
icon="pi pi-download"
size="small"
outlined
class="whitespace-nowrap"
:loading="carregandoGuia === debito.id"
@click="emitirGuia(debito)"
/>
</div>
</div>
</div>
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,147 @@
<script setup>
import { ref, onMounted } from 'vue'
import { portalService } from '@/services/portalService'
definePageMeta({
layout: 'portal',
middleware: 'auth',
})
const pagamentos = ref([])
const carregando = ref(true)
const carregandoComprovante = ref(null)
const mensagemErro = ref('')
const filtroAno = ref(new Date().getFullYear())
const anosDisponiveis = Array.from({ length: 5 }, (_, i) => new Date().getFullYear() - i)
onMounted(() => carregar())
async function carregar() {
carregando.value = true
mensagemErro.value = ''
try {
const res = await portalService.getPagamentos({ ano: filtroAno.value })
pagamentos.value = res.data?.content ?? []
} catch (e) {
mensagemErro.value = e?.data?.description ?? 'Não foi possível carregar os pagamentos.'
} finally {
carregando.value = false
}
}
async function baixarComprovante(pag) {
carregandoComprovante.value = pag.id
try {
const buf = await portalService.getComprovante(pag.id)
const url = URL.createObjectURL(new Blob([buf], { type: 'application/pdf' }))
const a = document.createElement('a')
a.href = url
a.download = `comprovante-${pag.id}.pdf`
a.click()
URL.revokeObjectURL(url)
} catch {
mensagemErro.value = 'Erro ao baixar o comprovante.'
} finally {
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>
<template>
<div class="space-y-6">
<div>
<h1 class="text-2xl font-bold text-slate-800 dark:text-slate-100">Histórico de Pagamentos</h1>
<p class="text-sm text-slate-500 dark:text-slate-400 mt-0.5">Todos os seus pagamentos com comprovantes para download.</p>
</div>
<div class="flex gap-2 flex-wrap">
<button
v-for="ano in anosDisponiveis"
:key="ano"
:class="['px-4 py-2 rounded-lg text-sm font-semibold border transition-colors', filtroAno === ano ? 'bg-primary text-white border-primary' : 'bg-white dark:bg-slate-800 text-slate-600 dark:text-slate-300 border-slate-200 dark:border-slate-700 hover:border-slate-300']"
@click="filtroAno = ano; carregar()"
>
{{ ano }}
</button>
</div>
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 overflow-hidden">
<div v-if="carregando" class="divide-y divide-slate-100 dark:divide-slate-700">
<div v-for="i in 5" :key="i" class="p-5 flex items-center gap-4">
<div class="flex-1 space-y-2">
<div class="h-3.5 bg-slate-200 dark:bg-slate-700 rounded animate-pulse w-2/5" />
<div class="h-3 bg-slate-200 dark:bg-slate-700 rounded animate-pulse w-1/4" />
</div>
<div class="h-4 bg-slate-200 dark:bg-slate-700 rounded animate-pulse w-20" />
<div class="h-8 bg-slate-200 dark:bg-slate-700 rounded-lg animate-pulse w-28" />
</div>
</div>
<div 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="pagamentos.length === 0" class="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 em {{ filtroAno }}</p>
<p class="text-sm text-slate-400 dark:text-slate-500 mt-1">Pagamentos realizados aparecerão aqui.</p>
</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">Descrição</span>
<span class="hidden sm:block w-28 text-right">Data</span>
<span class="hidden sm:block w-24 text-center">Forma</span>
<span class="w-28 text-right">Valor</span>
<span class="w-28" />
</div>
<div class="divide-y divide-slate-100 dark:divide-slate-700">
<div
v-for="pag in pagamentos"
:key="pag.id"
class="flex items-center gap-4 px-5 py-4 hover:bg-slate-50 dark:hover:bg-slate-700/30 transition-colors"
>
<div class="flex-1 min-w-0">
<p class="text-sm font-semibold text-slate-800 dark:text-slate-100 truncate">{{ pag.descricao }}</p>
<p class="text-xs text-slate-400 dark:text-slate-500 mt-0.5">Ref: {{ pag.referencia }}</p>
</div>
<p class="hidden sm:block text-sm text-slate-500 dark:text-slate-400 w-28 text-right whitespace-nowrap">{{ pag.dataPagamento }}</p>
<div class="hidden sm:flex w-24 justify-center">
<span class="inline-flex items-center gap-1.5 text-xs text-slate-500 dark:text-slate-400 bg-slate-100 dark:bg-slate-700 px-2 py-1 rounded-full">
<i :class="['pi', formaPagMap[pag.formaPagamento]?.icone ?? 'pi-circle', 'text-xs']" aria-hidden="true" />
{{ formaPagMap[pag.formaPagamento]?.label ?? pag.formaPagamento }}
</span>
</div>
<p class="text-sm font-bold text-emerald-600 dark:text-emerald-400 w-28 text-right whitespace-nowrap">
{{ formatarMoeda(pag.valor) }}
</p>
<div class="w-28 flex justify-end">
<Button
icon="pi pi-download"
label="Comprovante"
size="small"
text
:loading="carregandoComprovante === pag.id"
@click="baixarComprovante(pag)"
/>
</div>
</div>
</div>
</div>
</div>
</div>
</template>

187
src/pages/portal/painel.vue Normal file
View File

@ -0,0 +1,187 @@
<script setup>
import { ref, onMounted } from 'vue'
import { useAuth } from '@/composables/useAuth'
import { portalService } from '@/services/portalService'
definePageMeta({
layout: 'portal',
middleware: 'auth',
})
const { nomeUsuario } = useAuth()
const router = useRouter()
const resumo = ref(null)
const atividades = ref([])
const carregando = ref(true)
onMounted(async () => {
try {
const [resResumo, resAtividades] = await Promise.all([
portalService.getPainelResumo(),
portalService.getAtividades(),
])
resumo.value = resResumo.data
atividades.value = resAtividades.data?.content ?? []
} catch {
// silencioso exibe zeros
} finally {
carregando.value = false
}
})
const acesRapidos = [
{ icon: 'pi-receipt', label: 'Emitir Guia', to: '/portal/debitos', cor: 'text-primary' },
{ icon: 'pi-file-check', label: 'Nova Certidão', to: '/portal/certidoes', cor: 'text-emerald-600 dark:text-emerald-400' },
{ icon: 'pi-briefcase', label: 'Acompanhar Alvará', to: '/portal/alvaras', cor: 'text-amber-600 dark:text-amber-400' },
{ icon: 'pi-user', label: 'Meus Dados', to: '/portal/dados', cor: 'text-violet-600 dark:text-violet-400' },
]
function formatarMoeda(valor) {
return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(valor ?? 0)
}
const iconeAtividade = {
DEBITO: 'pi-receipt',
CERTIDAO: 'pi-file-check',
ALVARA: 'pi-briefcase',
PAGAMENTO: 'pi-credit-card',
CADASTRO: 'pi-user',
}
</script>
<template>
<div class="space-y-8">
<div>
<h1 class="text-2xl font-bold text-slate-800 dark:text-slate-100">
Olá, {{ nomeUsuario || 'Contribuinte' }} 👋
</h1>
<p class="text-slate-500 dark:text-slate-400 mt-1">Bem-vindo ao seu painel de gestão fiscal.</p>
</div>
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4">
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-5 space-y-3">
<div class="w-10 h-10 bg-red-50 dark:bg-red-900/20 rounded-lg flex items-center justify-center">
<i class="pi pi-receipt text-red-600 dark:text-red-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-12 h-7 bg-slate-200 dark:bg-slate-700 rounded animate-pulse" />
<template v-else>{{ formatarMoeda(resumo?.totalDebitos) }}</template>
</p>
<p class="text-xs text-slate-500 dark:text-slate-400 mt-0.5">Débitos em aberto</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="w-10 h-10 bg-emerald-50 dark:bg-emerald-900/20 rounded-lg flex items-center justify-center">
<i class="pi pi-file-check text-emerald-600 dark:text-emerald-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?.certidoesAtivas ?? 0 }}</template>
</p>
<p class="text-xs text-slate-500 dark:text-slate-400 mt-0.5">Certidões ativas</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="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="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" />
</div>
<div>
<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" />
<template v-else>{{ formatarMoeda(resumo?.ultimoPagamento) }}</template>
</p>
<p class="text-xs text-slate-500 dark:text-slate-400 mt-0.5">Último pagamento</p>
</div>
</div>
</div>
<div
v-if="!carregando && resumo?.debitosVencidos > 0"
class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-700/50 rounded-xl p-4 flex items-center gap-4"
>
<i class="pi pi-exclamation-triangle text-red-600 dark:text-red-400 text-xl flex-shrink-0" aria-hidden="true" />
<div class="flex-1">
<p class="font-semibold text-red-800 dark:text-red-300 text-sm">
{{ resumo.debitosVencidos }} débito{{ resumo.debitosVencidos > 1 ? 's' : '' }} vencido{{ resumo.debitosVencidos > 1 ? 's' : '' }}
</p>
<p class="text-xs text-red-600 dark:text-red-400 mt-0.5">Regularize para evitar juros e negativação.</p>
</div>
<Button label="Ver débitos" size="small" severity="danger" @click="router.push('/portal/debitos')" />
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-6">
<p class="text-sm font-bold text-slate-700 dark:text-slate-200 mb-4">Acesso rápido</p>
<div class="grid grid-cols-2 gap-3">
<button
v-for="a in acesRapidos"
:key="a.label"
class="flex flex-col items-center gap-2 p-4 rounded-xl border border-slate-100 dark:border-slate-700 hover:border-primary/40 hover:bg-primary/5 dark:hover:bg-primary/10 transition-colors group"
@click="router.push(a.to)"
>
<i :class="['pi', a.icon, a.cor, 'text-xl']" aria-hidden="true" />
<span class="text-xs font-semibold text-slate-600 dark:text-slate-300 text-center leading-tight group-hover:text-primary transition-colors">{{ a.label }}</span>
</button>
</div>
</div>
<div class="lg:col-span-2 bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-6">
<p class="text-sm font-bold text-slate-700 dark:text-slate-200 mb-4">Atividade recente</p>
<div v-if="carregando" class="space-y-3">
<div v-for="i in 4" :key="i" class="flex gap-3 items-center">
<div class="w-8 h-8 bg-slate-200 dark:bg-slate-700 rounded-lg animate-pulse flex-shrink-0" />
<div class="flex-1 space-y-1.5">
<div class="h-3 bg-slate-200 dark:bg-slate-700 rounded animate-pulse w-3/4" />
<div class="h-2.5 bg-slate-200 dark:bg-slate-700 rounded animate-pulse w-1/2" />
</div>
</div>
</div>
<div v-else-if="atividades.length === 0" class="text-center py-6">
<i class="pi pi-inbox text-slate-300 dark:text-slate-600 text-3xl mb-2 block" aria-hidden="true" />
<p class="text-sm text-slate-400 dark:text-slate-500">Nenhuma atividade registrada.</p>
</div>
<div v-else class="space-y-1">
<div
v-for="(ativ, i) in atividades"
:key="i"
class="flex items-center gap-3 py-3 border-b border-slate-100 dark:border-slate-700 last:border-0"
>
<div class="w-8 h-8 bg-primary/8 dark:bg-primary/15 rounded-lg flex items-center justify-center flex-shrink-0">
<i :class="['pi', iconeAtividade[ativ.tipo] ?? 'pi-circle', 'text-primary text-sm']" aria-hidden="true" />
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-slate-800 dark:text-slate-100 truncate">{{ ativ.descricao }}</p>
<p class="text-xs text-slate-400 dark:text-slate-500 mt-0.5">{{ ativ.data }}</p>
</div>
</div>
</div>
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,258 @@
<script setup>
import { ref, computed } from 'vue'
import { useAuth } from '@/composables/useAuth'
import { primeiroAcessoService } from '@/services/primeiroAcessoService'
const router = useRouter()
const { login } = useAuth()
const etapa = ref(0)
const carregando = ref(false)
const erro = ref('')
const documento = ref('')
const contribuinteNome = 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 docValido = computed(() => docDigitos.value.length === 11 || docDigitos.value.length === 14)
const senhaForte = computed(() => senha.value.length >= 8)
const senhasIguais = computed(() => senha.value === senhaConfirm.value)
async function identificar() {
if (!docValido.value) return
carregando.value = true
erro.value = ''
try {
const res = await primeiroAcessoService.verificarDocumento(docDigitos.value)
contribuinteNome.value = res.data.nome
canais.value = res.data.canais
canalSelecionado.value = canais.value[0] ?? null
etapa.value = 1
} catch (e) {
erro.value = e?.data?.description ?? 'Documento não encontrado ou não habilitado para este fluxo.'
} finally {
carregando.value = false
}
}
async function enviarCodigo() {
if (!canalSelecionado.value) return
carregando.value = true
erro.value = ''
try {
await primeiroAcessoService.solicitarCodigo(docDigitos.value, canalSelecionado.value.tipo)
etapa.value = 2
} catch (e) {
erro.value = e?.data?.description ?? 'Não foi possível enviar o código. Tente novamente.'
} finally {
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>
<template>
<div class="min-h-[calc(100vh-8rem)] flex items-center justify-center px-4 py-12">
<div class="w-full max-w-md">
<div v-if="etapa < 4" class="flex items-center justify-center gap-2 mb-8">
<div
v-for="i in 4"
:key="i"
class="h-1.5 rounded-full transition-all duration-300"
:class="[
i - 1 <= etapa ? 'bg-primary' : 'bg-slate-200 dark:bg-slate-700',
i - 1 === etapa ? 'w-8' : 'w-4',
]"
/>
</div>
<div class="bg-white dark:bg-slate-800 rounded-2xl shadow-lg border border-slate-200 dark:border-slate-700 overflow-hidden">
<div class="bg-gradient-to-r from-primary to-primary-700 px-8 py-6">
<div class="flex items-center gap-3">
<div class="w-10 h-10 bg-white/20 rounded-xl flex items-center justify-center" aria-hidden="true">
<i class="pi pi-key text-white text-lg" />
</div>
<div>
<h1 class="text-white font-bold text-base leading-tight">Primeiro Acesso</h1>
<p class="text-white/80 text-xs mt-0.5">
{{ ['Identificação', 'Canal de envio', 'Código de verificação', 'Crie sua senha', 'Pronto!'][etapa] }}
</p>
</div>
</div>
</div>
<div class="px-8 py-8 space-y-6">
<template v-if="etapa === 0">
<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.
</p>
<div>
<label class="block text-sm font-semibold text-slate-700 dark:text-slate-200 mb-1.5">CPF ou CNPJ</label>
<DocumentoInput v-model="documento" @keyup.enter="identificar" />
</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="Continuar" icon="pi pi-arrow-right" icon-pos="right" class="w-full" size="large" :loading="carregando" :disabled="!docValido" @click="identificar" />
</template>
<template v-else-if="etapa === 1">
<div>
<p class="text-sm text-slate-600 dark:text-slate-300 mb-1">Olá, <strong class="text-slate-800 dark:text-slate-100">{{ contribuinteNome }}</strong>.</p>
<p class="text-sm text-slate-600 dark:text-slate-300">
Enviaremos um código de verificação. Escolha como prefere receber:
</p>
</div>
<div class="space-y-2">
<button
v-for="canal in canais"
:key="canal.tipo"
class="w-full flex items-center gap-3 p-4 rounded-xl border transition-colors text-left"
:class="canalSelecionado?.tipo === canal.tipo
? 'border-primary bg-primary/5 dark:bg-primary/10'
: 'border-slate-200 dark:border-slate-600 hover:border-slate-300 dark:hover:border-slate-500'"
@click="canalSelecionado = canal"
>
<div class="w-9 h-9 rounded-lg flex items-center justify-center flex-shrink-0"
:class="canalSelecionado?.tipo === canal.tipo ? 'bg-primary text-white' : 'bg-slate-100 dark:bg-slate-700 text-slate-500 dark:text-slate-400'">
<i :class="['pi', iconeCanal[canal.tipo] ?? 'pi-send', 'text-sm']" aria-hidden="true" />
</div>
<div>
<p class="font-semibold text-slate-800 dark:text-slate-100 text-sm">{{ labelCanal[canal.tipo] ?? canal.tipo }}</p>
<p class="text-xs text-slate-500 dark:text-slate-400">{{ canal.valor }}</p>
</div>
</button>
</div>
<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>
<div class="flex gap-3">
<Button label="Voltar" severity="secondary" outlined class="flex-1" @click="etapa = 0" />
<Button label="Enviar código" icon="pi pi-send" class="flex-1" :loading="carregando" :disabled="!canalSelecionado" @click="enviarCodigo" />
</div>
</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>
<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">
<i class="pi pi-check-circle text-emerald-600 dark:text-emerald-400 text-3xl" aria-hidden="true" />
</div>
<h2 class="text-lg font-bold text-slate-800 dark:text-slate-100 mb-2">Senha criada com sucesso!</h2>
<p class="text-sm text-slate-500 dark:text-slate-400">
Você pode acessar o portal com seu CPF/CNPJ e a nova senha.
</p>
</div>
<Button label="Ir para o login" icon="pi pi-sign-in" class="w-full" size="large" :loading="carregando" @click="entrarKeycloak" />
</template>
</div>
</div>
<div v-if="etapa < 4" class="text-center mt-4">
<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"
@click="router.push('/')"
>
<i class="pi pi-arrow-left text-xs" aria-hidden="true" />
Voltar à página inicial
</button>
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,210 @@
<script setup>
import { ref, computed } from 'vue'
import { useAuth } from '@/composables/useAuth'
import { certidaoService } from '@/services/certidaoService'
const router = useRouter()
const { isAuthenticated, documento: docUsuarioLogado, nomeUsuario } = useAuth()
const documento = ref(isAuthenticated.value ? docUsuarioLogado.value : '')
const tipoCertidao = ref('negativa')
const etapa = ref('formulario')
const carregandoConsulta = ref(false)
const carregandoEmissao = ref(false)
const resultado = ref(null)
const mensagemErro = ref('')
const tiposCertidao = [
{ value: 'negativa', label: 'Certidão Negativa', descricao: 'Confirma que não há débitos pendentes.' },
{ value: 'positiva_efeitos_negativa', label: 'Positiva com Efeitos de Negativa', descricao: 'Débitos com parcelamento em dia ou com exigibilidade suspensa.' },
{ value: 'positiva', label: 'Certidão Positiva', descricao: 'Confirma a existência de débitos.' },
]
const docValido = computed(() => {
const d = documento.value.replace(/\D/g, '')
return d.length === 11 || d.length === 14
})
async function consultar() {
if (!docValido.value) return
carregandoConsulta.value = true
mensagemErro.value = ''
try {
const res = await certidaoService.consultar(documento.value)
resultado.value = res.data
etapa.value = 'resultado'
} catch (e) {
mensagemErro.value = e?.data?.description ?? 'Não foi possível consultar a situação fiscal. Tente novamente.'
} finally {
carregandoConsulta.value = false
}
}
async function emitir() {
carregandoEmissao.value = true
try {
const buf = await certidaoService.emitir(documento.value, tipoCertidao.value)
const url = URL.createObjectURL(new Blob([buf], { type: 'application/pdf' }))
const a = document.createElement('a')
a.href = url
a.download = `certidao-${tipoCertidao.value}-${documento.value}.pdf`
a.click()
URL.revokeObjectURL(url)
} catch {
mensagemErro.value = 'Erro ao gerar o PDF. Tente novamente.'
} finally {
carregandoEmissao.value = false
}
}
function reiniciar() {
documento.value = ''
resultado.value = null
mensagemErro.value = ''
etapa.value = 'formulario'
}
</script>
<template>
<div class="max-w-2xl mx-auto px-4 sm:px-6 py-12">
<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"
@click="router.push('/servicos')"
>
<i class="pi pi-arrow-left text-xs" aria-hidden="true" />
Voltar aos serviços
</button>
<div class="flex items-center gap-4 mb-8">
<div class="w-12 h-12 bg-primary/10 dark:bg-primary/20 rounded-xl flex items-center justify-center flex-shrink-0">
<i class="pi pi-file-check text-primary text-xl" aria-hidden="true" />
</div>
<div>
<h1 class="text-2xl font-bold text-slate-800 dark:text-slate-100">Emissão de Certidão</h1>
<p class="text-sm text-slate-500 dark:text-slate-400 mt-0.5">Consulte sua situação fiscal e emita a certidão em PDF.</p>
</div>
</div>
<div v-if="etapa === 'formulario'" class="bg-white dark:bg-slate-800 rounded-2xl border border-slate-200 dark:border-slate-700 p-8 space-y-6">
<div>
<label class="block text-sm font-semibold text-slate-700 dark:text-slate-200 mb-1.5">
CPF ou CNPJ do contribuinte
</label>
<DocumentoInput
v-model="documento"
:disabled="isAuthenticated"
@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">
<i class="pi pi-info-circle text-xs text-primary" aria-hidden="true" />
Logado como <strong class="text-slate-700 dark:text-slate-200">{{ nomeUsuario }}</strong> documento bloqueado para segurança.
</p>
</div>
<div>
<p class="text-sm font-semibold text-slate-700 dark:text-slate-200 mb-3">Tipo de certidão</p>
<div class="space-y-2">
<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>
</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>
<Button
label="Consultar situação fiscal"
icon="pi pi-search"
class="w-full"
size="large"
:loading="carregandoConsulta"
:disabled="!docValido"
@click="consultar"
/>
</div>
<div v-else-if="etapa === 'resultado'" class="space-y-4">
<div
class="bg-white dark:bg-slate-800 rounded-2xl border p-6"
:class="resultado?.situacao === 'NEGATIVA'
? 'border-emerald-200 dark:border-emerald-700/50'
: 'border-amber-200 dark:border-amber-700/50'"
>
<div class="flex items-center gap-4">
<div
class="w-12 h-12 rounded-xl flex items-center justify-center flex-shrink-0"
:class="resultado?.situacao === 'NEGATIVA'
? 'bg-emerald-100 dark:bg-emerald-900/30'
: 'bg-amber-100 dark:bg-amber-900/30'"
>
<i
:class="resultado?.situacao === 'NEGATIVA'
? 'pi pi-check-circle text-emerald-600 dark:text-emerald-400 text-xl'
: 'pi pi-exclamation-triangle text-amber-600 dark:text-amber-400 text-xl'"
aria-hidden="true"
/>
</div>
<div>
<p class="font-bold text-slate-800 dark:text-slate-100">
{{ resultado?.situacao === 'NEGATIVA' ? 'Nada consta' : 'Existem pendências' }}
</p>
<p class="text-sm text-slate-500 dark:text-slate-400 mt-0.5">
{{ resultado?.nomeContribuinte ?? documento }}
</p>
</div>
</div>
</div>
<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="font-semibold text-slate-800 dark:text-slate-100">
{{ tiposCertidao.find(t => t.value === tipoCertidao)?.label }}
</p>
<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>
</p>
</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="Emitir PDF"
icon="pi pi-download"
class="flex-1"
:loading="carregandoEmissao"
@click="emitir"
/>
</div>
</div>
</div>
</template>

View File

@ -1,12 +1,9 @@
<script setup> <script setup>
import { useRouter } from 'vue-router'
import ServiceCard from '@/components/common/ServiceCard.vue'
const router = useRouter() const router = useRouter()
const servicos = [ const servicos = [
{ icon: 'pi-file-check', titulo: 'Emissão de Certidão', descricao: 'Emita certidões de situação fiscal.', to: { name: 'certidao' } }, { icon: 'pi-file-check', titulo: 'Emissão de Certidão', descricao: 'Emita certidões de situação fiscal.', to: '/servicos/certidao' },
{ icon: 'pi-home', titulo: 'IPTU — Débitos e Carnê', descricao: 'Consulte débitos e imprima o carnê.', to: { name: 'iptu' } }, { icon: 'pi-home', titulo: 'IPTU — Débitos e Carnê', descricao: 'Consulte débitos e imprima o carnê.', to: '/servicos/iptu' },
{ icon: 'pi-verified', titulo: 'Validar Documento', descricao: 'Valide certidões e documentos emitidos.' }, { icon: 'pi-verified', titulo: 'Validar Documento', descricao: 'Valide certidões e documentos emitidos.' },
{ icon: 'pi-map-marker', titulo: 'Consulta Cadastral', descricao: 'Consulte dados cadastrais do imóvel ou empresa.' }, { icon: 'pi-map-marker', titulo: 'Consulta Cadastral', descricao: 'Consulte dados cadastrais do imóvel ou empresa.' },
{ icon: 'pi-building', titulo: 'Alvará — Consulta Pública', descricao: 'Situação de alvarás pelo número do processo.' }, { icon: 'pi-building', titulo: 'Alvará — Consulta Pública', descricao: 'Situação de alvarás pelo número do processo.' },
@ -16,10 +13,9 @@ const servicos = [
<template> <template>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12"> <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<!-- Voltar -->
<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-6 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-6 py-1"
@click="router.push({ name: 'home' })" @click="router.push('/')"
> >
<i class="pi pi-arrow-left text-xs" aria-hidden="true" /> <i class="pi pi-arrow-left text-xs" aria-hidden="true" />
Voltar Voltar

271
src/pages/servicos/iptu.vue Normal file
View File

@ -0,0 +1,271 @@
<script setup>
import { ref } from 'vue'
import { useAuth } from '@/composables/useAuth'
import { iptuService } from '@/services/iptuService'
const router = useRouter()
const { isAuthenticated, documento: docUsuarioLogado, nomeUsuario } = useAuth()
const modoConsulta = ref('documento')
const documento = ref(isAuthenticated.value ? docUsuarioLogado.value : '')
const inscricao = ref('')
const etapa = ref('formulario')
const carregando = ref(false)
const carregandoPdf = ref(null)
const imoveis = ref([])
const imovelSelecionado = ref(null)
const mensagemErro = ref('')
const exercicioAtual = new Date().getFullYear()
async function consultar() {
carregando.value = true
mensagemErro.value = ''
imoveis.value = []
imovelSelecionado.value = null
try {
const res = modoConsulta.value === 'documento'
? await iptuService.consultarPorDocumento(documento.value)
: await iptuService.consultarPorInscricao(inscricao.value)
imoveis.value = res.data ?? []
if (imoveis.value.length === 1) imovelSelecionado.value = imoveis.value[0]
etapa.value = 'resultado'
} catch (e) {
mensagemErro.value = e?.data?.description ?? 'Não foi possível localizar os imóveis. Verifique os dados e tente novamente.'
} finally {
carregando.value = false
}
}
async function emitirCarne(imovel) {
carregandoPdf.value = `carne-${imovel.inscricaoImobiliaria}`
try {
const buf = await iptuService.emitirCarne(imovel.inscricaoImobiliaria, exercicioAtual)
const url = URL.createObjectURL(new Blob([buf], { type: 'application/pdf' }))
const a = document.createElement('a')
a.href = url
a.download = `carne-iptu-${imovel.inscricaoImobiliaria}-${exercicioAtual}.pdf`
a.click()
URL.revokeObjectURL(url)
} catch {
mensagemErro.value = 'Erro ao gerar o carnê. Tente novamente.'
} finally {
carregandoPdf.value = null
}
}
async function emitirBoleto(debito) {
carregandoPdf.value = `boleto-${debito.id}`
try {
const buf = await iptuService.emitirBoleto(debito.id)
const url = URL.createObjectURL(new Blob([buf], { type: 'application/pdf' }))
const a = document.createElement('a')
a.href = url
a.download = `boleto-iptu-${debito.id}.pdf`
a.click()
URL.revokeObjectURL(url)
} catch {
mensagemErro.value = 'Erro ao gerar o boleto. Tente novamente.'
} finally {
carregandoPdf.value = null
}
}
function reiniciar() {
documento.value = ''
inscricao.value = ''
imoveis.value = []
imovelSelecionado.value = null
mensagemErro.value = ''
etapa.value = 'formulario'
}
function formatarMoeda(valor) {
return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(valor ?? 0)
}
</script>
<template>
<div class="max-w-2xl mx-auto px-4 sm:px-6 py-12">
<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"
@click="router.push('/servicos')"
>
<i class="pi pi-arrow-left text-xs" aria-hidden="true" />
Voltar aos serviços
</button>
<div class="flex items-center gap-4 mb-8">
<div class="w-12 h-12 bg-primary/10 dark:bg-primary/20 rounded-xl flex items-center justify-center flex-shrink-0">
<i class="pi pi-home text-primary text-xl" aria-hidden="true" />
</div>
<div>
<h1 class="text-2xl font-bold text-slate-800 dark:text-slate-100">IPTU Débitos e Carnê</h1>
<p class="text-sm text-slate-500 dark:text-slate-400 mt-0.5">Consulte débitos do imóvel e imprima o carnê ou boleto avulso.</p>
</div>
</div>
<div v-if="etapa === 'formulario'" class="bg-white dark:bg-slate-800 rounded-2xl border border-slate-200 dark:border-slate-700 p-8 space-y-6">
<div>
<p class="text-sm font-semibold text-slate-700 dark:text-slate-200 mb-3">Buscar por</p>
<div class="flex gap-3">
<button
:class="[
'flex-1 py-2.5 rounded-lg border text-sm font-semibold transition-colors',
modoConsulta === 'documento'
? 'bg-primary text-white border-primary'
: 'bg-white dark:bg-slate-700 text-slate-600 dark:text-slate-300 border-slate-200 dark:border-slate-600 hover:border-primary/40'
]"
@click="modoConsulta = 'documento'"
>
CPF / CNPJ
</button>
<button
:class="[
'flex-1 py-2.5 rounded-lg border text-sm font-semibold transition-colors',
modoConsulta === 'inscricao'
? 'bg-primary text-white border-primary'
: 'bg-white dark:bg-slate-700 text-slate-600 dark:text-slate-300 border-slate-200 dark:border-slate-600 hover:border-primary/40'
]"
@click="modoConsulta = 'inscricao'"
>
Inscrição Imobiliária
</button>
</div>
</div>
<div>
<label class="block text-sm font-semibold text-slate-700 dark:text-slate-200 mb-1.5">
{{ modoConsulta === 'documento' ? 'CPF ou CNPJ do proprietário' : 'Número da inscrição imobiliária' }}
</label>
<DocumentoInput
v-if="modoConsulta === 'documento'"
v-model="documento"
:disabled="isAuthenticated"
@keyup.enter="consultar"
/>
<InputText
v-else
v-model="inscricao"
placeholder="Ex.: 0001.001.0001.001"
class="w-full"
size="large"
@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">
<i class="pi pi-info-circle text-xs text-primary" aria-hidden="true" />
Logado como <strong class="text-slate-700 dark:text-slate-200">{{ nomeUsuario }}</strong> documento bloqueado para segurança.
</p>
</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>
<Button
label="Consultar imóveis"
icon="pi pi-search"
class="w-full"
size="large"
:loading="carregando"
:disabled="modoConsulta === 'documento' ? documento.replace(/\D/g,'').length < 11 : !inscricao.trim()"
@click="consultar"
/>
</div>
<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">
<p class="text-sm font-semibold text-slate-700 dark:text-slate-200 mb-3">
{{ imoveis.length }} imóveis encontrados selecione um
</p>
<div class="space-y-2">
<button
v-for="imovel in imoveis"
:key="imovel.inscricaoImobiliaria"
class="w-full text-left p-4 rounded-xl border transition-colors"
:class="imovelSelecionado?.inscricaoImobiliaria === imovel.inscricaoImobiliaria
? '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="imovelSelecionado = imovel"
>
<p class="font-semibold text-slate-800 dark:text-slate-100 text-sm">{{ imovel.enderecoCompleto }}</p>
<p class="text-xs text-slate-500 dark:text-slate-400 mt-0.5">Inscrição: {{ imovel.inscricaoImobiliaria }}</p>
</button>
</div>
</div>
<template v-if="imovelSelecionado">
<div class="bg-white dark:bg-slate-800 rounded-2xl border border-slate-200 dark:border-slate-700 p-6">
<div class="flex items-start justify-between gap-4 mb-4">
<div>
<p class="font-bold text-slate-800 dark:text-slate-100">{{ imovelSelecionado.enderecoCompleto }}</p>
<p class="text-xs text-slate-500 dark:text-slate-400 mt-0.5">Inscrição: {{ imovelSelecionado.inscricaoImobiliaria }}</p>
</div>
<Button
:label="`Carnê ${exercicioAtual}`"
icon="pi pi-download"
size="small"
outlined
:loading="carregandoPdf === `carne-${imovelSelecionado.inscricaoImobiliaria}`"
@click="emitirCarne(imovelSelecionado)"
/>
</div>
<div v-if="imovelSelecionado.debitos?.length" class="mt-4">
<p class="text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wide mb-3">Débitos em aberto</p>
<div class="space-y-2">
<div
v-for="debito in imovelSelecionado.debitos"
:key="debito.id"
class="flex items-center justify-between gap-4 py-3 border-b border-slate-100 dark:border-slate-700 last:border-0"
>
<div>
<p class="text-sm font-semibold text-slate-800 dark:text-slate-100">{{ debito.descricao }}</p>
<p class="text-xs text-slate-500 dark:text-slate-400">Venc.: {{ debito.vencimento }}</p>
</div>
<div class="flex items-center gap-3">
<p class="text-sm font-bold text-slate-800 dark:text-slate-100 whitespace-nowrap">
{{ formatarMoeda(debito.valor) }}
</p>
<Button
icon="pi pi-download"
size="small"
text
aria-label="Emitir boleto"
:loading="carregandoPdf === `boleto-${debito.id}`"
@click="emitirBoleto(debito)"
/>
</div>
</div>
</div>
</div>
<p v-else class="text-sm text-emerald-600 dark:text-emerald-400 font-medium flex items-center gap-2 mt-2">
<i class="pi pi-check-circle" aria-hidden="true" />
Nenhum débito em aberto para este imóvel.
</p>
</div>
</template>
<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>
<Button
label="Nova consulta"
severity="secondary"
icon="pi pi-arrow-left"
outlined
class="w-full"
@click="reiniciar"
/>
</div>
</div>
</template>

View File

@ -0,0 +1,25 @@
import { useAuthStore } from '@/stores/authStore'
interface MeResponse {
name: string
documento: string
email: string
roles: string[]
}
/**
* Hidrata o authStore no SSR a partir do cookie de sessão.
* Se o usuário está logado, o HTML inicial reflete o estado autenticado.
*/
export default defineNuxtPlugin(async () => {
const store = useAuthStore()
try {
const me = await $fetch<MeResponse>('/api/auth/me', {
headers: useRequestHeaders(['cookie']),
})
store.setUser(me)
} catch {
// 401 esperado quando não há sessão — segue como anônimo
}
})

View File

@ -0,0 +1,50 @@
import { tenantFromHost } from '@/utils/tenant'
import { usePrefeituraStore } from '@/stores/prefeituraStore'
interface PrefeituraInfo {
codigoMunicipio: number
nomePrefeitura: string
dominio: string
template: string
pathLogo?: string
pathBackground?: string
}
interface ApiEnvelope<T> {
data: T
}
function resolverUrl(path: string | undefined, base: string): string | null {
if (!path) return null
if (path.startsWith('http')) return path
return `${base}${path}`
}
export default defineNuxtPlugin(async (nuxtApp) => {
const event = useRequestEvent(nuxtApp)
const host = event?.node.req.headers.host
const dominio = tenantFromHost(host)
const cfg = useRuntimeConfig()
const store = usePrefeituraStore()
try {
const res = await $fetch<ApiEnvelope<PrefeituraInfo>>(
`${cfg.coreApiUrl}/api/v1/publico/prefeitura/${dominio}`,
{ timeout: 8000 },
)
const info = res?.data
if (!info?.codigoMunicipio) return
store.$patch({
codigoMunicipio: info.codigoMunicipio,
nomePrefeitura: info.nomePrefeitura,
dominio: info.dominio,
template: info.template,
pathLogo: resolverUrl(info.pathLogo, cfg.coreApiUrl),
pathBackground: resolverUrl(info.pathBackground, cfg.coreApiUrl),
})
} catch (err) {
console.warn(`[prefeitura.server] bootstrap falhou para '${dominio}':`, (err as Error).message)
}
})

View File

@ -1,106 +0,0 @@
import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '@/stores/authStore'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
scrollBehavior: () => ({ top: 0 }),
routes: [
{
path: '/',
component: () => import('@/layouts/PublicLayout.vue'),
children: [
{
path: '',
name: 'home',
component: () => import('@/views/public/HomeView.vue'),
},
{
path: 'login',
name: 'login',
component: () => import('@/views/public/LoginView.vue'),
},
{
path: 'primeiro-acesso',
name: 'primeiro-acesso',
component: () => import('@/views/public/PrimeiroAcessoView.vue'),
},
{
path: 'credenciamento',
name: 'credenciamento',
component: () => import('@/views/public/CredenciamentoView.vue'),
},
{
path: 'servicos',
name: 'servicos',
component: () => import('@/views/servicos/ServicosHubView.vue'),
},
{
path: 'servicos/certidao',
name: 'certidao',
component: () => import('@/views/servicos/CertidaoView.vue'),
},
{
path: 'servicos/iptu',
name: 'iptu',
component: () => import('@/views/servicos/IptuView.vue'),
},
],
},
{
path: '/portal',
component: () => import('@/layouts/PortalLayout.vue'),
meta: { requiresAuth: true },
children: [
{
path: '',
redirect: { name: 'painel' },
},
{
path: 'painel',
name: 'painel',
component: () => import('@/views/portal/PainelView.vue'),
},
{
path: 'debitos',
name: 'debitos',
component: () => import('@/views/portal/DebitosView.vue'),
},
{
path: 'certidoes',
name: 'certidoes-portal',
component: () => import('@/views/portal/CertidoesView.vue'),
},
{
path: 'alvaras',
name: 'alvaras',
component: () => import('@/views/portal/AlvarasView.vue'),
},
{
path: 'pagamentos',
name: 'pagamentos',
component: () => import('@/views/portal/PagamentosView.vue'),
},
{
path: 'dados',
name: 'dados',
component: () => import('@/views/portal/DadosView.vue'),
},
],
},
{
path: '/:pathMatch(.*)*',
redirect: { name: 'home' },
},
],
})
router.beforeEach((to) => {
if (!to.meta.requiresAuth) return true
const auth = useAuthStore()
if (!auth.isAuthenticated) {
return { name: 'home' }
}
})
export default router

View File

@ -1,34 +0,0 @@
/**
* Serviço de autenticação Keycloak PKCE
*
* Fluxo:
* 1. Home coleta o documento (CPF/CNPJ)
* 2. Redireciona para /login?doc=XXX
* 3. LoginView exibe o documento e solicita a senha
* 4. Ao submeter, este serviço inicia o PKCE e redireciona ao Keycloak
* 5. Keycloak devolve ao callback com o code
* 6. handleCallback troca o code pelo token e salva na authStore
*/
const KEYCLOAK_BASE = import.meta.env.VITE_KEYCLOAK_URL ?? 'https://sso.modumfiscal.com.br/realms/modumfiscal-dev'
const CLIENT_ID = import.meta.env.VITE_KEYCLOAK_CLIENT_ID ?? 'portal-modumfiscal'
const REDIRECT_URI = `${window.location.origin}/callback`
export const authService = {
async iniciarLogin(documento) {
// Implementar PKCE com pkce-challenge quando integrar com Keycloak
// Por ora redireciona para /login com o documento
return { documento }
},
async handleCallback(code) {
// Trocar code por token via Keycloak
// A ser implementado na fase de integração
throw new Error('Keycloak callback não implementado nesta fase')
},
logout() {
const logoutUrl = `${KEYCLOAK_BASE}/protocol/openid-connect/logout?redirect_uri=${encodeURIComponent(window.location.origin)}`
window.location.href = logoutUrl
},
}

View File

@ -0,0 +1,22 @@
const FETCH_HEADERS = { 'X-Requested-With': 'fetch' }
function proxyUrl(path) {
return `/api/proxy${path}`
}
export const certidaoService = {
consultar(documento) {
return $fetch(proxyUrl('/publico/certidao/consultar'), {
headers: FETCH_HEADERS,
query: { documento },
})
},
emitir(documento, tipoCertidao) {
return $fetch(proxyUrl('/publico/certidao/emitir'), {
headers: FETCH_HEADERS,
query: { documento, tipoCertidao },
responseType: 'arrayBuffer',
})
},
}

View File

@ -0,0 +1,26 @@
const FETCH_HEADERS = { 'X-Requested-With': 'fetch' }
function proxyUrl(path) {
return `/api/proxy${path}`
}
export const credenciamentoService = {
verificarDocumento(documento) {
return $fetch(proxyUrl('/publico/credenciamento/verificar'), {
headers: FETCH_HEADERS,
query: { documento },
})
},
buscarCep(cep) {
return $fetch(proxyUrl(`/publico/cep/${cep}`), { headers: FETCH_HEADERS })
},
solicitar(payload) {
return $fetch(proxyUrl('/publico/credenciamento/solicitar'), {
method: 'POST',
headers: FETCH_HEADERS,
body: payload,
})
},
}

View File

@ -0,0 +1,36 @@
const FETCH_HEADERS = { 'X-Requested-With': 'fetch' }
function proxyUrl(path) {
return `/api/proxy${path}`
}
export const iptuService = {
consultarPorDocumento(documento) {
return $fetch(proxyUrl('/publico/iptu/consultar'), {
headers: FETCH_HEADERS,
query: { documento },
})
},
consultarPorInscricao(inscricao) {
return $fetch(proxyUrl('/publico/iptu/consultar'), {
headers: FETCH_HEADERS,
query: { inscricaoImobiliaria: inscricao },
})
},
emitirCarne(inscricao, exercicio) {
return $fetch(proxyUrl('/publico/iptu/carne'), {
headers: FETCH_HEADERS,
query: { inscricaoImobiliaria: inscricao, exercicio },
responseType: 'arrayBuffer',
})
},
emitirBoleto(idDebito) {
return $fetch(proxyUrl(`/publico/iptu/boleto/${idDebito}`), {
headers: FETCH_HEADERS,
responseType: 'arrayBuffer',
})
},
}

View File

@ -0,0 +1,86 @@
// 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' }
function proxyUrl(path) {
return `/api/proxy${path}`
}
export const portalService = {
// ─── Painel ──────────────────────────────────────────────────────────────
getPainelResumo() {
return $fetch(proxyUrl('/contribuinte/painel/resumo'), { headers: FETCH_HEADERS })
},
getAtividades(pagina = 0, tamanho = 5) {
return $fetch(proxyUrl('/contribuinte/painel/atividades'), {
headers: FETCH_HEADERS,
query: { pagina, tamanho },
})
},
// ─── Débitos ─────────────────────────────────────────────────────────────
getDebitos(params = {}) {
return $fetch(proxyUrl('/contribuinte/debitos'), {
headers: FETCH_HEADERS,
query: params,
})
},
emitirGuia(idDebito) {
return $fetch(proxyUrl(`/contribuinte/debitos/${idDebito}/guia`), {
headers: FETCH_HEADERS,
responseType: 'arrayBuffer',
})
},
// ─── Certidões ───────────────────────────────────────────────────────────
getCertidoes() {
return $fetch(proxyUrl('/contribuinte/certidoes'), { headers: FETCH_HEADERS })
},
reemitirCertidao(idCertidao) {
return $fetch(proxyUrl(`/contribuinte/certidoes/${idCertidao}/pdf`), {
headers: FETCH_HEADERS,
responseType: 'arrayBuffer',
})
},
// ─── Alvarás ─────────────────────────────────────────────────────────────
getAlvaras(params = {}) {
return $fetch(proxyUrl('/contribuinte/alvaras'), {
headers: FETCH_HEADERS,
query: params,
})
},
// ─── Pagamentos ──────────────────────────────────────────────────────────
getPagamentos(params = {}) {
return $fetch(proxyUrl('/contribuinte/pagamentos'), {
headers: FETCH_HEADERS,
query: params,
})
},
getComprovante(idPagamento) {
return $fetch(proxyUrl(`/contribuinte/pagamentos/${idPagamento}/comprovante`), {
headers: FETCH_HEADERS,
responseType: 'arrayBuffer',
})
},
// ─── Dados cadastrais ────────────────────────────────────────────────────
getDadosCadastrais() {
return $fetch(proxyUrl('/contribuinte/dados'), { headers: FETCH_HEADERS })
},
atualizarContato(payload) {
return $fetch(proxyUrl('/contribuinte/dados/contato'), {
method: 'PUT',
headers: FETCH_HEADERS,
body: payload,
})
},
}

View File

@ -1,7 +0,0 @@
import { apiClientPublico } from '@/config/apiClient'
export const prefeituraService = {
getPrefeituraInfo(dominio) {
return apiClientPublico.get(`/publico/prefeitura/${dominio}`)
},
}

View File

@ -0,0 +1,38 @@
const FETCH_HEADERS = { 'X-Requested-With': 'fetch' }
function proxyUrl(path) {
return `/api/proxy${path}`
}
export const primeiroAcessoService = {
verificarDocumento(documento) {
return $fetch(proxyUrl('/publico/primeiro-acesso/verificar'), {
headers: FETCH_HEADERS,
query: { documento },
})
},
solicitarCodigo(documento, canal) {
return $fetch(proxyUrl('/publico/primeiro-acesso/codigo'), {
method: 'POST',
headers: FETCH_HEADERS,
body: { documento, canal },
})
},
validarCodigo(documento, codigo) {
return $fetch(proxyUrl('/publico/primeiro-acesso/validar'), {
method: 'POST',
headers: FETCH_HEADERS,
body: { documento, codigo },
})
},
definirSenha(token, senha) {
return $fetch(proxyUrl('/publico/primeiro-acesso/senha'), {
method: 'POST',
headers: FETCH_HEADERS,
body: { token, senha },
})
},
}

View File

@ -1,31 +1,32 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
export const useAuthStore = defineStore( // Token NÃO vive aqui — fica server-side em Redis, exposto ao front
'auth', // apenas como cookie httpOnly opaco. Este store guarda só metadados do usuário,
() => { // hidratados via /api/auth/me em plugins/auth.server.ts (SSR) e useAuth().fetchMe().
const token = ref(null) export const useAuthStore = defineStore('auth', () => {
const userInfo = ref(null) const user = ref(null)
const isAuthenticated = computed(() => !!token.value) const isAuthenticated = computed(() => !!user.value)
const nomeUsuario = computed(() => userInfo.value?.name ?? '') const nomeUsuario = computed(() => user.value?.name ?? '')
const documento = computed(() => userInfo.value?.preferred_username ?? '') const documento = computed(() => user.value?.documento ?? '')
const roles = computed(() => user.value?.roles ?? [])
function setSession(accessToken, info) { function setUser(info) {
token.value = accessToken user.value = info
userInfo.value = info }
}
function clearSession() { function clearUser() {
token.value = null user.value = null
userInfo.value = null }
}
return { token, userInfo, isAuthenticated, nomeUsuario, documento, setSession, clearSession } return {
}, user,
{ isAuthenticated,
persist: { nomeUsuario,
paths: ['token', 'userInfo'], documento,
}, roles,
}, setUser,
) clearUser,
}
})

View File

@ -1,5 +1,7 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
// Store hidratado server-side via plugins/prefeitura.server.ts.
// Sem persist — a cada SSR o bootstrap re-popula a partir do core-api.
export const usePrefeituraStore = defineStore('prefeitura', { export const usePrefeituraStore = defineStore('prefeitura', {
state: () => ({ state: () => ({
codigoMunicipio: null, codigoMunicipio: null,
@ -7,7 +9,6 @@ export const usePrefeituraStore = defineStore('prefeitura', {
dominio: null, dominio: null,
template: null, template: null,
pathLogo: null, pathLogo: null,
pathBackground: null, // URL de foto de fundo do município (quando a API suportar) pathBackground: null,
}), }),
persist: true,
}) })

View File

@ -1,26 +0,0 @@
export function getTenant() {
const tenant = window.location.hostname.split('.')[0]
if (isValidTenant(tenant)) return tenant
const stored = localStorage.getItem('current_dominio')
if (stored && isValidTenant(stored)) return stored
return 'sistema'
}
export function setTenant(tenant) {
if (isValidTenant(tenant)) {
localStorage.setItem('current_dominio', tenant)
}
}
export function clearTenant() {
localStorage.removeItem('current_dominio')
}
function isValidTenant(tenant) {
if (!tenant || typeof tenant !== 'string') return false
const invalidos = ['0.0.0.0', 'www', 'api', 'admin', 'test', 'dev', 'development', 'staging', 'production', 'localhost']
return !invalidos.includes(tenant.toLowerCase()) && tenant.length > 1 && /^[a-zA-Z0-9-]+$/.test(tenant)
}

32
src/utils/tenant.ts Normal file
View File

@ -0,0 +1,32 @@
const INVALID_SUBDOMAINS = new Set([
'0.0.0.0',
'www',
'api',
'admin',
'test',
'dev',
'development',
'staging',
'production',
'localhost',
])
const DEFAULT_TENANT = 'sistema'
export function tenantFromHost(host: string | undefined | null): string {
if (!host) return DEFAULT_TENANT
const sub = host.split(':')[0]?.split('.')[0]?.toLowerCase()
if (!sub || sub.length <= 1) return DEFAULT_TENANT
if (INVALID_SUBDOMAINS.has(sub)) return DEFAULT_TENANT
if (!/^[a-z0-9-]+$/.test(sub)) return DEFAULT_TENANT
return sub
}
/**
* Resolve o tenant atual isomorfo (server e client).
* No server, prefira passar o host via `tenantFromHost(useRequestHeaders(['host']).host)`.
*/
export function getClientTenant(): string {
if (typeof window === 'undefined') return DEFAULT_TENANT
return tenantFromHost(window.location.host)
}

View File

@ -1,12 +0,0 @@
<script setup>
</script>
<template>
<div class="space-y-6">
<h1 class="text-2xl font-bold text-slate-800">Alvarás</h1>
<div class="bg-white rounded-xl border border-slate-200 p-8 text-center text-slate-400">
<i class="pi pi-briefcase text-4xl mb-3 block" />
<p>Acompanhamento de alvarás e processos a ser implementado.</p>
</div>
</div>
</template>

View File

@ -1,12 +0,0 @@
<script setup>
</script>
<template>
<div class="space-y-6">
<h1 class="text-2xl font-bold text-slate-800">Certidões</h1>
<div class="bg-white rounded-xl border border-slate-200 p-8 text-center text-slate-400">
<i class="pi pi-file-edit text-4xl mb-3 block" />
<p>Certidões ativas e reemissão a ser implementado.</p>
</div>
</div>
</template>

View File

@ -1,12 +0,0 @@
<script setup>
</script>
<template>
<div class="space-y-6">
<h1 class="text-2xl font-bold text-slate-800">Dados Cadastrais</h1>
<div class="bg-white rounded-xl border border-slate-200 p-8 text-center text-slate-400">
<i class="pi pi-user text-4xl mb-3 block" />
<p>Visualização e atualização de dados cadastrais a ser implementado.</p>
</div>
</div>
</template>

View File

@ -1,12 +0,0 @@
<script setup>
</script>
<template>
<div class="space-y-6">
<h1 class="text-2xl font-bold text-slate-800">Débitos e Guias</h1>
<div class="bg-white rounded-xl border border-slate-200 p-8 text-center text-slate-400">
<i class="pi pi-receipt text-4xl mb-3 block" />
<p>Consulta de débitos e emissão de guias a ser implementado.</p>
</div>
</div>
</template>

View File

@ -1,12 +0,0 @@
<script setup>
</script>
<template>
<div class="space-y-6">
<h1 class="text-2xl font-bold text-slate-800">Histórico de Pagamentos</h1>
<div class="bg-white rounded-xl border border-slate-200 p-8 text-center text-slate-400">
<i class="pi pi-credit-card text-4xl mb-3 block" />
<p>Histórico de pagamentos com comprovantes a ser implementado.</p>
</div>
</div>
</template>

View File

@ -1,55 +0,0 @@
<script setup>
import { useAuthStore } from '@/stores/authStore'
const auth = useAuthStore()
const cards = [
{ icon: 'pi-receipt', label: 'Débitos em Aberto', valor: '—', cor: 'red' },
{ icon: 'pi-file-check', label: 'Certidões Ativas', valor: '—', cor: 'green' },
{ icon: 'pi-briefcase', label: 'Alvarás em Andamento', valor: '—', cor: 'orange' },
{ icon: 'pi-credit-card', label: 'Último Pagamento', valor: '—', cor: 'blue' },
]
const corMap = {
red: 'bg-red-50 text-red-700',
green: 'bg-green-50 text-green-700',
orange: 'bg-orange-50 text-orange-700',
blue: 'bg-blue-50 text-blue-700',
}
</script>
<template>
<div class="space-y-8">
<div>
<h1 class="text-2xl font-bold text-slate-800">
Olá, {{ auth.nomeUsuario || 'Contribuinte' }}
</h1>
<p class="text-slate-500 mt-1">Bem-vindo ao seu painel de gestão fiscal.</p>
</div>
<!-- Cards resumo -->
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4">
<div
v-for="card in cards"
:key="card.label"
class="bg-white rounded-xl border border-slate-200 p-5 space-y-3"
>
<div :class="['w-10 h-10 rounded-lg flex items-center justify-center', corMap[card.cor]]">
<i :class="['pi', card.icon]" />
</div>
<div>
<p class="text-2xl font-bold text-slate-800">{{ card.valor }}</p>
<p class="text-xs text-slate-500 mt-0.5">{{ card.label }}</p>
</div>
</div>
</div>
<!-- Placeholder conteúdo -->
<div class="bg-blue-50 border border-blue-100 rounded-xl p-6 text-center">
<i class="pi pi-info-circle text-blue-500 text-2xl mb-3 block" />
<p class="text-sm text-slate-600">
Os dados do painel serão carregados quando a integração com a API estiver configurada.
</p>
</div>
</div>
</template>

View File

@ -1,17 +0,0 @@
<script setup>
</script>
<template>
<div class="max-w-2xl mx-auto px-4 py-16 text-center">
<div class="w-16 h-16 bg-green-100 rounded-2xl flex items-center justify-center mx-auto mb-6">
<i class="pi pi-user-plus text-green-700 text-2xl" />
</div>
<h1 class="text-2xl font-bold text-slate-800 mb-3">Credenciamento</h1>
<p class="text-slate-500 mb-8">
Wizard de cadastro do contribuinte (6 etapas) a ser implementado.
</p>
<RouterLink :to="{ name: 'home' }">
<Button label="Voltar ao início" severity="secondary" icon="pi pi-arrow-left" outlined />
</RouterLink>
</div>
</template>

View File

@ -1,172 +0,0 @@
<script setup>
import { ref, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
const route = useRoute()
const router = useRouter()
const senha = ref('')
const carregando = ref(false)
const erro = ref('')
const senhaId = 'campo-senha'
const erroId = 'erro-senha'
const docBruto = computed(() => (route.query.doc ?? '').replace(/\D/g, ''))
const docFormatado = computed(() => {
const d = docBruto.value
if (d.length <= 11) {
return d
.replace(/(\d{3})(\d)/, '$1.$2')
.replace(/(\d{3})(\d)/, '$1.$2')
.replace(/(\d{3})(\d{1,2})$/, '$1-$2')
}
return d
.replace(/(\d{2})(\d)/, '$1.$2')
.replace(/(\d{3})(\d)/, '$1.$2')
.replace(/(\d{3})(\d)/, '$1/$2')
.replace(/(\d{4})(\d{1,2})$/, '$1-$2')
})
const tipoDoc = computed(() => docBruto.value.length > 11 ? 'CNPJ' : 'CPF')
function trocarDocumento() {
router.push({ name: 'home' })
}
async function entrar() {
if (!senha.value) {
erro.value = 'Informe a senha para continuar.'
return
}
erro.value = ''
carregando.value = true
// Integração com Keycloak PKCE a ser implementado
setTimeout(() => {
carregando.value = false
erro.value = 'Integração com Keycloak ainda não configurada nesta fase.'
}, 800)
}
</script>
<template>
<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="bg-white dark:bg-slate-800 rounded-2xl shadow-lg border border-slate-200 dark:border-slate-700 overflow-hidden">
<!-- Cabeçalho h1 para hierarquia de heading correta -->
<div class="bg-gradient-to-r from-primary-700 to-primary-800 px-8 py-6 bg-primary">
<div class="flex items-center gap-3">
<div class="w-10 h-10 bg-white/20 rounded-xl flex items-center justify-center" aria-hidden="true">
<i class="pi pi-lock text-white text-lg" />
</div>
<div>
<h1 class="text-white font-bold text-base leading-tight">Acesso seguro</h1>
<p class="text-white/80 text-xs mt-0.5">Portal do Contribuinte</p>
</div>
</div>
</div>
<div class="px-8 py-8 space-y-6">
<!-- Documento identificado -->
<div>
<p class="text-xs font-semibold text-slate-600 dark:text-slate-400 uppercase tracking-wide mb-2">
Entrando como
</p>
<div class="flex items-center justify-between bg-slate-50 dark:bg-slate-700 border border-slate-200 dark:border-slate-600 rounded-xl px-4 py-3">
<div class="flex items-center gap-3">
<div class="w-8 h-8 bg-primary/10 rounded-lg flex items-center justify-center" aria-hidden="true">
<i class="pi pi-id-card text-primary text-sm" />
</div>
<div>
<p class="font-mono font-semibold text-slate-800 dark:text-slate-100 text-sm">{{ docFormatado }}</p>
<p class="text-xs text-slate-600 dark:text-slate-300">{{ tipoDoc }}</p>
</div>
</div>
<!-- Alvo de 44px via py-3 px-3 -->
<button
class="inline-flex items-center gap-1.5 text-sm text-primary hover:text-primary font-semibold px-3 py-3 rounded-lg hover:bg-primary/8 transition-colors"
aria-label="Trocar documento de identificação"
@click="trocarDocumento"
>
<i class="pi pi-pencil text-xs" aria-hidden="true" />
Trocar
</button>
</div>
</div>
<!-- Senha -->
<div>
<label :for="senhaId" class="block text-sm font-semibold text-slate-700 dark:text-slate-200 mb-1.5">
Senha
</label>
<Password
v-model="senha"
:input-id="senhaId"
:feedback="false"
toggle-mask
placeholder="Digite sua senha"
class="w-full"
input-class="w-full"
size="large"
:invalid="!!erro"
:input-props="{ 'aria-describedby': erro ? erroId : undefined, 'aria-invalid': !!erro || undefined }"
@keyup.enter="entrar"
/>
<!-- aria-live="assertive": anuncia o erro imediatamente para leitores de tela -->
<p
:id="erroId"
role="alert"
aria-live="assertive"
class="mt-2 text-sm text-red-700 font-medium flex items-center gap-1.5 min-h-[1.25rem]"
>
<template v-if="erro">
<i class="pi pi-exclamation-circle text-sm" aria-hidden="true" />
{{ erro }}
</template>
</p>
</div>
<Button
label="Entrar"
icon="pi pi-sign-in"
class="w-full"
size="large"
:loading="carregando"
@click="entrar"
/>
<div class="text-center space-y-1">
<RouterLink
:to="{ name: 'primeiro-acesso' }"
class="block text-sm text-primary font-medium py-2 hover:underline"
>
Esqueci minha senha
</RouterLink>
<RouterLink
:to="{ name: 'credenciamento' }"
class="block text-sm text-slate-600 dark:text-slate-400 py-2 hover:text-slate-800 dark:hover:text-slate-200 transition-colors"
>
Ainda não tem acesso? <span class="text-primary font-semibold">Credenciar-se</span>
</RouterLink>
</div>
</div>
</div>
<!-- Voltar alvo de 44px via py-3 -->
<div class="text-center mt-4">
<button
class="inline-flex items-center gap-2 text-sm text-slate-600 dark:text-slate-400 hover:text-slate-800 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({ name: 'home' })"
>
<i class="pi pi-arrow-left text-xs" aria-hidden="true" />
Voltar à página inicial
</button>
</div>
</div>
</div>
</template>

View File

@ -1,17 +0,0 @@
<script setup>
</script>
<template>
<div class="max-w-2xl mx-auto px-4 py-16 text-center">
<div class="w-16 h-16 bg-blue-100 rounded-2xl flex items-center justify-center mx-auto mb-6">
<i class="pi pi-key text-blue-700 text-2xl" />
</div>
<h1 class="text-2xl font-bold text-slate-800 mb-3">Primeiro Acesso / Esqueci minha senha</h1>
<p class="text-slate-500 mb-8">
Fluxo de criação e recuperação de senha a ser implementado.
</p>
<RouterLink :to="{ name: 'home' }">
<Button label="Voltar ao início" severity="secondary" icon="pi pi-arrow-left" outlined />
</RouterLink>
</div>
</template>

View File

@ -1,17 +0,0 @@
<script setup>
</script>
<template>
<div class="max-w-3xl mx-auto px-4 py-12 text-center">
<div class="w-16 h-16 bg-blue-100 rounded-2xl flex items-center justify-center mx-auto mb-6">
<i class="pi pi-file-check text-blue-700 text-2xl" />
</div>
<h1 class="text-2xl font-bold text-slate-800 mb-3">Emissão de Certidão</h1>
<p class="text-slate-500 mb-8">
Consulta de situação fiscal e emissão de certidão em PDF a ser implementado.
</p>
<RouterLink :to="{ name: 'servicos' }">
<Button label="Voltar aos serviços" severity="secondary" icon="pi pi-arrow-left" outlined />
</RouterLink>
</div>
</template>

View File

@ -1,17 +0,0 @@
<script setup>
</script>
<template>
<div class="max-w-3xl mx-auto px-4 py-12 text-center">
<div class="w-16 h-16 bg-blue-100 rounded-2xl flex items-center justify-center mx-auto mb-6">
<i class="pi pi-home text-blue-700 text-2xl" />
</div>
<h1 class="text-2xl font-bold text-slate-800 mb-3">IPTU Débitos e Carnê</h1>
<p class="text-slate-500 mb-8">
Consulta por inscrição imobiliária ou CPF/CNPJ e impressão do carnê a ser implementado.
</p>
<RouterLink :to="{ name: 'servicos' }">
<Button label="Voltar aos serviços" severity="secondary" icon="pi pi-arrow-left" outlined />
</RouterLink>
</div>
</template>

3
tsconfig.json Normal file
View File

@ -0,0 +1,3 @@
{
"extends": "./.nuxt/tsconfig.json"
}

View File

@ -1,21 +0,0 @@
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import tailwindcss from '@tailwindcss/vite'
import Components from 'unplugin-vue-components/vite'
import { PrimeVueResolver } from '@primevue/auto-import-resolver'
export default defineConfig({
plugins: [
vue(),
tailwindcss(),
Components({
resolvers: [PrimeVueResolver()],
}),
],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
})