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=
|
# Arquivo template, mantido no repositório vazio por convenção.
|
||||||
VITE_KEYCLOAK_REALM=
|
# Para rodar o portal em dev, copie .env.example para .env e preencha os valores reais.
|
||||||
VITE_KEYCLOAK_CLIENT_ID=
|
# .env é ignorado pelo git.
|
||||||
VITE_API_URL=
|
|
||||||
|
|||||||
26
.env.example
Normal file
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*
|
pnpm-debug.log*
|
||||||
lerna-debug.log*
|
lerna-debug.log*
|
||||||
|
|
||||||
|
# Dependencies
|
||||||
node_modules
|
node_modules
|
||||||
|
|
||||||
|
# Build artifacts (Vite legado — pode ser removido após migração)
|
||||||
dist
|
dist
|
||||||
dist-ssr
|
dist-ssr
|
||||||
|
|
||||||
|
# Nuxt
|
||||||
|
.nuxt
|
||||||
|
.output
|
||||||
|
.nitro
|
||||||
|
.cache
|
||||||
|
|
||||||
|
# Local env files
|
||||||
*.local
|
*.local
|
||||||
|
.env
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
# Editor directories and files
|
# Editor directories and files
|
||||||
.vscode/*
|
.vscode/*
|
||||||
!.vscode/extensions.json
|
!.vscode/extensions.json
|
||||||
.idea
|
.idea
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
.claude
|
||||||
*.suo
|
*.suo
|
||||||
*.ntvs*
|
*.ntvs*
|
||||||
*.njsproj
|
*.njsproj
|
||||||
|
|||||||
20
Dockerfile
Normal file
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",
|
"name": "portal-modumfiscal-web",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.0",
|
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"build": "nuxt build",
|
||||||
"build": "vite build",
|
"dev": "nuxt dev",
|
||||||
"preview": "vite preview",
|
"generate": "nuxt generate",
|
||||||
"lint": "eslint --fix . --ext .vue,.js --ignore-path .gitignore"
|
"preview": "nuxt preview",
|
||||||
|
"postinstall": "nuxt prepare",
|
||||||
|
"lint": "eslint --fix . --ext .vue,.js,.ts --ignore-path .gitignore"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@pinia/nuxt": "^0.11.2",
|
||||||
"@primeuix/themes": "^2.0.3",
|
"@primeuix/themes": "^2.0.3",
|
||||||
|
"@primevue/nuxt-module": "^4.5.5",
|
||||||
"axios": "^1.16.1",
|
"axios": "^1.16.1",
|
||||||
|
"ioredis": "^5.4.1",
|
||||||
|
"jose": "^5.9.6",
|
||||||
"jwt-decode": "^4.0.0",
|
"jwt-decode": "^4.0.0",
|
||||||
|
"nuxt": "^3.14.0",
|
||||||
"pinia": "^3.0.4",
|
"pinia": "^3.0.4",
|
||||||
"pinia-plugin-persistedstate": "^4.7.1",
|
"pinia-plugin-persistedstate": "^4.7.1",
|
||||||
"pkce-challenge": "^6.0.0",
|
"pkce-challenge": "^6.0.0",
|
||||||
@ -20,21 +26,14 @@
|
|||||||
"primevue": "^4.5.5",
|
"primevue": "^4.5.5",
|
||||||
"tailwindcss-primeui": "^0.6.1",
|
"tailwindcss-primeui": "^0.6.1",
|
||||||
"vue": "^3.5.34",
|
"vue": "^3.5.34",
|
||||||
"vue-router": "^5.0.7",
|
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@primevue/auto-import-resolver": "^4.5.5",
|
|
||||||
"@rushstack/eslint-patch": "^1.16.1",
|
|
||||||
"@tailwindcss/vite": "^4.3.0",
|
"@tailwindcss/vite": "^4.3.0",
|
||||||
"@vitejs/plugin-vue": "^6.0.6",
|
|
||||||
"@vue/eslint-config-prettier": "^10.2.0",
|
"@vue/eslint-config-prettier": "^10.2.0",
|
||||||
"autoprefixer": "^10.5.0",
|
|
||||||
"eslint-plugin-vue": "^10.9.1",
|
"eslint-plugin-vue": "^10.9.1",
|
||||||
"postcss": "^8.5.14",
|
|
||||||
"sass": "^1.99.0",
|
"sass": "^1.99.0",
|
||||||
"tailwindcss": "^4.3.0",
|
"tailwindcss": "^4.3.0",
|
||||||
"unplugin-vue-components": "^32.0.0",
|
"typescript": "^5.6.3"
|
||||||
"vite": "^8.0.12"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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 */
|
font-size: 16px; /* base mínimo para leitura confortável */
|
||||||
}
|
}
|
||||||
|
|
||||||
#app {
|
#__nuxt {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@ -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({
|
const props = defineProps({
|
||||||
modelValue: { type: String, default: '' },
|
modelValue: { type: String, default: '' },
|
||||||
|
disabled: { type: Boolean, default: false },
|
||||||
})
|
})
|
||||||
const emit = defineEmits(['update:modelValue'])
|
const emit = defineEmits(['update:modelValue'])
|
||||||
|
|
||||||
|
const wrapper = ref(null)
|
||||||
const input = ref(props.modelValue)
|
const input = ref(props.modelValue)
|
||||||
|
|
||||||
watch(() => props.modelValue, (v) => { input.value = v })
|
watch(() => props.modelValue, (v) => { input.value = v })
|
||||||
|
|
||||||
const apenasDigitos = computed(() => input.value.replace(/\D/g, ''))
|
const apenasDigitos = computed(() => input.value.replace(/\D/g, ''))
|
||||||
|
|
||||||
const isCnpj = computed(() => apenasDigitos.value.length > 11)
|
const isCnpj = computed(() => apenasDigitos.value.length > 11)
|
||||||
|
|
||||||
const valorFormatado = computed(() => {
|
const valorFormatado = computed(() => {
|
||||||
@ -36,13 +37,20 @@ function onInput(e) {
|
|||||||
input.value = raw
|
input.value = raw
|
||||||
emit('update:modelValue', raw)
|
emit('update:modelValue', raw)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function focus() {
|
||||||
|
wrapper.value?.querySelector('input')?.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ focus })
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="relative">
|
<div ref="wrapper" class="relative">
|
||||||
<InputText
|
<InputText
|
||||||
:value="valorFormatado"
|
:value="valorFormatado"
|
||||||
:placeholder="placeholder"
|
:placeholder="placeholder"
|
||||||
|
:disabled="disabled"
|
||||||
inputmode="numeric"
|
inputmode="numeric"
|
||||||
autocomplete="username"
|
autocomplete="username"
|
||||||
class="w-full text-lg tracking-wide"
|
class="w-full text-lg tracking-wide"
|
||||||
@ -52,8 +60,15 @@ function onInput(e) {
|
|||||||
@input="onInput"
|
@input="onInput"
|
||||||
/>
|
/>
|
||||||
<span
|
<span
|
||||||
v-if="apenasDigitos.length > 0"
|
v-if="disabled"
|
||||||
class="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-slate-400 font-medium"
|
class="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-slate-400 font-medium flex items-center gap-1 pointer-events-none"
|
||||||
|
>
|
||||||
|
<i class="pi pi-lock text-[10px]" aria-hidden="true" />
|
||||||
|
{{ isCnpj ? 'CNPJ' : 'CPF' }}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-else-if="apenasDigitos.length > 0"
|
||||||
|
class="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-slate-400 font-medium pointer-events-none"
|
||||||
>
|
>
|
||||||
{{ isCnpj ? 'CNPJ' : 'CPF' }}
|
{{ isCnpj ? 'CNPJ' : 'CPF' }}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
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'
|
import { ref, watch, onMounted } from 'vue'
|
||||||
|
|
||||||
const aberto = ref(false)
|
const aberto = ref(false)
|
||||||
const nivelFonte = ref(Number(localStorage.getItem('a11y-fonte') || 0))
|
const nivelFonte = ref(0)
|
||||||
const altoContraste = ref(localStorage.getItem('a11y-contraste') === '1')
|
const altoContraste = ref(false)
|
||||||
const modoEscuro = ref(localStorage.getItem('a11y-escuro') === '1')
|
const modoEscuro = ref(false)
|
||||||
|
|
||||||
const opcoesFonte = [
|
const opcoesFonte = [
|
||||||
{ nivel: 0, label: 'A', title: 'Texto normal' },
|
{ nivel: 0, label: 'A', title: 'Texto normal' },
|
||||||
@ -18,23 +18,32 @@ function applyFonte(nivel) {
|
|||||||
if (nivel === 2) document.documentElement.classList.add('a11y-font-xl')
|
if (nivel === 2) document.documentElement.classList.add('a11y-font-xl')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Toda leitura de localStorage e DOM precisa estar dentro de onMounted —
|
||||||
|
// caso contrário o componente quebra no SSR.
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
nivelFonte.value = Number(localStorage.getItem('a11y-fonte') || 0)
|
||||||
|
altoContraste.value = localStorage.getItem('a11y-contraste') === '1'
|
||||||
|
modoEscuro.value = localStorage.getItem('a11y-escuro') === '1'
|
||||||
|
|
||||||
applyFonte(nivelFonte.value)
|
applyFonte(nivelFonte.value)
|
||||||
document.documentElement.classList.toggle('a11y-contrast', altoContraste.value)
|
document.documentElement.classList.toggle('a11y-contrast', altoContraste.value)
|
||||||
document.documentElement.classList.toggle('app-dark', modoEscuro.value)
|
document.documentElement.classList.toggle('app-dark', modoEscuro.value)
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(nivelFonte, (val) => {
|
watch(nivelFonte, (val) => {
|
||||||
|
if (!import.meta.client) return
|
||||||
applyFonte(val)
|
applyFonte(val)
|
||||||
localStorage.setItem('a11y-fonte', val)
|
localStorage.setItem('a11y-fonte', val)
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(altoContraste, (val) => {
|
watch(altoContraste, (val) => {
|
||||||
|
if (!import.meta.client) return
|
||||||
document.documentElement.classList.toggle('a11y-contrast', val)
|
document.documentElement.classList.toggle('a11y-contrast', val)
|
||||||
localStorage.setItem('a11y-contraste', val ? '1' : '0')
|
localStorage.setItem('a11y-contraste', val ? '1' : '0')
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(modoEscuro, (val) => {
|
watch(modoEscuro, (val) => {
|
||||||
|
if (!import.meta.client) return
|
||||||
document.documentElement.classList.toggle('app-dark', val)
|
document.documentElement.classList.toggle('app-dark', val)
|
||||||
localStorage.setItem('a11y-escuro', val ? '1' : '0')
|
localStorage.setItem('a11y-escuro', val ? '1' : '0')
|
||||||
})
|
})
|
||||||
|
|||||||
@ -1,21 +1,32 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { useAuthStore } from '@/stores/authStore'
|
|
||||||
import { usePrefeituraStore } from '@/stores/prefeituraStore'
|
import { usePrefeituraStore } from '@/stores/prefeituraStore'
|
||||||
|
import { useAuth } from '@/composables/useAuth'
|
||||||
|
import { useFocusLoginInput } from '@/composables/useFocusLoginInput'
|
||||||
import logoFallback from '@/assets/images/logo-modum-fiscal.png'
|
import logoFallback from '@/assets/images/logo-modum-fiscal.png'
|
||||||
|
|
||||||
const auth = useAuthStore()
|
const { isAuthenticated } = useAuth()
|
||||||
const prefeitura = usePrefeituraStore()
|
const prefeitura = usePrefeituraStore()
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const { request: requestFocusLogin } = useFocusLoginInput()
|
||||||
|
|
||||||
const logoSrc = computed(() => prefeitura.pathLogo || logoFallback)
|
const logoSrc = computed(() => prefeitura.pathLogo || logoFallback)
|
||||||
|
|
||||||
|
async function clicarEntrar() {
|
||||||
|
if (route.path !== '/') {
|
||||||
|
await router.push('/')
|
||||||
|
}
|
||||||
|
requestFocusLogin()
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<header class="bg-white dark:bg-slate-900 border-b border-slate-200 dark:border-slate-700">
|
<header class="bg-white dark:bg-slate-900 border-b border-slate-200 dark:border-slate-700">
|
||||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 h-16 flex items-center justify-between">
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 h-16 flex items-center justify-between">
|
||||||
|
|
||||||
<RouterLink
|
<NuxtLink
|
||||||
:to="{ name: 'home' }"
|
to="/"
|
||||||
class="flex items-center gap-3 min-w-0"
|
class="flex items-center gap-3 min-w-0"
|
||||||
:aria-label="`Ir para a página inicial — ${prefeitura.nomePrefeitura || 'ModumFiscal'}`"
|
:aria-label="`Ir para a página inicial — ${prefeitura.nomePrefeitura || 'ModumFiscal'}`"
|
||||||
>
|
>
|
||||||
@ -31,28 +42,32 @@ const logoSrc = computed(() => prefeitura.pathLogo || logoFallback)
|
|||||||
{{ prefeitura.nomePrefeitura || 'ModumFiscal' }}
|
{{ prefeitura.nomePrefeitura || 'ModumFiscal' }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</RouterLink>
|
</NuxtLink>
|
||||||
|
|
||||||
<nav aria-label="Navegação principal" class="hidden md:flex items-center gap-4">
|
<nav aria-label="Navegação principal" class="hidden md:flex items-center gap-4">
|
||||||
<RouterLink
|
<NuxtLink
|
||||||
:to="{ name: 'servicos' }"
|
to="/servicos"
|
||||||
class="text-sm text-slate-600 dark:text-slate-300 hover:text-primary transition-colors"
|
class="text-sm text-slate-600 dark:text-slate-300 hover:text-primary transition-colors"
|
||||||
:aria-current="$route.name === 'servicos' ? 'page' : undefined"
|
:aria-current="route.path === '/servicos' ? 'page' : undefined"
|
||||||
>
|
>
|
||||||
Serviços
|
Serviços
|
||||||
</RouterLink>
|
</NuxtLink>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<template v-if="auth.isAuthenticated">
|
<template v-if="isAuthenticated">
|
||||||
<RouterLink :to="{ name: 'painel' }">
|
<NuxtLink to="/portal/painel">
|
||||||
<Button label="Meu Painel" icon="pi pi-user" size="small" aria-label="Ir para o meu painel" />
|
<Button label="Meu Painel" icon="pi pi-user" size="small" aria-label="Ir para o meu painel" />
|
||||||
</RouterLink>
|
</NuxtLink>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<RouterLink :to="{ name: 'home' }">
|
<Button
|
||||||
<Button label="Entrar" icon="pi pi-sign-in" size="small" aria-label="Entrar no portal" />
|
label="Entrar"
|
||||||
</RouterLink>
|
icon="pi pi-sign-in"
|
||||||
|
size="small"
|
||||||
|
aria-label="Entrar no portal"
|
||||||
|
@click="clicarEntrar"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -6,14 +6,12 @@ defineProps({
|
|||||||
to: { type: [String, Object], default: null },
|
to: { type: [String, Object], default: null },
|
||||||
requiresAuth: { type: Boolean, default: false },
|
requiresAuth: { type: Boolean, default: false },
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const cardClasses = 'group flex flex-col gap-3 bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-5 hover:border-primary/40 dark:hover:border-primary/50 hover:shadow-md transition-all duration-200 cursor-pointer'
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<component
|
<NuxtLink v-if="to" :to="to" :class="cardClasses">
|
||||||
:is="to ? 'RouterLink' : 'div'"
|
|
||||||
:to="to ?? undefined"
|
|
||||||
class="group flex flex-col gap-3 bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-5 hover:border-primary/40 dark:hover:border-primary/50 hover:shadow-md transition-all duration-200 cursor-pointer"
|
|
||||||
>
|
|
||||||
<div class="flex items-start justify-between">
|
<div class="flex items-start justify-between">
|
||||||
<div class="w-10 h-10 bg-primary/8 dark:bg-primary/15 rounded-lg flex items-center justify-center group-hover:bg-primary/15 dark:group-hover:bg-primary/25 transition-colors">
|
<div class="w-10 h-10 bg-primary/8 dark:bg-primary/15 rounded-lg flex items-center justify-center group-hover:bg-primary/15 dark:group-hover:bg-primary/25 transition-colors">
|
||||||
<i :class="['pi', icon, 'text-primary text-lg']" />
|
<i :class="['pi', icon, 'text-primary text-lg']" />
|
||||||
@ -28,5 +26,18 @@ defineProps({
|
|||||||
<span>Acessar</span>
|
<span>Acessar</span>
|
||||||
<i class="pi pi-arrow-right text-xs" />
|
<i class="pi pi-arrow-right text-xs" />
|
||||||
</div>
|
</div>
|
||||||
</component>
|
</NuxtLink>
|
||||||
|
|
||||||
|
<div v-else :class="cardClasses">
|
||||||
|
<div class="flex items-start justify-between">
|
||||||
|
<div class="w-10 h-10 bg-primary/8 dark:bg-primary/15 rounded-lg flex items-center justify-center group-hover:bg-primary/15 dark:group-hover:bg-primary/25 transition-colors">
|
||||||
|
<i :class="['pi', icon, 'text-primary text-lg']" />
|
||||||
|
</div>
|
||||||
|
<i v-if="requiresAuth" class="pi pi-lock text-slate-300 dark:text-slate-600 text-xs" title="Requer login" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="font-semibold text-slate-800 dark:text-slate-100 text-sm">{{ titulo }}</p>
|
||||||
|
<p v-if="descricao" class="text-xs text-slate-500 dark:text-slate-400 mt-1 leading-relaxed">{{ descricao }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
43
src/composables/useApi.ts
Normal file
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á -->
|
<!-- tabindex="-1" permite que o skip link mova o foco para cá -->
|
||||||
<main id="main-content" tabindex="-1" class="flex-1 outline-none">
|
<main id="main-content" tabindex="-1" class="flex-1 outline-none">
|
||||||
<RouterView v-slot="{ Component }">
|
<slot />
|
||||||
<Transition name="page" mode="out-in">
|
|
||||||
<component :is="Component" />
|
|
||||||
</Transition>
|
|
||||||
</RouterView>
|
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<AppFooter />
|
<AppFooter />
|
||||||
@ -1,21 +1,20 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { useAuthStore } from '@/stores/authStore'
|
import { useAuth } from '@/composables/useAuth'
|
||||||
import { authService } from '@/services/authService'
|
|
||||||
|
|
||||||
const auth = useAuthStore()
|
const { nomeUsuario, logout } = useAuth()
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ name: 'painel', label: 'Painel' },
|
{ path: '/portal/painel', label: 'Painel' },
|
||||||
{ name: 'debitos', label: 'Débitos' },
|
{ path: '/portal/debitos', label: 'Débitos' },
|
||||||
{ name: 'certidoes-portal', label: 'Certidões' },
|
{ path: '/portal/certidoes', label: 'Certidões' },
|
||||||
{ name: 'alvaras', label: 'Alvarás' },
|
{ path: '/portal/alvaras', label: 'Alvarás' },
|
||||||
{ name: 'pagamentos', label: 'Pagamentos' },
|
{ path: '/portal/pagamentos', label: 'Pagamentos' },
|
||||||
{ name: 'dados', label: 'Dados Cadastrais' },
|
{ path: '/portal/dados', label: 'Dados Cadastrais' },
|
||||||
]
|
]
|
||||||
|
|
||||||
function sair() {
|
function sair() {
|
||||||
auth.clearSession()
|
logout()
|
||||||
authService.logout()
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@ -23,8 +22,8 @@ function sair() {
|
|||||||
<div class="min-h-screen flex flex-col bg-slate-50 dark:bg-slate-950">
|
<div class="min-h-screen flex flex-col bg-slate-50 dark:bg-slate-950">
|
||||||
<header class="bg-white dark:bg-slate-900 border-b border-slate-200 dark:border-slate-700 sticky top-0 z-40" role="banner">
|
<header class="bg-white dark:bg-slate-900 border-b border-slate-200 dark:border-slate-700 sticky top-0 z-40" role="banner">
|
||||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 h-16 flex items-center justify-between">
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 h-16 flex items-center justify-between">
|
||||||
<RouterLink
|
<NuxtLink
|
||||||
:to="{ name: 'painel' }"
|
to="/portal/painel"
|
||||||
class="flex items-center gap-3"
|
class="flex items-center gap-3"
|
||||||
aria-label="Ir para o painel principal"
|
aria-label="Ir para o painel principal"
|
||||||
>
|
>
|
||||||
@ -32,24 +31,24 @@ function sair() {
|
|||||||
<i class="pi pi-building text-white text-sm" aria-hidden="true" />
|
<i class="pi pi-building text-white text-sm" aria-hidden="true" />
|
||||||
</div>
|
</div>
|
||||||
<span class="font-semibold text-slate-800 dark:text-slate-100">Portal do Contribuinte</span>
|
<span class="font-semibold text-slate-800 dark:text-slate-100">Portal do Contribuinte</span>
|
||||||
</RouterLink>
|
</NuxtLink>
|
||||||
|
|
||||||
<nav aria-label="Menu do contribuinte" class="hidden md:flex items-center gap-1">
|
<nav aria-label="Menu do contribuinte" class="hidden md:flex items-center gap-1">
|
||||||
<RouterLink
|
<NuxtLink
|
||||||
v-for="item in navItems"
|
v-for="item in navItems"
|
||||||
:key="item.name"
|
:key="item.path"
|
||||||
:to="{ name: item.name }"
|
:to="item.path"
|
||||||
class="px-4 py-2 rounded-lg text-sm text-slate-600 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800 hover:text-slate-900 dark:hover:text-slate-100 transition-colors"
|
class="px-4 py-2 rounded-lg text-sm text-slate-600 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800 hover:text-slate-900 dark:hover:text-slate-100 transition-colors"
|
||||||
active-class="bg-primary/10 text-primary font-semibold"
|
active-class="bg-primary/10 text-primary font-semibold"
|
||||||
:aria-current="$route.name === item.name ? 'page' : undefined"
|
:aria-current="route.path === item.path ? 'page' : undefined"
|
||||||
>
|
>
|
||||||
{{ item.label }}
|
{{ item.label }}
|
||||||
</RouterLink>
|
</NuxtLink>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<span class="hidden sm:block text-sm text-slate-600 dark:text-slate-300" aria-live="polite">
|
<span class="hidden sm:block text-sm text-slate-600 dark:text-slate-300" aria-live="polite">
|
||||||
{{ auth.nomeUsuario }}
|
{{ nomeUsuario }}
|
||||||
</span>
|
</span>
|
||||||
<Button
|
<Button
|
||||||
label="Sair"
|
label="Sair"
|
||||||
@ -65,11 +64,7 @@ function sair() {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main id="main-content" tabindex="-1" class="flex-1 max-w-7xl mx-auto w-full px-4 sm:px-6 lg:px-8 py-8 outline-none">
|
<main id="main-content" tabindex="-1" class="flex-1 max-w-7xl mx-auto w-full px-4 sm:px-6 lg:px-8 py-8 outline-none">
|
||||||
<RouterView v-slot="{ Component }">
|
<slot />
|
||||||
<Transition name="page" mode="out-in">
|
|
||||||
<component :is="Component" />
|
|
||||||
</Transition>
|
|
||||||
</RouterView>
|
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<AppFooter />
|
<AppFooter />
|
||||||
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>
|
<script setup>
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed, watch, nextTick } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
|
||||||
import { usePrefeituraStore } from '@/stores/prefeituraStore'
|
import { usePrefeituraStore } from '@/stores/prefeituraStore'
|
||||||
|
import { useAuth } from '@/composables/useAuth'
|
||||||
|
import { useFocusLoginInput } from '@/composables/useFocusLoginInput'
|
||||||
import { useMotion } from '@/composables/useMotion'
|
import { useMotion } from '@/composables/useMotion'
|
||||||
import DocumentoInput from '@/components/auth/DocumentoInput.vue'
|
|
||||||
import ServiceCard from '@/components/common/ServiceCard.vue'
|
|
||||||
|
|
||||||
import bgTutoia from '@/assets/images/bg-tutoia.jpeg'
|
import bgTutoia from '@/assets/images/bg-tutoia.jpeg'
|
||||||
|
|
||||||
@ -12,13 +11,30 @@ const { prefersReducedMotion } = useMotion()
|
|||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const prefeitura = usePrefeituraStore()
|
const prefeitura = usePrefeituraStore()
|
||||||
|
const { isAuthenticated, nomeUsuario, login } = useAuth()
|
||||||
|
const { requested: focusLoginRequested, consume: consumeFocusLogin } = useFocusLoginInput()
|
||||||
|
|
||||||
const documento = ref('')
|
const documento = ref('')
|
||||||
const erro = ref('')
|
const erro = ref('')
|
||||||
|
const carregando = ref(false)
|
||||||
|
|
||||||
|
// Ref ao DocumentoInput — usado pelo botão "Entrar" do AppHeader pra focar o campo
|
||||||
|
const documentoRef = ref(null)
|
||||||
|
|
||||||
|
// Quando o AppHeader sinaliza intenção de login (clique no botão Entrar),
|
||||||
|
// foca o campo + scroll suave pra ele.
|
||||||
|
watch(focusLoginRequested, async (v) => {
|
||||||
|
if (!v || isAuthenticated.value) {
|
||||||
|
if (v) consumeFocusLogin()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await nextTick()
|
||||||
|
const el = documentoRef.value?.$el ?? documentoRef.value
|
||||||
|
el?.scrollIntoView?.({ behavior: 'smooth', block: 'center' })
|
||||||
|
documentoRef.value?.focus?.()
|
||||||
|
consumeFocusLogin()
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
// ─── Hero background ─────────────────────────────────────────────────────────
|
|
||||||
// Mapa template → imagem estática (Vite resolve na build).
|
|
||||||
// Para adicionar novo município: importar a foto e adicionar a chave abaixo.
|
|
||||||
const heroBgMap = {
|
const heroBgMap = {
|
||||||
tutoia: bgTutoia,
|
tutoia: bgTutoia,
|
||||||
}
|
}
|
||||||
@ -37,8 +53,7 @@ const heroBgStyle = computed(() => {
|
|||||||
|
|
||||||
const heroHasPhoto = computed(() => !!heroBgUrl.value)
|
const heroHasPhoto = computed(() => !!heroBgUrl.value)
|
||||||
|
|
||||||
// ─── Avisos (carousel) ──────────────────────────────────────────────────────
|
// Dados mockados — conectar ao endpoint /publico/avisos/{dominio} futuramente
|
||||||
// Dados mockados — conectar ao endpoint /api/v1/publico/avisos/{dominio} futuramente
|
|
||||||
const avisos = ref([
|
const avisos = ref([
|
||||||
{
|
{
|
||||||
id: 1,
|
id: 1,
|
||||||
@ -47,7 +62,7 @@ const avisos = ref([
|
|||||||
titulo: 'IPTU 2025 — Parcela única com desconto',
|
titulo: 'IPTU 2025 — Parcela única com desconto',
|
||||||
descricao: 'Pague até 31/07 e ganhe 10% de desconto. Emita seu boleto agora mesmo.',
|
descricao: 'Pague até 31/07 e ganhe 10% de desconto. Emita seu boleto agora mesmo.',
|
||||||
cor: 'amber',
|
cor: 'amber',
|
||||||
acao: { label: 'Emitir boleto', to: { name: 'iptu' } },
|
acao: { label: 'Emitir boleto', to: '/servicos/iptu' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 2,
|
id: 2,
|
||||||
@ -56,7 +71,7 @@ const avisos = ref([
|
|||||||
titulo: 'Novo serviço: Certidão Online Instantânea',
|
titulo: 'Novo serviço: Certidão Online Instantânea',
|
||||||
descricao: 'Emita certidões negativas em segundos, sem sair de casa e com validade legal.',
|
descricao: 'Emita certidões negativas em segundos, sem sair de casa e com validade legal.',
|
||||||
cor: 'green',
|
cor: 'green',
|
||||||
acao: { label: 'Emitir agora', to: { name: 'certidao' } },
|
acao: { label: 'Emitir agora', to: '/servicos/certidao' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 3,
|
id: 3,
|
||||||
@ -75,28 +90,35 @@ const corAviso = {
|
|||||||
blue: { bg: 'bg-blue-50 dark:bg-blue-900/20', borda: 'border-blue-200 dark:border-blue-700/40', icone: 'text-blue-600 dark:text-blue-400', tag: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300' },
|
blue: { bg: 'bg-blue-50 dark:bg-blue-900/20', borda: 'border-blue-200 dark:border-blue-700/40', icone: 'text-blue-600 dark:text-blue-400', tag: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300' },
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Serviços ────────────────────────────────────────────────────────────────
|
|
||||||
const servicosPublicos = [
|
const servicosPublicos = [
|
||||||
{ icon: 'pi-file-check', titulo: 'Emissão de Certidão', descricao: 'Certidão negativa, positiva e situação fiscal.', to: { name: 'certidao' } },
|
{ icon: 'pi-file-check', titulo: 'Emissão de Certidão', descricao: 'Certidão negativa, positiva e situação fiscal.', to: '/servicos/certidao' },
|
||||||
{ icon: 'pi-home', titulo: 'IPTU — Débitos e Carnê', descricao: 'Consulte débitos e imprima o carnê de pagamento.', to: { name: 'iptu' } },
|
{ icon: 'pi-home', titulo: 'IPTU — Débitos e Carnê', descricao: 'Consulte débitos e imprima o carnê de pagamento.', to: '/servicos/iptu' },
|
||||||
{ icon: 'pi-verified', titulo: 'Validar Documento', descricao: 'Verifique a autenticidade de certidões emitidas.', to: { name: 'servicos' } },
|
{ icon: 'pi-verified', titulo: 'Validar Documento', descricao: 'Verifique a autenticidade de certidões emitidas.', to: '/servicos' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const servicosAutenticados = [
|
const servicosAutenticados = [
|
||||||
{ icon: 'pi-receipt', titulo: 'Débitos e Guias', descricao: 'Emita guias de pagamento de todos os seus tributos.', to: { name: 'debitos' } },
|
{ icon: 'pi-receipt', titulo: 'Débitos e Guias', descricao: 'Emita guias de pagamento de todos os seus tributos.', to: '/portal/debitos' },
|
||||||
{ icon: 'pi-file-edit', titulo: 'Certidões Ativas', descricao: 'Acesse e reemita certidões emitidas anteriormente.', to: { name: 'certidoes-portal' } },
|
{ icon: 'pi-file-edit', titulo: 'Certidões Ativas', descricao: 'Acesse e reemita certidões emitidas anteriormente.', to: '/portal/certidoes' },
|
||||||
{ icon: 'pi-briefcase', titulo: 'Alvarás', descricao: 'Acompanhe processos e solicite novos alvarás.', to: { name: 'alvaras' } },
|
{ icon: 'pi-briefcase', titulo: 'Alvarás', descricao: 'Acompanhe processos e solicite novos alvarás.', to: '/portal/alvaras' },
|
||||||
{ icon: 'pi-credit-card', titulo: 'Pagamentos', descricao: 'Histórico completo com comprovantes para download.', to: { name: 'pagamentos' } },
|
{ icon: 'pi-credit-card', titulo: 'Pagamentos', descricao: 'Histórico completo com comprovantes para download.', to: '/portal/pagamentos' },
|
||||||
{ icon: 'pi-user', titulo: 'Dados Cadastrais', descricao: 'Visualize e mantenha seus dados sempre atualizados.', to: { name: 'dados' } },
|
{ icon: 'pi-user', titulo: 'Dados Cadastrais', descricao: 'Visualize e mantenha seus dados sempre atualizados.', to: '/portal/dados' },
|
||||||
]
|
]
|
||||||
|
|
||||||
function continuar() {
|
async function continuar() {
|
||||||
if (documento.value.replace(/\D/g, '').length < 11) {
|
const doc = documento.value.replace(/\D/g, '')
|
||||||
|
if (doc.length < 11) {
|
||||||
erro.value = 'Informe um CPF (11 dígitos) ou CNPJ (14 dígitos) válido.'
|
erro.value = 'Informe um CPF (11 dígitos) ou CNPJ (14 dígitos) válido.'
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
erro.value = ''
|
erro.value = ''
|
||||||
router.push({ name: 'login', query: { doc: documento.value } })
|
carregando.value = true
|
||||||
|
try {
|
||||||
|
await login(doc, '/portal/painel')
|
||||||
|
// login() faz window.location → não retorna aqui em condições normais
|
||||||
|
} catch (e) {
|
||||||
|
carregando.value = false
|
||||||
|
erro.value = e?.data?.statusMessage ?? 'Não foi possível iniciar o login.'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@ -109,15 +131,12 @@ function continuar() {
|
|||||||
:class="heroHasPhoto ? '' : 'bg-gradient-to-br from-slate-900 via-primary-900 to-slate-800'"
|
:class="heroHasPhoto ? '' : 'bg-gradient-to-br from-slate-900 via-primary-900 to-slate-800'"
|
||||||
:style="heroBgStyle"
|
:style="heroBgStyle"
|
||||||
>
|
>
|
||||||
<!-- Padrão geométrico sutil (visível só sem foto) -->
|
|
||||||
<div
|
<div
|
||||||
v-if="!heroHasPhoto"
|
v-if="!heroHasPhoto"
|
||||||
class="absolute inset-0 opacity-5 pointer-events-none"
|
class="absolute inset-0 opacity-5 pointer-events-none"
|
||||||
style="background-image: radial-gradient(circle at 25px 25px, white 2px, transparent 0); background-size: 50px 50px;"
|
style="background-image: radial-gradient(circle at 25px 25px, white 2px, transparent 0); background-size: 50px 50px;"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Gradiente lateral esquerdo — garante legibilidade dos textos
|
|
||||||
independente do conteúdo da foto de fundo -->
|
|
||||||
<div
|
<div
|
||||||
v-if="heroHasPhoto"
|
v-if="heroHasPhoto"
|
||||||
class="absolute inset-0 pointer-events-none"
|
class="absolute inset-0 pointer-events-none"
|
||||||
@ -127,9 +146,7 @@ function continuar() {
|
|||||||
<div class="relative max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-14 lg:py-20">
|
<div class="relative max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-14 lg:py-20">
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-12 lg:gap-20 items-center">
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-12 lg:gap-20 items-center">
|
||||||
|
|
||||||
<!-- Esquerda -->
|
|
||||||
<div>
|
<div>
|
||||||
<!-- Identidade do município -->
|
|
||||||
<div class="flex items-center gap-3 mb-6">
|
<div class="flex items-center gap-3 mb-6">
|
||||||
<img
|
<img
|
||||||
v-if="prefeitura.pathLogo"
|
v-if="prefeitura.pathLogo"
|
||||||
@ -160,7 +177,6 @@ function continuar() {
|
|||||||
da prefeitura sem precisar sair de casa.
|
da prefeitura sem precisar sair de casa.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<!-- Lista de serviços públicos -->
|
|
||||||
<ul class="space-y-2 mb-8">
|
<ul class="space-y-2 mb-8">
|
||||||
<li
|
<li
|
||||||
v-for="s in servicosPublicos"
|
v-for="s in servicosPublicos"
|
||||||
@ -177,20 +193,47 @@ function continuar() {
|
|||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<RouterLink
|
<NuxtLink
|
||||||
:to="{ name: 'servicos' }"
|
to="/servicos"
|
||||||
class="inline-flex items-center gap-2 text-sm text-white/80 hover:text-white transition-colors font-medium"
|
class="inline-flex items-center gap-2 text-sm text-white/80 hover:text-white transition-colors font-medium"
|
||||||
>
|
>
|
||||||
Ver todos os serviços disponíveis
|
Ver todos os serviços disponíveis
|
||||||
<i class="pi pi-arrow-right text-xs" />
|
<i class="pi pi-arrow-right text-xs" />
|
||||||
</RouterLink>
|
</NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Direita — Card de acesso -->
|
|
||||||
<div class="flex justify-center lg:justify-end">
|
<div class="flex justify-center lg:justify-end">
|
||||||
<div class="bg-white dark:bg-slate-800 rounded-2xl shadow-2xl p-8 w-full max-w-sm backdrop-blur-sm">
|
<!-- ── Card de saudação (usuário autenticado) ── -->
|
||||||
|
<div v-if="isAuthenticated" class="bg-white dark:bg-slate-800 rounded-2xl shadow-2xl p-8 w-full max-w-sm backdrop-blur-sm">
|
||||||
|
<div class="flex items-center gap-3 mb-6">
|
||||||
|
<div class="w-11 h-11 bg-primary rounded-xl flex items-center justify-center shadow-sm">
|
||||||
|
<i class="pi pi-user text-white text-lg" />
|
||||||
|
</div>
|
||||||
|
<div class="min-w-0">
|
||||||
|
<p class="text-xs text-slate-500 dark:text-slate-400">Bem-vindo(a) de volta</p>
|
||||||
|
<p class="font-bold text-slate-800 dark:text-slate-100 text-base truncate">{{ nomeUsuario || 'Contribuinte' }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-sm text-slate-600 dark:text-slate-300 mb-6 leading-relaxed">
|
||||||
|
Você 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="flex items-center gap-3 mb-7">
|
||||||
<div class="w-11 h-11 bg-primary rounded-xl flex items-center justify-center shadow-sm">
|
<div class="w-11 h-11 bg-primary rounded-xl flex items-center justify-center shadow-sm">
|
||||||
<i class="pi pi-lock-open text-white text-lg" />
|
<i class="pi pi-lock-open text-white text-lg" />
|
||||||
@ -201,13 +244,13 @@ function continuar() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Formulário -->
|
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-slate-700 dark:text-slate-200 mb-1.5">
|
<label class="block text-sm font-medium text-slate-700 dark:text-slate-200 mb-1.5">
|
||||||
CPF ou CNPJ
|
CPF ou CNPJ
|
||||||
</label>
|
</label>
|
||||||
<DocumentoInput
|
<DocumentoInput
|
||||||
|
ref="documentoRef"
|
||||||
v-model="documento"
|
v-model="documento"
|
||||||
@keyup.enter="continuar"
|
@keyup.enter="continuar"
|
||||||
/>
|
/>
|
||||||
@ -223,6 +266,7 @@ function continuar() {
|
|||||||
icon-pos="right"
|
icon-pos="right"
|
||||||
class="w-full"
|
class="w-full"
|
||||||
size="large"
|
size="large"
|
||||||
|
:loading="carregando"
|
||||||
@click="continuar"
|
@click="continuar"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -232,19 +276,19 @@ function continuar() {
|
|||||||
</Divider>
|
</Divider>
|
||||||
|
|
||||||
<div class="space-y-2.5 text-center">
|
<div class="space-y-2.5 text-center">
|
||||||
<RouterLink
|
<NuxtLink
|
||||||
:to="{ name: 'primeiro-acesso' }"
|
to="/primeiro-acesso"
|
||||||
class="flex items-center justify-center gap-2 w-full px-4 py-2.5 rounded-lg border border-slate-200 dark:border-slate-600 text-sm text-slate-700 dark:text-slate-200 hover:bg-slate-50 dark:hover:bg-slate-700 hover:border-slate-300 dark:hover:border-slate-500 transition-colors font-medium"
|
class="flex items-center justify-center gap-2 w-full px-4 py-2.5 rounded-lg border border-slate-200 dark:border-slate-600 text-sm text-slate-700 dark:text-slate-200 hover:bg-slate-50 dark:hover:bg-slate-700 hover:border-slate-300 dark:hover:border-slate-500 transition-colors font-medium"
|
||||||
>
|
>
|
||||||
<i class="pi pi-key text-slate-500 dark:text-slate-400 text-sm" />
|
<i class="pi pi-key text-slate-500 dark:text-slate-400 text-sm" />
|
||||||
Criar minha senha
|
Criar minha senha
|
||||||
</RouterLink>
|
</NuxtLink>
|
||||||
<RouterLink
|
<NuxtLink
|
||||||
:to="{ name: 'credenciamento' }"
|
to="/credenciamento"
|
||||||
class="block text-xs text-slate-400 dark:text-slate-500 hover:text-slate-600 dark:hover:text-slate-300 transition-colors"
|
class="block text-xs text-slate-400 dark:text-slate-500 hover:text-slate-600 dark:hover:text-slate-300 transition-colors"
|
||||||
>
|
>
|
||||||
Ainda não cadastrado? <span class="text-primary font-semibold">Credenciar-se</span>
|
Ainda não cadastrado? <span class="text-primary font-semibold">Credenciar-se</span>
|
||||||
</RouterLink>
|
</NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@ -257,7 +301,6 @@ function continuar() {
|
|||||||
<!-- ─── CAROUSEL DE AVISOS ────────────────────────────────────────── -->
|
<!-- ─── CAROUSEL DE AVISOS ────────────────────────────────────────── -->
|
||||||
<section class="bg-white dark:bg-slate-900 border-b border-slate-100 dark:border-slate-800" aria-label="Avisos e comunicados">
|
<section class="bg-white dark:bg-slate-900 border-b border-slate-100 dark:border-slate-800" aria-label="Avisos e comunicados">
|
||||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||||
<!-- autoplay desativado quando prefers-reduced-motion está ativo -->
|
|
||||||
<Carousel
|
<Carousel
|
||||||
:value="avisos"
|
:value="avisos"
|
||||||
:num-visible="1"
|
:num-visible="1"
|
||||||
@ -275,17 +318,13 @@ function continuar() {
|
|||||||
corAviso[aviso.cor].borda,
|
corAviso[aviso.cor].borda,
|
||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
<div
|
<div class="w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0 bg-white shadow-sm">
|
||||||
:class="[
|
|
||||||
'w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0 bg-white shadow-sm',
|
|
||||||
]"
|
|
||||||
>
|
|
||||||
<i :class="['pi', aviso.icone, 'text-lg', corAviso[aviso.cor].icone]" />
|
<i :class="['pi', aviso.icone, 'text-lg', corAviso[aviso.cor].icone]" />
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<p class="font-semibold text-slate-800 dark:text-slate-100 text-sm">{{ aviso.titulo }}</p>
|
<p class="font-semibold text-slate-800 dark:text-slate-100 text-sm">{{ aviso.titulo }}</p>
|
||||||
<p class="text-xs text-slate-600 dark:text-slate-300 mt-0.5 leading-relaxed">{{ aviso.descricao }}</p>
|
<p class="text-xs text-slate-600 dark:text-slate-300 mt-0.5 leading-relaxed">{{ aviso.descricao }}</p>
|
||||||
<RouterLink
|
<NuxtLink
|
||||||
v-if="aviso.acao"
|
v-if="aviso.acao"
|
||||||
:to="aviso.acao.to"
|
:to="aviso.acao.to"
|
||||||
class="inline-block mt-3"
|
class="inline-block mt-3"
|
||||||
@ -296,7 +335,7 @@ function continuar() {
|
|||||||
outlined
|
outlined
|
||||||
class="whitespace-nowrap"
|
class="whitespace-nowrap"
|
||||||
/>
|
/>
|
||||||
</RouterLink>
|
</NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@ -322,11 +361,10 @@ function continuar() {
|
|||||||
v-for="s in servicosAutenticados"
|
v-for="s in servicosAutenticados"
|
||||||
:key="s.titulo"
|
:key="s.titulo"
|
||||||
v-bind="s"
|
v-bind="s"
|
||||||
:require-auth="true"
|
:requires-auth="true"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- CTA credenciamento -->
|
|
||||||
<div class="mt-10 bg-gradient-to-r from-primary/5 to-primary/10 dark:from-primary/10 dark:to-primary/15 border border-primary/15 dark:border-primary/20 rounded-2xl p-6 flex flex-col sm:flex-row items-center justify-between gap-5">
|
<div class="mt-10 bg-gradient-to-r from-primary/5 to-primary/10 dark:from-primary/10 dark:to-primary/15 border border-primary/15 dark:border-primary/20 rounded-2xl p-6 flex flex-col sm:flex-row items-center justify-between gap-5">
|
||||||
<div>
|
<div>
|
||||||
<p class="font-bold text-slate-800 dark:text-slate-100 text-base">Ainda não tem acesso ao portal?</p>
|
<p class="font-bold text-slate-800 dark:text-slate-100 text-base">Ainda não tem acesso ao portal?</p>
|
||||||
@ -334,9 +372,9 @@ function continuar() {
|
|||||||
Solicite seu credenciamento e passe a gerenciar todos os seus tributos municipais de forma online.
|
Solicite seu credenciamento e passe a gerenciar todos os seus tributos municipais de forma online.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<RouterLink :to="{ name: 'credenciamento' }">
|
<NuxtLink to="/credenciamento">
|
||||||
<Button label="Solicitar Credenciamento" icon="pi pi-user-plus" class="whitespace-nowrap" />
|
<Button label="Solicitar Credenciamento" icon="pi pi-user-plus" class="whitespace-nowrap" />
|
||||||
</RouterLink>
|
</NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@ -344,7 +382,6 @@ function continuar() {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
/* Remove as setas de navegação padrão do Carousel no contexto dos avisos */
|
|
||||||
.aviso-carousel :deep(.p-carousel-prev),
|
.aviso-carousel :deep(.p-carousel-prev),
|
||||||
.aviso-carousel :deep(.p-carousel-next) {
|
.aviso-carousel :deep(.p-carousel-next) {
|
||||||
width: 1.75rem;
|
width: 1.75rem;
|
||||||
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>
|
<script setup>
|
||||||
import { useRouter } from 'vue-router'
|
|
||||||
import ServiceCard from '@/components/common/ServiceCard.vue'
|
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
const servicos = [
|
const servicos = [
|
||||||
{ icon: 'pi-file-check', titulo: 'Emissão de Certidão', descricao: 'Emita certidões de situação fiscal.', to: { name: 'certidao' } },
|
{ icon: 'pi-file-check', titulo: 'Emissão de Certidão', descricao: 'Emita certidões de situação fiscal.', to: '/servicos/certidao' },
|
||||||
{ icon: 'pi-home', titulo: 'IPTU — Débitos e Carnê', descricao: 'Consulte débitos e imprima o carnê.', to: { name: 'iptu' } },
|
{ icon: 'pi-home', titulo: 'IPTU — Débitos e Carnê', descricao: 'Consulte débitos e imprima o carnê.', to: '/servicos/iptu' },
|
||||||
{ icon: 'pi-verified', titulo: 'Validar Documento', descricao: 'Valide certidões e documentos emitidos.' },
|
{ icon: 'pi-verified', titulo: 'Validar Documento', descricao: 'Valide certidões e documentos emitidos.' },
|
||||||
{ icon: 'pi-map-marker', titulo: 'Consulta Cadastral', descricao: 'Consulte dados cadastrais do imóvel ou empresa.' },
|
{ icon: 'pi-map-marker', titulo: 'Consulta Cadastral', descricao: 'Consulte dados cadastrais do imóvel ou empresa.' },
|
||||||
{ icon: 'pi-building', titulo: 'Alvará — Consulta Pública', descricao: 'Situação de alvarás pelo número do processo.' },
|
{ icon: 'pi-building', titulo: 'Alvará — Consulta Pública', descricao: 'Situação de alvarás pelo número do processo.' },
|
||||||
@ -16,10 +13,9 @@ const servicos = [
|
|||||||
<template>
|
<template>
|
||||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||||
|
|
||||||
<!-- Voltar -->
|
|
||||||
<button
|
<button
|
||||||
class="inline-flex items-center gap-2 text-sm text-slate-500 dark:text-slate-400 hover:text-slate-800 dark:hover:text-slate-200 transition-colors mb-6 py-1"
|
class="inline-flex items-center gap-2 text-sm text-slate-500 dark:text-slate-400 hover:text-slate-800 dark:hover:text-slate-200 transition-colors mb-6 py-1"
|
||||||
@click="router.push({ name: 'home' })"
|
@click="router.push('/')"
|
||||||
>
|
>
|
||||||
<i class="pi pi-arrow-left text-xs" aria-hidden="true" />
|
<i class="pi pi-arrow-left text-xs" aria-hidden="true" />
|
||||||
Voltar
|
Voltar
|
||||||
271
src/pages/servicos/iptu.vue
Normal file
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 { defineStore } from 'pinia'
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
|
|
||||||
export const useAuthStore = defineStore(
|
// Token NÃO vive aqui — fica server-side em Redis, exposto ao front
|
||||||
'auth',
|
// apenas como cookie httpOnly opaco. Este store guarda só metadados do usuário,
|
||||||
() => {
|
// hidratados via /api/auth/me em plugins/auth.server.ts (SSR) e useAuth().fetchMe().
|
||||||
const token = ref(null)
|
export const useAuthStore = defineStore('auth', () => {
|
||||||
const userInfo = ref(null)
|
const user = ref(null)
|
||||||
|
|
||||||
const isAuthenticated = computed(() => !!token.value)
|
const isAuthenticated = computed(() => !!user.value)
|
||||||
const nomeUsuario = computed(() => userInfo.value?.name ?? '')
|
const nomeUsuario = computed(() => user.value?.name ?? '')
|
||||||
const documento = computed(() => userInfo.value?.preferred_username ?? '')
|
const documento = computed(() => user.value?.documento ?? '')
|
||||||
|
const roles = computed(() => user.value?.roles ?? [])
|
||||||
|
|
||||||
function setSession(accessToken, info) {
|
function setUser(info) {
|
||||||
token.value = accessToken
|
user.value = info
|
||||||
userInfo.value = info
|
}
|
||||||
}
|
|
||||||
|
|
||||||
function clearSession() {
|
function clearUser() {
|
||||||
token.value = null
|
user.value = null
|
||||||
userInfo.value = null
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return { token, userInfo, isAuthenticated, nomeUsuario, documento, setSession, clearSession }
|
return {
|
||||||
},
|
user,
|
||||||
{
|
isAuthenticated,
|
||||||
persist: {
|
nomeUsuario,
|
||||||
paths: ['token', 'userInfo'],
|
documento,
|
||||||
},
|
roles,
|
||||||
},
|
setUser,
|
||||||
)
|
clearUser,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
|
|
||||||
|
// Store hidratado server-side via plugins/prefeitura.server.ts.
|
||||||
|
// Sem persist — a cada SSR o bootstrap re-popula a partir do core-api.
|
||||||
export const usePrefeituraStore = defineStore('prefeitura', {
|
export const usePrefeituraStore = defineStore('prefeitura', {
|
||||||
state: () => ({
|
state: () => ({
|
||||||
codigoMunicipio: null,
|
codigoMunicipio: null,
|
||||||
@ -7,7 +9,6 @@ export const usePrefeituraStore = defineStore('prefeitura', {
|
|||||||
dominio: null,
|
dominio: null,
|
||||||
template: null,
|
template: null,
|
||||||
pathLogo: null,
|
pathLogo: null,
|
||||||
pathBackground: null, // URL de foto de fundo do município (quando a API suportar)
|
pathBackground: null,
|
||||||
}),
|
}),
|
||||||
persist: true,
|
|
||||||
})
|
})
|
||||||
|
|||||||
@ -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