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