feat: SPA autonomo de Divida Ativa com bootstrap multi-tenant e CRUDs Livro/Folha/CDA
Some checks failed
Dev Build & Deploy Divida Ativa Web / build-deploy (push) Failing after 33s
Some checks failed
Dev Build & Deploy Divida Ativa Web / build-deploy (push) Failing after 33s
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
commit
81727c16f8
1
.env.development
Normal file
1
.env.development
Normal file
@ -0,0 +1 @@
|
|||||||
|
VITE_API_URL=http://localhost:8094
|
||||||
1
.env.production
Normal file
1
.env.production
Normal file
@ -0,0 +1 @@
|
|||||||
|
VITE_API_URL=
|
||||||
50
.gitea/workflows/dev-build-deploy.yml
Normal file
50
.gitea/workflows/dev-build-deploy.yml
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
name: Dev Build & Deploy Divida Ativa Web
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- developer
|
||||||
|
|
||||||
|
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 \
|
||||||
|
--build-arg VITE_API_URL="${{ vars.DEV_VITE_DIVIDA_API_URL }}" \
|
||||||
|
-t git.modumsolucao.com.br/modumsolucao/modumfiscal-divida-ativa-web:latest \
|
||||||
|
-t git.modumsolucao.com.br/modumsolucao/modumfiscal-divida-ativa-web:${{ steps.version.outputs.version }} \
|
||||||
|
.
|
||||||
|
|
||||||
|
- name: Push image
|
||||||
|
run: |
|
||||||
|
docker push git.modumsolucao.com.br/modumsolucao/modumfiscal-divida-ativa-web:latest
|
||||||
|
docker push git.modumsolucao.com.br/modumsolucao/modumfiscal-divida-ativa-web:${{ steps.version.outputs.version }}
|
||||||
|
|
||||||
|
- name: Deploy DEV
|
||||||
|
run: |
|
||||||
|
docker service update \
|
||||||
|
--image git.modumsolucao.com.br/modumsolucao/modumfiscal-divida-ativa-web:${{ steps.version.outputs.version }} \
|
||||||
|
--with-registry-auth \
|
||||||
|
app_modumfiscal-divida-ativa-web
|
||||||
11
.gitignore
vendored
Normal file
11
.gitignore
vendored
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
coverage
|
||||||
|
*.log*
|
||||||
|
.DS_Store
|
||||||
|
.idea
|
||||||
|
.vscode
|
||||||
|
*.local
|
||||||
|
.env.local
|
||||||
|
.eslintcache
|
||||||
|
**/.DS_Store
|
||||||
53
CLAUDE.md
Normal file
53
CLAUDE.md
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
# divida-ativa-modumfiscal-web
|
||||||
|
|
||||||
|
Frontend SPA autônomo para gestão de Dívida Ativa municipal.
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
- Vue 3 + Vite
|
||||||
|
- PrimeVue 4 + TailwindCSS
|
||||||
|
- Pinia + Vue Router
|
||||||
|
- Axios → `divida-ativa-modumfiscal-service` (porta **8094**)
|
||||||
|
|
||||||
|
## Multi-tenancy (igual ao core)
|
||||||
|
|
||||||
|
1. `getTenant()` extrai o subdomínio de `window.location.hostname` (ex.: `tutoia.divida.modumfiscal.com.br` → `tutoia`)
|
||||||
|
2. Antes de montar a app, `bootstrapPrefeitura()` chama `GET /api/v1/publico/prefeitura/{dominio}`
|
||||||
|
3. Resposta da tabela `divida_ativa.T065_CONFIGAPP`: `codigoMunicipio`, `nomePrefeitura`, `dominio`, `template`, `pathLogo`
|
||||||
|
4. `prefeituraStore` persiste os dados; interceptors enviam `X-Municipio` e `X-Dominio` em todas as requisições
|
||||||
|
|
||||||
|
## Desenvolvimento local
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
- Vite: `http://localhost:5174`
|
||||||
|
- `.env.development`: `VITE_API_URL=http://localhost:8094` (igual ao core — API direta, sem proxy)
|
||||||
|
- Tenant vem do subdomínio da URL (`tutoia.divida.modumfiscal.com.br` → `tutoia`); em localhost puro o tenant é `localhost`, fallback `sistema`
|
||||||
|
- Para simular prefeitura em dev: apontar hosts para `tutoia.divida.modumfiscal.com.br` ou acessar via nginx (`*.divida.modumfiscal.com.br`)
|
||||||
|
|
||||||
|
## Telas (MVP)
|
||||||
|
|
||||||
|
| Rota | Descrição |
|
||||||
|
|---|---|
|
||||||
|
| `/app` | Tela inicial |
|
||||||
|
| `/app/livro` | Listagem de livros |
|
||||||
|
| `/app/livro/cadastro` | Cadastro/edição de livro |
|
||||||
|
| `/app/folha` | Listagem de folhas |
|
||||||
|
| `/app/folha/cadastro` | Cadastro/edição de folha |
|
||||||
|
| `/app/cda` | Listagem de CDAs |
|
||||||
|
| `/app/cda/cadastro` | Cadastro/edição de CDA |
|
||||||
|
|
||||||
|
## Deploy
|
||||||
|
|
||||||
|
- Imagem Docker: `modumfiscal-divida-ativa-web`
|
||||||
|
- URL: `{tenant}.divida.modumfiscal.com.br` (ex.: `tutoia.divida.modumfiscal.com.br`)
|
||||||
|
- Nginx encaminha `/api/` para o serviço Java e `/` para o SPA
|
||||||
|
|
||||||
|
## Variáveis
|
||||||
|
|
||||||
|
| Variável | Descrição |
|
||||||
|
|---|---|
|
||||||
|
| `VITE_API_URL` | Base da API (`http://localhost:8094` em dev; vazio em prod = same-origin via nginx) |
|
||||||
17
Dockerfile
Normal file
17
Dockerfile
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
FROM node:20-alpine AS build-stage
|
||||||
|
|
||||||
|
ARG VITE_API_URL
|
||||||
|
|
||||||
|
ENV VITE_API_URL=$VITE_API_URL
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm install
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM nginx:alpine
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
COPY --from=build-stage /app/dist /usr/share/nginx/html
|
||||||
|
EXPOSE 80
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
13
index.html
Normal file
13
index.html
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="pt-BR">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Modum Fiscal — Dívida Ativa</title>
|
||||||
|
<link href="https://fonts.cdnfonts.com/css/lato" rel="stylesheet" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
10
nginx.conf
Normal file
10
nginx.conf
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name localhost;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
}
|
||||||
3551
package-lock.json
generated
Normal file
3551
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
31
package.json
Normal file
31
package.json
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"name": "divida-ativa-modumfiscal-web",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@primeuix/themes": "^1.0.0",
|
||||||
|
"axios": "^1.9.0",
|
||||||
|
"pinia": "^3.0.3",
|
||||||
|
"pinia-plugin-persistedstate": "^4.5.0",
|
||||||
|
"primeicons": "^7.0.0",
|
||||||
|
"primevue": "^4.3.9",
|
||||||
|
"tailwindcss-primeui": "^0.5.0",
|
||||||
|
"vue": "^3.4.34",
|
||||||
|
"vue-router": "^4.4.0",
|
||||||
|
"zod": "^4.3.6"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@primevue/auto-import-resolver": "^4.3.1",
|
||||||
|
"@vitejs/plugin-vue": "^5.0.5",
|
||||||
|
"autoprefixer": "^10.4.19",
|
||||||
|
"postcss": "^8.4.40",
|
||||||
|
"sass": "^1.55.0",
|
||||||
|
"tailwindcss": "^3.4.6",
|
||||||
|
"unplugin-vue-components": "^0.27.3",
|
||||||
|
"vite": "^5.3.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
postcss.config.js
Normal file
6
postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
16
src/App.vue
Normal file
16
src/App.vue
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
<script setup>
|
||||||
|
import { useMenuStore } from '@/store/menuStore';
|
||||||
|
import { onBeforeMount } from 'vue';
|
||||||
|
|
||||||
|
const menuStore = useMenuStore();
|
||||||
|
|
||||||
|
onBeforeMount(() => {
|
||||||
|
menuStore.activeMenuItems = [];
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Toast />
|
||||||
|
<ConfirmDialog />
|
||||||
|
<router-view />
|
||||||
|
</template>
|
||||||
24
src/assets/layout/_core.scss
Normal file
24
src/assets/layout/_core.scss
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
html {
|
||||||
|
height: 100%;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'Lato', sans-serif;
|
||||||
|
color: var(--text-color);
|
||||||
|
background-color: var(--surface-ground);
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
min-height: 100%;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout-wrapper {
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
8
src/assets/layout/_footer.scss
Normal file
8
src/assets/layout/_footer.scss
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
.layout-footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 1rem 0 1rem 0;
|
||||||
|
gap: 0.5rem;
|
||||||
|
border-top: 1px solid var(--surface-border);
|
||||||
|
}
|
||||||
35
src/assets/layout/_main.scss
Normal file
35
src/assets/layout/_main.scss
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
.layout-main-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 100vh;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 6rem 2rem 0 2rem;
|
||||||
|
transition: margin-left var(--layout-section-transition-duration);
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout-main {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
padding-bottom: 2rem;
|
||||||
|
position: relative;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout-breadcrumb {
|
||||||
|
padding: 0 0 1rem 0;
|
||||||
|
border-radius: var(--content-border-radius);
|
||||||
|
|
||||||
|
a {
|
||||||
|
pointer-events: none;
|
||||||
|
cursor: default;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.p-paginator-rpp-dropdown {
|
||||||
|
width: 6rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.p-datatable-column-title {
|
||||||
|
font-weight: 700 !important;
|
||||||
|
}
|
||||||
160
src/assets/layout/_menu.scss
Normal file
160
src/assets/layout/_menu.scss
Normal file
@ -0,0 +1,160 @@
|
|||||||
|
@use 'mixins' as *;
|
||||||
|
|
||||||
|
.layout-sidebar {
|
||||||
|
position: fixed;
|
||||||
|
width: 20rem;
|
||||||
|
height: calc(100vh - 8rem);
|
||||||
|
z-index: 100;
|
||||||
|
overflow-y: auto;
|
||||||
|
user-select: none;
|
||||||
|
top: 6rem;
|
||||||
|
left: 2rem;
|
||||||
|
transition:
|
||||||
|
transform var(--layout-section-transition-duration),
|
||||||
|
left var(--layout-section-transition-duration);
|
||||||
|
background-color: var(--surface-overlay);
|
||||||
|
border-radius: var(--content-border-radius);
|
||||||
|
padding: 0.5rem 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout-menu {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style-type: none;
|
||||||
|
|
||||||
|
.layout-root-menuitem {
|
||||||
|
> .layout-menuitem-root-text {
|
||||||
|
font-size: 0.857rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-color);
|
||||||
|
margin: 0.75rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
> a {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
user-select: none;
|
||||||
|
|
||||||
|
&.active-menuitem {
|
||||||
|
> .layout-submenu-toggler {
|
||||||
|
transform: rotate(-180deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
li.active-menuitem {
|
||||||
|
> a {
|
||||||
|
.layout-submenu-toggler {
|
||||||
|
transform: rotate(-180deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ul {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style-type: none;
|
||||||
|
|
||||||
|
a {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
position: relative;
|
||||||
|
outline: 0 none;
|
||||||
|
color: var(--text-color);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-radius: var(--content-border-radius);
|
||||||
|
transition:
|
||||||
|
background-color var(--element-transition-duration),
|
||||||
|
box-shadow var(--element-transition-duration);
|
||||||
|
|
||||||
|
.layout-menuitem-icon {
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout-submenu-toggler {
|
||||||
|
font-size: 75%;
|
||||||
|
margin-left: auto;
|
||||||
|
transition: transform var(--element-transition-duration);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active-route {
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--surface-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
@include focused-inset();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ul {
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: var(--content-border-radius);
|
||||||
|
|
||||||
|
li {
|
||||||
|
a {
|
||||||
|
margin-left: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
li {
|
||||||
|
a {
|
||||||
|
margin-left: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
li {
|
||||||
|
a {
|
||||||
|
margin-left: 2.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
li {
|
||||||
|
a {
|
||||||
|
margin-left: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
li {
|
||||||
|
a {
|
||||||
|
margin-left: 3.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
li {
|
||||||
|
a {
|
||||||
|
margin-left: 4rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout-submenu-enter-from,
|
||||||
|
.layout-submenu-leave-to {
|
||||||
|
max-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout-submenu-enter-to,
|
||||||
|
.layout-submenu-leave-from {
|
||||||
|
max-height: 1000px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout-submenu-leave-active {
|
||||||
|
overflow: hidden;
|
||||||
|
transition: max-height 0.45s cubic-bezier(0, 1, 0, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout-submenu-enter-active {
|
||||||
|
overflow: hidden;
|
||||||
|
transition: max-height 1s ease-in-out;
|
||||||
|
}
|
||||||
15
src/assets/layout/_mixins.scss
Normal file
15
src/assets/layout/_mixins.scss
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
@mixin focused() {
|
||||||
|
outline-width: var(--focus-ring-width);
|
||||||
|
outline-style: var(--focus-ring-style);
|
||||||
|
outline-color: var(--focus-ring-color);
|
||||||
|
outline-offset: var(--focus-ring-offset);
|
||||||
|
box-shadow: var(--focus-ring-shadow);
|
||||||
|
transition:
|
||||||
|
box-shadow var(--transition-duration),
|
||||||
|
outline-color var(--transition-duration);
|
||||||
|
}
|
||||||
|
|
||||||
|
@mixin focused-inset() {
|
||||||
|
outline-offset: -1px;
|
||||||
|
box-shadow: inset var(--focus-ring-shadow);
|
||||||
|
}
|
||||||
47
src/assets/layout/_preloading.scss
Normal file
47
src/assets/layout/_preloading.scss
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
.preloader {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 9999;
|
||||||
|
background: #edf1f5;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
.preloader-content {
|
||||||
|
border: 0 solid transparent;
|
||||||
|
border-radius: 50%;
|
||||||
|
width: 150px;
|
||||||
|
height: 150px;
|
||||||
|
position: absolute;
|
||||||
|
top: calc(50vh - 75px);
|
||||||
|
left: calc(50vw - 75px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preloader-content:before, .preloader-content:after{
|
||||||
|
content: '';
|
||||||
|
border: 1em solid var(--primary-color);
|
||||||
|
border-radius: 50%;
|
||||||
|
width: inherit;
|
||||||
|
height: inherit;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
animation: loader 2s linear infinite;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preloader-content:before{
|
||||||
|
animation-delay: 0.5s;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes loader{
|
||||||
|
0%{
|
||||||
|
transform: scale(0);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
50%{
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
100%{
|
||||||
|
transform: scale(1);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
111
src/assets/layout/_responsive.scss
Normal file
111
src/assets/layout/_responsive.scss
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
@media screen and (min-width: 1960px) {
|
||||||
|
.layout-main,
|
||||||
|
.landing-wrapper {
|
||||||
|
width: 1504px;
|
||||||
|
margin-left: auto !important;
|
||||||
|
margin-right: auto !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 992px) {
|
||||||
|
.layout-wrapper {
|
||||||
|
&.layout-overlay {
|
||||||
|
.layout-main-container {
|
||||||
|
margin-left: 0;
|
||||||
|
padding-left: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout-sidebar {
|
||||||
|
transform: translateX(-100%);
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
height: 100vh;
|
||||||
|
border-top-left-radius: 0;
|
||||||
|
border-bottom-left-radius: 0;
|
||||||
|
border-right: 1px solid var(--surface-border);
|
||||||
|
transition:
|
||||||
|
transform 0.4s cubic-bezier(0.05, 0.74, 0.2, 0.99),
|
||||||
|
left 0.4s cubic-bezier(0.05, 0.74, 0.2, 0.99);
|
||||||
|
box-shadow:
|
||||||
|
0px 3px 5px rgba(0, 0, 0, 0.02),
|
||||||
|
0px 0px 2px rgba(0, 0, 0, 0.05),
|
||||||
|
0px 1px 4px rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.layout-overlay-active {
|
||||||
|
.layout-sidebar {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.layout-static {
|
||||||
|
.layout-main-container {
|
||||||
|
margin-left: 22rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.layout-static-inactive {
|
||||||
|
.layout-sidebar {
|
||||||
|
transform: translateX(-100%);
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout-main-container {
|
||||||
|
margin-left: 0;
|
||||||
|
padding-left: 2rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout-mask {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 991px) {
|
||||||
|
.blocked-scroll {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout-wrapper {
|
||||||
|
.layout-main-container {
|
||||||
|
margin-left: 0;
|
||||||
|
padding-left: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout-sidebar {
|
||||||
|
transform: translateX(-100%);
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
height: 100vh;
|
||||||
|
border-top-left-radius: 0;
|
||||||
|
border-bottom-left-radius: 0;
|
||||||
|
z-index: 999;
|
||||||
|
transition:
|
||||||
|
transform 0.4s cubic-bezier(0.05, 0.74, 0.2, 0.99),
|
||||||
|
left 0.4s cubic-bezier(0.05, 0.74, 0.2, 0.99);
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout-mask {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
z-index: 200;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background-color: var(--maskbg);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.layout-mobile-active {
|
||||||
|
.layout-sidebar {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout-mask {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
201
src/assets/layout/_topbar.scss
Normal file
201
src/assets/layout/_topbar.scss
Normal file
@ -0,0 +1,201 @@
|
|||||||
|
@use 'mixins' as *;
|
||||||
|
|
||||||
|
.layout-topbar {
|
||||||
|
position: fixed;
|
||||||
|
height: 4rem;
|
||||||
|
z-index: 100;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0 2rem;
|
||||||
|
background-color: var(--surface-card);
|
||||||
|
transition: left var(--layout-section-transition-duration);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
.layout-topbar-logo-container {
|
||||||
|
width: 20rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout-topbar-logo {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
border-radius: var(--content-border-radius);
|
||||||
|
color: var(--text-color);
|
||||||
|
font-weight: 500;
|
||||||
|
gap: 0.5rem;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
@include focused();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout-topbar-action {
|
||||||
|
display: inline-flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
width: 2.5rem;
|
||||||
|
height: 2.5rem;
|
||||||
|
color: var(--text-color);
|
||||||
|
transition: background-color var(--element-transition-duration);
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--surface-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
@include focused();
|
||||||
|
}
|
||||||
|
|
||||||
|
i {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
span {
|
||||||
|
font-size: 1rem;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.layout-topbar-action-highlight {
|
||||||
|
background-color: var(--primary-color);
|
||||||
|
color: var(--primary-contrast-color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout-menu-button {
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout-topbar-menu-button {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout-topbar-actions {
|
||||||
|
margin-left: auto;
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout-topbar-menu-content {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout-config-menu {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 991px) {
|
||||||
|
.layout-topbar {
|
||||||
|
padding: 0 2rem;
|
||||||
|
|
||||||
|
.layout-topbar-logo-container {
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout-menu-button {
|
||||||
|
margin-left: 0;
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout-topbar-menu-button {
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout-topbar-menu {
|
||||||
|
position: absolute;
|
||||||
|
background-color: var(--surface-overlay);
|
||||||
|
transform-origin: top;
|
||||||
|
box-shadow:
|
||||||
|
0px 3px 5px rgba(0, 0, 0, 0.02),
|
||||||
|
0px 0px 2px rgba(0, 0, 0, 0.05),
|
||||||
|
0px 1px 4px rgba(0, 0, 0, 0.08);
|
||||||
|
border-radius: var(--content-border-radius);
|
||||||
|
padding: 1rem;
|
||||||
|
right: 2rem;
|
||||||
|
top: 4rem;
|
||||||
|
min-width: 15rem;
|
||||||
|
border: 1px solid var(--surface-border);
|
||||||
|
|
||||||
|
.layout-topbar-menu-content {
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout-topbar-action {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
justify-content: flex-start;
|
||||||
|
border-radius: var(--content-border-radius);
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
|
||||||
|
i {
|
||||||
|
font-size: 1rem;
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
span {
|
||||||
|
font-weight: medium;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout-topbar-menu-content {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-panel {
|
||||||
|
.config-panel-label {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--text-secondary-color);
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-panel-colors {
|
||||||
|
> div {
|
||||||
|
padding-top: 0.5rem;
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: space-between;
|
||||||
|
|
||||||
|
button {
|
||||||
|
border: none;
|
||||||
|
width: 1.25rem;
|
||||||
|
height: 1.25rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
outline-color: transparent;
|
||||||
|
outline-width: 2px;
|
||||||
|
outline-style: solid;
|
||||||
|
outline-offset: 1px;
|
||||||
|
|
||||||
|
&.active-color {
|
||||||
|
outline-color: var(--primary-color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-panel-settings {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
68
src/assets/layout/_typography.scss
Normal file
68
src/assets/layout/_typography.scss
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3,
|
||||||
|
h4,
|
||||||
|
h5,
|
||||||
|
h6 {
|
||||||
|
margin: 1.5rem 0 1rem 0;
|
||||||
|
font-family: inherit;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--text-color);
|
||||||
|
|
||||||
|
&:first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-size: 1.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h4 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h5 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h6 {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
mark {
|
||||||
|
background: #fff8e1;
|
||||||
|
padding: 0.25rem 0.4rem;
|
||||||
|
border-radius: var(--content-border-radius);
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
blockquote {
|
||||||
|
margin: 1rem 0;
|
||||||
|
padding: 0 2rem;
|
||||||
|
border-left: 4px solid #90a4ae;
|
||||||
|
}
|
||||||
|
|
||||||
|
hr {
|
||||||
|
border-top: solid var(--surface-border);
|
||||||
|
border-width: 1px 0 0 0;
|
||||||
|
margin: 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0 0 1rem 0;
|
||||||
|
line-height: 1.5;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
25
src/assets/layout/_utils.scss
Normal file
25
src/assets/layout/_utils.scss
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
/* Utils */
|
||||||
|
.clearfix:after {
|
||||||
|
content: ' ';
|
||||||
|
display: block;
|
||||||
|
clear: both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: var(--surface-card);
|
||||||
|
padding: 2rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
border-radius: var(--content-border-radius);
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.p-toast {
|
||||||
|
&.p-toast-top-right,
|
||||||
|
&.p-toast-top-left,
|
||||||
|
&.p-toast-top-center {
|
||||||
|
top: 100px;
|
||||||
|
}
|
||||||
|
}
|
||||||
13
src/assets/layout/layout.scss
Normal file
13
src/assets/layout/layout.scss
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
@use './variables/_common';
|
||||||
|
@use './variables/_light';
|
||||||
|
@use './variables/_dark';
|
||||||
|
@use './_mixins';
|
||||||
|
@use './_preloading';
|
||||||
|
@use './_core';
|
||||||
|
@use './_main';
|
||||||
|
@use './_topbar';
|
||||||
|
@use './_menu';
|
||||||
|
@use './_footer';
|
||||||
|
@use './_responsive';
|
||||||
|
@use './_utils';
|
||||||
|
@use './_typography';
|
||||||
44
src/assets/layout/variables/_common.scss
Normal file
44
src/assets/layout/variables/_common.scss
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
:root {
|
||||||
|
--primary-color: var(--p-primary-color);
|
||||||
|
--primary-contrast-color: var(--p-primary-contrast-color);
|
||||||
|
--text-color: var(--p-text-color);
|
||||||
|
--text-color-secondary: var(--p-text-muted-color);
|
||||||
|
--surface-border: var(--p-content-border-color);
|
||||||
|
--surface-card: var(--p-content-background);
|
||||||
|
--surface-hover: var(--p-content-hover-background);
|
||||||
|
--surface-overlay: var(--p-overlay-popover-background);
|
||||||
|
--transition-duration: var(--p-transition-duration);
|
||||||
|
--maskbg: var(--p-mask-background);
|
||||||
|
--content-border-radius: var(--p-content-border-radius);
|
||||||
|
--layout-section-transition-duration: 0.2s;
|
||||||
|
--element-transition-duration: var(--p-transition-duration);
|
||||||
|
--focus-ring-width: var(--p-focus-ring-width);
|
||||||
|
--focus-ring-style: var(--p-focus-ring-style);
|
||||||
|
--focus-ring-color: var(--p-focus-ring-color);
|
||||||
|
--focus-ring-offset: var(--p-focus-ring-offset);
|
||||||
|
--focus-ring-shadow: var(--p-focus-ring-shadow);
|
||||||
|
|
||||||
|
// Cores semânticas de feedback
|
||||||
|
--color-error: var(--p-red-500);
|
||||||
|
--color-success: var(--p-green-500);
|
||||||
|
--color-warning: var(--p-yellow-500);
|
||||||
|
--color-info: var(--p-blue-500);
|
||||||
|
|
||||||
|
// Spacing scale
|
||||||
|
--spacing-xs: 0.25rem;
|
||||||
|
--spacing-sm: 0.5rem;
|
||||||
|
--spacing-md: 1rem;
|
||||||
|
--spacing-lg: 1.5rem;
|
||||||
|
--spacing-xl: 2rem;
|
||||||
|
|
||||||
|
// Border radius scale
|
||||||
|
--radius-sm: 4px;
|
||||||
|
--radius-md: 8px;
|
||||||
|
--radius-lg: 12px;
|
||||||
|
|
||||||
|
// Layout
|
||||||
|
--topbar-height: 4rem;
|
||||||
|
--footer-height: 3rem;
|
||||||
|
--sidebar-width: 20rem;
|
||||||
|
--content-sidebar-width: 280px;
|
||||||
|
}
|
||||||
5
src/assets/layout/variables/_dark.scss
Normal file
5
src/assets/layout/variables/_dark.scss
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
:root[class*='app-dark'] {
|
||||||
|
--surface-ground: var(--p-surface-950);
|
||||||
|
--code-background: var(--p-surface-800);
|
||||||
|
--code-color: var(--p-surface-100);
|
||||||
|
}
|
||||||
5
src/assets/layout/variables/_light.scss
Normal file
5
src/assets/layout/variables/_light.scss
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
:root {
|
||||||
|
--surface-ground: var(--p-surface-100);
|
||||||
|
--code-background: var(--p-surface-900);
|
||||||
|
--code-color: var(--p-surface-200);
|
||||||
|
}
|
||||||
11
src/assets/styles.scss
Normal file
11
src/assets/styles.scss
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
/* You can add global styles to this file, and also import other style files */
|
||||||
|
@use 'primeicons/primeicons.css';
|
||||||
|
@use '@/assets/tailwind.css';
|
||||||
|
@use '@/assets/layout/layout.scss';
|
||||||
|
|
||||||
|
input[type='number']::-webkit-outer-spin-button,
|
||||||
|
input[type='number']::-webkit-inner-spin-button {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
3
src/assets/tailwind.css
Normal file
3
src/assets/tailwind.css
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
27
src/bootstrap/prefeituraBoot.js
Normal file
27
src/bootstrap/prefeituraBoot.js
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import PrefeituraService from '@/service/prefeitura/PrefeituraService';
|
||||||
|
import { usePrefeituraStore } from '@/store/prefeituraStore';
|
||||||
|
import { getTenant } from '@/utils/tenant';
|
||||||
|
|
||||||
|
export async function bootstrapPrefeitura(pinia) {
|
||||||
|
const store = usePrefeituraStore(pinia);
|
||||||
|
const dominioAtual = getTenant();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { data } = await PrefeituraService.getPrefeituraInfo(dominioAtual);
|
||||||
|
|
||||||
|
store.$patch({
|
||||||
|
codigoMunicipio: data.data.codigoMunicipio,
|
||||||
|
nomePrefeitura: data.data.nomePrefeitura,
|
||||||
|
dominio: data.data.dominio,
|
||||||
|
template: data.data.template,
|
||||||
|
pathLogo: data.data.pathLogo,
|
||||||
|
});
|
||||||
|
|
||||||
|
localStorage.setItem('current_municipio', data.data.codigoMunicipio);
|
||||||
|
localStorage.setItem('current_dominio', data.data.dominio);
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
81
src/components/forms/FormField.vue
Normal file
81
src/components/forms/FormField.vue
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
id: { type: String, default: null },
|
||||||
|
label: { type: String, default: null },
|
||||||
|
error: { type: String, default: null },
|
||||||
|
required: { type: Boolean, default: false },
|
||||||
|
span: {
|
||||||
|
type: [Number, String],
|
||||||
|
default: 12,
|
||||||
|
validator: (value) => Number(value) > 0 && Number(value) <= 12,
|
||||||
|
},
|
||||||
|
align: {
|
||||||
|
type: String,
|
||||||
|
default: 'start',
|
||||||
|
validator: (value) => ['start', 'end', 'center'].includes(value),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="form-field"
|
||||||
|
:style="{ '--field-span': span, '--align': align }"
|
||||||
|
:aria-invalid="!!error"
|
||||||
|
:aria-describedby="error && id ? `${id}-error` : undefined"
|
||||||
|
>
|
||||||
|
<label :for="id" class="field-label">
|
||||||
|
<slot name="label">
|
||||||
|
<span v-if="label">
|
||||||
|
{{ label }}
|
||||||
|
<span v-if="required" class="field-required" aria-hidden="true">*</span>
|
||||||
|
</span>
|
||||||
|
<span v-else class="invisible"> </span>
|
||||||
|
</slot>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="field-control">
|
||||||
|
<slot :id="id" :error="error"></slot>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<small v-if="error" :id="id ? `${id}-error` : undefined" class="field-error">
|
||||||
|
{{ error }}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.form-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: var(--align, flex-start);
|
||||||
|
grid-column: span var(--field-span, 12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-label {
|
||||||
|
margin-bottom: var(--spacing-sm, 0.5rem);
|
||||||
|
font-weight: 500;
|
||||||
|
min-height: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-required {
|
||||||
|
color: var(--color-error);
|
||||||
|
margin-left: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-control {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-error {
|
||||||
|
color: var(--color-error);
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 992px) {
|
||||||
|
.form-field {
|
||||||
|
grid-column: span 12 !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
67
src/components/forms/FormGrid.vue
Normal file
67
src/components/forms/FormGrid.vue
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
cols: {
|
||||||
|
type: [Number, String],
|
||||||
|
default: 12,
|
||||||
|
validator: (value) => Number(value) > 0 && Number(value) <= 12,
|
||||||
|
},
|
||||||
|
gap: {
|
||||||
|
type: String,
|
||||||
|
default: '1rem',
|
||||||
|
},
|
||||||
|
gapRow: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
subtitle: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
subtitleIcon: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
subtitleClass: {
|
||||||
|
type: String,
|
||||||
|
default: 'text-base font-semibold',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const gridStyle = computed(() => ({
|
||||||
|
'--cols': props.cols,
|
||||||
|
'--gap': props.gap,
|
||||||
|
}));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Divider v-if="subtitle" style="margin: 0.5rem 0 !important" align="left" type="solid">
|
||||||
|
<span :class="subtitleClass" class="flex items-center gap-2">
|
||||||
|
<i v-if="subtitleIcon" :class="subtitleIcon"></i>
|
||||||
|
{{ subtitle }}
|
||||||
|
</span>
|
||||||
|
</Divider>
|
||||||
|
<div class="form-grid" :class="{ 'gap-row': gapRow }" :style="gridStyle">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.form-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: var(--gap);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-grid.gap-row {
|
||||||
|
row-gap: var(--gap);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile-first: expande para múltiplas colunas em lg+ */
|
||||||
|
@media (min-width: 992px) {
|
||||||
|
.form-grid {
|
||||||
|
grid-template-columns: repeat(var(--cols, 12), 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
130
src/components/forms/FormPanel.vue
Normal file
130
src/components/forms/FormPanel.vue
Normal file
@ -0,0 +1,130 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
title: {
|
||||||
|
type: String,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
icon: {
|
||||||
|
type: String,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
divider: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
noPadding: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
footerAlign: {
|
||||||
|
type: String,
|
||||||
|
default: 'between', // between, end, start
|
||||||
|
validator: (value) => ['between', 'end', 'start', 'around'].includes(value),
|
||||||
|
},
|
||||||
|
blocked: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
|
||||||
|
messageBlock: {
|
||||||
|
type: String,
|
||||||
|
default: 'Painel Bloqueado',
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Variante visual do painel: 'default' | 'success' | 'error' | 'warning' | 'info' */
|
||||||
|
status: {
|
||||||
|
type: String,
|
||||||
|
default: 'default',
|
||||||
|
validator: (value) => ['default', 'success', 'error', 'warning', 'info'].includes(value),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const STATUS_BORDER = {
|
||||||
|
default: 'transparent',
|
||||||
|
success: 'var(--color-success)',
|
||||||
|
error: 'var(--color-error)',
|
||||||
|
warning: 'var(--color-warning)',
|
||||||
|
info: 'var(--color-info)',
|
||||||
|
};
|
||||||
|
|
||||||
|
const footerClass = computed(() => {
|
||||||
|
return {
|
||||||
|
'justify-between': props.footerAlign === 'between',
|
||||||
|
'justify-end': props.footerAlign === 'end',
|
||||||
|
'justify-start': props.footerAlign === 'start',
|
||||||
|
'justify-around': props.footerAlign === 'around',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<BlockUI
|
||||||
|
v-tooltip.top="blocked ? { value: messageBlock, showDelay: 300, hideDelay: 300 } : null"
|
||||||
|
:blocked="blocked"
|
||||||
|
>
|
||||||
|
<div :blocked="blocked">
|
||||||
|
<Fluid class="mb-6">
|
||||||
|
<div
|
||||||
|
class="card flex flex-col gap-2 w-full mb-4 relative"
|
||||||
|
:style="{
|
||||||
|
padding: noPadding ? '0' : undefined,
|
||||||
|
borderLeft: status !== 'default' ? `3px solid ${STATUS_BORDER[status]}` : undefined,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<!-- Header -->
|
||||||
|
<div v-if="title">
|
||||||
|
<div class="flex items-center justify-between mb-1">
|
||||||
|
<span class="font-bold text-xl">
|
||||||
|
<i v-show="icon" :class="icon"></i>
|
||||||
|
{{ title }}
|
||||||
|
</span>
|
||||||
|
<slot name="actions" />
|
||||||
|
</div>
|
||||||
|
<Divider v-if="divider" style="margin: 0.5rem 0 !important" type="dashed" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Conteúdo principal -->
|
||||||
|
<slot />
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<div v-if="$slots.footer" class="form-panel-footer" :class="footerClass">
|
||||||
|
<slot name="footer" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Fluid>
|
||||||
|
</div>
|
||||||
|
</BlockUI>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.form-panel-footer {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding-top: 1rem;
|
||||||
|
border-top: 1px solid var(--surface-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.justify-between {
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.justify-end {
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.justify-start {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.justify-around {
|
||||||
|
justify-content: space-around;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Garante que os botões não cresçam desnecessariamente */
|
||||||
|
.form-panel-footer ::v-deep(.p-button) {
|
||||||
|
width: auto !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
125
src/components/listagem/AcoesTabela.vue
Normal file
125
src/components/listagem/AcoesTabela.vue
Normal file
@ -0,0 +1,125 @@
|
|||||||
|
<script setup>
|
||||||
|
/**
|
||||||
|
* AcoesTabela
|
||||||
|
* @description Renderiza ações de linha em tabelas: ícone direto para ação única, menu dropdown (3 pontos) para múltiplas.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // Ação única — botão direto com ícone
|
||||||
|
* <AcoesTabela
|
||||||
|
* :acoes="[{ label: 'Editar', icon: 'pi pi-pencil', command: (row) => editar(row) }]"
|
||||||
|
* :linha="slotProps.data"
|
||||||
|
* />
|
||||||
|
*
|
||||||
|
* // Múltiplas ações — botão pi-ellipsis-v com dropdown
|
||||||
|
* <AcoesTabela
|
||||||
|
* :acoes="[
|
||||||
|
* { label: 'Visualizar', icon: 'pi pi-eye', command: (row) => ver(row) },
|
||||||
|
* { label: 'Editar', icon: 'pi pi-pencil', command: (row) => editar(row) },
|
||||||
|
* { label: 'Excluir', icon: 'pi pi-trash', severity: 'danger', command: (row) => excluir(row) },
|
||||||
|
* ]"
|
||||||
|
* :linha="slotProps.data"
|
||||||
|
* />
|
||||||
|
*
|
||||||
|
* // Ação condicional com visible como função
|
||||||
|
* <AcoesTabela
|
||||||
|
* :acoes="[
|
||||||
|
* { label: 'Ativar', icon: 'pi pi-check', command: ativar, visible: (row) => !row.ativo },
|
||||||
|
* { label: 'Desativar', icon: 'pi pi-ban', severity: 'danger', command: desativar, visible: (row) => row.ativo },
|
||||||
|
* ]"
|
||||||
|
* :linha="slotProps.data"
|
||||||
|
* />
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} AcaoTabela
|
||||||
|
* @property {string} label - Rótulo exibido no dropdown
|
||||||
|
* @property {string} icon - Classe do ícone PrimeIcons (ex: 'pi pi-pencil')
|
||||||
|
* @property {Function} command - Chamado com a linha como argumento: (linha) => void
|
||||||
|
* @property {string} [severity] - Severidade do botão/item: 'secondary' | 'danger' | 'success' | 'warn' | 'info'
|
||||||
|
* @property {string} [tooltip] - Tooltip exibido no botão de ação única (padrão: label)
|
||||||
|
* @property {boolean|Function} [visible] - Se false ou fn(linha) => false, a ação é ocultada
|
||||||
|
* @property {boolean|Function} [disabled] - Se true ou fn(linha) => true, a ação é desabilitada
|
||||||
|
* @property {boolean|Function} [loading] - Se true ou fn(linha) => true, exibe spinner no botão de ação única
|
||||||
|
*/
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
/** Array de ações disponíveis para a linha */
|
||||||
|
acoes: { type: Array, required: true },
|
||||||
|
/** Dados da linha da tabela */
|
||||||
|
linha: { type: Object, required: true },
|
||||||
|
/** Tamanho dos botões */
|
||||||
|
tamanho: { type: String, default: 'small' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const menuRef = ref();
|
||||||
|
|
||||||
|
const acoesVisiveis = computed(() =>
|
||||||
|
props.acoes.filter((acao) => {
|
||||||
|
if (typeof acao.visible === 'function') return acao.visible(props.linha);
|
||||||
|
return acao.visible !== false;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
function resolverDisabled(acao) {
|
||||||
|
return typeof acao.disabled === 'function' ? acao.disabled(props.linha) : (acao.disabled ?? false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolverLoading(acao) {
|
||||||
|
return typeof acao.loading === 'function' ? acao.loading(props.linha) : (acao.loading ?? false);
|
||||||
|
}
|
||||||
|
|
||||||
|
const menuItems = computed(() =>
|
||||||
|
acoesVisiveis.value.map((acao) => ({
|
||||||
|
label: acao.label,
|
||||||
|
icon: acao.icon,
|
||||||
|
class: acao.severity === 'danger' ? 'text-red-500' : undefined,
|
||||||
|
disabled: resolverDisabled(acao),
|
||||||
|
command: () => acao.command(props.linha),
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
const algumCarregando = computed(() => acoesVisiveis.value.some((acao) => resolverLoading(acao)));
|
||||||
|
|
||||||
|
function abrirMenu(event) {
|
||||||
|
menuRef.value.toggle(event);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex justify-center items-center">
|
||||||
|
<!-- Ação única: botão direto com ícone -->
|
||||||
|
<template v-if="acoesVisiveis.length === 1">
|
||||||
|
<Button
|
||||||
|
v-tooltip.top="acoesVisiveis[0].tooltip ?? acoesVisiveis[0].label"
|
||||||
|
type="button"
|
||||||
|
:icon="acoesVisiveis[0].icon"
|
||||||
|
:severity="acoesVisiveis[0].severity ?? 'secondary'"
|
||||||
|
:size="tamanho"
|
||||||
|
:disabled="resolverDisabled(acoesVisiveis[0])"
|
||||||
|
:loading="resolverLoading(acoesVisiveis[0])"
|
||||||
|
text
|
||||||
|
@click="acoesVisiveis[0].command(linha)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Múltiplas ações: dropdown com pi-ellipsis-v -->
|
||||||
|
<template v-else-if="acoesVisiveis.length > 1">
|
||||||
|
<Menu ref="menuRef" :model="menuItems" popup />
|
||||||
|
<Button
|
||||||
|
v-tooltip.top="'Ações'"
|
||||||
|
type="button"
|
||||||
|
icon="pi pi-ellipsis-v"
|
||||||
|
severity="secondary"
|
||||||
|
:size="tamanho"
|
||||||
|
:loading="algumCarregando"
|
||||||
|
:disabled="algumCarregando"
|
||||||
|
aria-haspopup="true"
|
||||||
|
text
|
||||||
|
rounded
|
||||||
|
@click="abrirMenu"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
47
src/components/listagem/HeaderOrdenavel.vue
Normal file
47
src/components/listagem/HeaderOrdenavel.vue
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
<script setup>
|
||||||
|
const props = defineProps({
|
||||||
|
field: { type: String, required: true },
|
||||||
|
label: { type: String, required: true },
|
||||||
|
sortField: { type: String, default: null },
|
||||||
|
sortOrder: { type: Number, default: null },
|
||||||
|
ordenar: { type: Function, required: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const icone = () => {
|
||||||
|
if (props.sortField !== props.field) return 'pi pi-sort';
|
||||||
|
return props.sortOrder === 1 ? 'pi pi-sort-amount-up-alt' : 'pi pi-sort-amount-down-alt';
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<button class="header-ordenavel" @click="ordenar(field)">
|
||||||
|
<span>{{ label }}</span>
|
||||||
|
<i :class="['header-ordenavel__icone', icone(), { ativo: sortField === field }]" />
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.header-ordenavel {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: inherit;
|
||||||
|
color: inherit;
|
||||||
|
font-weight: 700 !important;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-ordenavel__icone {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--p-text-muted-color);
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-ordenavel__icone.ativo {
|
||||||
|
color: var(--p-primary-color);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
251
src/components/listagem/TabelaPaginada.vue
Normal file
251
src/components/listagem/TabelaPaginada.vue
Normal file
@ -0,0 +1,251 @@
|
|||||||
|
<script setup>
|
||||||
|
/**
|
||||||
|
* TabelaPaginada
|
||||||
|
* ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
* Wrapper genérico sobre o DataTable do PrimeVue com paginação lazy e
|
||||||
|
* ordenação client-side (via useTabelaPaginada).
|
||||||
|
*
|
||||||
|
* Uso mínimo:
|
||||||
|
*
|
||||||
|
* <TabelaPaginada
|
||||||
|
* :value="retornoOrdenado"
|
||||||
|
* :loading="isLoading"
|
||||||
|
* :total-records="elementosTotais"
|
||||||
|
* :rows="tamanhoPagina"
|
||||||
|
* :first="first"
|
||||||
|
* :sort-field="sortField"
|
||||||
|
* :sort-order="sortOrder"
|
||||||
|
* :busca-realizada="buscaRealizada"
|
||||||
|
* @page="onPageChange"
|
||||||
|
* @sort="onSort"
|
||||||
|
* @size-change="onSizeChange"
|
||||||
|
* >
|
||||||
|
* <Column field="nome" header="Nome" :sortable="true" />
|
||||||
|
* </TabelaPaginada>
|
||||||
|
*
|
||||||
|
* Props opcionais:
|
||||||
|
* - export-csv: habilita botão de exportação CSV no canto direito do paginator
|
||||||
|
* - export-filename: nome do arquivo gerado (sem extensão, padrão: 'export')
|
||||||
|
*
|
||||||
|
* Slots disponíveis:
|
||||||
|
* - Todos os slots nativos do DataTable do PrimeVue são repassados
|
||||||
|
* automaticamente (header, footer, empty, etc.)
|
||||||
|
* ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { computed, onMounted, onUnmounted, ref } from 'vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
/** Dados a exibir (use `retornoOrdenado` do composable para ordenação client-side) */
|
||||||
|
value: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Controla o overlay de carregamento */
|
||||||
|
loading: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Total de registros no backend (para o paginator) */
|
||||||
|
totalRecords: {
|
||||||
|
type: Number,
|
||||||
|
default: 0,
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Quantidade de linhas por página */
|
||||||
|
rows: {
|
||||||
|
type: [Number, String],
|
||||||
|
default: 10,
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Offset da primeira linha (page * rows) */
|
||||||
|
first: {
|
||||||
|
type: Number,
|
||||||
|
default: 0,
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Indica se ao menos uma busca foi disparada (controla mensagem do slot empty) */
|
||||||
|
buscaRealizada: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Exibe o paginator */
|
||||||
|
paginator: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
responsiveLayout: {
|
||||||
|
type: String,
|
||||||
|
default: 'scroll',
|
||||||
|
},
|
||||||
|
|
||||||
|
showGridlines: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
stripedRows: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Texto exibido antes de qualquer busca */
|
||||||
|
msgAguardando: {
|
||||||
|
type: String,
|
||||||
|
default: 'Use os filtros acima para realizar a busca',
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Texto exibido quando a busca não retorna resultados */
|
||||||
|
msgVazia: {
|
||||||
|
type: String,
|
||||||
|
default: 'Nenhum resultado encontrado',
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Habilita botão de exportação CSV no canto direito do paginator */
|
||||||
|
exportCsv: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Nome do arquivo CSV exportado (sem extensão) */
|
||||||
|
exportFilename: {
|
||||||
|
type: String,
|
||||||
|
default: 'export',
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Opções de linhas por página no paginator */
|
||||||
|
rowsPerPageOptions: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [5, 10, 20, 50],
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Ícone do estado vazio (antes e depois de busca realizada) */
|
||||||
|
emptyIcon: {
|
||||||
|
type: String,
|
||||||
|
default: 'pi pi-search',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['page', 'size-change']);
|
||||||
|
|
||||||
|
const isMobile = ref(window.innerWidth < 576);
|
||||||
|
const onResize = () => {
|
||||||
|
isMobile.value = window.innerWidth < 576;
|
||||||
|
};
|
||||||
|
onMounted(() => window.addEventListener('resize', onResize));
|
||||||
|
onUnmounted(() => window.removeEventListener('resize', onResize));
|
||||||
|
|
||||||
|
const paginatorTemplate = computed(() =>
|
||||||
|
isMobile.value
|
||||||
|
? 'FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink'
|
||||||
|
: 'RowsPerPageDropdown FirstPageLink PrevPageLink CurrentPageReport NextPageLink LastPageLink'
|
||||||
|
);
|
||||||
|
|
||||||
|
const dt = ref();
|
||||||
|
|
||||||
|
function onPageInternal(event) {
|
||||||
|
if (event.rows !== Number(props.rows)) {
|
||||||
|
emit('size-change', event.rows);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
emit('page', event);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DataTable
|
||||||
|
ref="dt"
|
||||||
|
:loading="loading"
|
||||||
|
:value="value"
|
||||||
|
:rows="Number(rows)"
|
||||||
|
:paginator="paginator"
|
||||||
|
:total-records="totalRecords"
|
||||||
|
:responsive-layout="responsiveLayout"
|
||||||
|
:show-gridlines="showGridlines"
|
||||||
|
:striped-rows="stripedRows"
|
||||||
|
:lazy="true"
|
||||||
|
:first="first"
|
||||||
|
:rows-per-page-options="rowsPerPageOptions"
|
||||||
|
current-page-report-template="Mostrando de {first} a {last} de {totalRecords} registros"
|
||||||
|
:paginator-template="paginatorTemplate"
|
||||||
|
@page="onPageInternal"
|
||||||
|
>
|
||||||
|
<!-- Repassa todos os slots definidos pelo consumidor -->
|
||||||
|
<template v-for="(_, name) in $slots" #[name]="slotProps">
|
||||||
|
<slot :name="name" v-bind="slotProps ?? {}" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Slot vazio padrão (pode ser sobrescrito pelo consumidor) -->
|
||||||
|
<template v-if="!$slots.empty" #empty>
|
||||||
|
<div class="flex flex-col items-center justify-center py-8 text-gray-500">
|
||||||
|
<i :class="[emptyIcon, 'text-3xl mb-3']" />
|
||||||
|
<span v-if="!buscaRealizada">{{ msgAguardando }}</span>
|
||||||
|
<span v-else>{{ msgVazia }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Botão de exportação CSV no canto direito do paginator -->
|
||||||
|
<template #paginatorend>
|
||||||
|
<Button
|
||||||
|
v-if="exportCsv && value.length > 0"
|
||||||
|
v-tooltip.top="'Exportar CSV'"
|
||||||
|
type="button"
|
||||||
|
icon="pi pi-download"
|
||||||
|
severity="secondary"
|
||||||
|
text
|
||||||
|
@click="dt.exportCSV({ filename: exportFilename })"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div></div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #paginatorstart>
|
||||||
|
<div></div>
|
||||||
|
</template>
|
||||||
|
</DataTable>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.p-datatable-header-cell {
|
||||||
|
background: var(--p-datatable-header-cell-selected-background) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.p-datatable-column-title {
|
||||||
|
font-weight: 700 !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
@media (max-width: 575px) {
|
||||||
|
.p-datatable .p-paginator {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
padding: 0.5rem 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.p-datatable .p-paginator .p-paginator-current {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
padding: 0 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.p-datatable .p-paginator .p-paginator-first,
|
||||||
|
.p-datatable .p-paginator .p-paginator-prev,
|
||||||
|
.p-datatable .p-paginator .p-paginator-next,
|
||||||
|
.p-datatable .p-paginator .p-paginator-last {
|
||||||
|
min-width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.p-datatable .p-paginator-end {
|
||||||
|
order: -1;
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
116
src/composables/useAcoesTabela.js
Normal file
116
src/composables/useAcoesTabela.js
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
/**
|
||||||
|
* useAcoesTabela
|
||||||
|
* ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
* Fábrica de ações padronizadas para o componente AcoesTabela.
|
||||||
|
* Centraliza ícones, labels e severidades para manter consistência visual
|
||||||
|
* entre todos os módulos.
|
||||||
|
*
|
||||||
|
* ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
* USO
|
||||||
|
* ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
*
|
||||||
|
* ```js
|
||||||
|
* import { useAcoesTabela } from '@/composables/useAcoesTabela'
|
||||||
|
*
|
||||||
|
* const { acaoVisualizar, acaoEditar, acaoExcluir } = useAcoesTabela()
|
||||||
|
*
|
||||||
|
* // Array simples
|
||||||
|
* const acoes = [
|
||||||
|
* acaoVisualizar((row) => router.push({ name: 'ver', params: { id: row.id } })),
|
||||||
|
* acaoEditar((row) => router.push({ name: 'editar', params: { id: row.id } })),
|
||||||
|
* acaoExcluir((row) => confirmarExclusao(row)),
|
||||||
|
* ]
|
||||||
|
*
|
||||||
|
* // Com opções extras (visible, disabled, tooltip, severity)
|
||||||
|
* const acoes = [
|
||||||
|
* acaoEditar(editar, { visible: (row) => row.ativo }),
|
||||||
|
* acaoExcluir(excluir, { disabled: (row) => row.bloqueado }),
|
||||||
|
* ]
|
||||||
|
*
|
||||||
|
* // Ação personalizada
|
||||||
|
* const acoes = [
|
||||||
|
* acaoCustom({ label: 'Reenviar', icon: 'pi pi-envelope', command: reenviar }),
|
||||||
|
* ]
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} AcaoTabela
|
||||||
|
* @property {string} label
|
||||||
|
* @property {string} icon
|
||||||
|
* @property {Function} command
|
||||||
|
* @property {string} [severity]
|
||||||
|
* @property {string} [tooltip]
|
||||||
|
* @property {boolean|Function} [visible]
|
||||||
|
* @property {boolean|Function} [disabled]
|
||||||
|
* @property {boolean|Function} [loading]
|
||||||
|
*/
|
||||||
|
|
||||||
|
function criar(base, command, opcoes = {}) {
|
||||||
|
return { ...base, command, ...opcoes };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAcoesTabela() {
|
||||||
|
/** Visualizar — lupa, secondary */
|
||||||
|
function acaoVisualizar(command, opcoes = {}) {
|
||||||
|
return criar({ label: 'Visualizar', icon: 'pi pi-eye', severity: 'secondary' }, command, opcoes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Editar — lápis, secondary */
|
||||||
|
function acaoEditar(command, opcoes = {}) {
|
||||||
|
return criar({ label: 'Editar', icon: 'pi pi-pencil', severity: 'secondary' }, command, opcoes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Excluir — lixeira, danger */
|
||||||
|
function acaoExcluir(command, opcoes = {}) {
|
||||||
|
return criar({ label: 'Excluir', icon: 'pi pi-trash', severity: 'danger' }, command, opcoes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Selecionar / confirmar — check, success */
|
||||||
|
function acaoSelecionar(command, opcoes = {}) {
|
||||||
|
return criar({ label: 'Selecionar', icon: 'pi pi-check', severity: 'success' }, command, opcoes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Download / exportar — download */
|
||||||
|
function acaoBaixar(command, opcoes = {}) {
|
||||||
|
return criar({ label: 'Baixar', icon: 'pi pi-download', severity: 'secondary' }, command, opcoes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Imprimir / PDF — impressora */
|
||||||
|
function acaoImprimir(command, opcoes = {}) {
|
||||||
|
return criar({ label: 'Imprimir', icon: 'pi pi-print', severity: 'secondary' }, command, opcoes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ativar — círculo check, success */
|
||||||
|
function acaoAtivar(command, opcoes = {}) {
|
||||||
|
return criar({ label: 'Ativar', icon: 'pi pi-check-circle', severity: 'success' }, command, opcoes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Desativar / bloquear — ban, danger */
|
||||||
|
function acaoDesativar(command, opcoes = {}) {
|
||||||
|
return criar({ label: 'Desativar', icon: 'pi pi-ban', severity: 'danger' }, command, opcoes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reenviar (e-mail / convite) — envelope */
|
||||||
|
function acaoReenviar(command, opcoes = {}) {
|
||||||
|
return criar({ label: 'Reenviar', icon: 'pi pi-envelope', severity: 'secondary' }, command, opcoes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ação totalmente personalizada — passa objeto completo */
|
||||||
|
function acaoCustom(acao) {
|
||||||
|
return acao;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
acaoVisualizar,
|
||||||
|
acaoEditar,
|
||||||
|
acaoExcluir,
|
||||||
|
acaoSelecionar,
|
||||||
|
acaoBaixar,
|
||||||
|
acaoImprimir,
|
||||||
|
acaoAtivar,
|
||||||
|
acaoDesativar,
|
||||||
|
acaoReenviar,
|
||||||
|
acaoCustom,
|
||||||
|
};
|
||||||
|
}
|
||||||
59
src/composables/useBreadcrumb.js
Normal file
59
src/composables/useBreadcrumb.js
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
import { getMenuModel } from '@/config/menu';
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { useRoute } from 'vue-router';
|
||||||
|
|
||||||
|
function findBreadcrumbTrail(menuModel, path) {
|
||||||
|
const normalizedPath = path.split('?')[0] || path;
|
||||||
|
let bestMatch = null;
|
||||||
|
let bestPathLength = 0;
|
||||||
|
|
||||||
|
function search(sections, chain) {
|
||||||
|
if (!sections) return;
|
||||||
|
for (const section of sections) {
|
||||||
|
const items = section.items || [];
|
||||||
|
for (const item of items) {
|
||||||
|
if (item.to) {
|
||||||
|
const itemPath =
|
||||||
|
typeof item.to === 'string' ? item.to.split('?')[0] : item.to?.path?.split('?')[0] || '';
|
||||||
|
const isExact = normalizedPath === itemPath;
|
||||||
|
const isPrefix = itemPath && normalizedPath.startsWith(itemPath + '/');
|
||||||
|
if (isExact || isPrefix) {
|
||||||
|
const score = isExact ? 9999 : itemPath.length;
|
||||||
|
if (score > bestPathLength) {
|
||||||
|
bestPathLength = score;
|
||||||
|
bestMatch = [...chain, { label: item.label, to: item.to, icon: item.icon }];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (item.items) {
|
||||||
|
search([{ items: item.items }], [...chain, { label: item.label, icon: item.icon }]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
search(menuModel, []);
|
||||||
|
return bestMatch;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useBreadcrumb() {
|
||||||
|
const route = useRoute();
|
||||||
|
const menuModel = getMenuModel();
|
||||||
|
|
||||||
|
const breadcrumbItems = computed(() => {
|
||||||
|
const trail = findBreadcrumbTrail(menuModel, route.path);
|
||||||
|
if (!trail || trail.length === 0) return [];
|
||||||
|
if (trail.length === 1 && trail[0]?.to?.split('?')[0] === '/app') return [];
|
||||||
|
return trail.map((item) => ({ label: item.label }));
|
||||||
|
});
|
||||||
|
|
||||||
|
const home = computed(() => {
|
||||||
|
const trail = findBreadcrumbTrail(menuModel, route.path);
|
||||||
|
return {
|
||||||
|
icon: trail?.[0]?.icon || 'pi pi-home',
|
||||||
|
route: '#',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return { breadcrumbItems, home };
|
||||||
|
}
|
||||||
373
src/composables/useFormularioCRUD.js
Normal file
373
src/composables/useFormularioCRUD.js
Normal file
@ -0,0 +1,373 @@
|
|||||||
|
/**
|
||||||
|
* useFormularioCRUD
|
||||||
|
* ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
* Composable genérico para telas de cadastro/edição (formulários CRUD).
|
||||||
|
*
|
||||||
|
* Centraliza o padrão recorrente entre as views de formulário:
|
||||||
|
* - Estado de loading
|
||||||
|
* - Detecção automática de modo (novo / edição / visualização)
|
||||||
|
* - Título dinâmico da tela
|
||||||
|
* - Leitura do ID na rota (query string ou params)
|
||||||
|
* - Carregamento do registro ao montar (carregarPorId)
|
||||||
|
* - Função salvar com try/catch, toast e navegação pós-save
|
||||||
|
* - Função voltar (rota de listagem ou router.back())
|
||||||
|
* - Reinicialização do formulário
|
||||||
|
*
|
||||||
|
* ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
* USO BÁSICO
|
||||||
|
* ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
*
|
||||||
|
* ```js
|
||||||
|
* const {
|
||||||
|
* form, erros, isLoading, isNewMode, isModoVisualizacao,
|
||||||
|
* tituloTela, registroId, salvar, voltar, carregarPorId, reiniciarForm
|
||||||
|
* } = useFormularioCRUD({
|
||||||
|
* formInicial: { descricao: '', codigo: '' },
|
||||||
|
* titulos: { novo: 'Novo Convênio', editar: 'Editar Convênio', visualizar: 'Visualizar Convênio' },
|
||||||
|
* service: { salvar: ConvenioService.salvar, atualizar: ConvenioService.atualizar, buscarPorId: ConvenioService.buscarPorId },
|
||||||
|
* rotaListagem: 'ConsultarConvenios',
|
||||||
|
* transformarDaAPI: (dados) => ({ descricao: dados.descricao, codigo: dados.codigo }),
|
||||||
|
* transformarParaSalvar: (form) => ({ ...form }),
|
||||||
|
* validar: (form, erros) => {
|
||||||
|
* if (!form.descricao) { erros.descricao = 'Campo obrigatório'; return false; }
|
||||||
|
* return true;
|
||||||
|
* },
|
||||||
|
* onDepoisSalvar: async (resposta) => { console.log('ID salvo:', resposta.data?.data?.id); },
|
||||||
|
* mensagens: { sucesso: { novo: 'Convênio cadastrado!', editar: 'Convênio atualizado!' } },
|
||||||
|
* });
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
* OPÇÕES DISPONÍVEIS
|
||||||
|
* ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
*
|
||||||
|
* @param {Object} opcoes - Configuração do composable
|
||||||
|
*
|
||||||
|
* @param {Object|Function} [opcoes.formInicial={}]
|
||||||
|
* Valores iniciais do formulário. Pode ser um objeto literal ou uma função
|
||||||
|
* que retorna o objeto (útil quando os valores dependem de variáveis externas
|
||||||
|
* ou precisam ser criados em cada reinicialização).
|
||||||
|
* Ex: `{ descricao: '', ativo: true }` ou `() => ({ descricao: '' })`
|
||||||
|
*
|
||||||
|
* @param {Object} [opcoes.titulos]
|
||||||
|
* Títulos dinâmicos exibidos conforme o modo da tela.
|
||||||
|
* Propriedades: `novo`, `editar`, `visualizar`.
|
||||||
|
* Ex: `{ novo: 'Novo Convênio', editar: 'Editar Convênio', visualizar: 'Visualizar Convênio' }`
|
||||||
|
*
|
||||||
|
* @param {Object} [opcoes.service]
|
||||||
|
* Métodos do service a serem chamados pelo composable:
|
||||||
|
* - `salvar(payload)` → chamado no modo novo
|
||||||
|
* - `atualizar(id, payload)` → chamado no modo edição
|
||||||
|
* - `buscarPorId(id)` → chamado em `carregarPorId()`; espera resposta com `{ data: { data: {...} } }`
|
||||||
|
*
|
||||||
|
* @param {string} [opcoes.rotaListagem]
|
||||||
|
* Nome da rota (Vue Router) para redirecionar após salvar ou ao chamar `voltar()`.
|
||||||
|
* Se não fornecida, `voltar()` usa `router.back()`.
|
||||||
|
*
|
||||||
|
* @param {'query'|'params'} [opcoes.origemId='query']
|
||||||
|
* De onde o ID do registro deve ser lido na rota atual.
|
||||||
|
* - `'query'` → `route.query[campoId]` (ex: `/cadastro?id=42`)
|
||||||
|
* - `'params'` → `route.params[campoId]` (ex: `/cadastro/42`)
|
||||||
|
*
|
||||||
|
* @param {string} [opcoes.campoId='id']
|
||||||
|
* Nome do parâmetro de ID na rota. Padrão: `'id'`.
|
||||||
|
*
|
||||||
|
* @param {Function} [opcoes.transformarDaAPI]
|
||||||
|
* Função `(dados) => formValues` que converte o objeto retornado pela API
|
||||||
|
* para o formato do formulário. Se não fornecida, o objeto é atribuído diretamente.
|
||||||
|
*
|
||||||
|
* @param {Function} [opcoes.transformarParaSalvar]
|
||||||
|
* Função `(formValues) => payload` que converte os dados do formulário
|
||||||
|
* para o payload enviado à API. Se não fornecida, `form.value` é usado diretamente.
|
||||||
|
*
|
||||||
|
* @param {Function} [opcoes.validar]
|
||||||
|
* Função de validação `(formValues, errosRef) => boolean`.
|
||||||
|
* Deve popular `errosRef` com mensagens de erro e retornar `false` se inválido.
|
||||||
|
* Se retornar `false`, o `salvar()` exibe um toast de validação e interrompe.
|
||||||
|
* Se não fornecida, a validação é ignorada.
|
||||||
|
*
|
||||||
|
* @param {Function} [opcoes.onDepoisSalvar]
|
||||||
|
* Callback `async (resposta, id) => void` executado após o save com sucesso,
|
||||||
|
* antes da navegação para a rota de listagem.
|
||||||
|
* Útil para lógicas extras pós-save (ex: sincronizar sub-recursos).
|
||||||
|
* Recebe a resposta completa do axios e o ID do registro salvo/atualizado.
|
||||||
|
*
|
||||||
|
* @param {Object} [opcoes.mensagens]
|
||||||
|
* Personalização das mensagens de toast.
|
||||||
|
* - `sucesso.novo`: mensagem ao criar (padrão: 'Registro salvo com sucesso!')
|
||||||
|
* - `sucesso.editar`: mensagem ao atualizar (padrão: 'Registro atualizado com sucesso!')
|
||||||
|
* - `validacao`: mensagem de validação (padrão: 'Preencha todos os campos obrigatórios.')
|
||||||
|
*
|
||||||
|
* ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
* RETORNO
|
||||||
|
* ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
*
|
||||||
|
* @returns {Object}
|
||||||
|
*
|
||||||
|
* @returns {import('vue').Ref<Object>} form
|
||||||
|
* Ref reativo com os dados do formulário. Inicializado com `formInicial`.
|
||||||
|
*
|
||||||
|
* @returns {import('vue').Ref<Object>} erros
|
||||||
|
* Ref reativo com os erros de validação por campo. Ex: `{ descricao: 'Obrigatório' }`.
|
||||||
|
*
|
||||||
|
* @returns {import('vue').Ref<boolean>} isLoading
|
||||||
|
* Indica se há uma operação assíncrona em andamento (salvar ou carregar).
|
||||||
|
*
|
||||||
|
* @returns {import('vue').ComputedRef<string|number|null>} registroId
|
||||||
|
* ID do registro lido da rota (query string ou params). Null se não existir.
|
||||||
|
*
|
||||||
|
* @returns {import('vue').ComputedRef<boolean>} isNewMode
|
||||||
|
* `true` quando não há ID na rota (modo criação).
|
||||||
|
*
|
||||||
|
* @returns {import('vue').ComputedRef<boolean>} isModoVisualizacao
|
||||||
|
* `true` quando `route.query.readOnly === 'true'` (modo somente leitura).
|
||||||
|
*
|
||||||
|
* @returns {import('vue').ComputedRef<string>} tituloTela
|
||||||
|
* Título dinâmico conforme o modo: `titulos.visualizar` | `titulos.editar` | `titulos.novo`.
|
||||||
|
*
|
||||||
|
* @returns {Function} salvar
|
||||||
|
* `async (payloadCustom?) => void`
|
||||||
|
* Executa validação → isLoading → service → toast → onDepoisSalvar → navegar.
|
||||||
|
* Aceita um `payloadCustom` opcional para sobrescrever o payload gerado por `transformarParaSalvar`.
|
||||||
|
* Útil para views com lógica de payload mais complexa.
|
||||||
|
*
|
||||||
|
* @returns {Function} voltar
|
||||||
|
* Navega para `rotaListagem` ou chama `router.back()`.
|
||||||
|
*
|
||||||
|
* @returns {Function} carregarPorId
|
||||||
|
* `async (id?, fnCustom?) => void`
|
||||||
|
* Carrega o registro pelo ID e preenche `form.value`.
|
||||||
|
* - `id`: sobrescreve o ID da rota (opcional)
|
||||||
|
* - `fnCustom`: função `async (id) => dados` para substituir `service.buscarPorId`
|
||||||
|
* Em caso de erro, exibe toast e redireciona para `rotaListagem`.
|
||||||
|
*
|
||||||
|
* @returns {Function} tocarCampo
|
||||||
|
* `(campo) => void`
|
||||||
|
* Atalho para `validarCampo(campo, form.value[campo])`. Use em `@blur` / `@change` no template.
|
||||||
|
*
|
||||||
|
* @returns {Function} reiniciarForm
|
||||||
|
* Restaura `form.value` para `formInicial` e limpa `erros.value`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
|
import { useToast } from './useToast';
|
||||||
|
import { useValidacao } from './useValidacao';
|
||||||
|
|
||||||
|
export function useFormularioCRUD(opcoes = {}) {
|
||||||
|
const {
|
||||||
|
formInicial = {},
|
||||||
|
titulos = {},
|
||||||
|
service = {},
|
||||||
|
rotaListagem = null,
|
||||||
|
origemId = 'query',
|
||||||
|
campoId = 'id',
|
||||||
|
transformarDaAPI = null,
|
||||||
|
transformarParaSalvar = null,
|
||||||
|
schema = null,
|
||||||
|
validar = null,
|
||||||
|
onDepoisSalvar = null,
|
||||||
|
mensagens = {},
|
||||||
|
} = opcoes;
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const route = useRoute();
|
||||||
|
const toast = useToast();
|
||||||
|
|
||||||
|
// ── Estado ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const isLoading = ref(false);
|
||||||
|
const form = ref(_criarFormInicial());
|
||||||
|
|
||||||
|
// Validação: Zod (schema) tem prioridade sobre validar fn legada
|
||||||
|
const _validacao = schema
|
||||||
|
? useValidacao(schema)
|
||||||
|
: { erros: ref({}), validar: () => true, validarCampo: () => true, limparErros: () => {} };
|
||||||
|
|
||||||
|
const erros = _validacao.erros;
|
||||||
|
const validarCampo = _validacao.validarCampo;
|
||||||
|
const limparErros = _validacao.limparErros;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Valida um campo lendo o valor atual de form.value.
|
||||||
|
* Atalho para uso em @blur / @change no template.
|
||||||
|
*/
|
||||||
|
function tocarCampo(campo) {
|
||||||
|
validarCampo(campo, form.value[campo]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Computeds de rota ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ID do registro na rota (string). Null se ausente ou inválido.
|
||||||
|
*/
|
||||||
|
const registroId = computed(() => {
|
||||||
|
const raw = origemId === 'params' ? route.params[campoId] : route.query[campoId];
|
||||||
|
if (!raw) return null;
|
||||||
|
return raw;
|
||||||
|
});
|
||||||
|
|
||||||
|
const isNewMode = computed(() => !registroId.value);
|
||||||
|
|
||||||
|
const isModoVisualizacao = computed(() => route.query.readOnly === 'true');
|
||||||
|
|
||||||
|
const tituloTela = computed(() => {
|
||||||
|
if (isModoVisualizacao.value && titulos.visualizar) return titulos.visualizar;
|
||||||
|
if (isNewMode.value) return titulos.novo || 'Novo Cadastro';
|
||||||
|
return titulos.editar || 'Edição';
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Helpers privados ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function _criarFormInicial() {
|
||||||
|
return typeof formInicial === 'function' ? formInicial() : { ...formInicial };
|
||||||
|
}
|
||||||
|
|
||||||
|
function _msgSucesso() {
|
||||||
|
return isNewMode.value
|
||||||
|
? mensagens.sucesso?.novo || 'Registro salvo com sucesso!'
|
||||||
|
: mensagens.sucesso?.editar || 'Registro atualizado com sucesso!';
|
||||||
|
}
|
||||||
|
|
||||||
|
function _msgValidacao() {
|
||||||
|
return mensagens.validacao || 'Preencha todos os campos obrigatórios.';
|
||||||
|
}
|
||||||
|
|
||||||
|
function _exibirErroAPI(error, fallback = 'Erro ao processar a requisição. Tente novamente.') {
|
||||||
|
toast.apiError(error, fallback);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── API pública ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Carrega o registro pelo ID e preenche form.value.
|
||||||
|
* @param {string|number} [id] - ID a carregar (sobrescreve o ID da rota)
|
||||||
|
* @param {Function} [fnCustom] - Função async (id) => dados para substituir service.buscarPorId
|
||||||
|
*/
|
||||||
|
async function carregarPorId(id = null, fnCustom = null) {
|
||||||
|
const idToLoad = id ?? registroId.value;
|
||||||
|
if (!idToLoad) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
isLoading.value = true;
|
||||||
|
|
||||||
|
let dados;
|
||||||
|
if (fnCustom) {
|
||||||
|
dados = await fnCustom(idToLoad);
|
||||||
|
} else if (service.buscarPorId) {
|
||||||
|
const response = await service.buscarPorId(idToLoad);
|
||||||
|
dados = response.data?.data ?? response.data;
|
||||||
|
} else {
|
||||||
|
throw new Error('Nenhuma função de carregamento fornecida (service.buscarPorId ou fnCustom).');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!dados) throw new Error('Registro não encontrado.');
|
||||||
|
|
||||||
|
form.value = transformarDaAPI ? transformarDaAPI(dados) : { ...dados };
|
||||||
|
} catch (error) {
|
||||||
|
_exibirErroAPI(error, 'Erro ao carregar os dados do registro.');
|
||||||
|
if (rotaListagem) router.push({ name: rotaListagem });
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Valida, chama o service (salvar ou atualizar), exibe toast e navega.
|
||||||
|
* @param {Object} [payloadCustom] - Payload alternativo (ignora transformarParaSalvar)
|
||||||
|
*/
|
||||||
|
async function salvar(payloadCustom = undefined) {
|
||||||
|
// Validação: schema Zod tem prioridade; fallback para validar fn legada
|
||||||
|
if (schema) {
|
||||||
|
limparErros();
|
||||||
|
if (!_validacao.validar(form.value)) {
|
||||||
|
toast.warn(_msgValidacao(), { summary: 'Validação' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else if (validar) {
|
||||||
|
erros.value = {};
|
||||||
|
const valido = validar(form.value, erros.value);
|
||||||
|
if (!valido) {
|
||||||
|
toast.warn(_msgValidacao(), { summary: 'Validação' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
isLoading.value = true;
|
||||||
|
|
||||||
|
const effectiveCustom = payloadCustom instanceof Event ? undefined : payloadCustom;
|
||||||
|
const payload =
|
||||||
|
effectiveCustom !== undefined
|
||||||
|
? effectiveCustom
|
||||||
|
: transformarParaSalvar
|
||||||
|
? transformarParaSalvar(form.value)
|
||||||
|
: form.value;
|
||||||
|
|
||||||
|
let resposta;
|
||||||
|
if (isNewMode.value) {
|
||||||
|
if (!service.salvar) throw new Error('service.salvar não está definido nas opções.');
|
||||||
|
resposta = await service.salvar(payload);
|
||||||
|
} else {
|
||||||
|
if (!service.atualizar) throw new Error('service.atualizar não está definido nas opções.');
|
||||||
|
resposta = await service.atualizar(registroId.value, payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success(_msgSucesso());
|
||||||
|
|
||||||
|
if (onDepoisSalvar) {
|
||||||
|
const idSalvo = resposta?.data?.data?.id ?? registroId.value;
|
||||||
|
await onDepoisSalvar(resposta, idSalvo);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rotaListagem) router.push({ name: rotaListagem });
|
||||||
|
} catch (error) {
|
||||||
|
_exibirErroAPI(error, 'Erro ao salvar. Tente novamente.');
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navega para a rota de listagem ou usa router.back().
|
||||||
|
*/
|
||||||
|
function voltar() {
|
||||||
|
if (rotaListagem) {
|
||||||
|
router.push({ name: rotaListagem });
|
||||||
|
} else {
|
||||||
|
router.back();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restaura form.value para formInicial e limpa erros.
|
||||||
|
*/
|
||||||
|
function reiniciarForm() {
|
||||||
|
form.value = _criarFormInicial();
|
||||||
|
limparErros();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
// Estado
|
||||||
|
form,
|
||||||
|
erros,
|
||||||
|
isLoading,
|
||||||
|
|
||||||
|
// Rota
|
||||||
|
registroId,
|
||||||
|
isNewMode,
|
||||||
|
isModoVisualizacao,
|
||||||
|
tituloTela,
|
||||||
|
|
||||||
|
// Validação
|
||||||
|
validarCampo,
|
||||||
|
tocarCampo,
|
||||||
|
limparErros,
|
||||||
|
|
||||||
|
// Ações
|
||||||
|
salvar,
|
||||||
|
voltar,
|
||||||
|
carregarPorId,
|
||||||
|
reiniciarForm,
|
||||||
|
};
|
||||||
|
}
|
||||||
154
src/composables/useListagemUrl.js
Normal file
154
src/composables/useListagemUrl.js
Normal file
@ -0,0 +1,154 @@
|
|||||||
|
// src/composables/useListagemUrl.js
|
||||||
|
import { computed, ref, watch } from 'vue';
|
||||||
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
|
|
||||||
|
const KEYS_SISTEMA = ['page', 'size', 'sort'];
|
||||||
|
const PARAMS_PADRAO = ['page', 'size', 'sort', 'status', 'q', 'dataInicio', 'dataFim', 'tipo'];
|
||||||
|
|
||||||
|
const PADROES = {
|
||||||
|
size: 10,
|
||||||
|
page: 1,
|
||||||
|
sort: 'id,desc',
|
||||||
|
};
|
||||||
|
|
||||||
|
function temValor(val) {
|
||||||
|
return val !== '' && val !== null && val !== undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chaves no padrão id[Maiúscula] (idDistrito, idSetor, idBairro...) armazenam
|
||||||
|
// números, mas a URL sempre serializa como string. Recoerce para Number ao ler
|
||||||
|
// de volta para que os <Select :option-value="id"> (numérico) encontrem o match.
|
||||||
|
function coercerValor(key, raw) {
|
||||||
|
if (raw === '' || raw === undefined || raw === null) return '';
|
||||||
|
if (/^id[A-Z]/.test(key) && /^\d+$/.test(String(raw))) return Number(raw);
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string[]} keysPermitidas - Chaves permitidas na query
|
||||||
|
* @param {Object} [opcoes] - Padrões configuráveis por componente
|
||||||
|
* @param {number} [opcoes.pagePadrao=1] - Página padrão
|
||||||
|
* @param {string} [opcoes.sortPadrao='id,desc'] - Ordenação padrão
|
||||||
|
*/
|
||||||
|
export function useListagemUrl(keysPermitidas = PARAMS_PADRAO, opcoes = {}) {
|
||||||
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const padroes = {
|
||||||
|
size: PADROES.size,
|
||||||
|
page: opcoes.pagePadrao ?? PADROES.page,
|
||||||
|
sort: opcoes.sortPadrao ?? PADROES.sort,
|
||||||
|
};
|
||||||
|
const keysFormulario = keysPermitidas.filter((k) => !KEYS_SISTEMA.includes(k));
|
||||||
|
|
||||||
|
const filtros = computed(() => {
|
||||||
|
const query = route.query;
|
||||||
|
const result = {};
|
||||||
|
|
||||||
|
for (const key of keysPermitidas) {
|
||||||
|
if (query[key] !== undefined) {
|
||||||
|
result[key] = query[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
page: Number(query.page) || padroes.page,
|
||||||
|
size: Number(query.size) || padroes.size,
|
||||||
|
sort: query.sort || padroes.sort,
|
||||||
|
...result,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const tamanhoPagina = computed(() => filtros.value.size);
|
||||||
|
|
||||||
|
const paginaAtual = computed(() => (filtros.value.page || 1) - 1);
|
||||||
|
const first = computed(() => paginaAtual.value * filtros.value.size);
|
||||||
|
|
||||||
|
const formValues = ref(
|
||||||
|
keysFormulario.reduce((acc, key) => {
|
||||||
|
acc[key] = coercerValor(key, route.query[key] ?? '');
|
||||||
|
return acc;
|
||||||
|
}, {})
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => route.query,
|
||||||
|
(query) => {
|
||||||
|
keysFormulario.forEach((key) => {
|
||||||
|
formValues.value[key] = coercerValor(key, query[key] ?? '');
|
||||||
|
});
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
const temFiltrosNaUrl = computed(() => keysFormulario.some((key) => temValor(filtros.value[key])));
|
||||||
|
|
||||||
|
const temFiltrosNoFormulario = computed(() => keysFormulario.some((key) => temValor(formValues.value[key])));
|
||||||
|
|
||||||
|
const temAlgoParaLimpar = computed(() => temFiltrosNoFormulario.value || temFiltrosNaUrl.value);
|
||||||
|
|
||||||
|
function atualizarUrl(params = {}) {
|
||||||
|
const novaQuery = { ...route.query };
|
||||||
|
|
||||||
|
Object.entries(params).forEach(([k, v]) => {
|
||||||
|
if (v === undefined || v === '' || v === null) {
|
||||||
|
delete novaQuery[k];
|
||||||
|
} else {
|
||||||
|
novaQuery[k] = String(v);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.replace({ query: novaQuery });
|
||||||
|
}
|
||||||
|
|
||||||
|
function mudarPagina(page, size) {
|
||||||
|
atualizarUrl({ page, size });
|
||||||
|
}
|
||||||
|
|
||||||
|
function ordenar(campo, direcao = 'asc') {
|
||||||
|
atualizarUrl({
|
||||||
|
sort: `${campo},${direcao}`,
|
||||||
|
page: 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function aplicarFiltros(novosFiltros) {
|
||||||
|
atualizarUrl({
|
||||||
|
...novosFiltros,
|
||||||
|
page: 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function limparFiltros() {
|
||||||
|
keysFormulario.forEach((key) => {
|
||||||
|
formValues.value[key] = '';
|
||||||
|
});
|
||||||
|
router.replace({ query: {} });
|
||||||
|
}
|
||||||
|
|
||||||
|
function aplicarFiltrosDoFormulario() {
|
||||||
|
const params = { size: filtros.value.size };
|
||||||
|
keysFormulario.forEach((key) => {
|
||||||
|
const v = formValues.value[key];
|
||||||
|
if (temValor(v)) params[key] = v;
|
||||||
|
});
|
||||||
|
aplicarFiltros(params);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
filtros,
|
||||||
|
tamanhoPagina,
|
||||||
|
paginaAtual,
|
||||||
|
first,
|
||||||
|
formValues,
|
||||||
|
temFiltrosNaUrl,
|
||||||
|
temFiltrosNoFormulario,
|
||||||
|
temAlgoParaLimpar,
|
||||||
|
atualizarUrl,
|
||||||
|
mudarPagina,
|
||||||
|
ordenar,
|
||||||
|
aplicarFiltros,
|
||||||
|
aplicarFiltrosDoFormulario,
|
||||||
|
limparFiltros,
|
||||||
|
};
|
||||||
|
}
|
||||||
148
src/composables/useTabelaPaginada.js
Normal file
148
src/composables/useTabelaPaginada.js
Normal file
@ -0,0 +1,148 @@
|
|||||||
|
import { useToast } from './useToast';
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Composable genérico para tabelas paginadas com ordenação client-side.
|
||||||
|
*
|
||||||
|
* Uso básico:
|
||||||
|
* const { isLoading, retorno, elementosTotais, buscaRealizada, buscar, sortField, sortOrder, retornoOrdenado } =
|
||||||
|
* useTabelaPaginada();
|
||||||
|
*
|
||||||
|
* // Chamar quando os filtros mudarem:
|
||||||
|
* await buscar(() => MeuService.listar(params));
|
||||||
|
*/
|
||||||
|
export function useTabelaPaginada() {
|
||||||
|
const toast = useToast();
|
||||||
|
|
||||||
|
// ── Estado ────────────────────────────────────────────────────────────────
|
||||||
|
const isLoading = ref(false);
|
||||||
|
const retorno = ref([]);
|
||||||
|
const elementosTotais = ref(0);
|
||||||
|
const buscaRealizada = ref(false);
|
||||||
|
const erros = ref('');
|
||||||
|
|
||||||
|
// Ordenação client-side
|
||||||
|
const sortField = ref(null);
|
||||||
|
const sortOrder = ref(null); // 1 = asc, -1 = desc
|
||||||
|
|
||||||
|
// ── Ordenação ─────────────────────────────────────────────────────────────
|
||||||
|
const retornoOrdenado = computed(() => {
|
||||||
|
if (!sortField.value || !retorno.value.length) return retorno.value;
|
||||||
|
|
||||||
|
return [...retorno.value].sort((a, b) => {
|
||||||
|
const valA = resolveField(a, sortField.value);
|
||||||
|
const valB = resolveField(b, sortField.value);
|
||||||
|
return compareValues(valA, valB) * (sortOrder.value ?? 1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ordena pelo campo informado.
|
||||||
|
* - Mesmo campo: alterna asc → desc → limpa
|
||||||
|
* - Campo diferente: inicia em asc
|
||||||
|
*/
|
||||||
|
function ordenar(campo) {
|
||||||
|
if (sortField.value === campo) {
|
||||||
|
if (sortOrder.value === 1) {
|
||||||
|
sortOrder.value = -1;
|
||||||
|
} else {
|
||||||
|
sortField.value = null;
|
||||||
|
sortOrder.value = null;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
sortField.value = campo;
|
||||||
|
sortOrder.value = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Busca ─────────────────────────────────────────────────────────────────
|
||||||
|
/**
|
||||||
|
* @param {() => Promise} serviceFn Função que retorna a Promise do serviço.
|
||||||
|
* @param {object} [opts]
|
||||||
|
* @param {string} [opts.msgVazia] Mensagem customizada quando não há resultados.
|
||||||
|
*/
|
||||||
|
async function buscar(serviceFn, opts = {}) {
|
||||||
|
isLoading.value = true;
|
||||||
|
buscaRealizada.value = true;
|
||||||
|
erros.value = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await serviceFn();
|
||||||
|
const resposta = response.data;
|
||||||
|
|
||||||
|
if (resposta.responseType === 'SUCCESS') {
|
||||||
|
const conteudo = resposta.data;
|
||||||
|
retorno.value = conteudo?.data ?? [];
|
||||||
|
elementosTotais.value = conteudo?.elementosTotais ?? 0;
|
||||||
|
|
||||||
|
if (retorno.value.length === 0) {
|
||||||
|
toast.info(opts.msgVazia ?? 'Não foram encontrados dados para a consulta realizada!', {
|
||||||
|
summary: 'Consulta',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error('Erro ao realizar a consulta.');
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function limpar() {
|
||||||
|
retorno.value = [];
|
||||||
|
elementosTotais.value = 0;
|
||||||
|
buscaRealizada.value = false;
|
||||||
|
erros.value = '';
|
||||||
|
sortField.value = null;
|
||||||
|
sortOrder.value = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
// estado
|
||||||
|
isLoading,
|
||||||
|
retorno,
|
||||||
|
retornoOrdenado,
|
||||||
|
elementosTotais,
|
||||||
|
buscaRealizada,
|
||||||
|
erros,
|
||||||
|
// ordenação
|
||||||
|
sortField,
|
||||||
|
sortOrder,
|
||||||
|
ordenar,
|
||||||
|
// ações
|
||||||
|
buscar,
|
||||||
|
limpar,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers internos ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Suporta campos aninhados: "endereco.cidade" */
|
||||||
|
function resolveField(obj, field) {
|
||||||
|
return field.split('.').reduce((acc, key) => acc?.[key], obj);
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareValues(a, b) {
|
||||||
|
if (a == null && b == null) return 0;
|
||||||
|
if (a == null) return -1;
|
||||||
|
if (b == null) return 1;
|
||||||
|
|
||||||
|
// Datas no formato ISO ou dd/mm/yyyy
|
||||||
|
const dateA = tryParseDate(a);
|
||||||
|
const dateB = tryParseDate(b);
|
||||||
|
if (dateA && dateB) return dateA - dateB;
|
||||||
|
|
||||||
|
if (typeof a === 'number' && typeof b === 'number') return a - b;
|
||||||
|
|
||||||
|
return String(a).localeCompare(String(b), 'pt-BR', { sensitivity: 'base' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function tryParseDate(value) {
|
||||||
|
if (!value) return null;
|
||||||
|
const iso = Date.parse(value);
|
||||||
|
if (!isNaN(iso)) return new Date(iso);
|
||||||
|
// dd/mm/yyyy
|
||||||
|
const match = String(value).match(/^(\d{2})\/(\d{2})\/(\d{4})$/);
|
||||||
|
if (match) return new Date(`${match[3]}-${match[2]}-${match[1]}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
197
src/composables/useToast.js
Normal file
197
src/composables/useToast.js
Normal file
@ -0,0 +1,197 @@
|
|||||||
|
/**
|
||||||
|
* useToast
|
||||||
|
* ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
* Wrapper sobre o `useToast` do PrimeVue que padroniza severidades, durações
|
||||||
|
* e extração de mensagens de erro da API em todo o projeto.
|
||||||
|
*
|
||||||
|
* Motivação: 37+ arquivos chamavam `toast.add()` diretamente com `life` e
|
||||||
|
* `severity` inconsistentes. Este composable centraliza essas decisões.
|
||||||
|
*
|
||||||
|
* ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
* USO
|
||||||
|
* ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
*
|
||||||
|
* ```js
|
||||||
|
* import { useToast } from '@/composables/useToast'
|
||||||
|
*
|
||||||
|
* const toast = useToast()
|
||||||
|
*
|
||||||
|
* toast.success('Convênio salvo com sucesso')
|
||||||
|
* toast.error('Erro ao salvar convênio')
|
||||||
|
* toast.warn('Campos obrigatórios não preenchidos')
|
||||||
|
* toast.info('Carregando dados...')
|
||||||
|
*
|
||||||
|
* // Extrai mensagem do erro de API automaticamente:
|
||||||
|
* toast.apiError(error)
|
||||||
|
* toast.apiError(error, 'Fallback se não houver mensagem no erro')
|
||||||
|
*
|
||||||
|
* // Operação com loading (mostra info enquanto aguarda, depois sucesso/erro):
|
||||||
|
* await toast.promise(minhaPromise, {
|
||||||
|
* loading: 'Salvando...',
|
||||||
|
* success: 'Salvo com sucesso!',
|
||||||
|
* error: 'Erro ao salvar'
|
||||||
|
* })
|
||||||
|
*
|
||||||
|
* // Para casos especiais com summary ou life customizados:
|
||||||
|
* toast.success('Recalculo concluído', { summary: 'Processamento', life: TOAST_DURACAO.longa })
|
||||||
|
*
|
||||||
|
* // Acesso direto ao toast do PrimeVue (edge cases como AppLayout interceptor):
|
||||||
|
* toast.add({ severity: 'error', summary: `Erro no campo ${campo}`, detail: msg, life: 10000 })
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useToast as usePrimeToast } from 'primevue/usetoast';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Durações padrão para notificações (em ms).
|
||||||
|
* Exporte para uso externo quando necessário.
|
||||||
|
*/
|
||||||
|
export const TOAST_DURACAO = {
|
||||||
|
/** Notificações rápidas de confirmação (ex: salvo com sucesso) */
|
||||||
|
curta: 3000,
|
||||||
|
/** Padrão para erros e avisos */
|
||||||
|
padrao: 5000,
|
||||||
|
/** Mensagens complexas que precisam de mais tempo de leitura */
|
||||||
|
longa: 8000,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Títulos padrão por severidade */
|
||||||
|
const TITULO = {
|
||||||
|
sucesso: 'Sucesso',
|
||||||
|
erro: 'Erro',
|
||||||
|
aviso: 'Atenção',
|
||||||
|
info: 'Informação',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useToast() {
|
||||||
|
const primeToast = usePrimeToast();
|
||||||
|
|
||||||
|
// ── Helpers privados ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adiciona um toast via PrimeVue.
|
||||||
|
* @param {Object} opcoes - Opções do toast
|
||||||
|
*/
|
||||||
|
function _add(opcoes) {
|
||||||
|
primeToast.add(opcoes);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── API pública ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exibe toast de sucesso.
|
||||||
|
* @param {string} detail - Mensagem principal
|
||||||
|
* @param {Object} [opcoes]
|
||||||
|
* @param {string} [opcoes.summary] - Título (padrão: 'Sucesso')
|
||||||
|
* @param {number} [opcoes.life] - Duração em ms (padrão: 3000)
|
||||||
|
*/
|
||||||
|
function success(detail, { summary = TITULO.sucesso, life = TOAST_DURACAO.curta } = {}) {
|
||||||
|
_add({ severity: 'success', summary, detail, life });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exibe toast de erro.
|
||||||
|
* @param {string} detail - Mensagem principal
|
||||||
|
* @param {Object} [opcoes]
|
||||||
|
* @param {string} [opcoes.summary] - Título (padrão: 'Erro')
|
||||||
|
* @param {number} [opcoes.life] - Duração em ms (padrão: 5000)
|
||||||
|
*/
|
||||||
|
function error(detail, { summary = TITULO.erro, life = TOAST_DURACAO.padrao } = {}) {
|
||||||
|
_add({ severity: 'error', summary, detail, life });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exibe toast de aviso.
|
||||||
|
* @param {string} detail - Mensagem principal
|
||||||
|
* @param {Object} [opcoes]
|
||||||
|
* @param {string} [opcoes.summary] - Título (padrão: 'Atenção')
|
||||||
|
* @param {number} [opcoes.life] - Duração em ms (padrão: 5000)
|
||||||
|
*/
|
||||||
|
function warn(detail, { summary = TITULO.aviso, life = TOAST_DURACAO.padrao } = {}) {
|
||||||
|
_add({ severity: 'warn', summary, detail, life });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exibe toast informativo.
|
||||||
|
* @param {string} detail - Mensagem principal
|
||||||
|
* @param {Object} [opcoes]
|
||||||
|
* @param {string} [opcoes.summary] - Título (padrão: 'Informação')
|
||||||
|
* @param {number} [opcoes.life] - Duração em ms (padrão: 5000)
|
||||||
|
*/
|
||||||
|
function info(detail, { summary = TITULO.info, life = TOAST_DURACAO.padrao } = {}) {
|
||||||
|
_add({ severity: 'info', summary, detail, life });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exibe toast de erro extraindo a mensagem do erro da API automaticamente.
|
||||||
|
*
|
||||||
|
* Hierarquia de extração:
|
||||||
|
* 1. `error.response.data.message` (mensagem do backend)
|
||||||
|
* 2. `error.message` (mensagem do JS/Axios)
|
||||||
|
* 3. `fallback` fornecido
|
||||||
|
* 4. Mensagem genérica
|
||||||
|
*
|
||||||
|
* @param {Error|AxiosError} err - Erro capturado no catch
|
||||||
|
* @param {string} [fallback] - Mensagem de fallback se nenhuma for encontrada
|
||||||
|
*/
|
||||||
|
function apiError(err, fallback = 'Erro ao processar a requisição. Tente novamente.') {
|
||||||
|
const detail = err?.response?.data?.message || err?.message || fallback;
|
||||||
|
error(detail);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Executa uma Promise exibindo toasts de loading → sucesso/erro.
|
||||||
|
*
|
||||||
|
* - Mostra toast de "aguarde" durante a execução
|
||||||
|
* - Remove o toast de loading ao finalizar
|
||||||
|
* - Exibe sucesso ou erro conforme o resultado
|
||||||
|
*
|
||||||
|
* @param {Promise} promessa - A Promise a ser aguardada
|
||||||
|
* @param {Object} mensagens
|
||||||
|
* @param {string} mensagens.loading - Texto exibido durante a execução
|
||||||
|
* @param {string|Function} mensagens.success - Texto de sucesso (ou fn(result) => string)
|
||||||
|
* @param {string|Function} mensagens.error - Texto de erro (ou fn(err) => string)
|
||||||
|
* @returns {Promise} A mesma Promise original (re-lança o erro em caso de falha)
|
||||||
|
*/
|
||||||
|
async function promise(promessa, { loading: msgLoading, success: msgSucesso, error: msgErro }) {
|
||||||
|
const LOADING_GROUP = 'toast-promise-loading';
|
||||||
|
|
||||||
|
_add({
|
||||||
|
severity: 'info',
|
||||||
|
summary: TITULO.info,
|
||||||
|
detail: msgLoading,
|
||||||
|
group: LOADING_GROUP,
|
||||||
|
life: 60000,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resultado = await promessa;
|
||||||
|
primeToast.removeGroup(LOADING_GROUP);
|
||||||
|
const detail = typeof msgSucesso === 'function' ? msgSucesso(resultado) : msgSucesso;
|
||||||
|
success(detail);
|
||||||
|
return resultado;
|
||||||
|
} catch (err) {
|
||||||
|
primeToast.removeGroup(LOADING_GROUP);
|
||||||
|
const detail = typeof msgErro === 'function' ? msgErro(err) : msgErro;
|
||||||
|
error(detail);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Acesso direto ao `toast.add()` do PrimeVue.
|
||||||
|
* Use apenas para casos que exigem controle total do toast
|
||||||
|
* (ex: múltiplos toasts em loop, summary dinâmico, life > 8s).
|
||||||
|
*/
|
||||||
|
const add = (opcoes) => primeToast.add(opcoes);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success,
|
||||||
|
error,
|
||||||
|
warn,
|
||||||
|
info,
|
||||||
|
apiError,
|
||||||
|
promise,
|
||||||
|
add,
|
||||||
|
};
|
||||||
|
}
|
||||||
97
src/composables/useValidacao.js
Normal file
97
src/composables/useValidacao.js
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
/**
|
||||||
|
* Composable genérico de validação baseado em Zod.
|
||||||
|
*
|
||||||
|
* Uso:
|
||||||
|
* const schema = z.object({ email: z.string().email() })
|
||||||
|
* const { erros, validar, validarCampo, limparErros } = useValidacao(schema)
|
||||||
|
*
|
||||||
|
* // Tempo real: chamado no @blur ou watch de cada campo
|
||||||
|
* validarCampo('email', valor)
|
||||||
|
*
|
||||||
|
* // Submit: valida tudo e retorna boolean
|
||||||
|
* if (!validar(form)) return
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {z.ZodObject} schema - Schema Zod do formulário
|
||||||
|
*/
|
||||||
|
export function useValidacao(schema) {
|
||||||
|
const erros = ref({});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Valida um campo individualmente (tempo real).
|
||||||
|
* Extrai apenas o campo do schema para evitar erros de outros campos.
|
||||||
|
*
|
||||||
|
* @param {string} campo - Nome do campo
|
||||||
|
* @param {*} valor - Valor atual
|
||||||
|
* @returns {boolean} True se válido
|
||||||
|
*/
|
||||||
|
const validarCampo = (campo, valor) => {
|
||||||
|
const schemaCampo = schema.shape?.[campo];
|
||||||
|
if (!schemaCampo) return true;
|
||||||
|
|
||||||
|
const resultado = schemaCampo.safeParse(valor);
|
||||||
|
if (!resultado.success) {
|
||||||
|
erros.value = { ...erros.value, [campo]: resultado.error.issues[0].message };
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const novosErros = { ...erros.value };
|
||||||
|
delete novosErros[campo];
|
||||||
|
erros.value = novosErros;
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Valida o formulário inteiro. Popula `erros` com todos os problemas.
|
||||||
|
*
|
||||||
|
* @param {Object} form - Dados do formulário
|
||||||
|
* @returns {boolean} True se válido
|
||||||
|
*/
|
||||||
|
const validar = (form) => {
|
||||||
|
const resultado = schema.safeParse(form);
|
||||||
|
|
||||||
|
if (!resultado.success) {
|
||||||
|
const mapa = {};
|
||||||
|
for (const issue of resultado.error.issues) {
|
||||||
|
const campo = issue.path[0];
|
||||||
|
if (campo && !mapa[campo]) {
|
||||||
|
mapa[campo] = issue.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
erros.value = mapa;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
erros.value = {};
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const limparErros = () => {
|
||||||
|
erros.value = {};
|
||||||
|
};
|
||||||
|
|
||||||
|
return { erros, validar, validarCampo, limparErros };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Mensagens padrão em português (Zod v4) ───────────────────────────────────
|
||||||
|
|
||||||
|
z.setErrorMap((issue) => {
|
||||||
|
switch (issue.code) {
|
||||||
|
case 'too_small':
|
||||||
|
if (issue.origin === 'string') {
|
||||||
|
return issue.minimum === 1 ? 'Campo obrigatório.' : `Mínimo ${issue.minimum} caracteres.`;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'invalid_format':
|
||||||
|
if (issue.format === 'email') return 'E-mail inválido.';
|
||||||
|
if (issue.format === 'regex') return 'Formato inválido.';
|
||||||
|
break;
|
||||||
|
case 'invalid_type':
|
||||||
|
return 'Campo obrigatório.';
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
});
|
||||||
29
src/config/apiClient.js
Normal file
29
src/config/apiClient.js
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import { usePrefeituraStore } from '@/store/prefeituraStore';
|
||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
const apiClient = axios.create({
|
||||||
|
baseURL: `${import.meta.env.VITE_API_URL}/api/v1`,
|
||||||
|
timeout: 10000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const apiClientPublico = axios.create({
|
||||||
|
baseURL: `${import.meta.env.VITE_API_URL}/api/v1`,
|
||||||
|
timeout: 10000,
|
||||||
|
});
|
||||||
|
|
||||||
|
function setupTenantHeaders(config) {
|
||||||
|
const prefeituraStore = usePrefeituraStore();
|
||||||
|
if (prefeituraStore.codigoMunicipio) {
|
||||||
|
config.headers['X-Municipio'] = prefeituraStore.codigoMunicipio;
|
||||||
|
}
|
||||||
|
if (prefeituraStore.dominio) {
|
||||||
|
config.headers['X-Dominio'] = prefeituraStore.dominio;
|
||||||
|
}
|
||||||
|
config.headers['X-Tenant-Origin'] = window.location.origin;
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
apiClient.interceptors.request.use(setupTenantHeaders);
|
||||||
|
|
||||||
|
export default apiClient;
|
||||||
|
export { apiClientPublico };
|
||||||
19
src/config/globalComponents.js
Normal file
19
src/config/globalComponents.js
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import FormField from '@/components/forms/FormField.vue';
|
||||||
|
import FormGrid from '@/components/forms/FormGrid.vue';
|
||||||
|
import FormPanel from '@/components/forms/FormPanel.vue';
|
||||||
|
import AcoesTabela from '@/components/listagem/AcoesTabela.vue';
|
||||||
|
import HeaderOrdenavel from '@/components/listagem/HeaderOrdenavel.vue';
|
||||||
|
import TabelaPaginada from '@/components/listagem/TabelaPaginada.vue';
|
||||||
|
import LoadingOverlay from '@/layout/LoadingOverlay.vue';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
install(app) {
|
||||||
|
app.component('FormField', FormField);
|
||||||
|
app.component('FormGrid', FormGrid);
|
||||||
|
app.component('FormPanel', FormPanel);
|
||||||
|
app.component('TabelaPaginada', TabelaPaginada);
|
||||||
|
app.component('HeaderOrdenavel', HeaderOrdenavel);
|
||||||
|
app.component('AcoesTabela', AcoesTabela);
|
||||||
|
app.component('LoadingOverlay', LoadingOverlay);
|
||||||
|
},
|
||||||
|
};
|
||||||
64
src/config/menu.js
Normal file
64
src/config/menu.js
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
export const getMenuModel = () => [
|
||||||
|
{
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
label: 'Tela Inicial',
|
||||||
|
icon: 'pi pi-fw pi-home',
|
||||||
|
to: '/app',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
label: 'Dívida Ativa',
|
||||||
|
icon: 'pi pi-fw pi-book',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
label: 'Livros',
|
||||||
|
icon: 'pi pi-fw pi-book',
|
||||||
|
to: '/app/livro',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Folhas',
|
||||||
|
icon: 'pi pi-fw pi-file',
|
||||||
|
to: '/app/folha',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'CDAs',
|
||||||
|
icon: 'pi pi-fw pi-file-edit',
|
||||||
|
to: '/app/cda',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const SITUACAO_OPCOES = [
|
||||||
|
{ label: 'Ativo', value: '1' },
|
||||||
|
{ label: 'Encerrado', value: '2' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function labelSituacao(valor) {
|
||||||
|
return SITUACAO_OPCOES.find((o) => o.value === valor)?.label ?? valor;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDateTime(valor) {
|
||||||
|
if (!valor) return '—';
|
||||||
|
const d = new Date(valor);
|
||||||
|
if (Number.isNaN(d.getTime())) return valor;
|
||||||
|
return d.toLocaleString('pt-BR');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toIsoDateTime(date) {
|
||||||
|
if (!date) return null;
|
||||||
|
if (date instanceof Date) return date.toISOString().slice(0, 19);
|
||||||
|
return date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseIsoToDate(valor) {
|
||||||
|
if (!valor) return null;
|
||||||
|
const d = new Date(valor);
|
||||||
|
return Number.isNaN(d.getTime()) ? null : d;
|
||||||
|
}
|
||||||
38
src/config/primevue.config.js
Normal file
38
src/config/primevue.config.js
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
import Aura from '@primeuix/themes/aura';
|
||||||
|
|
||||||
|
export const primeVueConfig = {
|
||||||
|
theme: {
|
||||||
|
preset: Aura,
|
||||||
|
options: {
|
||||||
|
darkModeSelector: '.app-dark',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
locale: {
|
||||||
|
firstDayOfWeek: 1,
|
||||||
|
dayNames: ['Domingo', 'Segunda-feira', 'Terça-feira', 'Quarta-feira', 'Quinta-feira', 'Sexta-feira', 'Sábado'],
|
||||||
|
dayNamesShort: ['Dom', 'Seg', 'Ter', 'Qua', 'Qui', 'Sex', 'Sáb'],
|
||||||
|
dayNamesMin: ['D', 'S', 'T', 'Q', 'Q', 'S', 'S'],
|
||||||
|
monthNames: [
|
||||||
|
'Janeiro',
|
||||||
|
'Fevereiro',
|
||||||
|
'Março',
|
||||||
|
'Abril',
|
||||||
|
'Maio',
|
||||||
|
'Junho',
|
||||||
|
'Julho',
|
||||||
|
'Agosto',
|
||||||
|
'Setembro',
|
||||||
|
'Outubro',
|
||||||
|
'Novembro',
|
||||||
|
'Dezembro',
|
||||||
|
],
|
||||||
|
monthNamesShort: ['Jan', 'Fev', 'Mar', 'Abr', 'Mai', 'Jun', 'Jul', 'Ago', 'Set', 'Out', 'Nov', 'Dez'],
|
||||||
|
today: 'Hoje',
|
||||||
|
clear: 'Limpar',
|
||||||
|
accept: 'Sim',
|
||||||
|
reject: 'Não',
|
||||||
|
choose: 'Escolher',
|
||||||
|
cancel: 'Cancelar',
|
||||||
|
emptyMessage: 'Nenhum resultado encontrado',
|
||||||
|
},
|
||||||
|
};
|
||||||
5
src/layout/AppFooter.vue
Normal file
5
src/layout/AppFooter.vue
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
<script setup></script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="layout-footer">Modum Fiscal — Dívida Ativa</div>
|
||||||
|
</template>
|
||||||
117
src/layout/AppLayout.vue
Normal file
117
src/layout/AppLayout.vue
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
<script setup>
|
||||||
|
import { useBreadcrumb } from '@/composables/useBreadcrumb';
|
||||||
|
import { useToast } from '@/composables/useToast';
|
||||||
|
import apiClient from '@/config/apiClient';
|
||||||
|
import { useLayout } from '@/layout/composables/layout';
|
||||||
|
import Breadcrumb from 'primevue/breadcrumb';
|
||||||
|
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||||
|
import AppFooter from './AppFooter.vue';
|
||||||
|
import AppSidebar from './AppSidebar.vue';
|
||||||
|
import AppTopbar from './AppTopbar.vue';
|
||||||
|
|
||||||
|
const toast = useToast();
|
||||||
|
let interceptor;
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
interceptor = apiClient.interceptors.response.use(
|
||||||
|
(response) => response,
|
||||||
|
(error) => {
|
||||||
|
if (error.response) {
|
||||||
|
const { status, data } = error.response;
|
||||||
|
if (status === 422) {
|
||||||
|
toast.warn(data?.detail || data?.message || 'Erro de regra de negócio', {
|
||||||
|
summary: 'Regra de negócio',
|
||||||
|
});
|
||||||
|
} else if (status === 404) {
|
||||||
|
toast.info('Registro não encontrado.', { summary: 'Não encontrado' });
|
||||||
|
} else {
|
||||||
|
toast.error(data?.message || 'Erro inesperado no servidor', { summary: `Erro ${status}` });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
toast.error('Servidor fora ou sem internet', { summary: 'Erro de conexão' });
|
||||||
|
}
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (interceptor !== undefined) apiClient.interceptors.response.eject(interceptor);
|
||||||
|
});
|
||||||
|
|
||||||
|
const { layoutConfig, layoutState, isSidebarActive } = useLayout();
|
||||||
|
const { breadcrumbItems, home } = useBreadcrumb();
|
||||||
|
const outsideClickListener = ref(null);
|
||||||
|
|
||||||
|
watch(isSidebarActive, (newVal) => {
|
||||||
|
if (newVal) bindOutsideClickListener();
|
||||||
|
else unbindOutsideClickListener();
|
||||||
|
});
|
||||||
|
|
||||||
|
const containerClass = computed(() => ({
|
||||||
|
'layout-overlay': layoutConfig.menuMode === 'overlay',
|
||||||
|
'layout-static': layoutConfig.menuMode === 'static',
|
||||||
|
'layout-static-inactive': layoutState.staticMenuDesktopInactive && layoutConfig.menuMode === 'static',
|
||||||
|
'layout-overlay-active': layoutState.overlayMenuActive,
|
||||||
|
'layout-mobile-active': layoutState.staticMenuMobileActive,
|
||||||
|
}));
|
||||||
|
|
||||||
|
function bindOutsideClickListener() {
|
||||||
|
if (!outsideClickListener.value) {
|
||||||
|
outsideClickListener.value = (event) => {
|
||||||
|
if (isOutsideClicked(event)) {
|
||||||
|
layoutState.overlayMenuActive = false;
|
||||||
|
layoutState.staticMenuMobileActive = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('click', outsideClickListener.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function unbindOutsideClickListener() {
|
||||||
|
if (outsideClickListener.value) {
|
||||||
|
document.removeEventListener('click', outsideClickListener.value);
|
||||||
|
outsideClickListener.value = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isOutsideClicked(event) {
|
||||||
|
const sidebarEl = document.querySelector('.layout-sidebar');
|
||||||
|
const topbarEl = document.querySelector('.layout-menu-button');
|
||||||
|
if (!sidebarEl || !topbarEl) return false;
|
||||||
|
return !(
|
||||||
|
sidebarEl.isSameNode(event.target) ||
|
||||||
|
sidebarEl.contains(event.target) ||
|
||||||
|
topbarEl.isSameNode(event.target) ||
|
||||||
|
topbarEl.contains(event.target)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="layout-wrapper" :class="containerClass">
|
||||||
|
<app-topbar />
|
||||||
|
<app-sidebar />
|
||||||
|
<div class="layout-main-container">
|
||||||
|
<div class="layout-main relative">
|
||||||
|
<div
|
||||||
|
v-if="breadcrumbItems.length > 0 && breadcrumbItems[0]?.label !== 'Tela Inicial'"
|
||||||
|
class="layout-breadcrumb"
|
||||||
|
>
|
||||||
|
<Breadcrumb :home="home" :model="breadcrumbItems" class="shadow-md">
|
||||||
|
<template #item="{ item, props: linkProps }">
|
||||||
|
<router-link v-if="item.to" :to="item.to" v-bind="linkProps.action">
|
||||||
|
<span>{{ item.label }}</span>
|
||||||
|
</router-link>
|
||||||
|
<span v-else>{{ item.label }}</span>
|
||||||
|
</template>
|
||||||
|
</Breadcrumb>
|
||||||
|
</div>
|
||||||
|
<router-view :key="$route.path" />
|
||||||
|
</div>
|
||||||
|
<app-footer />
|
||||||
|
</div>
|
||||||
|
<div class="layout-mask animate-fadein"></div>
|
||||||
|
</div>
|
||||||
|
<Toast />
|
||||||
|
</template>
|
||||||
74
src/layout/AppMenu.vue
Normal file
74
src/layout/AppMenu.vue
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
<script setup>
|
||||||
|
import { getMenuModel } from '@/config/menu';
|
||||||
|
import { useLayout } from '@/layout/composables/layout';
|
||||||
|
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue';
|
||||||
|
import AppMenuItem from './AppMenuItem.vue';
|
||||||
|
|
||||||
|
const { layoutConfig, layoutState } = useLayout();
|
||||||
|
const searchQuery = ref('');
|
||||||
|
const searchInput = ref(null);
|
||||||
|
|
||||||
|
const menuModel = computed(() => getMenuModel());
|
||||||
|
|
||||||
|
const handleKeyDown = (event) => {
|
||||||
|
if ((event.ctrlKey || event.metaKey) && event.key === 'k') {
|
||||||
|
event.preventDefault();
|
||||||
|
const sidebarCollapsed =
|
||||||
|
layoutConfig.menuMode === 'static' && layoutState.staticMenuDesktopInactive && window.innerWidth > 991;
|
||||||
|
if (sidebarCollapsed) {
|
||||||
|
layoutState.staticMenuDesktopInactive = false;
|
||||||
|
}
|
||||||
|
nextTick(() => searchInput.value?.$el?.focus());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => window.addEventListener('keydown', handleKeyDown));
|
||||||
|
onUnmounted(() => window.removeEventListener('keydown', handleKeyDown));
|
||||||
|
|
||||||
|
const filteredMenu = computed(() => {
|
||||||
|
const query = searchQuery.value.toLowerCase().trim();
|
||||||
|
if (!query) return menuModel.value;
|
||||||
|
|
||||||
|
const filterItems = (items) =>
|
||||||
|
items
|
||||||
|
.map((item) => ({ ...item }))
|
||||||
|
.filter((item) => {
|
||||||
|
const label = item.label?.toLowerCase() || '';
|
||||||
|
const hasChildren = item.items && item.items.length > 0;
|
||||||
|
let filteredChildren = [];
|
||||||
|
if (hasChildren) filteredChildren = filterItems(item.items);
|
||||||
|
const matches = label.includes(query) || filteredChildren.length > 0;
|
||||||
|
if (matches && hasChildren) item.items = filteredChildren;
|
||||||
|
return matches;
|
||||||
|
});
|
||||||
|
|
||||||
|
return filterItems(menuModel.value);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="menu-search-wrapper p-3">
|
||||||
|
<IconField class="w-full">
|
||||||
|
<InputIcon class="pi pi-search" />
|
||||||
|
<InputText
|
||||||
|
ref="searchInput"
|
||||||
|
v-model="searchQuery"
|
||||||
|
class="w-full p-inputtext-sm"
|
||||||
|
placeholder="Buscar menu... (Ctrl+K)"
|
||||||
|
/>
|
||||||
|
<InputIcon
|
||||||
|
v-if="searchQuery"
|
||||||
|
class="pi pi-times cursor-pointer"
|
||||||
|
style="font-size: 0.8rem"
|
||||||
|
@click="searchQuery = ''"
|
||||||
|
/>
|
||||||
|
</IconField>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul class="layout-menu">
|
||||||
|
<template v-for="(item, i) in filteredMenu" :key="item.label">
|
||||||
|
<app-menu-item v-if="!item.separator" :item="item" :index="i" :search-active="!!searchQuery" />
|
||||||
|
<li v-if="item.separator" class="menu-separator"></li>
|
||||||
|
</template>
|
||||||
|
</ul>
|
||||||
|
</template>
|
||||||
128
src/layout/AppMenuItem.vue
Normal file
128
src/layout/AppMenuItem.vue
Normal file
@ -0,0 +1,128 @@
|
|||||||
|
<script setup>
|
||||||
|
import { useLayout } from '@/layout/composables/layout';
|
||||||
|
import { useMenuStore } from '@/store/menuStore';
|
||||||
|
import { computed, watch } from 'vue';
|
||||||
|
import { useRoute } from 'vue-router';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
item: { type: Object, default: () => ({}) },
|
||||||
|
index: { type: Number, default: 0 },
|
||||||
|
root: { type: Boolean, default: true },
|
||||||
|
parentItemKey: { type: String, default: '' },
|
||||||
|
searchActive: Boolean,
|
||||||
|
});
|
||||||
|
|
||||||
|
const menuStore = useMenuStore();
|
||||||
|
const { layoutState, toggleMenu } = useLayout();
|
||||||
|
const route = useRoute();
|
||||||
|
|
||||||
|
const itemKey = computed(() =>
|
||||||
|
props.parentItemKey ? `${props.parentItemKey}-${props.index}` : String(props.index)
|
||||||
|
);
|
||||||
|
|
||||||
|
const isActiveMenu = computed(() => menuStore.activeMenuItems.includes(itemKey.value));
|
||||||
|
|
||||||
|
const resolvedTo = computed(() => {
|
||||||
|
if (!props.item.to) return null;
|
||||||
|
if (typeof props.item.to === 'function') return props.item.to();
|
||||||
|
return props.item.to;
|
||||||
|
});
|
||||||
|
|
||||||
|
function itemClick(event, item) {
|
||||||
|
if (item.disabled) {
|
||||||
|
event.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (item.items) {
|
||||||
|
event.preventDefault();
|
||||||
|
menuStore.toggleMenuItem(itemKey.value);
|
||||||
|
}
|
||||||
|
if ((item.to || item.url) && (layoutState.staticMenuMobileActive || layoutState.overlayMenuActive)) {
|
||||||
|
toggleMenu();
|
||||||
|
}
|
||||||
|
if (!item.items && item.to) {
|
||||||
|
menuStore.addToRecent({ label: item.label, to: item.to, icon: item.icon });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkActiveRoute(item) {
|
||||||
|
if (!item) return false;
|
||||||
|
if (item.to) {
|
||||||
|
const currentPath = route.path;
|
||||||
|
const itemPath = typeof item.to === 'string' ? item.to.split('?')[0] : item.to?.path?.split('?')[0];
|
||||||
|
if (currentPath === itemPath) return true;
|
||||||
|
}
|
||||||
|
if (item.items) return item.items.some((child) => checkActiveRoute(child));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => route.path,
|
||||||
|
() => {
|
||||||
|
if (checkActiveRoute(props.item)) menuStore.addActiveMenuItem(itemKey.value);
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.searchActive,
|
||||||
|
(active) => {
|
||||||
|
if (active && props.item.items) menuStore.addActiveMenuItem(itemKey.value);
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<li
|
||||||
|
:class="{
|
||||||
|
'layout-root-menuitem': root,
|
||||||
|
'active-menuitem': isActiveMenu,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<div v-if="root" class="layout-menuitem-root-text">{{ item.label }}</div>
|
||||||
|
|
||||||
|
<template v-if="!item.items">
|
||||||
|
<router-link
|
||||||
|
v-if="resolvedTo"
|
||||||
|
:class="{ 'active-route': checkActiveRoute(item) }"
|
||||||
|
:to="resolvedTo"
|
||||||
|
@click="itemClick($event, item)"
|
||||||
|
>
|
||||||
|
<i :class="item.icon" class="layout-menuitem-icon"></i>
|
||||||
|
<span class="layout-menuitem-text">{{ item.label }}</span>
|
||||||
|
</router-link>
|
||||||
|
<a v-else href="#" @click="itemClick($event, item)">
|
||||||
|
<i :class="item.icon" class="layout-menuitem-icon"></i>
|
||||||
|
<span class="layout-menuitem-text">{{ item.label }}</span>
|
||||||
|
</a>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<a href="#" @click="itemClick($event, item)">
|
||||||
|
<i :class="item.icon" class="layout-menuitem-icon"></i>
|
||||||
|
<span class="layout-menuitem-text">{{ item.label }}</span>
|
||||||
|
<i class="pi pi-fw pi-angle-down layout-submenu-toggler"></i>
|
||||||
|
</a>
|
||||||
|
<Transition name="layout-submenu">
|
||||||
|
<ul v-show="root ? true : isActiveMenu" class="layout-submenu">
|
||||||
|
<app-menu-item
|
||||||
|
v-for="(child, i) in item.items"
|
||||||
|
:key="i"
|
||||||
|
:item="child"
|
||||||
|
:index="i"
|
||||||
|
:parent-item-key="itemKey"
|
||||||
|
:root="false"
|
||||||
|
/>
|
||||||
|
</ul>
|
||||||
|
</Transition>
|
||||||
|
</template>
|
||||||
|
</li>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.active-route {
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--primary-color) !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
9
src/layout/AppSidebar.vue
Normal file
9
src/layout/AppSidebar.vue
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
<script setup>
|
||||||
|
import AppMenu from './AppMenu.vue';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="layout-sidebar shadow-lg">
|
||||||
|
<app-menu />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
42
src/layout/AppTopbar.vue
Normal file
42
src/layout/AppTopbar.vue
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
<script setup>
|
||||||
|
import { useLayout } from '@/layout/composables/layout';
|
||||||
|
import { usePrefeituraStore } from '@/store/prefeituraStore';
|
||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
|
const { toggleMenu, toggleDarkMode, isDarkTheme } = useLayout();
|
||||||
|
const prefeituraStore = usePrefeituraStore();
|
||||||
|
|
||||||
|
const logoSrc = computed(() => prefeituraStore.pathLogo || null);
|
||||||
|
const municipioLabel = computed(() => {
|
||||||
|
if (prefeituraStore.nomePrefeitura) {
|
||||||
|
return prefeituraStore.nomePrefeitura;
|
||||||
|
}
|
||||||
|
return prefeituraStore.codigoMunicipio ? `Município ${prefeituraStore.codigoMunicipio}` : '';
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="layout-topbar shadow-md">
|
||||||
|
<div class="layout-topbar-logo-container">
|
||||||
|
<button class="layout-menu-button layout-topbar-action" @click="toggleMenu">
|
||||||
|
<i class="pi pi-bars"></i>
|
||||||
|
</button>
|
||||||
|
<router-link to="/app" class="layout-topbar-logo">
|
||||||
|
<img v-if="logoSrc" :src="logoSrc" alt="Logo Prefeitura" width="150" />
|
||||||
|
<span v-else class="font-bold text-xl">Dívida Ativa</span>
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="layout-topbar-actions">
|
||||||
|
<div class="layout-config-menu">
|
||||||
|
<div v-if="municipioLabel" class="hidden lg:flex items-center gap-2 mr-2">
|
||||||
|
<i class="pi pi-map-marker"></i>
|
||||||
|
<span class="text-sm font-semibold">{{ municipioLabel }}</span>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="layout-topbar-action" @click="toggleDarkMode">
|
||||||
|
<i :class="['pi', { 'pi-moon': isDarkTheme, 'pi-sun': !isDarkTheme }]"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
25
src/layout/LoadingOverlay.vue
Normal file
25
src/layout/LoadingOverlay.vue
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
visible: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
zindex: {
|
||||||
|
type: Number,
|
||||||
|
default: 10,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
v-if="visible"
|
||||||
|
class="absolute inset-0 bg-black/35 backdrop-blur-sm flex items-center justify-center rounded-lg"
|
||||||
|
:style="{ zIndex: zindex }"
|
||||||
|
>
|
||||||
|
<div class="flex flex-col items-center space-y-4">
|
||||||
|
<i class="pi pi-spin pi-spinner text-white" style="font-size: 2.5rem"></i>
|
||||||
|
<p class="text-xl font-bold text-white">Carregando informações...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
80
src/layout/composables/layout.js
Normal file
80
src/layout/composables/layout.js
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
import { usePrefeituraStore } from '@/store/prefeituraStore';
|
||||||
|
import { computed, onMounted, reactive, watch } from 'vue';
|
||||||
|
import { applyTemplate, applySurface } from './theme.config';
|
||||||
|
|
||||||
|
const layoutConfig = reactive({
|
||||||
|
darkTheme: false,
|
||||||
|
menuMode: 'static',
|
||||||
|
template: 'sistema',
|
||||||
|
});
|
||||||
|
|
||||||
|
const layoutState = reactive({
|
||||||
|
staticMenuDesktopInactive: false,
|
||||||
|
overlayMenuActive: false,
|
||||||
|
staticMenuMobileActive: false,
|
||||||
|
menuHoverActive: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
export function useLayout() {
|
||||||
|
const storePrefeitura = usePrefeituraStore();
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
const savedTheme = localStorage.getItem('divida-app-dark-theme');
|
||||||
|
if (savedTheme === 'true') {
|
||||||
|
layoutConfig.darkTheme = true;
|
||||||
|
document.documentElement.classList.add('app-dark');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (storePrefeitura.template) {
|
||||||
|
layoutConfig.template = storePrefeitura.template;
|
||||||
|
}
|
||||||
|
|
||||||
|
applyTemplate(layoutConfig.template);
|
||||||
|
applySurface('slate');
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => layoutConfig.darkTheme,
|
||||||
|
(value) => {
|
||||||
|
localStorage.setItem('divida-app-dark-theme', value);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => storePrefeitura.template,
|
||||||
|
(template) => {
|
||||||
|
if (template) {
|
||||||
|
layoutConfig.template = template;
|
||||||
|
applyTemplate(template);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const toggleDarkMode = () => {
|
||||||
|
layoutConfig.darkTheme = !layoutConfig.darkTheme;
|
||||||
|
document.documentElement.classList.toggle('app-dark');
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleMenu = () => {
|
||||||
|
if (layoutConfig.menuMode === 'overlay') {
|
||||||
|
layoutState.overlayMenuActive = !layoutState.overlayMenuActive;
|
||||||
|
}
|
||||||
|
if (window.innerWidth > 991) {
|
||||||
|
layoutState.staticMenuDesktopInactive = !layoutState.staticMenuDesktopInactive;
|
||||||
|
} else {
|
||||||
|
layoutState.staticMenuMobileActive = !layoutState.staticMenuMobileActive;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isSidebarActive = computed(() => layoutState.overlayMenuActive || layoutState.staticMenuMobileActive);
|
||||||
|
const isDarkTheme = computed(() => layoutConfig.darkTheme);
|
||||||
|
|
||||||
|
return {
|
||||||
|
layoutConfig,
|
||||||
|
layoutState,
|
||||||
|
toggleMenu,
|
||||||
|
isSidebarActive,
|
||||||
|
isDarkTheme,
|
||||||
|
toggleDarkMode,
|
||||||
|
};
|
||||||
|
}
|
||||||
119
src/layout/composables/theme.config.js
Normal file
119
src/layout/composables/theme.config.js
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
import { updatePreset, updateSurfacePalette } from '@primeuix/themes';
|
||||||
|
import { ref } from 'vue';
|
||||||
|
|
||||||
|
export const templates = ref([
|
||||||
|
{ name: 'sistema', palette: null },
|
||||||
|
{
|
||||||
|
name: 'indigo',
|
||||||
|
palette: {
|
||||||
|
50: '#eef2ff',
|
||||||
|
100: '#e0e7ff',
|
||||||
|
200: '#c7d2fe',
|
||||||
|
300: '#a5b4fc',
|
||||||
|
400: '#818cf8',
|
||||||
|
500: '#6366f1',
|
||||||
|
600: '#4f46e5',
|
||||||
|
700: '#4338ca',
|
||||||
|
800: '#3730a3',
|
||||||
|
900: '#312e81',
|
||||||
|
950: '#1e1b4b',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const surfaces = ref([
|
||||||
|
{
|
||||||
|
name: 'slate',
|
||||||
|
palette: {
|
||||||
|
0: '#ffffff',
|
||||||
|
50: '#f8fafc',
|
||||||
|
100: '#f1f5f9',
|
||||||
|
200: '#e2e8f0',
|
||||||
|
300: '#cbd5e1',
|
||||||
|
400: '#94a3b8',
|
||||||
|
500: '#64748b',
|
||||||
|
600: '#475569',
|
||||||
|
700: '#334155',
|
||||||
|
800: '#1e293b',
|
||||||
|
900: '#0f172a',
|
||||||
|
950: '#020617',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
export function getPresetExt(templateName) {
|
||||||
|
const template = templates.value.find((t) => t.name === templateName) ?? templates.value[0];
|
||||||
|
|
||||||
|
if (template.palette === null) {
|
||||||
|
return {
|
||||||
|
semantic: {
|
||||||
|
primary: {
|
||||||
|
50: '{surface.50}',
|
||||||
|
100: '{surface.100}',
|
||||||
|
200: '{surface.200}',
|
||||||
|
300: '{surface.300}',
|
||||||
|
400: '{surface.400}',
|
||||||
|
500: '{surface.500}',
|
||||||
|
600: '{surface.600}',
|
||||||
|
700: '{surface.700}',
|
||||||
|
800: '{surface.800}',
|
||||||
|
900: '{surface.900}',
|
||||||
|
950: '{surface.950}',
|
||||||
|
},
|
||||||
|
colorScheme: {
|
||||||
|
light: {
|
||||||
|
primary: {
|
||||||
|
color: '{primary.950}',
|
||||||
|
contrastColor: '#ffffff',
|
||||||
|
hoverColor: '{primary.800}',
|
||||||
|
activeColor: '{primary.700}',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
dark: {
|
||||||
|
primary: {
|
||||||
|
color: '{primary.50}',
|
||||||
|
contrastColor: '{primary.950}',
|
||||||
|
hoverColor: '{primary.200}',
|
||||||
|
activeColor: '{primary.300}',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
semantic: {
|
||||||
|
primary: template.palette,
|
||||||
|
colorScheme: {
|
||||||
|
light: {
|
||||||
|
primary: {
|
||||||
|
color: '{primary.500}',
|
||||||
|
contrastColor: '{surface.50}',
|
||||||
|
hoverColor: '{primary.600}',
|
||||||
|
activeColor: '{primary.700}',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
dark: {
|
||||||
|
primary: {
|
||||||
|
color: '{primary.400}',
|
||||||
|
contrastColor: '{surface.900}',
|
||||||
|
hoverColor: '{primary.300}',
|
||||||
|
activeColor: '{primary.200}',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyTemplate(templateName) {
|
||||||
|
updatePreset(getPresetExt(templateName));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applySurface(surfaceName) {
|
||||||
|
const surface = surfaces.value.find((s) => s.name === surfaceName);
|
||||||
|
if (surface) {
|
||||||
|
updateSurfacePalette(surface.palette);
|
||||||
|
}
|
||||||
|
}
|
||||||
40
src/main.js
Normal file
40
src/main.js
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
import { createPinia } from 'pinia';
|
||||||
|
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate';
|
||||||
|
import { createApp } from 'vue';
|
||||||
|
import App from './App.vue';
|
||||||
|
import router from './router';
|
||||||
|
|
||||||
|
import PrimeVue from 'primevue/config';
|
||||||
|
import ConfirmationService from 'primevue/confirmationservice';
|
||||||
|
import ToastService from 'primevue/toastservice';
|
||||||
|
|
||||||
|
import GlobalComponents from './config/globalComponents';
|
||||||
|
import { primeVueConfig } from './config/primevue.config';
|
||||||
|
import { bootstrapPrefeitura } from './bootstrap/prefeituraBoot';
|
||||||
|
|
||||||
|
import '@/assets/styles.scss';
|
||||||
|
|
||||||
|
async function startApp() {
|
||||||
|
const app = createApp(App);
|
||||||
|
|
||||||
|
const pinia = createPinia();
|
||||||
|
pinia.use(piniaPluginPersistedstate);
|
||||||
|
app.use(pinia);
|
||||||
|
|
||||||
|
const bootstrapResult = await bootstrapPrefeitura(pinia);
|
||||||
|
|
||||||
|
app.use(router);
|
||||||
|
|
||||||
|
if (!bootstrapResult.success) {
|
||||||
|
router.push({ name: 'error' });
|
||||||
|
}
|
||||||
|
|
||||||
|
app.use(PrimeVue, primeVueConfig);
|
||||||
|
app.use(ToastService);
|
||||||
|
app.use(ConfirmationService);
|
||||||
|
app.use(GlobalComponents);
|
||||||
|
|
||||||
|
app.mount('#app');
|
||||||
|
}
|
||||||
|
|
||||||
|
startApp();
|
||||||
31
src/router/index.js
Normal file
31
src/router/index.js
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import AppLayout from '@/layout/AppLayout.vue';
|
||||||
|
import { createRouter, createWebHistory } from 'vue-router';
|
||||||
|
import dividaAtivaRoutes from '@/views/modules/dividaAtiva/router.js';
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
history: createWebHistory(),
|
||||||
|
routes: [
|
||||||
|
{ path: '/', redirect: '/app' },
|
||||||
|
{
|
||||||
|
path: '/auth/error',
|
||||||
|
name: 'error',
|
||||||
|
meta: { public: true },
|
||||||
|
component: () => import('@/views/pages/Error.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/app',
|
||||||
|
component: AppLayout,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: '',
|
||||||
|
name: 'inicio',
|
||||||
|
meta: { title: 'Tela Inicial' },
|
||||||
|
component: () => import('@/views/pages/Inicio.vue'),
|
||||||
|
},
|
||||||
|
...dividaAtivaRoutes,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
18
src/service/divida/CdaService.js
Normal file
18
src/service/divida/CdaService.js
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import apiClient from '@/config/apiClient';
|
||||||
|
|
||||||
|
const resource = '/cda';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
listar(params) {
|
||||||
|
return apiClient.get(`${resource}/listar`, { params });
|
||||||
|
},
|
||||||
|
buscarPorId(id) {
|
||||||
|
return apiClient.get(`${resource}/${id}`);
|
||||||
|
},
|
||||||
|
salvar(data) {
|
||||||
|
return apiClient.post(resource, data);
|
||||||
|
},
|
||||||
|
atualizar(id, data) {
|
||||||
|
return apiClient.put(`${resource}/${id}`, data);
|
||||||
|
},
|
||||||
|
};
|
||||||
18
src/service/divida/FolhaService.js
Normal file
18
src/service/divida/FolhaService.js
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import apiClient from '@/config/apiClient';
|
||||||
|
|
||||||
|
const resource = '/folha';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
listar(params) {
|
||||||
|
return apiClient.get(`${resource}/listar`, { params });
|
||||||
|
},
|
||||||
|
buscarPorId(id) {
|
||||||
|
return apiClient.get(`${resource}/${id}`);
|
||||||
|
},
|
||||||
|
salvar(data) {
|
||||||
|
return apiClient.post(resource, data);
|
||||||
|
},
|
||||||
|
atualizar(id, data) {
|
||||||
|
return apiClient.put(`${resource}/${id}`, data);
|
||||||
|
},
|
||||||
|
};
|
||||||
18
src/service/divida/LivroService.js
Normal file
18
src/service/divida/LivroService.js
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import apiClient from '@/config/apiClient';
|
||||||
|
|
||||||
|
const resource = '/livro';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
listar(params) {
|
||||||
|
return apiClient.get(`${resource}/listar`, { params });
|
||||||
|
},
|
||||||
|
buscarPorId(id) {
|
||||||
|
return apiClient.get(`${resource}/${id}`);
|
||||||
|
},
|
||||||
|
salvar(data) {
|
||||||
|
return apiClient.post(resource, data);
|
||||||
|
},
|
||||||
|
atualizar(id, data) {
|
||||||
|
return apiClient.put(`${resource}/${id}`, data);
|
||||||
|
},
|
||||||
|
};
|
||||||
3
src/service/index.js
Normal file
3
src/service/index.js
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export { default as LivroService } from './divida/LivroService';
|
||||||
|
export { default as FolhaService } from './divida/FolhaService';
|
||||||
|
export { default as CdaService } from './divida/CdaService';
|
||||||
9
src/service/prefeitura/PrefeituraService.js
Normal file
9
src/service/prefeitura/PrefeituraService.js
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import { apiClientPublico } from '@/config/apiClient';
|
||||||
|
|
||||||
|
const resource = '/publico';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
getPrefeituraInfo(dominio) {
|
||||||
|
return apiClientPublico.get(`${resource}/prefeitura/${dominio}`);
|
||||||
|
},
|
||||||
|
};
|
||||||
43
src/store/menuStore.js
Normal file
43
src/store/menuStore.js
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
import { defineStore } from 'pinia';
|
||||||
|
|
||||||
|
export const useMenuStore = defineStore('menu', {
|
||||||
|
state: () => ({
|
||||||
|
activeMenuItems: [],
|
||||||
|
favorites: [],
|
||||||
|
recent: [],
|
||||||
|
}),
|
||||||
|
persist: {
|
||||||
|
pick: ['favorites', 'recent'],
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
toggleMenuItem(key) {
|
||||||
|
const idx = this.activeMenuItems.indexOf(key);
|
||||||
|
if (idx > -1) {
|
||||||
|
this.activeMenuItems.splice(idx, 1);
|
||||||
|
} else {
|
||||||
|
this.activeMenuItems.push(key);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
addActiveMenuItem(key) {
|
||||||
|
if (!this.activeMenuItems.includes(key)) {
|
||||||
|
this.activeMenuItems.push(key);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
toggleFavorite(item) {
|
||||||
|
const path = typeof item.to === 'string' ? item.to : item.to?.path;
|
||||||
|
const idx = this.favorites.findIndex((f) => f.to === path);
|
||||||
|
if (idx > -1) {
|
||||||
|
this.favorites.splice(idx, 1);
|
||||||
|
} else {
|
||||||
|
this.favorites.push({ label: item.label, to: path, icon: item.icon });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
addToRecent(item) {
|
||||||
|
const path = typeof item.to === 'string' ? item.to : item.to?.path;
|
||||||
|
this.recent = [{ label: item.label, to: path, icon: item.icon }, ...this.recent.filter((r) => r.to !== path)].slice(
|
||||||
|
0,
|
||||||
|
5
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
12
src/store/prefeituraStore.js
Normal file
12
src/store/prefeituraStore.js
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { defineStore } from 'pinia';
|
||||||
|
|
||||||
|
export const usePrefeituraStore = defineStore('prefeitura', {
|
||||||
|
state: () => ({
|
||||||
|
codigoMunicipio: null,
|
||||||
|
nomePrefeitura: null,
|
||||||
|
dominio: null,
|
||||||
|
template: null,
|
||||||
|
pathLogo: null,
|
||||||
|
}),
|
||||||
|
persist: true,
|
||||||
|
});
|
||||||
38
src/utils/tenant.js
Normal file
38
src/utils/tenant.js
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
// utils/tenant.js
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtém o tenant da URL ou sessionStorage, com fallback para "sistema"
|
||||||
|
* @returns {string} O tenant identificado ou "sistema" como fallback
|
||||||
|
*/
|
||||||
|
export function getTenant() {
|
||||||
|
const tenant = window.location.hostname.split('.')[0];
|
||||||
|
|
||||||
|
if (isValidTenant(tenant)) {
|
||||||
|
return tenant;
|
||||||
|
}
|
||||||
|
|
||||||
|
const storedTenant = localStorage.getItem('current_municipio');
|
||||||
|
if (storedTenant && isValidTenant(storedTenant)) {
|
||||||
|
return storedTenant;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'sistema';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidTenant(tenant) {
|
||||||
|
if (!tenant || typeof tenant !== 'string') return false;
|
||||||
|
|
||||||
|
const invalidTenants = ['0.0.0.0', 'www', 'api', 'admin', 'test', 'dev', 'development', 'staging', 'production'];
|
||||||
|
|
||||||
|
return !invalidTenants.includes(tenant.toLowerCase()) && tenant.length > 1 && /^[a-zA-Z0-9-]+$/.test(tenant);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setTenant(tenant) {
|
||||||
|
if (isValidTenant(tenant)) {
|
||||||
|
localStorage.setItem('current_municipio', tenant);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearTenant() {
|
||||||
|
localStorage.removeItem('current_municipio');
|
||||||
|
}
|
||||||
126
src/views/modules/dividaAtiva/CdaCadastro.vue
Normal file
126
src/views/modules/dividaAtiva/CdaCadastro.vue
Normal file
@ -0,0 +1,126 @@
|
|||||||
|
<script setup>
|
||||||
|
import { useFormularioCRUD } from '@/composables/useFormularioCRUD';
|
||||||
|
import { parseIsoToDate, toIsoDateTime } from '@/config/menu';
|
||||||
|
import { CdaService } from '@/service';
|
||||||
|
import { onMounted } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const schemaCda = z.object({
|
||||||
|
idGenerica: z.number({ invalid_type_error: 'Situação (genérica) é obrigatória.' }).int().min(1),
|
||||||
|
numero: z.number({ invalid_type_error: 'Número é obrigatório.' }).int().min(1),
|
||||||
|
dataEmissao: z.date({ invalid_type_error: 'Data de emissão é obrigatória.' }),
|
||||||
|
valorTotal: z.number({ invalid_type_error: 'Valor total é obrigatório.' }).min(0.01, 'Valor deve ser maior que zero.'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
form,
|
||||||
|
erros,
|
||||||
|
isLoading,
|
||||||
|
isNewMode,
|
||||||
|
registroId,
|
||||||
|
tituloTela,
|
||||||
|
salvar,
|
||||||
|
carregarPorId,
|
||||||
|
tocarCampo,
|
||||||
|
} = useFormularioCRUD({
|
||||||
|
formInicial: {
|
||||||
|
idGenerica: null,
|
||||||
|
numero: null,
|
||||||
|
dataEmissao: null,
|
||||||
|
valorTotal: null,
|
||||||
|
},
|
||||||
|
titulos: { novo: 'Cadastro de CDA', editar: 'Edição de CDA' },
|
||||||
|
service: {
|
||||||
|
salvar: CdaService.salvar,
|
||||||
|
atualizar: CdaService.atualizar,
|
||||||
|
buscarPorId: CdaService.buscarPorId,
|
||||||
|
},
|
||||||
|
schema: schemaCda,
|
||||||
|
transformarParaSalvar: (f) => ({
|
||||||
|
idGenerica: f.idGenerica,
|
||||||
|
numero: f.numero,
|
||||||
|
dataEmissao: toIsoDateTime(f.dataEmissao),
|
||||||
|
valorTotal: f.valorTotal,
|
||||||
|
}),
|
||||||
|
rotaListagem: 'ConsultarCdas',
|
||||||
|
mensagens: {
|
||||||
|
sucesso: { novo: 'CDA cadastrada com sucesso!', editar: 'CDA atualizada com sucesso!' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function voltar() {
|
||||||
|
router.push({ name: 'ConsultarCdas' });
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
if (registroId.value) {
|
||||||
|
await carregarPorId(null, async (id) => {
|
||||||
|
const response = await CdaService.buscarPorId(id);
|
||||||
|
const data = response.data?.data;
|
||||||
|
if (!data?.id) throw new Error('CDA não encontrada');
|
||||||
|
return {
|
||||||
|
idGenerica: data.idGenerica ?? null,
|
||||||
|
numero: data.numero ?? null,
|
||||||
|
dataEmissao: parseIsoToDate(data.dataEmissao),
|
||||||
|
valorTotal: data.valorTotal != null ? Number(data.valorTotal) : null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<LoadingOverlay :visible="isLoading" />
|
||||||
|
<FormPanel :title="tituloTela" :icon="isNewMode ? 'pi pi-plus' : 'pi pi-pencil'">
|
||||||
|
<FormGrid>
|
||||||
|
<FormField label="Número:" :error="erros.numero" span="3" required>
|
||||||
|
<InputNumber
|
||||||
|
v-model="form.numero"
|
||||||
|
:use-grouping="false"
|
||||||
|
:class="{ 'p-invalid': erros.numero }"
|
||||||
|
@blur="tocarCampo('numero')"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="ID Situação (genérica):" :error="erros.idGenerica" span="4" required>
|
||||||
|
<InputNumber
|
||||||
|
v-model="form.idGenerica"
|
||||||
|
:use-grouping="false"
|
||||||
|
:class="{ 'p-invalid': erros.idGenerica }"
|
||||||
|
@blur="tocarCampo('idGenerica')"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Data emissão:" :error="erros.dataEmissao" span="4" required>
|
||||||
|
<DatePicker
|
||||||
|
v-model="form.dataEmissao"
|
||||||
|
show-time
|
||||||
|
hour-format="24"
|
||||||
|
date-format="dd/mm/yy"
|
||||||
|
@hide="tocarCampo('dataEmissao')"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Valor total:" :error="erros.valorTotal" span="3" required>
|
||||||
|
<InputNumber
|
||||||
|
v-model="form.valorTotal"
|
||||||
|
mode="currency"
|
||||||
|
currency="BRL"
|
||||||
|
locale="pt-BR"
|
||||||
|
:class="{ 'p-invalid': erros.valorTotal }"
|
||||||
|
@blur="tocarCampo('valorTotal')"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
</FormGrid>
|
||||||
|
<template #footer>
|
||||||
|
<div>
|
||||||
|
<Button label="Voltar" class="p-button-secondary" icon="pi pi-arrow-left" @click="voltar" />
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<Button label="Salvar" icon="pi pi-save" @click="() => salvar()" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</FormPanel>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
139
src/views/modules/dividaAtiva/CdaListar.vue
Normal file
139
src/views/modules/dividaAtiva/CdaListar.vue
Normal file
@ -0,0 +1,139 @@
|
|||||||
|
<script setup>
|
||||||
|
import { useAcoesTabela } from '@/composables/useAcoesTabela';
|
||||||
|
import { useListagemUrl } from '@/composables/useListagemUrl';
|
||||||
|
import { useTabelaPaginada } from '@/composables/useTabelaPaginada';
|
||||||
|
import { formatDateTime } from '@/config/menu';
|
||||||
|
import { CdaService } from '@/service';
|
||||||
|
import { watch } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const {
|
||||||
|
filtros,
|
||||||
|
tamanhoPagina,
|
||||||
|
paginaAtual,
|
||||||
|
first,
|
||||||
|
formValues,
|
||||||
|
temAlgoParaLimpar,
|
||||||
|
temFiltrosNoFormulario,
|
||||||
|
mudarPagina,
|
||||||
|
aplicarFiltrosDoFormulario,
|
||||||
|
limparFiltros: limparFiltrosUrl,
|
||||||
|
} = useListagemUrl(['page', 'size', 'numero', 'idGenerica']);
|
||||||
|
|
||||||
|
const {
|
||||||
|
isLoading,
|
||||||
|
retornoOrdenado,
|
||||||
|
elementosTotais,
|
||||||
|
buscaRealizada,
|
||||||
|
buscar,
|
||||||
|
limpar: limparTabela,
|
||||||
|
} = useTabelaPaginada();
|
||||||
|
|
||||||
|
function onPageChange(event) {
|
||||||
|
mudarPagina(event.page + 1, tamanhoPagina.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function limparFiltros() {
|
||||||
|
limparFiltrosUrl();
|
||||||
|
limparTabela();
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCurrency(valor) {
|
||||||
|
if (valor == null) return '—';
|
||||||
|
return Number(valor).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
filtros,
|
||||||
|
() => {
|
||||||
|
const params = { ...filtros.value, page: paginaAtual.value };
|
||||||
|
if (params.numero) params.numero = Number(params.numero);
|
||||||
|
if (params.idGenerica) params.idGenerica = Number(params.idGenerica);
|
||||||
|
buscar(() => CdaService.listar(params));
|
||||||
|
},
|
||||||
|
{ immediate: true, deep: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
const { acaoEditar } = useAcoesTabela();
|
||||||
|
const acoes = [acaoEditar((row) => router.push({ name: 'CdaCadastro', query: { id: row.id } }))];
|
||||||
|
|
||||||
|
function novo() {
|
||||||
|
router.push({ name: 'CdaCadastro' });
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Fluid>
|
||||||
|
<form @submit.prevent="aplicarFiltrosDoFormulario">
|
||||||
|
<FormPanel title="Certidões de Dívida Ativa (CDA)" icon="pi pi-search">
|
||||||
|
<FormGrid cols="12">
|
||||||
|
<FormField label="Número" span="3">
|
||||||
|
<InputNumber v-model="formValues.numero" :use-grouping="false" placeholder="Número da CDA" />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="ID Situação (genérica)" span="4">
|
||||||
|
<InputNumber
|
||||||
|
v-model="formValues.idGenerica"
|
||||||
|
:use-grouping="false"
|
||||||
|
placeholder="ID genérica"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
</FormGrid>
|
||||||
|
<template #footer>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<Button label="Nova CDA" icon="pi pi-plus" type="button" :fluid="false" @click="novo" />
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<Button
|
||||||
|
label="Consultar"
|
||||||
|
icon="pi pi-search"
|
||||||
|
type="submit"
|
||||||
|
:fluid="false"
|
||||||
|
:loading="isLoading"
|
||||||
|
:disabled="!temFiltrosNoFormulario"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
label="Limpar"
|
||||||
|
icon="pi pi-eraser"
|
||||||
|
type="button"
|
||||||
|
:fluid="false"
|
||||||
|
class="p-button-secondary"
|
||||||
|
:disabled="!temAlgoParaLimpar"
|
||||||
|
@click="limparFiltros"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</FormPanel>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<FormPanel title="Lista de CDAs" icon="pi pi-list">
|
||||||
|
<TabelaPaginada
|
||||||
|
:value="retornoOrdenado"
|
||||||
|
:loading="isLoading"
|
||||||
|
:total-records="elementosTotais"
|
||||||
|
:rows="tamanhoPagina"
|
||||||
|
:first="first"
|
||||||
|
:busca-realizada="buscaRealizada"
|
||||||
|
msg-aguardando="Consulte as CDAs usando os filtros acima."
|
||||||
|
@page="onPageChange"
|
||||||
|
@size-change="(novoSize) => mudarPagina(1, novoSize)"
|
||||||
|
>
|
||||||
|
<Column field="id" header="ID" body-style="text-align: center" />
|
||||||
|
<Column field="numero" header="Número" body-style="text-align: center" />
|
||||||
|
<Column field="idGenerica" header="ID Situação" body-style="text-align: center" />
|
||||||
|
<Column field="dataEmissao" header="Emissão">
|
||||||
|
<template #body="{ data }">{{ formatDateTime(data.dataEmissao) }}</template>
|
||||||
|
</Column>
|
||||||
|
<Column field="valorTotal" header="Valor total">
|
||||||
|
<template #body="{ data }">{{ formatCurrency(data.valorTotal) }}</template>
|
||||||
|
</Column>
|
||||||
|
<Column header="Ações" body-style="text-align: center">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<AcoesTabela :acoes="acoes" :linha="data" />
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
</TabelaPaginada>
|
||||||
|
</FormPanel>
|
||||||
|
</Fluid>
|
||||||
|
</template>
|
||||||
169
src/views/modules/dividaAtiva/FolhaCadastro.vue
Normal file
169
src/views/modules/dividaAtiva/FolhaCadastro.vue
Normal file
@ -0,0 +1,169 @@
|
|||||||
|
<script setup>
|
||||||
|
import { useFormularioCRUD } from '@/composables/useFormularioCRUD';
|
||||||
|
import { parseIsoToDate, SITUACAO_OPCOES, toIsoDateTime } from '@/config/menu';
|
||||||
|
import { FolhaService } from '@/service';
|
||||||
|
import { onMounted } from 'vue';
|
||||||
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const route = useRoute();
|
||||||
|
|
||||||
|
const schemaFolha = z
|
||||||
|
.object({
|
||||||
|
idLivro: z.number().int().min(1).nullable().optional(),
|
||||||
|
numero: z.number({ invalid_type_error: 'Número é obrigatório.' }).int().min(1),
|
||||||
|
qtdInscricoes: z.number({ invalid_type_error: 'Quantidade de inscrições é obrigatória.' }).int().min(0),
|
||||||
|
dataAbertura: z.date({ invalid_type_error: 'Data de abertura é obrigatória.' }),
|
||||||
|
dataEncerramento: z.date().nullable().optional(),
|
||||||
|
situacao: z.string().min(1, 'Situação é obrigatória.'),
|
||||||
|
})
|
||||||
|
.superRefine((data, ctx) => {
|
||||||
|
if (!route.query.id && !data.idLivro) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
path: ['idLivro'],
|
||||||
|
message: 'Livro é obrigatório.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
form,
|
||||||
|
erros,
|
||||||
|
isLoading,
|
||||||
|
isNewMode,
|
||||||
|
registroId,
|
||||||
|
tituloTela,
|
||||||
|
salvar,
|
||||||
|
carregarPorId,
|
||||||
|
tocarCampo,
|
||||||
|
} = useFormularioCRUD({
|
||||||
|
formInicial: () => ({
|
||||||
|
idLivro: route.query.idLivro ? Number(route.query.idLivro) : null,
|
||||||
|
numero: null,
|
||||||
|
qtdInscricoes: 0,
|
||||||
|
dataAbertura: null,
|
||||||
|
dataEncerramento: null,
|
||||||
|
situacao: '1',
|
||||||
|
}),
|
||||||
|
titulos: { novo: 'Cadastro de Folha', editar: 'Edição de Folha' },
|
||||||
|
service: {
|
||||||
|
salvar: FolhaService.salvar,
|
||||||
|
atualizar: FolhaService.atualizar,
|
||||||
|
buscarPorId: FolhaService.buscarPorId,
|
||||||
|
},
|
||||||
|
schema: schemaFolha,
|
||||||
|
transformarParaSalvar: (f) => {
|
||||||
|
const payload = {
|
||||||
|
numero: f.numero,
|
||||||
|
qtdInscricoes: f.qtdInscricoes,
|
||||||
|
dataAbertura: toIsoDateTime(f.dataAbertura),
|
||||||
|
dataEncerramento: toIsoDateTime(f.dataEncerramento),
|
||||||
|
situacao: f.situacao,
|
||||||
|
};
|
||||||
|
if (!route.query.id) {
|
||||||
|
payload.idLivro = f.idLivro;
|
||||||
|
}
|
||||||
|
return payload;
|
||||||
|
},
|
||||||
|
rotaListagem: 'ConsultarFolhas',
|
||||||
|
mensagens: {
|
||||||
|
sucesso: { novo: 'Folha cadastrada com sucesso!', editar: 'Folha atualizada com sucesso!' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function voltar() {
|
||||||
|
router.push({ name: 'ConsultarFolhas' });
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
if (registroId.value) {
|
||||||
|
await carregarPorId(null, async (id) => {
|
||||||
|
const response = await FolhaService.buscarPorId(id);
|
||||||
|
const data = response.data?.data;
|
||||||
|
if (!data?.id) throw new Error('Folha não encontrada');
|
||||||
|
return {
|
||||||
|
idLivro: data.idLivro ?? null,
|
||||||
|
numero: data.numero ?? null,
|
||||||
|
qtdInscricoes: data.qtdInscricoes ?? 0,
|
||||||
|
dataAbertura: parseIsoToDate(data.dataAbertura),
|
||||||
|
dataEncerramento: parseIsoToDate(data.dataEncerramento),
|
||||||
|
situacao: data.situacao || '1',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<LoadingOverlay :visible="isLoading" />
|
||||||
|
<FormPanel :title="tituloTela" :icon="isNewMode ? 'pi pi-plus' : 'pi pi-pencil'">
|
||||||
|
<FormGrid>
|
||||||
|
<FormField v-if="isNewMode" label="ID Livro:" :error="erros.idLivro" span="3" required>
|
||||||
|
<InputNumber
|
||||||
|
v-model="form.idLivro"
|
||||||
|
:use-grouping="false"
|
||||||
|
:class="{ 'p-invalid': erros.idLivro }"
|
||||||
|
@blur="tocarCampo('idLivro')"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField v-else label="ID Livro:" span="3">
|
||||||
|
<InputNumber v-model="form.idLivro" :use-grouping="false" disabled />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Número:" :error="erros.numero" span="2" required>
|
||||||
|
<InputNumber
|
||||||
|
v-model="form.numero"
|
||||||
|
:use-grouping="false"
|
||||||
|
:class="{ 'p-invalid': erros.numero }"
|
||||||
|
@blur="tocarCampo('numero')"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Qtd. inscrições:" :error="erros.qtdInscricoes" span="2" required>
|
||||||
|
<InputNumber
|
||||||
|
v-model="form.qtdInscricoes"
|
||||||
|
:use-grouping="false"
|
||||||
|
:class="{ 'p-invalid': erros.qtdInscricoes }"
|
||||||
|
@blur="tocarCampo('qtdInscricoes')"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Situação:" :error="erros.situacao" span="3" required>
|
||||||
|
<Select
|
||||||
|
v-model="form.situacao"
|
||||||
|
:options="SITUACAO_OPCOES"
|
||||||
|
option-label="label"
|
||||||
|
option-value="value"
|
||||||
|
@change="tocarCampo('situacao')"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Data abertura:" :error="erros.dataAbertura" span="4" required>
|
||||||
|
<DatePicker
|
||||||
|
v-model="form.dataAbertura"
|
||||||
|
show-time
|
||||||
|
hour-format="24"
|
||||||
|
date-format="dd/mm/yy"
|
||||||
|
@hide="tocarCampo('dataAbertura')"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Data encerramento:" span="4">
|
||||||
|
<DatePicker
|
||||||
|
v-model="form.dataEncerramento"
|
||||||
|
show-time
|
||||||
|
hour-format="24"
|
||||||
|
date-format="dd/mm/yy"
|
||||||
|
show-clear
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
</FormGrid>
|
||||||
|
<template #footer>
|
||||||
|
<div>
|
||||||
|
<Button label="Voltar" class="p-button-secondary" icon="pi pi-arrow-left" @click="voltar" />
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<Button label="Salvar" icon="pi pi-save" @click="() => salvar()" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</FormPanel>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
140
src/views/modules/dividaAtiva/FolhaListar.vue
Normal file
140
src/views/modules/dividaAtiva/FolhaListar.vue
Normal file
@ -0,0 +1,140 @@
|
|||||||
|
<script setup>
|
||||||
|
import { useAcoesTabela } from '@/composables/useAcoesTabela';
|
||||||
|
import { useListagemUrl } from '@/composables/useListagemUrl';
|
||||||
|
import { useTabelaPaginada } from '@/composables/useTabelaPaginada';
|
||||||
|
import { formatDateTime, labelSituacao, SITUACAO_OPCOES } from '@/config/menu';
|
||||||
|
import { FolhaService } from '@/service';
|
||||||
|
import { watch } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const {
|
||||||
|
filtros,
|
||||||
|
tamanhoPagina,
|
||||||
|
paginaAtual,
|
||||||
|
first,
|
||||||
|
formValues,
|
||||||
|
temAlgoParaLimpar,
|
||||||
|
temFiltrosNoFormulario,
|
||||||
|
mudarPagina,
|
||||||
|
aplicarFiltrosDoFormulario,
|
||||||
|
limparFiltros: limparFiltrosUrl,
|
||||||
|
} = useListagemUrl(['page', 'size', 'idLivro', 'situacao']);
|
||||||
|
|
||||||
|
const {
|
||||||
|
isLoading,
|
||||||
|
retornoOrdenado,
|
||||||
|
elementosTotais,
|
||||||
|
buscaRealizada,
|
||||||
|
buscar,
|
||||||
|
ordenar,
|
||||||
|
sortField,
|
||||||
|
sortOrder,
|
||||||
|
limpar: limparTabela,
|
||||||
|
} = useTabelaPaginada();
|
||||||
|
|
||||||
|
function onPageChange(event) {
|
||||||
|
mudarPagina(event.page + 1, tamanhoPagina.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function limparFiltros() {
|
||||||
|
limparFiltrosUrl();
|
||||||
|
limparTabela();
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
filtros,
|
||||||
|
() => {
|
||||||
|
const params = { ...filtros.value, page: paginaAtual.value };
|
||||||
|
if (params.idLivro) params.idLivro = Number(params.idLivro);
|
||||||
|
buscar(() => FolhaService.listar(params));
|
||||||
|
},
|
||||||
|
{ immediate: true, deep: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
const { acaoEditar } = useAcoesTabela();
|
||||||
|
const acoes = [acaoEditar((row) => router.push({ name: 'FolhaCadastro', query: { id: row.id } }))];
|
||||||
|
|
||||||
|
function novo() {
|
||||||
|
router.push({ name: 'FolhaCadastro' });
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Fluid>
|
||||||
|
<form @submit.prevent="aplicarFiltrosDoFormulario">
|
||||||
|
<FormPanel title="Folhas de Dívida Ativa" icon="pi pi-search">
|
||||||
|
<FormGrid cols="12">
|
||||||
|
<FormField label="ID Livro" span="3">
|
||||||
|
<InputNumber v-model="formValues.idLivro" :use-grouping="false" placeholder="ID do livro" />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Situação" span="3">
|
||||||
|
<Select
|
||||||
|
v-model="formValues.situacao"
|
||||||
|
:options="SITUACAO_OPCOES"
|
||||||
|
option-label="label"
|
||||||
|
option-value="value"
|
||||||
|
show-clear
|
||||||
|
placeholder="Todas"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
</FormGrid>
|
||||||
|
<template #footer>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<Button label="Nova Folha" icon="pi pi-plus" type="button" :fluid="false" @click="novo" />
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<Button
|
||||||
|
label="Consultar"
|
||||||
|
icon="pi pi-search"
|
||||||
|
type="submit"
|
||||||
|
:fluid="false"
|
||||||
|
:loading="isLoading"
|
||||||
|
:disabled="!temFiltrosNoFormulario"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
label="Limpar"
|
||||||
|
icon="pi pi-eraser"
|
||||||
|
type="button"
|
||||||
|
:fluid="false"
|
||||||
|
class="p-button-secondary"
|
||||||
|
:disabled="!temAlgoParaLimpar"
|
||||||
|
@click="limparFiltros"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</FormPanel>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<FormPanel title="Lista de Folhas" icon="pi pi-list">
|
||||||
|
<TabelaPaginada
|
||||||
|
:value="retornoOrdenado"
|
||||||
|
:loading="isLoading"
|
||||||
|
:total-records="elementosTotais"
|
||||||
|
:rows="tamanhoPagina"
|
||||||
|
:first="first"
|
||||||
|
:busca-realizada="buscaRealizada"
|
||||||
|
msg-aguardando="Consulte as folhas usando os filtros acima."
|
||||||
|
@page="onPageChange"
|
||||||
|
@size-change="(novoSize) => mudarPagina(1, novoSize)"
|
||||||
|
>
|
||||||
|
<Column field="id" header="ID" body-style="text-align: center" />
|
||||||
|
<Column field="idLivro" header="ID Livro" body-style="text-align: center" />
|
||||||
|
<Column field="numero" header="Número" body-style="text-align: center" />
|
||||||
|
<Column field="qtdInscricoes" header="Inscrições" body-style="text-align: center" />
|
||||||
|
<Column field="dataAbertura" header="Abertura">
|
||||||
|
<template #body="{ data }">{{ formatDateTime(data.dataAbertura) }}</template>
|
||||||
|
</Column>
|
||||||
|
<Column field="situacao" header="Situação">
|
||||||
|
<template #body="{ data }">{{ labelSituacao(data.situacao) }}</template>
|
||||||
|
</Column>
|
||||||
|
<Column header="Ações" body-style="text-align: center">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<AcoesTabela :acoes="acoes" :linha="data" />
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
</TabelaPaginada>
|
||||||
|
</FormPanel>
|
||||||
|
</Fluid>
|
||||||
|
</template>
|
||||||
167
src/views/modules/dividaAtiva/LivroCadastro.vue
Normal file
167
src/views/modules/dividaAtiva/LivroCadastro.vue
Normal file
@ -0,0 +1,167 @@
|
|||||||
|
<script setup>
|
||||||
|
import { useFormularioCRUD } from '@/composables/useFormularioCRUD';
|
||||||
|
import { parseIsoToDate, SITUACAO_OPCOES, toIsoDateTime } from '@/config/menu';
|
||||||
|
import { LivroService } from '@/service';
|
||||||
|
import { onMounted } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const schemaLivro = z.object({
|
||||||
|
numero: z.string().min(1, 'Número é obrigatório.'),
|
||||||
|
ano: z.number({ invalid_type_error: 'Ano é obrigatório.' }).int().min(1900).max(2100),
|
||||||
|
dataAbertura: z.date({ invalid_type_error: 'Data de abertura é obrigatória.' }),
|
||||||
|
dataEncerramento: z.date().nullable().optional(),
|
||||||
|
qtdMaxFolha: z.number({ invalid_type_error: 'Quantidade máxima de folhas é obrigatória.' }).int().min(1),
|
||||||
|
qtdMaxInscFolha: z
|
||||||
|
.number({ invalid_type_error: 'Quantidade máxima de inscrições por folha é obrigatória.' })
|
||||||
|
.int()
|
||||||
|
.min(1),
|
||||||
|
situacao: z.string().min(1, 'Situação é obrigatória.'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
form,
|
||||||
|
erros,
|
||||||
|
isLoading,
|
||||||
|
isNewMode,
|
||||||
|
registroId,
|
||||||
|
tituloTela,
|
||||||
|
salvar,
|
||||||
|
carregarPorId,
|
||||||
|
tocarCampo,
|
||||||
|
} = useFormularioCRUD({
|
||||||
|
formInicial: {
|
||||||
|
numero: '',
|
||||||
|
ano: null,
|
||||||
|
dataAbertura: null,
|
||||||
|
dataEncerramento: null,
|
||||||
|
qtdMaxFolha: null,
|
||||||
|
qtdMaxInscFolha: null,
|
||||||
|
situacao: '1',
|
||||||
|
},
|
||||||
|
titulos: { novo: 'Cadastro de Livro', editar: 'Edição de Livro' },
|
||||||
|
service: {
|
||||||
|
salvar: LivroService.salvar,
|
||||||
|
atualizar: LivroService.atualizar,
|
||||||
|
buscarPorId: LivroService.buscarPorId,
|
||||||
|
},
|
||||||
|
schema: schemaLivro,
|
||||||
|
transformarParaSalvar: (f) => ({
|
||||||
|
numero: f.numero,
|
||||||
|
ano: f.ano,
|
||||||
|
dataAbertura: toIsoDateTime(f.dataAbertura),
|
||||||
|
dataEncerramento: toIsoDateTime(f.dataEncerramento),
|
||||||
|
qtdMaxFolha: f.qtdMaxFolha,
|
||||||
|
qtdMaxInscFolha: f.qtdMaxInscFolha,
|
||||||
|
situacao: f.situacao,
|
||||||
|
}),
|
||||||
|
rotaListagem: 'ConsultarLivros',
|
||||||
|
mensagens: {
|
||||||
|
sucesso: { novo: 'Livro cadastrado com sucesso!', editar: 'Livro atualizado com sucesso!' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function voltar() {
|
||||||
|
router.push({ name: 'ConsultarLivros' });
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
if (registroId.value) {
|
||||||
|
await carregarPorId(null, async (id) => {
|
||||||
|
const response = await LivroService.buscarPorId(id);
|
||||||
|
const data = response.data?.data;
|
||||||
|
if (!data?.id) throw new Error('Livro não encontrado');
|
||||||
|
return {
|
||||||
|
numero: data.numero || '',
|
||||||
|
ano: data.ano ?? null,
|
||||||
|
dataAbertura: parseIsoToDate(data.dataAbertura),
|
||||||
|
dataEncerramento: parseIsoToDate(data.dataEncerramento),
|
||||||
|
qtdMaxFolha: data.qtdMaxFolha ?? null,
|
||||||
|
qtdMaxInscFolha: data.qtdMaxInscFolha ?? null,
|
||||||
|
situacao: data.situacao || '1',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<LoadingOverlay :visible="isLoading" />
|
||||||
|
<FormPanel :title="tituloTela" :icon="isNewMode ? 'pi pi-plus' : 'pi pi-pencil'">
|
||||||
|
<FormGrid>
|
||||||
|
<FormField label="Número:" :error="erros.numero" span="3" required>
|
||||||
|
<InputText
|
||||||
|
v-model="form.numero"
|
||||||
|
:class="{ 'p-invalid': erros.numero }"
|
||||||
|
@blur="tocarCampo('numero')"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Ano:" :error="erros.ano" span="2" required>
|
||||||
|
<InputNumber
|
||||||
|
v-model="form.ano"
|
||||||
|
:use-grouping="false"
|
||||||
|
:class="{ 'p-invalid': erros.ano }"
|
||||||
|
@blur="tocarCampo('ano')"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Situação:" :error="erros.situacao" span="3" required>
|
||||||
|
<Select
|
||||||
|
v-model="form.situacao"
|
||||||
|
:options="SITUACAO_OPCOES"
|
||||||
|
option-label="label"
|
||||||
|
option-value="value"
|
||||||
|
:class="{ 'p-invalid': erros.situacao }"
|
||||||
|
@change="tocarCampo('situacao')"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Data abertura:" :error="erros.dataAbertura" span="4" required>
|
||||||
|
<DatePicker
|
||||||
|
v-model="form.dataAbertura"
|
||||||
|
show-time
|
||||||
|
hour-format="24"
|
||||||
|
date-format="dd/mm/yy"
|
||||||
|
:class="{ 'p-invalid': erros.dataAbertura }"
|
||||||
|
@hide="tocarCampo('dataAbertura')"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Data encerramento:" :error="erros.dataEncerramento" span="4">
|
||||||
|
<DatePicker
|
||||||
|
v-model="form.dataEncerramento"
|
||||||
|
show-time
|
||||||
|
hour-format="24"
|
||||||
|
date-format="dd/mm/yy"
|
||||||
|
show-clear
|
||||||
|
@hide="tocarCampo('dataEncerramento')"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Qtd. máx. folhas:" :error="erros.qtdMaxFolha" span="3" required>
|
||||||
|
<InputNumber
|
||||||
|
v-model="form.qtdMaxFolha"
|
||||||
|
:use-grouping="false"
|
||||||
|
:class="{ 'p-invalid': erros.qtdMaxFolha }"
|
||||||
|
@blur="tocarCampo('qtdMaxFolha')"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Qtd. máx. inscrições/folha:" :error="erros.qtdMaxInscFolha" span="3" required>
|
||||||
|
<InputNumber
|
||||||
|
v-model="form.qtdMaxInscFolha"
|
||||||
|
:use-grouping="false"
|
||||||
|
:class="{ 'p-invalid': erros.qtdMaxInscFolha }"
|
||||||
|
@blur="tocarCampo('qtdMaxInscFolha')"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
</FormGrid>
|
||||||
|
<template #footer>
|
||||||
|
<div>
|
||||||
|
<Button label="Voltar" class="p-button-secondary" icon="pi pi-arrow-left" @click="voltar" />
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<Button label="Salvar" icon="pi pi-save" @click="() => salvar()" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</FormPanel>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
152
src/views/modules/dividaAtiva/LivroListar.vue
Normal file
152
src/views/modules/dividaAtiva/LivroListar.vue
Normal file
@ -0,0 +1,152 @@
|
|||||||
|
<script setup>
|
||||||
|
import { useAcoesTabela } from '@/composables/useAcoesTabela';
|
||||||
|
import { useListagemUrl } from '@/composables/useListagemUrl';
|
||||||
|
import { useTabelaPaginada } from '@/composables/useTabelaPaginada';
|
||||||
|
import { formatDateTime, labelSituacao, SITUACAO_OPCOES } from '@/config/menu';
|
||||||
|
import { LivroService } from '@/service';
|
||||||
|
import { watch } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const {
|
||||||
|
filtros,
|
||||||
|
tamanhoPagina,
|
||||||
|
paginaAtual,
|
||||||
|
first,
|
||||||
|
formValues,
|
||||||
|
temAlgoParaLimpar,
|
||||||
|
temFiltrosNoFormulario,
|
||||||
|
mudarPagina,
|
||||||
|
aplicarFiltrosDoFormulario,
|
||||||
|
limparFiltros: limparFiltrosUrl,
|
||||||
|
} = useListagemUrl(['page', 'size', 'numero', 'ano', 'situacao']);
|
||||||
|
|
||||||
|
const {
|
||||||
|
isLoading,
|
||||||
|
retornoOrdenado,
|
||||||
|
elementosTotais,
|
||||||
|
buscaRealizada,
|
||||||
|
buscar,
|
||||||
|
ordenar,
|
||||||
|
sortField,
|
||||||
|
sortOrder,
|
||||||
|
limpar: limparTabela,
|
||||||
|
} = useTabelaPaginada();
|
||||||
|
|
||||||
|
function onPageChange(event) {
|
||||||
|
mudarPagina(event.page + 1, tamanhoPagina.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function limparFiltros() {
|
||||||
|
limparFiltrosUrl();
|
||||||
|
limparTabela();
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
filtros,
|
||||||
|
() => {
|
||||||
|
const params = { ...filtros.value, page: paginaAtual.value };
|
||||||
|
if (params.ano) params.ano = Number(params.ano);
|
||||||
|
buscar(() => LivroService.listar(params));
|
||||||
|
},
|
||||||
|
{ immediate: true, deep: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
const { acaoEditar } = useAcoesTabela();
|
||||||
|
const acoes = [acaoEditar((row) => router.push({ name: 'LivroCadastro', query: { id: row.id } }))];
|
||||||
|
|
||||||
|
function novo() {
|
||||||
|
router.push({ name: 'LivroCadastro' });
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Fluid>
|
||||||
|
<form @submit.prevent="aplicarFiltrosDoFormulario">
|
||||||
|
<FormPanel title="Livros de Dívida Ativa" icon="pi pi-search">
|
||||||
|
<FormGrid cols="12">
|
||||||
|
<FormField label="Número" span="3">
|
||||||
|
<InputText v-model="formValues.numero" placeholder="Número do livro" />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Ano" span="2">
|
||||||
|
<InputNumber v-model="formValues.ano" :use-grouping="false" placeholder="Ano" />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Situação" span="3">
|
||||||
|
<Select
|
||||||
|
v-model="formValues.situacao"
|
||||||
|
:options="SITUACAO_OPCOES"
|
||||||
|
option-label="label"
|
||||||
|
option-value="value"
|
||||||
|
show-clear
|
||||||
|
placeholder="Todas"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
</FormGrid>
|
||||||
|
<template #footer>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<Button label="Novo Livro" icon="pi pi-plus" type="button" :fluid="false" @click="novo" />
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<Button
|
||||||
|
label="Consultar"
|
||||||
|
icon="pi pi-search"
|
||||||
|
type="submit"
|
||||||
|
:fluid="false"
|
||||||
|
:loading="isLoading"
|
||||||
|
:disabled="!temFiltrosNoFormulario"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
label="Limpar"
|
||||||
|
icon="pi pi-eraser"
|
||||||
|
type="button"
|
||||||
|
:fluid="false"
|
||||||
|
class="p-button-secondary"
|
||||||
|
:disabled="!temAlgoParaLimpar"
|
||||||
|
@click="limparFiltros"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</FormPanel>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<FormPanel title="Lista de Livros" icon="pi pi-list">
|
||||||
|
<TabelaPaginada
|
||||||
|
:value="retornoOrdenado"
|
||||||
|
:loading="isLoading"
|
||||||
|
:total-records="elementosTotais"
|
||||||
|
:rows="tamanhoPagina"
|
||||||
|
:first="first"
|
||||||
|
:busca-realizada="buscaRealizada"
|
||||||
|
msg-aguardando="Consulte os livros usando os filtros acima."
|
||||||
|
@page="onPageChange"
|
||||||
|
@size-change="(novoSize) => mudarPagina(1, novoSize)"
|
||||||
|
>
|
||||||
|
<Column field="id" header="ID" body-style="text-align: center" />
|
||||||
|
<Column field="numero">
|
||||||
|
<template #header>
|
||||||
|
<HeaderOrdenavel
|
||||||
|
field="numero"
|
||||||
|
label="Número"
|
||||||
|
:sort-field="sortField"
|
||||||
|
:sort-order="sortOrder"
|
||||||
|
:ordenar="ordenar"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
<Column field="ano" header="Ano" body-style="text-align: center" />
|
||||||
|
<Column field="dataAbertura" header="Abertura">
|
||||||
|
<template #body="{ data }">{{ formatDateTime(data.dataAbertura) }}</template>
|
||||||
|
</Column>
|
||||||
|
<Column field="situacao" header="Situação">
|
||||||
|
<template #body="{ data }">{{ labelSituacao(data.situacao) }}</template>
|
||||||
|
</Column>
|
||||||
|
<Column header="Ações" body-style="text-align: center">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<AcoesTabela :acoes="acoes" :linha="data" />
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
</TabelaPaginada>
|
||||||
|
</FormPanel>
|
||||||
|
</Fluid>
|
||||||
|
</template>
|
||||||
40
src/views/modules/dividaAtiva/router.js
Normal file
40
src/views/modules/dividaAtiva/router.js
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
const prefix = 'livro';
|
||||||
|
|
||||||
|
export default [
|
||||||
|
{
|
||||||
|
path: prefix,
|
||||||
|
name: 'ConsultarLivros',
|
||||||
|
meta: { title: 'Livros' },
|
||||||
|
component: () => import('./LivroListar.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: `${prefix}/cadastro`,
|
||||||
|
name: 'LivroCadastro',
|
||||||
|
meta: { title: 'Cadastro de Livro' },
|
||||||
|
component: () => import('./LivroCadastro.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'folha',
|
||||||
|
name: 'ConsultarFolhas',
|
||||||
|
meta: { title: 'Folhas' },
|
||||||
|
component: () => import('./FolhaListar.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'folha/cadastro',
|
||||||
|
name: 'FolhaCadastro',
|
||||||
|
meta: { title: 'Cadastro de Folha' },
|
||||||
|
component: () => import('./FolhaCadastro.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'cda',
|
||||||
|
name: 'ConsultarCdas',
|
||||||
|
meta: { title: 'CDAs' },
|
||||||
|
component: () => import('./CdaListar.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'cda/cadastro',
|
||||||
|
name: 'CdaCadastro',
|
||||||
|
meta: { title: 'Cadastro de CDA' },
|
||||||
|
component: () => import('./CdaCadastro.vue'),
|
||||||
|
},
|
||||||
|
];
|
||||||
13
src/views/pages/Error.vue
Normal file
13
src/views/pages/Error.vue
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex items-center justify-center min-h-screen p-4">
|
||||||
|
<FormPanel title="Configuração indisponível" icon="pi pi-exclamation-triangle" status="error">
|
||||||
|
<p class="m-0">
|
||||||
|
Não foi possível carregar a configuração da prefeitura para este domínio. Verifique o subdomínio ou a
|
||||||
|
tabela de configuração (<code>divida_ativa.T065_CONFIGAPP</code>).
|
||||||
|
</p>
|
||||||
|
<template #footer>
|
||||||
|
<Button label="Tentar novamente" icon="pi pi-refresh" @click="$router.go(0)" />
|
||||||
|
</template>
|
||||||
|
</FormPanel>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
19
src/views/pages/Inicio.vue
Normal file
19
src/views/pages/Inicio.vue
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
<script setup>
|
||||||
|
import { usePrefeituraStore } from '@/store/prefeituraStore';
|
||||||
|
|
||||||
|
const prefeituraStore = usePrefeituraStore();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Fluid>
|
||||||
|
<FormPanel title="Dívida Ativa Municipal" icon="pi pi-home">
|
||||||
|
<p v-if="prefeituraStore.nomePrefeitura" class="m-0 text-lg">
|
||||||
|
Bem-vindo ao sistema de Dívida Ativa — {{ prefeituraStore.nomePrefeitura }}.
|
||||||
|
</p>
|
||||||
|
<p v-else class="m-0 text-lg">Bem-vindo ao sistema de gestão de Dívida Ativa.</p>
|
||||||
|
<p class="text-color-secondary mt-2">
|
||||||
|
Utilize o menu lateral para consultar e cadastrar Livros, Folhas e CDAs.
|
||||||
|
</p>
|
||||||
|
</FormPanel>
|
||||||
|
</Fluid>
|
||||||
|
</template>
|
||||||
17
tailwind.config.js
Normal file
17
tailwind.config.js
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
import PrimeUI from 'tailwindcss-primeui';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
darkMode: ['selector', '[class*="app-dark"]'],
|
||||||
|
content: ['./index.html', './src/**/*.{vue,js,ts,jsx,tsx}'],
|
||||||
|
plugins: [PrimeUI],
|
||||||
|
theme: {
|
||||||
|
screens: {
|
||||||
|
sm: '576px',
|
||||||
|
md: '768px',
|
||||||
|
lg: '992px',
|
||||||
|
xl: '1200px',
|
||||||
|
'2xl': '1920px',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
23
vite.config.mjs
Normal file
23
vite.config.mjs
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import { fileURLToPath, URL } from 'node:url';
|
||||||
|
|
||||||
|
import { PrimeVueResolver } from '@primevue/auto-import-resolver';
|
||||||
|
import vue from '@vitejs/plugin-vue';
|
||||||
|
import Components from 'unplugin-vue-components/vite';
|
||||||
|
import { defineConfig } from 'vite';
|
||||||
|
|
||||||
|
export default defineConfig(() => ({
|
||||||
|
plugins: [
|
||||||
|
vue(),
|
||||||
|
Components({
|
||||||
|
resolvers: [PrimeVueResolver()],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 5174,
|
||||||
|
},
|
||||||
|
}));
|
||||||
Loading…
x
Reference in New Issue
Block a user