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

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Joao Guilherme de Mesquita Mendes 2026-07-06 17:50:31 -03:00
commit 81727c16f8
76 changed files with 8391 additions and 0 deletions

1
.env.development Normal file
View File

@ -0,0 +1 @@
VITE_API_URL=http://localhost:8094

1
.env.production Normal file
View File

@ -0,0 +1 @@
VITE_API_URL=

View 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
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

31
package.json Normal file
View 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
View File

@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

16
src/App.vue Normal file
View 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>

View 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;
}

View 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);
}

View 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;
}

View 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;
}

View 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);
}

View 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;
}
}

View 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;
}
}
}
}

View 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;
}
}

View 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;
}
}

View 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;
}
}

View 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';

View 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;
}

View 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);
}

View 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
View 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
View File

@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

View 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 };
}
}

View 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">&nbsp;</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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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,
};
}

View 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 };
}

View 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 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 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,
};
}

View 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,
};
}

View 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 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
View 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,
};
}

View 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
View 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 };

View 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
View 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;
}

View 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
View 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
View 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
View 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
View 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>

View 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
View 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>

View 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>

View 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,
};
}

View 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
View 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
View 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;

View 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);
},
};

View 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);
},
};

View 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
View 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';

View 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
View 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
);
},
},
});

View 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
View 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');
}

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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
View 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>

View 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
View 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
View 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,
},
}));