Merge pull request 'developer' (#3) from developer into main
Reviewed-on: #3
This commit is contained in:
commit
4360a2d998
@ -1,4 +1,3 @@
|
||||
VITE_KEYCLOAK_URL=
|
||||
VITE_KEYCLOAK_REALM=
|
||||
VITE_KEYCLOAK_CLIENT_ID=
|
||||
VITE_API_URL=
|
||||
# Arquivo template, mantido no repositório vazio por convenção.
|
||||
# Para rodar o portal em dev, copie .env.example para .env e preencha os valores reais.
|
||||
# .env é ignorado pelo git.
|
||||
|
||||
26
.env.example
Normal file
26
.env.example
Normal 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
|
||||
86
.gitea/workflows/dev-build-deploy.yml
Normal file
86
.gitea/workflows/dev-build-deploy.yml
Normal 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
|
||||
102
.gitea/workflows/prod-build-deploy.yml
Normal file
102
.gitea/workflows/prod-build-deploy.yml
Normal 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
14
.gitignore
vendored
@ -7,16 +7,30 @@ yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
# Dependencies
|
||||
node_modules
|
||||
|
||||
# Build artifacts (Vite legado — pode ser removido após migração)
|
||||
dist
|
||||
dist-ssr
|
||||
|
||||
# Nuxt
|
||||
.nuxt
|
||||
.output
|
||||
.nitro
|
||||
.cache
|
||||
|
||||
# Local env files
|
||||
*.local
|
||||
.env
|
||||
.env.*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
.claude
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
|
||||
20
Dockerfile
Normal file
20
Dockerfile
Normal 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
18
docker-compose.yml
Normal 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
699
docs/api-backend.md
Normal 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 | — |
|
||||
16
index.html
16
index.html
@ -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
100
nuxt.config.ts
Normal 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
9716
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
25
package.json
25
package.json
@ -1,18 +1,24 @@
|
||||
{
|
||||
"name": "portal-modumfiscal-web",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint --fix . --ext .vue,.js --ignore-path .gitignore"
|
||||
"build": "nuxt build",
|
||||
"dev": "nuxt dev",
|
||||
"generate": "nuxt generate",
|
||||
"preview": "nuxt preview",
|
||||
"postinstall": "nuxt prepare",
|
||||
"lint": "eslint --fix . --ext .vue,.js,.ts --ignore-path .gitignore"
|
||||
},
|
||||
"dependencies": {
|
||||
"@pinia/nuxt": "^0.11.2",
|
||||
"@primeuix/themes": "^2.0.3",
|
||||
"@primevue/nuxt-module": "^4.5.5",
|
||||
"axios": "^1.16.1",
|
||||
"ioredis": "^5.4.1",
|
||||
"jose": "^5.9.6",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"nuxt": "^3.14.0",
|
||||
"pinia": "^3.0.4",
|
||||
"pinia-plugin-persistedstate": "^4.7.1",
|
||||
"pkce-challenge": "^6.0.0",
|
||||
@ -20,21 +26,14 @@
|
||||
"primevue": "^4.5.5",
|
||||
"tailwindcss-primeui": "^0.6.1",
|
||||
"vue": "^3.5.34",
|
||||
"vue-router": "^5.0.7",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@primevue/auto-import-resolver": "^4.5.5",
|
||||
"@rushstack/eslint-patch": "^1.16.1",
|
||||
"@tailwindcss/vite": "^4.3.0",
|
||||
"@vitejs/plugin-vue": "^6.0.6",
|
||||
"@vue/eslint-config-prettier": "^10.2.0",
|
||||
"autoprefixer": "^10.5.0",
|
||||
"eslint-plugin-vue": "^10.9.1",
|
||||
"postcss": "^8.5.14",
|
||||
"sass": "^1.99.0",
|
||||
"tailwindcss": "^4.3.0",
|
||||
"unplugin-vue-components": "^32.0.0",
|
||||
"vite": "^8.0.12"
|
||||
"typescript": "^5.6.3"
|
||||
}
|
||||
}
|
||||
|
||||
40
server/api/auth/callback.get.ts
Normal file
40
server/api/auth/callback.get.ts
Normal 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)
|
||||
}
|
||||
})
|
||||
32
server/api/auth/login.post.ts
Normal file
32
server/api/auth/login.post.ts
Normal 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 }
|
||||
})
|
||||
22
server/api/auth/logout.post.ts
Normal file
22
server/api/auth/logout.post.ts
Normal 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
16
server/api/auth/me.get.ts
Normal 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 ?? [],
|
||||
}
|
||||
})
|
||||
14
server/api/auth/refresh.post.ts
Normal file
14
server/api/auth/refresh.post.ts
Normal 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 }
|
||||
})
|
||||
72
server/api/proxy/[...path].ts
Normal file
72
server/api/proxy/[...path].ts
Normal file
@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Proxy genérico para o core-api.
|
||||
*
|
||||
* Fluxo:
|
||||
* 1. Lê 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
45
server/middleware/csrf.ts
Normal 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
103
server/utils/keycloak.ts
Normal 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
61
server/utils/pkce.ts
Normal 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)
|
||||
}
|
||||
|
||||
/**
|
||||
* Lê 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`
|
||||
}
|
||||
44
server/utils/prefeitura.ts
Normal file
44
server/utils/prefeitura.ts
Normal 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
27
server/utils/redis.ts
Normal 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
126
server/utils/session.ts
Normal 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
30
server/utils/tenant.ts
Normal 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)
|
||||
}
|
||||
21
src/App.vue
21
src/App.vue
@ -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
58
src/app.vue
Normal 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>
|
||||
@ -7,7 +7,7 @@ body {
|
||||
font-size: 16px; /* base mínimo para leitura confortável */
|
||||
}
|
||||
|
||||
#app {
|
||||
#__nuxt {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@ -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 }
|
||||
}
|
||||
}
|
||||
@ -3,15 +3,16 @@ import { ref, computed, watch } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: String, default: '' },
|
||||
disabled: { type: Boolean, default: false },
|
||||
})
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const wrapper = ref(null)
|
||||
const input = ref(props.modelValue)
|
||||
|
||||
watch(() => props.modelValue, (v) => { input.value = v })
|
||||
|
||||
const apenasDigitos = computed(() => input.value.replace(/\D/g, ''))
|
||||
|
||||
const isCnpj = computed(() => apenasDigitos.value.length > 11)
|
||||
|
||||
const valorFormatado = computed(() => {
|
||||
@ -36,13 +37,20 @@ function onInput(e) {
|
||||
input.value = raw
|
||||
emit('update:modelValue', raw)
|
||||
}
|
||||
|
||||
function focus() {
|
||||
wrapper.value?.querySelector('input')?.focus()
|
||||
}
|
||||
|
||||
defineExpose({ focus })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative">
|
||||
<div ref="wrapper" class="relative">
|
||||
<InputText
|
||||
:value="valorFormatado"
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
inputmode="numeric"
|
||||
autocomplete="username"
|
||||
class="w-full text-lg tracking-wide"
|
||||
@ -52,8 +60,15 @@ function onInput(e) {
|
||||
@input="onInput"
|
||||
/>
|
||||
<span
|
||||
v-if="apenasDigitos.length > 0"
|
||||
class="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-slate-400 font-medium"
|
||||
v-if="disabled"
|
||||
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' }}
|
||||
</span>
|
||||
|
||||
110
src/components/auth/LoginModal.vue
Normal file
110
src/components/auth/LoginModal.vue
Normal 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>
|
||||
@ -2,9 +2,9 @@
|
||||
import { ref, watch, onMounted } from 'vue'
|
||||
|
||||
const aberto = ref(false)
|
||||
const nivelFonte = ref(Number(localStorage.getItem('a11y-fonte') || 0))
|
||||
const altoContraste = ref(localStorage.getItem('a11y-contraste') === '1')
|
||||
const modoEscuro = ref(localStorage.getItem('a11y-escuro') === '1')
|
||||
const nivelFonte = ref(0)
|
||||
const altoContraste = ref(false)
|
||||
const modoEscuro = ref(false)
|
||||
|
||||
const opcoesFonte = [
|
||||
{ nivel: 0, label: 'A', title: 'Texto normal' },
|
||||
@ -18,23 +18,32 @@ function applyFonte(nivel) {
|
||||
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(() => {
|
||||
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)
|
||||
document.documentElement.classList.toggle('a11y-contrast', altoContraste.value)
|
||||
document.documentElement.classList.toggle('app-dark', modoEscuro.value)
|
||||
})
|
||||
|
||||
watch(nivelFonte, (val) => {
|
||||
if (!import.meta.client) return
|
||||
applyFonte(val)
|
||||
localStorage.setItem('a11y-fonte', val)
|
||||
})
|
||||
|
||||
watch(altoContraste, (val) => {
|
||||
if (!import.meta.client) return
|
||||
document.documentElement.classList.toggle('a11y-contrast', val)
|
||||
localStorage.setItem('a11y-contraste', val ? '1' : '0')
|
||||
})
|
||||
|
||||
watch(modoEscuro, (val) => {
|
||||
if (!import.meta.client) return
|
||||
document.documentElement.classList.toggle('app-dark', val)
|
||||
localStorage.setItem('a11y-escuro', val ? '1' : '0')
|
||||
})
|
||||
|
||||
@ -1,21 +1,32 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
import { usePrefeituraStore } from '@/stores/prefeituraStore'
|
||||
import { useAuth } from '@/composables/useAuth'
|
||||
import { useFocusLoginInput } from '@/composables/useFocusLoginInput'
|
||||
import logoFallback from '@/assets/images/logo-modum-fiscal.png'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const { isAuthenticated } = useAuth()
|
||||
const prefeitura = usePrefeituraStore()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { request: requestFocusLogin } = useFocusLoginInput()
|
||||
|
||||
const logoSrc = computed(() => prefeitura.pathLogo || logoFallback)
|
||||
|
||||
async function clicarEntrar() {
|
||||
if (route.path !== '/') {
|
||||
await router.push('/')
|
||||
}
|
||||
requestFocusLogin()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<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">
|
||||
|
||||
<RouterLink
|
||||
:to="{ name: 'home' }"
|
||||
<NuxtLink
|
||||
to="/"
|
||||
class="flex items-center gap-3 min-w-0"
|
||||
:aria-label="`Ir para a página inicial — ${prefeitura.nomePrefeitura || 'ModumFiscal'}`"
|
||||
>
|
||||
@ -31,28 +42,32 @@ const logoSrc = computed(() => prefeitura.pathLogo || logoFallback)
|
||||
{{ prefeitura.nomePrefeitura || 'ModumFiscal' }}
|
||||
</p>
|
||||
</div>
|
||||
</RouterLink>
|
||||
</NuxtLink>
|
||||
|
||||
<nav aria-label="Navegação principal" class="hidden md:flex items-center gap-4">
|
||||
<RouterLink
|
||||
:to="{ name: 'servicos' }"
|
||||
<NuxtLink
|
||||
to="/servicos"
|
||||
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
|
||||
</RouterLink>
|
||||
</NuxtLink>
|
||||
</nav>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<template v-if="auth.isAuthenticated">
|
||||
<RouterLink :to="{ name: 'painel' }">
|
||||
<template v-if="isAuthenticated">
|
||||
<NuxtLink to="/portal/painel">
|
||||
<Button label="Meu Painel" icon="pi pi-user" size="small" aria-label="Ir para o meu painel" />
|
||||
</RouterLink>
|
||||
</NuxtLink>
|
||||
</template>
|
||||
<template v-else>
|
||||
<RouterLink :to="{ name: 'home' }">
|
||||
<Button label="Entrar" icon="pi pi-sign-in" size="small" aria-label="Entrar no portal" />
|
||||
</RouterLink>
|
||||
<Button
|
||||
label="Entrar"
|
||||
icon="pi pi-sign-in"
|
||||
size="small"
|
||||
aria-label="Entrar no portal"
|
||||
@click="clicarEntrar"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
|
||||
@ -6,14 +6,12 @@ defineProps({
|
||||
to: { type: [String, Object], default: null },
|
||||
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>
|
||||
|
||||
<template>
|
||||
<component
|
||||
: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"
|
||||
>
|
||||
<NuxtLink v-if="to" :to="to" :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']" />
|
||||
@ -28,5 +26,18 @@ defineProps({
|
||||
<span>Acessar</span>
|
||||
<i class="pi pi-arrow-right text-xs" />
|
||||
</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>
|
||||
|
||||
43
src/composables/useApi.ts
Normal file
43
src/composables/useApi.ts
Normal 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' }),
|
||||
}
|
||||
}
|
||||
65
src/composables/useAuth.ts
Normal file
65
src/composables/useAuth.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
20
src/composables/useFocusLoginInput.ts
Normal file
20
src/composables/useFocusLoginInput.ts
Normal 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 dá focus no documento.
|
||||
*
|
||||
* Se a home já 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 }
|
||||
}
|
||||
26
src/composables/useLoginModal.ts
Normal file
26
src/composables/useLoginModal.ts
Normal 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 }
|
||||
}
|
||||
@ -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
|
||||
@ -7,11 +7,7 @@
|
||||
|
||||
<!-- tabindex="-1" permite que o skip link mova o foco para cá -->
|
||||
<main id="main-content" tabindex="-1" class="flex-1 outline-none">
|
||||
<RouterView v-slot="{ Component }">
|
||||
<Transition name="page" mode="out-in">
|
||||
<component :is="Component" />
|
||||
</Transition>
|
||||
</RouterView>
|
||||
<slot />
|
||||
</main>
|
||||
|
||||
<AppFooter />
|
||||
@ -1,21 +1,20 @@
|
||||
<script setup>
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
import { authService } from '@/services/authService'
|
||||
import { useAuth } from '@/composables/useAuth'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const { nomeUsuario, logout } = useAuth()
|
||||
const route = useRoute()
|
||||
|
||||
const navItems = [
|
||||
{ name: 'painel', label: 'Painel' },
|
||||
{ name: 'debitos', label: 'Débitos' },
|
||||
{ name: 'certidoes-portal', label: 'Certidões' },
|
||||
{ name: 'alvaras', label: 'Alvarás' },
|
||||
{ name: 'pagamentos', label: 'Pagamentos' },
|
||||
{ name: 'dados', label: 'Dados Cadastrais' },
|
||||
{ path: '/portal/painel', label: 'Painel' },
|
||||
{ path: '/portal/debitos', label: 'Débitos' },
|
||||
{ path: '/portal/certidoes', label: 'Certidões' },
|
||||
{ path: '/portal/alvaras', label: 'Alvarás' },
|
||||
{ path: '/portal/pagamentos', label: 'Pagamentos' },
|
||||
{ path: '/portal/dados', label: 'Dados Cadastrais' },
|
||||
]
|
||||
|
||||
function sair() {
|
||||
auth.clearSession()
|
||||
authService.logout()
|
||||
logout()
|
||||
}
|
||||
</script>
|
||||
|
||||
@ -23,8 +22,8 @@ function sair() {
|
||||
<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">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 h-16 flex items-center justify-between">
|
||||
<RouterLink
|
||||
:to="{ name: 'painel' }"
|
||||
<NuxtLink
|
||||
to="/portal/painel"
|
||||
class="flex items-center gap-3"
|
||||
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" />
|
||||
</div>
|
||||
<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">
|
||||
<RouterLink
|
||||
<NuxtLink
|
||||
v-for="item in navItems"
|
||||
:key="item.name"
|
||||
:to="{ name: item.name }"
|
||||
:key="item.path"
|
||||
:to="item.path"
|
||||
class="px-4 py-2 rounded-lg text-sm text-slate-600 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800 hover:text-slate-900 dark:hover:text-slate-100 transition-colors"
|
||||
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 }}
|
||||
</RouterLink>
|
||||
</NuxtLink>
|
||||
</nav>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="hidden sm:block text-sm text-slate-600 dark:text-slate-300" aria-live="polite">
|
||||
{{ auth.nomeUsuario }}
|
||||
{{ nomeUsuario }}
|
||||
</span>
|
||||
<Button
|
||||
label="Sair"
|
||||
@ -65,11 +64,7 @@ function sair() {
|
||||
</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">
|
||||
<RouterView v-slot="{ Component }">
|
||||
<Transition name="page" mode="out-in">
|
||||
<component :is="Component" />
|
||||
</Transition>
|
||||
</RouterView>
|
||||
<slot />
|
||||
</main>
|
||||
|
||||
<AppFooter />
|
||||
34
src/main.js
34
src/main.js
@ -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
20
src/middleware/auth.ts
Normal 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('/')
|
||||
})
|
||||
408
src/pages/credenciamento.vue
Normal file
408
src/pages/credenciamento.vue
Normal 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">
|
||||
Já 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>
|
||||
@ -1,10 +1,9 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ref, computed, watch, nextTick } from 'vue'
|
||||
import { usePrefeituraStore } from '@/stores/prefeituraStore'
|
||||
import { useAuth } from '@/composables/useAuth'
|
||||
import { useFocusLoginInput } from '@/composables/useFocusLoginInput'
|
||||
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'
|
||||
|
||||
@ -12,13 +11,30 @@ const { prefersReducedMotion } = useMotion()
|
||||
|
||||
const router = useRouter()
|
||||
const prefeitura = usePrefeituraStore()
|
||||
const { isAuthenticated, nomeUsuario, login } = useAuth()
|
||||
const { requested: focusLoginRequested, consume: consumeFocusLogin } = useFocusLoginInput()
|
||||
|
||||
const documento = 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 = {
|
||||
tutoia: bgTutoia,
|
||||
}
|
||||
@ -37,8 +53,7 @@ const heroBgStyle = computed(() => {
|
||||
|
||||
const heroHasPhoto = computed(() => !!heroBgUrl.value)
|
||||
|
||||
// ─── Avisos (carousel) ──────────────────────────────────────────────────────
|
||||
// Dados mockados — conectar ao endpoint /api/v1/publico/avisos/{dominio} futuramente
|
||||
// Dados mockados — conectar ao endpoint /publico/avisos/{dominio} futuramente
|
||||
const avisos = ref([
|
||||
{
|
||||
id: 1,
|
||||
@ -47,7 +62,7 @@ const avisos = ref([
|
||||
titulo: 'IPTU 2025 — Parcela única com desconto',
|
||||
descricao: 'Pague até 31/07 e ganhe 10% de desconto. Emita seu boleto agora mesmo.',
|
||||
cor: 'amber',
|
||||
acao: { label: 'Emitir boleto', to: { name: 'iptu' } },
|
||||
acao: { label: 'Emitir boleto', to: '/servicos/iptu' },
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
@ -56,7 +71,7 @@ const avisos = ref([
|
||||
titulo: 'Novo serviço: Certidão Online Instantânea',
|
||||
descricao: 'Emita certidões negativas em segundos, sem sair de casa e com validade legal.',
|
||||
cor: 'green',
|
||||
acao: { label: 'Emitir agora', to: { name: 'certidao' } },
|
||||
acao: { label: 'Emitir agora', to: '/servicos/certidao' },
|
||||
},
|
||||
{
|
||||
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' },
|
||||
}
|
||||
|
||||
// ─── Serviços ────────────────────────────────────────────────────────────────
|
||||
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-home', titulo: 'IPTU — Débitos e Carnê', descricao: 'Consulte débitos e imprima o carnê de pagamento.', to: { name: 'iptu' } },
|
||||
{ icon: 'pi-verified', titulo: 'Validar Documento', descricao: 'Verifique a autenticidade de certidões emitidas.', to: { name: 'servicos' } },
|
||||
{ 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: '/servicos/iptu' },
|
||||
{ icon: 'pi-verified', titulo: 'Validar Documento', descricao: 'Verifique a autenticidade de certidões emitidas.', to: '/servicos' },
|
||||
]
|
||||
|
||||
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-file-edit', titulo: 'Certidões Ativas', descricao: 'Acesse e reemita certidões emitidas anteriormente.', to: { name: 'certidoes-portal' } },
|
||||
{ icon: 'pi-briefcase', titulo: 'Alvarás', descricao: 'Acompanhe processos e solicite novos alvarás.', to: { name: 'alvaras' } },
|
||||
{ icon: 'pi-credit-card', titulo: 'Pagamentos', descricao: 'Histórico completo com comprovantes para download.', to: { name: 'pagamentos' } },
|
||||
{ icon: 'pi-user', titulo: 'Dados Cadastrais', descricao: 'Visualize e mantenha seus dados sempre atualizados.', to: { name: 'dados' } },
|
||||
{ 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: '/portal/certidoes' },
|
||||
{ icon: 'pi-briefcase', titulo: 'Alvarás', descricao: 'Acompanhe processos e solicite novos alvarás.', to: '/portal/alvaras' },
|
||||
{ icon: 'pi-credit-card', titulo: 'Pagamentos', descricao: 'Histórico completo com comprovantes para download.', to: '/portal/pagamentos' },
|
||||
{ icon: 'pi-user', titulo: 'Dados Cadastrais', descricao: 'Visualize e mantenha seus dados sempre atualizados.', to: '/portal/dados' },
|
||||
]
|
||||
|
||||
function continuar() {
|
||||
if (documento.value.replace(/\D/g, '').length < 11) {
|
||||
async function continuar() {
|
||||
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.'
|
||||
return
|
||||
}
|
||||
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>
|
||||
|
||||
@ -109,15 +131,12 @@ function continuar() {
|
||||
:class="heroHasPhoto ? '' : 'bg-gradient-to-br from-slate-900 via-primary-900 to-slate-800'"
|
||||
:style="heroBgStyle"
|
||||
>
|
||||
<!-- Padrão geométrico sutil (visível só sem foto) -->
|
||||
<div
|
||||
v-if="!heroHasPhoto"
|
||||
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;"
|
||||
/>
|
||||
|
||||
<!-- Gradiente lateral esquerdo — garante legibilidade dos textos
|
||||
independente do conteúdo da foto de fundo -->
|
||||
<div
|
||||
v-if="heroHasPhoto"
|
||||
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="grid grid-cols-1 lg:grid-cols-2 gap-12 lg:gap-20 items-center">
|
||||
|
||||
<!-- Esquerda -->
|
||||
<div>
|
||||
<!-- Identidade do município -->
|
||||
<div class="flex items-center gap-3 mb-6">
|
||||
<img
|
||||
v-if="prefeitura.pathLogo"
|
||||
@ -160,7 +177,6 @@ function continuar() {
|
||||
da prefeitura sem precisar sair de casa.
|
||||
</p>
|
||||
|
||||
<!-- Lista de serviços públicos -->
|
||||
<ul class="space-y-2 mb-8">
|
||||
<li
|
||||
v-for="s in servicosPublicos"
|
||||
@ -177,20 +193,47 @@ function continuar() {
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<RouterLink
|
||||
:to="{ name: 'servicos' }"
|
||||
<NuxtLink
|
||||
to="/servicos"
|
||||
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
|
||||
<i class="pi pi-arrow-right text-xs" />
|
||||
</RouterLink>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<!-- Direita — Card de acesso -->
|
||||
<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ê já 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="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" />
|
||||
@ -201,13 +244,13 @@ function continuar() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Formulário -->
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700 dark:text-slate-200 mb-1.5">
|
||||
CPF ou CNPJ
|
||||
</label>
|
||||
<DocumentoInput
|
||||
ref="documentoRef"
|
||||
v-model="documento"
|
||||
@keyup.enter="continuar"
|
||||
/>
|
||||
@ -223,6 +266,7 @@ function continuar() {
|
||||
icon-pos="right"
|
||||
class="w-full"
|
||||
size="large"
|
||||
:loading="carregando"
|
||||
@click="continuar"
|
||||
/>
|
||||
</div>
|
||||
@ -232,19 +276,19 @@ function continuar() {
|
||||
</Divider>
|
||||
|
||||
<div class="space-y-2.5 text-center">
|
||||
<RouterLink
|
||||
:to="{ name: 'primeiro-acesso' }"
|
||||
<NuxtLink
|
||||
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"
|
||||
>
|
||||
<i class="pi pi-key text-slate-500 dark:text-slate-400 text-sm" />
|
||||
Criar minha senha
|
||||
</RouterLink>
|
||||
<RouterLink
|
||||
:to="{ name: 'credenciamento' }"
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
to="/credenciamento"
|
||||
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>
|
||||
</RouterLink>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@ -257,7 +301,6 @@ function continuar() {
|
||||
<!-- ─── 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">
|
||||
<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
|
||||
:value="avisos"
|
||||
:num-visible="1"
|
||||
@ -275,17 +318,13 @@ function continuar() {
|
||||
corAviso[aviso.cor].borda,
|
||||
]"
|
||||
>
|
||||
<div
|
||||
:class="[
|
||||
'w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0 bg-white shadow-sm',
|
||||
]"
|
||||
>
|
||||
<div class="w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0 bg-white shadow-sm">
|
||||
<i :class="['pi', aviso.icone, 'text-lg', corAviso[aviso.cor].icone]" />
|
||||
</div>
|
||||
<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="text-xs text-slate-600 dark:text-slate-300 mt-0.5 leading-relaxed">{{ aviso.descricao }}</p>
|
||||
<RouterLink
|
||||
<NuxtLink
|
||||
v-if="aviso.acao"
|
||||
:to="aviso.acao.to"
|
||||
class="inline-block mt-3"
|
||||
@ -296,7 +335,7 @@ function continuar() {
|
||||
outlined
|
||||
class="whitespace-nowrap"
|
||||
/>
|
||||
</RouterLink>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@ -322,11 +361,10 @@ function continuar() {
|
||||
v-for="s in servicosAutenticados"
|
||||
:key="s.titulo"
|
||||
v-bind="s"
|
||||
:require-auth="true"
|
||||
:requires-auth="true"
|
||||
/>
|
||||
</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>
|
||||
<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.
|
||||
</p>
|
||||
</div>
|
||||
<RouterLink :to="{ name: 'credenciamento' }">
|
||||
<NuxtLink to="/credenciamento">
|
||||
<Button label="Solicitar Credenciamento" icon="pi pi-user-plus" class="whitespace-nowrap" />
|
||||
</RouterLink>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@ -344,7 +382,6 @@ function continuar() {
|
||||
</template>
|
||||
|
||||
<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-next) {
|
||||
width: 1.75rem;
|
||||
146
src/pages/portal/alvaras.vue
Normal file
146
src/pages/portal/alvaras.vue
Normal 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 nº {{ alv.numeroProcesso }}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 flex-shrink-0">
|
||||
<i :class="['pi', statusMap[alv.status]?.icone ?? 'pi-circle', 'text-xs', statusMap[alv.status]?.classe?.split(' ').find(c => c.startsWith('text'))]" aria-hidden="true" />
|
||||
<span :class="['text-xs font-semibold px-2 py-1 rounded-full whitespace-nowrap', statusMap[alv.status]?.classe ?? 'bg-slate-100 text-slate-500']">
|
||||
{{ statusMap[alv.status]?.label ?? alv.status }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="alv.etapas?.length" class="mt-4 flex items-center gap-1 overflow-x-auto pb-1">
|
||||
<template v-for="(etapa, idx) in alv.etapas" :key="idx">
|
||||
<div class="flex flex-col items-center gap-1 min-w-[72px]">
|
||||
<div
|
||||
class="w-6 h-6 rounded-full flex items-center justify-center text-xs"
|
||||
:class="etapa.concluida
|
||||
? 'bg-primary text-white'
|
||||
: etapa.atual
|
||||
? 'bg-primary/20 dark:bg-primary/30 text-primary border-2 border-primary'
|
||||
: 'bg-slate-100 dark:bg-slate-700 text-slate-400'"
|
||||
>
|
||||
<i v-if="etapa.concluida" class="pi pi-check text-xs" aria-hidden="true" />
|
||||
<span v-else class="text-xs font-bold">{{ idx + 1 }}</span>
|
||||
</div>
|
||||
<span class="text-xs text-slate-500 dark:text-slate-400 text-center leading-tight w-16">{{ etapa.nome }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="idx < alv.etapas.length - 1"
|
||||
class="flex-1 h-px min-w-[12px]"
|
||||
:class="alv.etapas[idx + 1].concluida || alv.etapas[idx + 1].atual ? 'bg-primary/40' : 'bg-slate-200 dark:bg-slate-700'"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<p v-if="alv.ultimaAtualizacao" class="text-xs text-slate-400 dark:text-slate-500 mt-3">
|
||||
Última atualização: {{ alv.ultimaAtualizacao }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
131
src/pages/portal/certidoes.vue
Normal file
131
src/pages/portal/certidoes.vue
Normal 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">Nº {{ 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
191
src/pages/portal/dados.vue
Normal 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>
|
||||
233
src/pages/portal/debitos.vue
Normal file
233
src/pages/portal/debitos.vue
Normal 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>
|
||||
147
src/pages/portal/pagamentos.vue
Normal file
147
src/pages/portal/pagamentos.vue
Normal 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
187
src/pages/portal/painel.vue
Normal 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>
|
||||
258
src/pages/primeiro-acesso.vue
Normal file
258
src/pages/primeiro-acesso.vue
Normal 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ê já 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>
|
||||
210
src/pages/servicos/certidao.vue
Normal file
210
src/pages/servicos/certidao.vue
Normal 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>
|
||||
@ -1,12 +1,9 @@
|
||||
<script setup>
|
||||
import { useRouter } from 'vue-router'
|
||||
import ServiceCard from '@/components/common/ServiceCard.vue'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
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-home', titulo: 'IPTU — Débitos e Carnê', descricao: 'Consulte débitos e imprima o carnê.', to: { name: 'iptu' } },
|
||||
{ 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: '/servicos/iptu' },
|
||||
{ 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-building', titulo: 'Alvará — Consulta Pública', descricao: 'Situação de alvarás pelo número do processo.' },
|
||||
@ -16,10 +13,9 @@ const servicos = [
|
||||
<template>
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||
|
||||
<!-- Voltar -->
|
||||
<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"
|
||||
@click="router.push({ name: 'home' })"
|
||||
@click="router.push('/')"
|
||||
>
|
||||
<i class="pi pi-arrow-left text-xs" aria-hidden="true" />
|
||||
Voltar
|
||||
271
src/pages/servicos/iptu.vue
Normal file
271
src/pages/servicos/iptu.vue
Normal 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>
|
||||
25
src/plugins/auth.server.ts
Normal file
25
src/plugins/auth.server.ts
Normal 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 já 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
|
||||
}
|
||||
})
|
||||
50
src/plugins/prefeitura.server.ts
Normal file
50
src/plugins/prefeitura.server.ts
Normal 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)
|
||||
}
|
||||
})
|
||||
@ -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
|
||||
@ -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
|
||||
},
|
||||
}
|
||||
22
src/services/certidaoService.js
Normal file
22
src/services/certidaoService.js
Normal 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',
|
||||
})
|
||||
},
|
||||
}
|
||||
26
src/services/credenciamentoService.js
Normal file
26
src/services/credenciamentoService.js
Normal 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,
|
||||
})
|
||||
},
|
||||
}
|
||||
36
src/services/iptuService.js
Normal file
36
src/services/iptuService.js
Normal 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',
|
||||
})
|
||||
},
|
||||
}
|
||||
86
src/services/portalService.js
Normal file
86
src/services/portalService.js
Normal 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,
|
||||
})
|
||||
},
|
||||
}
|
||||
@ -1,7 +0,0 @@
|
||||
import { apiClientPublico } from '@/config/apiClient'
|
||||
|
||||
export const prefeituraService = {
|
||||
getPrefeituraInfo(dominio) {
|
||||
return apiClientPublico.get(`/publico/prefeitura/${dominio}`)
|
||||
},
|
||||
}
|
||||
38
src/services/primeiroAcessoService.js
Normal file
38
src/services/primeiroAcessoService.js
Normal 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 },
|
||||
})
|
||||
},
|
||||
}
|
||||
@ -1,31 +1,32 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
export const useAuthStore = defineStore(
|
||||
'auth',
|
||||
() => {
|
||||
const token = ref(null)
|
||||
const userInfo = ref(null)
|
||||
// Token NÃO vive aqui — fica server-side em Redis, exposto ao front
|
||||
// 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().
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
const user = ref(null)
|
||||
|
||||
const isAuthenticated = computed(() => !!token.value)
|
||||
const nomeUsuario = computed(() => userInfo.value?.name ?? '')
|
||||
const documento = computed(() => userInfo.value?.preferred_username ?? '')
|
||||
const isAuthenticated = computed(() => !!user.value)
|
||||
const nomeUsuario = computed(() => user.value?.name ?? '')
|
||||
const documento = computed(() => user.value?.documento ?? '')
|
||||
const roles = computed(() => user.value?.roles ?? [])
|
||||
|
||||
function setSession(accessToken, info) {
|
||||
token.value = accessToken
|
||||
userInfo.value = info
|
||||
}
|
||||
function setUser(info) {
|
||||
user.value = info
|
||||
}
|
||||
|
||||
function clearSession() {
|
||||
token.value = null
|
||||
userInfo.value = null
|
||||
}
|
||||
function clearUser() {
|
||||
user.value = null
|
||||
}
|
||||
|
||||
return { token, userInfo, isAuthenticated, nomeUsuario, documento, setSession, clearSession }
|
||||
},
|
||||
{
|
||||
persist: {
|
||||
paths: ['token', 'userInfo'],
|
||||
},
|
||||
},
|
||||
)
|
||||
return {
|
||||
user,
|
||||
isAuthenticated,
|
||||
nomeUsuario,
|
||||
documento,
|
||||
roles,
|
||||
setUser,
|
||||
clearUser,
|
||||
}
|
||||
})
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
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', {
|
||||
state: () => ({
|
||||
codigoMunicipio: null,
|
||||
@ -7,7 +9,6 @@ export const usePrefeituraStore = defineStore('prefeitura', {
|
||||
dominio: null,
|
||||
template: null,
|
||||
pathLogo: null,
|
||||
pathBackground: null, // URL de foto de fundo do município (quando a API suportar)
|
||||
pathBackground: null,
|
||||
}),
|
||||
persist: true,
|
||||
})
|
||||
|
||||
@ -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
32
src/utils/tenant.ts
Normal 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)
|
||||
}
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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
3
tsconfig.json
Normal file
@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": "./.nuxt/tsconfig.json"
|
||||
}
|
||||
@ -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)),
|
||||
},
|
||||
},
|
||||
})
|
||||
Loading…
x
Reference in New Issue
Block a user