feat: scaffold inicial do portal público de autoatendimento fiscal
- Vue 3.5 + Vite 8 + PrimeVue 4 (Aura) + TailwindCSS 4 + DM Sans - Sistema de tenant multi-prefeitura: bootstrap, prefeituraStore, getTenant - Tema dinâmico por município via applyTemplate (9 paletas) - Logo e foto de fundo resolvidos a partir do VITE_API_URL + path relativo - HomeView: hero split com foto/gradiente, carousel de avisos, cards de serviços - LoginView: fluxo 2 etapas (documento na home → senha em /login) - Roteamento completo: público (/), serviços (/servicos/*), portal autenticado (/portal/*) - authStore + authService estruturados para Keycloak PKCE (integração pendente) - Placeholders para todas as telas da área logada Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
commit
5a7f4ba07a
4
.env.development
Normal file
4
.env.development
Normal file
@ -0,0 +1,4 @@
|
||||
VITE_KEYCLOAK_URL=
|
||||
VITE_KEYCLOAK_REALM=
|
||||
VITE_KEYCLOAK_CLIENT_ID=
|
||||
VITE_API_URL=
|
||||
4
.env.production
Normal file
4
.env.production
Normal file
@ -0,0 +1,4 @@
|
||||
VITE_KEYCLOAK_URL=
|
||||
VITE_KEYCLOAK_REALM=
|
||||
VITE_KEYCLOAK_CLIENT_ID=
|
||||
VITE_API_URL=
|
||||
2
.gitattributes
vendored
Normal file
2
.gitattributes
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
* text=auto eol=lf
|
||||
*.{jpg,jpeg,png,webp,svg,ico,woff,woff2,ttf,eot} binary
|
||||
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal file
@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
5
README.md
Normal file
5
README.md
Normal file
@ -0,0 +1,5 @@
|
||||
# Vue 3 + Vite
|
||||
|
||||
This template should help get you started developing with Vue 3 in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
|
||||
|
||||
Learn more about IDE Support for Vue in the [Vue Docs Scaling up Guide](https://vuejs.org/guide/scaling-up/tooling.html#ide-support).
|
||||
16
index.html
Normal file
16
index.html
Normal file
@ -0,0 +1,16 @@
|
||||
<!doctype html>
|
||||
<html lang="pt-BR">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Portal do Contribuinte</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,300;0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700;1,9..40,400&display=swap" rel="stylesheet" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
4322
package-lock.json
generated
Normal file
4322
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
40
package.json
Normal file
40
package.json
Normal file
@ -0,0 +1,40 @@
|
||||
{
|
||||
"name": "portal-modumfiscal-web",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint --fix . --ext .vue,.js --ignore-path .gitignore"
|
||||
},
|
||||
"dependencies": {
|
||||
"@primeuix/themes": "^2.0.3",
|
||||
"axios": "^1.16.1",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"pinia": "^3.0.4",
|
||||
"pinia-plugin-persistedstate": "^4.7.1",
|
||||
"pkce-challenge": "^6.0.0",
|
||||
"primeicons": "^7.0.0",
|
||||
"primevue": "^4.5.5",
|
||||
"tailwindcss-primeui": "^0.6.1",
|
||||
"vue": "^3.5.34",
|
||||
"vue-router": "^5.0.7",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@primevue/auto-import-resolver": "^4.5.5",
|
||||
"@rushstack/eslint-patch": "^1.16.1",
|
||||
"@tailwindcss/vite": "^4.3.0",
|
||||
"@vitejs/plugin-vue": "^6.0.6",
|
||||
"@vue/eslint-config-prettier": "^10.2.0",
|
||||
"autoprefixer": "^10.5.0",
|
||||
"eslint-plugin-vue": "^10.9.1",
|
||||
"postcss": "^8.5.14",
|
||||
"sass": "^1.99.0",
|
||||
"tailwindcss": "^4.3.0",
|
||||
"unplugin-vue-components": "^32.0.0",
|
||||
"vite": "^8.0.12"
|
||||
}
|
||||
}
|
||||
1
public/favicon.svg
Normal file
1
public/favicon.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
24
public/icons.svg
Normal file
24
public/icons.svg
Normal file
@ -0,0 +1,24 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||
</symbol>
|
||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||
</symbol>
|
||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||
</symbol>
|
||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||
</symbol>
|
||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
17
src/App.vue
Normal file
17
src/App.vue
Normal file
@ -0,0 +1,17 @@
|
||||
<script setup>
|
||||
import { onMounted } from 'vue'
|
||||
import { usePrefeituraStore } from '@/stores/prefeituraStore'
|
||||
import { applyTemplate, applySurface } from '@/config/theme.config'
|
||||
|
||||
const prefeitura = usePrefeituraStore()
|
||||
|
||||
onMounted(() => {
|
||||
applyTemplate(prefeitura.template ?? 'blue')
|
||||
applySurface('slate')
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RouterView />
|
||||
<Toast />
|
||||
</template>
|
||||
BIN
src/assets/hero.png
Normal file
BIN
src/assets/hero.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
BIN
src/assets/images/bg-tutoia.jpeg
Normal file
BIN
src/assets/images/bg-tutoia.jpeg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 39 KiB |
BIN
src/assets/images/logo-modum-fiscal.png
Normal file
BIN
src/assets/images/logo-modum-fiscal.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
25
src/assets/layout/layout.scss
Normal file
25
src/assets/layout/layout.scss
Normal file
@ -0,0 +1,25 @@
|
||||
/* Estilos globais de layout — sem @apply (regra do projeto) */
|
||||
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: 'DM Sans', system-ui, sans-serif;
|
||||
}
|
||||
|
||||
#app {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Transições de rota */
|
||||
.page-enter-active,
|
||||
.page-leave-active {
|
||||
transition: opacity 0.18s ease;
|
||||
}
|
||||
.page-enter-from,
|
||||
.page-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
14
src/assets/main.css
Normal file
14
src/assets/main.css
Normal file
@ -0,0 +1,14 @@
|
||||
@import "tailwindcss";
|
||||
@plugin "tailwindcss-primeui";
|
||||
@import "primeicons/primeicons.css";
|
||||
|
||||
@theme {
|
||||
--font-sans: 'DM Sans', system-ui, sans-serif;
|
||||
}
|
||||
|
||||
/* Remove setas de number input */
|
||||
input[type='number']::-webkit-outer-spin-button,
|
||||
input[type='number']::-webkit-inner-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
1
src/assets/vite.svg
Normal file
1
src/assets/vite.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.5 KiB |
37
src/bootstrap/prefeituraBoot.js
Normal file
37
src/bootstrap/prefeituraBoot.js
Normal file
@ -0,0 +1,37 @@
|
||||
import { prefeituraService } from '@/services/prefeituraService'
|
||||
import { usePrefeituraStore } from '@/stores/prefeituraStore'
|
||||
import { getTenant } from '@/utils/tenant'
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL ?? ''
|
||||
|
||||
function resolverUrl(path) {
|
||||
if (!path) return null
|
||||
if (path.startsWith('http')) return path
|
||||
return `${API_URL}${path}`
|
||||
}
|
||||
|
||||
export async function bootstrapPrefeitura(pinia) {
|
||||
const store = usePrefeituraStore(pinia)
|
||||
const dominio = getTenant()
|
||||
|
||||
try {
|
||||
const { data } = await prefeituraService.getPrefeituraInfo(dominio)
|
||||
const info = data.data
|
||||
|
||||
store.$patch({
|
||||
codigoMunicipio: info.codigoMunicipio,
|
||||
nomePrefeitura: info.nomePrefeitura,
|
||||
dominio: info.dominio,
|
||||
template: info.template,
|
||||
pathLogo: resolverUrl(info.pathLogo),
|
||||
pathBackground: resolverUrl(info.pathBackground),
|
||||
})
|
||||
|
||||
localStorage.setItem('current_municipio', info.codigoMunicipio)
|
||||
localStorage.setItem('current_dominio', info.dominio)
|
||||
|
||||
return { success: true }
|
||||
} catch {
|
||||
return { success: false }
|
||||
}
|
||||
}
|
||||
58
src/components/auth/DocumentoInput.vue
Normal file
58
src/components/auth/DocumentoInput.vue
Normal file
@ -0,0 +1,58 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: String, default: '' },
|
||||
})
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const input = ref(props.modelValue)
|
||||
|
||||
watch(() => props.modelValue, (v) => { input.value = v })
|
||||
|
||||
const apenasDigitos = computed(() => input.value.replace(/\D/g, ''))
|
||||
|
||||
const isCnpj = computed(() => apenasDigitos.value.length > 11)
|
||||
|
||||
const valorFormatado = computed(() => {
|
||||
const d = apenasDigitos.value
|
||||
if (d.length <= 11) {
|
||||
return d
|
||||
.replace(/(\d{3})(\d)/, '$1.$2')
|
||||
.replace(/(\d{3})(\d)/, '$1.$2')
|
||||
.replace(/(\d{3})(\d{1,2})$/, '$1-$2')
|
||||
}
|
||||
return d
|
||||
.replace(/(\d{2})(\d)/, '$1.$2')
|
||||
.replace(/(\d{3})(\d)/, '$1.$2')
|
||||
.replace(/(\d{3})(\d)/, '$1/$2')
|
||||
.replace(/(\d{4})(\d{1,2})$/, '$1-$2')
|
||||
})
|
||||
|
||||
const placeholder = computed(() => isCnpj.value ? '00.000.000/0000-00' : '000.000.000-00')
|
||||
|
||||
function onInput(e) {
|
||||
const raw = e.target.value.replace(/\D/g, '').slice(0, 14)
|
||||
input.value = raw
|
||||
emit('update:modelValue', raw)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative">
|
||||
<InputText
|
||||
:value="valorFormatado"
|
||||
:placeholder="placeholder"
|
||||
inputmode="numeric"
|
||||
class="w-full text-lg tracking-wide"
|
||||
size="large"
|
||||
@input="onInput"
|
||||
/>
|
||||
<span
|
||||
v-if="apenasDigitos.length > 0"
|
||||
class="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-slate-400 font-medium"
|
||||
>
|
||||
{{ isCnpj ? 'CNPJ' : 'CPF' }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
20
src/components/common/AppFooter.vue
Normal file
20
src/components/common/AppFooter.vue
Normal file
@ -0,0 +1,20 @@
|
||||
<script setup>
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<footer class="bg-white border-t border-slate-200 mt-auto">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
<div class="flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||
<div class="flex items-center gap-2 text-sm text-slate-500">
|
||||
<i class="pi pi-building text-blue-600" />
|
||||
<span>Portal do Contribuinte — ModumFiscal</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-4 text-xs text-slate-400">
|
||||
<a href="#" class="hover:text-slate-600 transition-colors">Política de Privacidade</a>
|
||||
<a href="#" class="hover:text-slate-600 transition-colors">Termos de Uso</a>
|
||||
<a href="#" class="hover:text-slate-600 transition-colors">Acessibilidade</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</template>
|
||||
56
src/components/common/AppHeader.vue
Normal file
56
src/components/common/AppHeader.vue
Normal file
@ -0,0 +1,56 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
import { usePrefeituraStore } from '@/stores/prefeituraStore'
|
||||
import logoFallback from '@/assets/images/logo-modum-fiscal.png'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const prefeitura = usePrefeituraStore()
|
||||
|
||||
const logoSrc = computed(() => prefeitura.pathLogo || logoFallback)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="bg-white border-b border-slate-200">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 h-16 flex items-center justify-between">
|
||||
|
||||
<RouterLink :to="{ name: 'home' }" class="flex items-center gap-3 min-w-0">
|
||||
<img
|
||||
:src="logoSrc"
|
||||
alt="Logo da Prefeitura"
|
||||
class="h-9 w-auto object-contain flex-shrink-0"
|
||||
/>
|
||||
|
||||
<div class="leading-tight min-w-0">
|
||||
<p class="text-xs text-slate-500 font-normal truncate">Portal do Contribuinte</p>
|
||||
<p class="text-sm font-semibold text-slate-800 truncate">
|
||||
{{ prefeitura.nomePrefeitura || 'ModumFiscal' }}
|
||||
</p>
|
||||
</div>
|
||||
</RouterLink>
|
||||
|
||||
<nav class="hidden md:flex items-center gap-4">
|
||||
<RouterLink
|
||||
:to="{ name: 'servicos' }"
|
||||
class="text-sm text-slate-600 hover:text-primary transition-colors"
|
||||
>
|
||||
Serviços
|
||||
</RouterLink>
|
||||
</nav>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<template v-if="auth.isAuthenticated">
|
||||
<RouterLink :to="{ name: 'painel' }">
|
||||
<Button label="Meu Painel" icon="pi pi-user" size="small" />
|
||||
</RouterLink>
|
||||
</template>
|
||||
<template v-else>
|
||||
<RouterLink :to="{ name: 'home' }">
|
||||
<Button label="Entrar" icon="pi pi-sign-in" size="small" />
|
||||
</RouterLink>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
32
src/components/common/ServiceCard.vue
Normal file
32
src/components/common/ServiceCard.vue
Normal file
@ -0,0 +1,32 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
icon: { type: String, required: true },
|
||||
titulo: { type: String, required: true },
|
||||
descricao: { type: String, default: '' },
|
||||
to: { type: [String, Object], default: null },
|
||||
requiresAuth: { type: Boolean, default: false },
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<component
|
||||
:is="to ? 'RouterLink' : 'div'"
|
||||
:to="to ?? undefined"
|
||||
class="group flex flex-col gap-3 bg-white rounded-xl border border-slate-200 p-5 hover:border-blue-300 hover:shadow-md transition-all duration-200 cursor-pointer"
|
||||
>
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="w-10 h-10 bg-blue-50 rounded-lg flex items-center justify-center group-hover:bg-blue-100 transition-colors">
|
||||
<i :class="['pi', icon, 'text-blue-700 text-lg']" />
|
||||
</div>
|
||||
<i v-if="requiresAuth" class="pi pi-lock text-slate-300 text-xs" title="Requer login" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-semibold text-slate-800 text-sm group-hover:text-blue-700 transition-colors">{{ titulo }}</p>
|
||||
<p v-if="descricao" class="text-xs text-slate-500 mt-1 leading-relaxed">{{ descricao }}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-1 text-xs text-blue-600 font-medium opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<span>Acessar</span>
|
||||
<i class="pi pi-arrow-right text-xs" />
|
||||
</div>
|
||||
</component>
|
||||
</template>
|
||||
41
src/config/apiClient.js
Normal file
41
src/config/apiClient.js
Normal file
@ -0,0 +1,41 @@
|
||||
import axios from 'axios'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
import { usePrefeituraStore } from '@/stores/prefeituraStore'
|
||||
|
||||
const BASE_URL = `${import.meta.env.VITE_API_URL ?? ''}/api/v1`
|
||||
|
||||
function addTenantHeaders(config) {
|
||||
const prefeitura = usePrefeituraStore()
|
||||
if (prefeitura.codigoMunicipio) config.headers['X-Municipio'] = prefeitura.codigoMunicipio
|
||||
if (prefeitura.dominio) config.headers['X-Dominio'] = prefeitura.dominio
|
||||
return config
|
||||
}
|
||||
|
||||
// ─── Cliente público (sem autenticação) ───────────────────────────────────────
|
||||
// Usado no bootstrap de prefeitura e em serviços públicos (certidão, IPTU)
|
||||
export const apiClientPublico = axios.create({ baseURL: BASE_URL, timeout: 10000 })
|
||||
|
||||
apiClientPublico.interceptors.request.use((config) => addTenantHeaders(config))
|
||||
|
||||
// ─── Cliente autenticado ───────────────────────────────────────────────────────
|
||||
// Usado nas rotas /portal/* que exigem login
|
||||
const apiClient = axios.create({ baseURL: BASE_URL, timeout: 10000 })
|
||||
|
||||
apiClient.interceptors.request.use((config) => {
|
||||
const auth = useAuthStore()
|
||||
if (auth.token) config.headers.Authorization = `Bearer ${auth.token}`
|
||||
return addTenantHeaders(config)
|
||||
})
|
||||
|
||||
apiClient.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
if (error.response?.status === 401) {
|
||||
useAuthStore().clearSession()
|
||||
window.location.href = '/'
|
||||
}
|
||||
return Promise.reject(error)
|
||||
},
|
||||
)
|
||||
|
||||
export default apiClient
|
||||
37
src/config/primevue.config.js
Normal file
37
src/config/primevue.config.js
Normal file
@ -0,0 +1,37 @@
|
||||
import Aura from '@primeuix/themes/aura'
|
||||
|
||||
// A cor primária NÃO é hardcoded aqui — é aplicada dinamicamente via
|
||||
// applyTemplate() no bootstrap, conforme o template de cada prefeitura.
|
||||
export const primeVueConfig = {
|
||||
pt: {
|
||||
global: {
|
||||
css: `* { font-family: 'DM Sans', system-ui, sans-serif; }`,
|
||||
},
|
||||
},
|
||||
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',
|
||||
upload: 'Enviar',
|
||||
cancel: 'Cancelar',
|
||||
apply: 'Aplicar',
|
||||
close: 'Fechar',
|
||||
emptyMessage: 'Nenhum resultado encontrado',
|
||||
emptySearchMessage: 'Nenhum resultado encontrado',
|
||||
emptySelectionMessage: 'Nenhum item selecionado',
|
||||
},
|
||||
}
|
||||
80
src/config/theme.config.js
Normal file
80
src/config/theme.config.js
Normal file
@ -0,0 +1,80 @@
|
||||
import { updatePreset, updateSurfacePalette } from '@primeuix/themes'
|
||||
import { ref } from 'vue'
|
||||
|
||||
export const templates = ref([
|
||||
{ name: 'sistema', palette: null },
|
||||
{
|
||||
name: 'tutoia',
|
||||
palette: { 50:'#fff7ed',100:'#ffedd5',200:'#fed7aa',300:'#fdba74',400:'#fb923c',500:'#f97316',600:'#ea580c',700:'#c2410c',800:'#9a3412',900:'#7c2d12',950:'#431407' },
|
||||
},
|
||||
{
|
||||
name: 'amber',
|
||||
palette: { 50:'#fffbeb',100:'#fef3c7',200:'#fde68a',300:'#fcd34d',400:'#fbbf24',500:'#f59e0b',600:'#d97706',700:'#b45309',800:'#92400e',900:'#78350f',950:'#451a03' },
|
||||
},
|
||||
{
|
||||
name: 'blue',
|
||||
palette: { 50:'#eff6ff',100:'#dbeafe',200:'#bfdbfe',300:'#93c5fd',400:'#60a5fa',500:'#3b82f6',600:'#2563eb',700:'#1d4ed8',800:'#1e40af',900:'#1e3a8a',950:'#172554' },
|
||||
},
|
||||
{
|
||||
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' },
|
||||
},
|
||||
{
|
||||
name: 'violet',
|
||||
palette: { 50:'#f5f3ff',100:'#ede9fe',200:'#ddd6fe',300:'#c4b5fd',400:'#a78bfa',500:'#8b5cf6',600:'#7c3aed',700:'#6d28d9',800:'#5b21b6',900:'#4c1d95',950:'#2e1065' },
|
||||
},
|
||||
{
|
||||
name: 'emerald',
|
||||
palette: { 50:'#ecfdf5',100:'#d1fae5',200:'#a7f3d0',300:'#6ee7b7',400:'#34d399',500:'#10b981',600:'#059669',700:'#047857',800:'#065f46',900:'#064e3b',950:'#022c22' },
|
||||
},
|
||||
{
|
||||
name: 'teal',
|
||||
palette: { 50:'#f0fdfa',100:'#ccfbf1',200:'#99f6e4',300:'#5eead4',400:'#2dd4bf',500:'#14b8a6',600:'#0d9488',700:'#0f766e',800:'#115e59',900:'#134e4a',950:'#042f2e' },
|
||||
},
|
||||
{
|
||||
name: 'rose',
|
||||
palette: { 50:'#fff1f2',100:'#ffe4e6',200:'#fecdd3',300:'#fda4af',400:'#fb7185',500:'#f43f5e',600:'#e11d48',700:'#be123c',800:'#9f1239',900:'#881337',950:'#4c0519' },
|
||||
},
|
||||
])
|
||||
|
||||
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 t = templates.value.find((t) => t.name === templateName) ?? templates.value.find((t) => t.name === 'blue')
|
||||
|
||||
if (!t || t.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: t.palette,
|
||||
colorScheme: {
|
||||
light: { primary: { color:'{primary.600}',contrastColor:'{surface.50}',hoverColor:'{primary.700}',activeColor:'{primary.800}' } },
|
||||
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 s = surfaces.value.find((s) => s.name === surfaceName)
|
||||
if (s) updateSurfacePalette(s.palette)
|
||||
}
|
||||
69
src/layouts/PortalLayout.vue
Normal file
69
src/layouts/PortalLayout.vue
Normal file
@ -0,0 +1,69 @@
|
||||
<script setup>
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
import { authService } from '@/services/authService'
|
||||
|
||||
const auth = useAuthStore()
|
||||
|
||||
const navItems = [
|
||||
{ name: 'painel', label: 'Painel' },
|
||||
{ name: 'debitos', label: 'Débitos' },
|
||||
{ name: 'certidoes-portal', label: 'Certidões' },
|
||||
{ name: 'alvaras', label: 'Alvarás' },
|
||||
{ name: 'pagamentos', label: 'Pagamentos' },
|
||||
{ name: 'dados', label: 'Dados Cadastrais' },
|
||||
]
|
||||
|
||||
function sair() {
|
||||
auth.clearSession()
|
||||
authService.logout()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-screen flex flex-col bg-slate-50">
|
||||
<header class="bg-white border-b border-slate-200 sticky top-0 z-40">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 h-16 flex items-center justify-between">
|
||||
<RouterLink :to="{ name: 'painel' }" class="flex items-center gap-3">
|
||||
<div class="w-8 h-8 bg-blue-700 rounded-lg flex items-center justify-center">
|
||||
<i class="pi pi-building text-white text-sm" />
|
||||
</div>
|
||||
<span class="font-semibold text-slate-800">Portal do Contribuinte</span>
|
||||
</RouterLink>
|
||||
|
||||
<nav class="hidden md:flex items-center gap-1">
|
||||
<RouterLink
|
||||
v-for="item in navItems"
|
||||
:key="item.name"
|
||||
:to="{ name: item.name }"
|
||||
class="px-3 py-2 rounded-lg text-sm text-slate-600 hover:bg-slate-100 hover:text-slate-900 transition-colors"
|
||||
active-class="bg-blue-50 text-blue-700 font-medium"
|
||||
>
|
||||
{{ item.label }}
|
||||
</RouterLink>
|
||||
</nav>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="hidden sm:block text-sm text-slate-600">{{ auth.nomeUsuario }}</span>
|
||||
<Button
|
||||
label="Sair"
|
||||
severity="secondary"
|
||||
size="small"
|
||||
icon="pi pi-sign-out"
|
||||
outlined
|
||||
@click="sair"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="flex-1 max-w-7xl mx-auto w-full px-4 sm:px-6 lg:px-8 py-8">
|
||||
<RouterView v-slot="{ Component }">
|
||||
<Transition name="page" mode="out-in">
|
||||
<component :is="Component" />
|
||||
</Transition>
|
||||
</RouterView>
|
||||
</main>
|
||||
|
||||
<AppFooter />
|
||||
</div>
|
||||
</template>
|
||||
16
src/layouts/PublicLayout.vue
Normal file
16
src/layouts/PublicLayout.vue
Normal file
@ -0,0 +1,16 @@
|
||||
<script setup>
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-screen flex flex-col bg-slate-50">
|
||||
<AppHeader />
|
||||
<main class="flex-1">
|
||||
<RouterView v-slot="{ Component }">
|
||||
<Transition name="page" mode="out-in">
|
||||
<component :is="Component" />
|
||||
</Transition>
|
||||
</RouterView>
|
||||
</main>
|
||||
<AppFooter />
|
||||
</div>
|
||||
</template>
|
||||
34
src/main.js
Normal file
34
src/main.js
Normal file
@ -0,0 +1,34 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
|
||||
|
||||
import PrimeVue from 'primevue/config'
|
||||
import ToastService from 'primevue/toastservice'
|
||||
import ConfirmationService from 'primevue/confirmationservice'
|
||||
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import { primeVueConfig } from './config/primevue.config'
|
||||
import { bootstrapPrefeitura } from './bootstrap/prefeituraBoot'
|
||||
|
||||
import '@/assets/main.css'
|
||||
import '@/assets/layout/layout.scss'
|
||||
|
||||
async function startApp() {
|
||||
const app = createApp(App)
|
||||
|
||||
const pinia = createPinia()
|
||||
pinia.use(piniaPluginPersistedstate)
|
||||
app.use(pinia)
|
||||
|
||||
await bootstrapPrefeitura(pinia)
|
||||
|
||||
app.use(router)
|
||||
app.use(PrimeVue, primeVueConfig)
|
||||
app.use(ToastService)
|
||||
app.use(ConfirmationService)
|
||||
|
||||
app.mount('#app')
|
||||
}
|
||||
|
||||
startApp()
|
||||
106
src/router/index.js
Normal file
106
src/router/index.js
Normal file
@ -0,0 +1,106 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
scrollBehavior: () => ({ top: 0 }),
|
||||
routes: [
|
||||
{
|
||||
path: '/',
|
||||
component: () => import('@/layouts/PublicLayout.vue'),
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: 'home',
|
||||
component: () => import('@/views/public/HomeView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'login',
|
||||
name: 'login',
|
||||
component: () => import('@/views/public/LoginView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'primeiro-acesso',
|
||||
name: 'primeiro-acesso',
|
||||
component: () => import('@/views/public/PrimeiroAcessoView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'credenciamento',
|
||||
name: 'credenciamento',
|
||||
component: () => import('@/views/public/CredenciamentoView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'servicos',
|
||||
name: 'servicos',
|
||||
component: () => import('@/views/servicos/ServicosHubView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'servicos/certidao',
|
||||
name: 'certidao',
|
||||
component: () => import('@/views/servicos/CertidaoView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'servicos/iptu',
|
||||
name: 'iptu',
|
||||
component: () => import('@/views/servicos/IptuView.vue'),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/portal',
|
||||
component: () => import('@/layouts/PortalLayout.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
redirect: { name: 'painel' },
|
||||
},
|
||||
{
|
||||
path: 'painel',
|
||||
name: 'painel',
|
||||
component: () => import('@/views/portal/PainelView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'debitos',
|
||||
name: 'debitos',
|
||||
component: () => import('@/views/portal/DebitosView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'certidoes',
|
||||
name: 'certidoes-portal',
|
||||
component: () => import('@/views/portal/CertidoesView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'alvaras',
|
||||
name: 'alvaras',
|
||||
component: () => import('@/views/portal/AlvarasView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'pagamentos',
|
||||
name: 'pagamentos',
|
||||
component: () => import('@/views/portal/PagamentosView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'dados',
|
||||
name: 'dados',
|
||||
component: () => import('@/views/portal/DadosView.vue'),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/:pathMatch(.*)*',
|
||||
redirect: { name: 'home' },
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
router.beforeEach((to) => {
|
||||
if (!to.meta.requiresAuth) return true
|
||||
|
||||
const auth = useAuthStore()
|
||||
if (!auth.isAuthenticated) {
|
||||
return { name: 'home' }
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
34
src/services/authService.js
Normal file
34
src/services/authService.js
Normal file
@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Serviço de autenticação — Keycloak PKCE
|
||||
*
|
||||
* Fluxo:
|
||||
* 1. Home coleta o documento (CPF/CNPJ)
|
||||
* 2. Redireciona para /login?doc=XXX
|
||||
* 3. LoginView exibe o documento e solicita a senha
|
||||
* 4. Ao submeter, este serviço inicia o PKCE e redireciona ao Keycloak
|
||||
* 5. Keycloak devolve ao callback com o code
|
||||
* 6. handleCallback troca o code pelo token e salva na authStore
|
||||
*/
|
||||
|
||||
const KEYCLOAK_BASE = import.meta.env.VITE_KEYCLOAK_URL ?? 'https://sso.modumfiscal.com.br/realms/modumfiscal-dev'
|
||||
const CLIENT_ID = import.meta.env.VITE_KEYCLOAK_CLIENT_ID ?? 'portal-modumfiscal'
|
||||
const REDIRECT_URI = `${window.location.origin}/callback`
|
||||
|
||||
export const authService = {
|
||||
async iniciarLogin(documento) {
|
||||
// Implementar PKCE com pkce-challenge quando integrar com Keycloak
|
||||
// Por ora redireciona para /login com o documento
|
||||
return { documento }
|
||||
},
|
||||
|
||||
async handleCallback(code) {
|
||||
// Trocar code por token via Keycloak
|
||||
// A ser implementado na fase de integração
|
||||
throw new Error('Keycloak callback não implementado nesta fase')
|
||||
},
|
||||
|
||||
logout() {
|
||||
const logoutUrl = `${KEYCLOAK_BASE}/protocol/openid-connect/logout?redirect_uri=${encodeURIComponent(window.location.origin)}`
|
||||
window.location.href = logoutUrl
|
||||
},
|
||||
}
|
||||
7
src/services/prefeituraService.js
Normal file
7
src/services/prefeituraService.js
Normal file
@ -0,0 +1,7 @@
|
||||
import { apiClientPublico } from '@/config/apiClient'
|
||||
|
||||
export const prefeituraService = {
|
||||
getPrefeituraInfo(dominio) {
|
||||
return apiClientPublico.get(`/publico/prefeitura/${dominio}`)
|
||||
},
|
||||
}
|
||||
31
src/stores/authStore.js
Normal file
31
src/stores/authStore.js
Normal file
@ -0,0 +1,31 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
export const useAuthStore = defineStore(
|
||||
'auth',
|
||||
() => {
|
||||
const token = ref(null)
|
||||
const userInfo = ref(null)
|
||||
|
||||
const isAuthenticated = computed(() => !!token.value)
|
||||
const nomeUsuario = computed(() => userInfo.value?.name ?? '')
|
||||
const documento = computed(() => userInfo.value?.preferred_username ?? '')
|
||||
|
||||
function setSession(accessToken, info) {
|
||||
token.value = accessToken
|
||||
userInfo.value = info
|
||||
}
|
||||
|
||||
function clearSession() {
|
||||
token.value = null
|
||||
userInfo.value = null
|
||||
}
|
||||
|
||||
return { token, userInfo, isAuthenticated, nomeUsuario, documento, setSession, clearSession }
|
||||
},
|
||||
{
|
||||
persist: {
|
||||
paths: ['token', 'userInfo'],
|
||||
},
|
||||
},
|
||||
)
|
||||
13
src/stores/prefeituraStore.js
Normal file
13
src/stores/prefeituraStore.js
Normal file
@ -0,0 +1,13 @@
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
export const usePrefeituraStore = defineStore('prefeitura', {
|
||||
state: () => ({
|
||||
codigoMunicipio: null,
|
||||
nomePrefeitura: null,
|
||||
dominio: null,
|
||||
template: null,
|
||||
pathLogo: null,
|
||||
pathBackground: null, // URL de foto de fundo do município (quando a API suportar)
|
||||
}),
|
||||
persist: true,
|
||||
})
|
||||
26
src/utils/tenant.js
Normal file
26
src/utils/tenant.js
Normal file
@ -0,0 +1,26 @@
|
||||
export function getTenant() {
|
||||
const tenant = window.location.hostname.split('.')[0]
|
||||
|
||||
if (isValidTenant(tenant)) return tenant
|
||||
|
||||
const stored = localStorage.getItem('current_dominio')
|
||||
if (stored && isValidTenant(stored)) return stored
|
||||
|
||||
return 'sistema'
|
||||
}
|
||||
|
||||
export function setTenant(tenant) {
|
||||
if (isValidTenant(tenant)) {
|
||||
localStorage.setItem('current_dominio', tenant)
|
||||
}
|
||||
}
|
||||
|
||||
export function clearTenant() {
|
||||
localStorage.removeItem('current_dominio')
|
||||
}
|
||||
|
||||
function isValidTenant(tenant) {
|
||||
if (!tenant || typeof tenant !== 'string') return false
|
||||
const invalidos = ['0.0.0.0', 'www', 'api', 'admin', 'test', 'dev', 'development', 'staging', 'production', 'localhost']
|
||||
return !invalidos.includes(tenant.toLowerCase()) && tenant.length > 1 && /^[a-zA-Z0-9-]+$/.test(tenant)
|
||||
}
|
||||
12
src/views/portal/AlvarasView.vue
Normal file
12
src/views/portal/AlvarasView.vue
Normal file
@ -0,0 +1,12 @@
|
||||
<script setup>
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<h1 class="text-2xl font-bold text-slate-800">Alvarás</h1>
|
||||
<div class="bg-white rounded-xl border border-slate-200 p-8 text-center text-slate-400">
|
||||
<i class="pi pi-briefcase text-4xl mb-3 block" />
|
||||
<p>Acompanhamento de alvarás e processos — a ser implementado.</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
12
src/views/portal/CertidoesView.vue
Normal file
12
src/views/portal/CertidoesView.vue
Normal file
@ -0,0 +1,12 @@
|
||||
<script setup>
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<h1 class="text-2xl font-bold text-slate-800">Certidões</h1>
|
||||
<div class="bg-white rounded-xl border border-slate-200 p-8 text-center text-slate-400">
|
||||
<i class="pi pi-file-edit text-4xl mb-3 block" />
|
||||
<p>Certidões ativas e reemissão — a ser implementado.</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
12
src/views/portal/DadosView.vue
Normal file
12
src/views/portal/DadosView.vue
Normal file
@ -0,0 +1,12 @@
|
||||
<script setup>
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<h1 class="text-2xl font-bold text-slate-800">Dados Cadastrais</h1>
|
||||
<div class="bg-white rounded-xl border border-slate-200 p-8 text-center text-slate-400">
|
||||
<i class="pi pi-user text-4xl mb-3 block" />
|
||||
<p>Visualização e atualização de dados cadastrais — a ser implementado.</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
12
src/views/portal/DebitosView.vue
Normal file
12
src/views/portal/DebitosView.vue
Normal file
@ -0,0 +1,12 @@
|
||||
<script setup>
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<h1 class="text-2xl font-bold text-slate-800">Débitos e Guias</h1>
|
||||
<div class="bg-white rounded-xl border border-slate-200 p-8 text-center text-slate-400">
|
||||
<i class="pi pi-receipt text-4xl mb-3 block" />
|
||||
<p>Consulta de débitos e emissão de guias — a ser implementado.</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
12
src/views/portal/PagamentosView.vue
Normal file
12
src/views/portal/PagamentosView.vue
Normal file
@ -0,0 +1,12 @@
|
||||
<script setup>
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<h1 class="text-2xl font-bold text-slate-800">Histórico de Pagamentos</h1>
|
||||
<div class="bg-white rounded-xl border border-slate-200 p-8 text-center text-slate-400">
|
||||
<i class="pi pi-credit-card text-4xl mb-3 block" />
|
||||
<p>Histórico de pagamentos com comprovantes — a ser implementado.</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
55
src/views/portal/PainelView.vue
Normal file
55
src/views/portal/PainelView.vue
Normal file
@ -0,0 +1,55 @@
|
||||
<script setup>
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
|
||||
const auth = useAuthStore()
|
||||
|
||||
const cards = [
|
||||
{ icon: 'pi-receipt', label: 'Débitos em Aberto', valor: '—', cor: 'red' },
|
||||
{ icon: 'pi-file-check', label: 'Certidões Ativas', valor: '—', cor: 'green' },
|
||||
{ icon: 'pi-briefcase', label: 'Alvarás em Andamento', valor: '—', cor: 'orange' },
|
||||
{ icon: 'pi-credit-card', label: 'Último Pagamento', valor: '—', cor: 'blue' },
|
||||
]
|
||||
|
||||
const corMap = {
|
||||
red: 'bg-red-50 text-red-700',
|
||||
green: 'bg-green-50 text-green-700',
|
||||
orange: 'bg-orange-50 text-orange-700',
|
||||
blue: 'bg-blue-50 text-blue-700',
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-8">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-slate-800">
|
||||
Olá, {{ auth.nomeUsuario || 'Contribuinte' }}
|
||||
</h1>
|
||||
<p class="text-slate-500 mt-1">Bem-vindo ao seu painel de gestão fiscal.</p>
|
||||
</div>
|
||||
|
||||
<!-- Cards resumo -->
|
||||
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div
|
||||
v-for="card in cards"
|
||||
:key="card.label"
|
||||
class="bg-white rounded-xl border border-slate-200 p-5 space-y-3"
|
||||
>
|
||||
<div :class="['w-10 h-10 rounded-lg flex items-center justify-center', corMap[card.cor]]">
|
||||
<i :class="['pi', card.icon]" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-2xl font-bold text-slate-800">{{ card.valor }}</p>
|
||||
<p class="text-xs text-slate-500 mt-0.5">{{ card.label }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Placeholder conteúdo -->
|
||||
<div class="bg-blue-50 border border-blue-100 rounded-xl p-6 text-center">
|
||||
<i class="pi pi-info-circle text-blue-500 text-2xl mb-3 block" />
|
||||
<p class="text-sm text-slate-600">
|
||||
Os dados do painel serão carregados quando a integração com a API estiver configurada.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
17
src/views/public/CredenciamentoView.vue
Normal file
17
src/views/public/CredenciamentoView.vue
Normal file
@ -0,0 +1,17 @@
|
||||
<script setup>
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="max-w-2xl mx-auto px-4 py-16 text-center">
|
||||
<div class="w-16 h-16 bg-green-100 rounded-2xl flex items-center justify-center mx-auto mb-6">
|
||||
<i class="pi pi-user-plus text-green-700 text-2xl" />
|
||||
</div>
|
||||
<h1 class="text-2xl font-bold text-slate-800 mb-3">Credenciamento</h1>
|
||||
<p class="text-slate-500 mb-8">
|
||||
Wizard de cadastro do contribuinte (6 etapas) — a ser implementado.
|
||||
</p>
|
||||
<RouterLink :to="{ name: 'home' }">
|
||||
<Button label="Voltar ao início" severity="secondary" icon="pi pi-arrow-left" outlined />
|
||||
</RouterLink>
|
||||
</div>
|
||||
</template>
|
||||
354
src/views/public/HomeView.vue
Normal file
354
src/views/public/HomeView.vue
Normal file
@ -0,0 +1,354 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { usePrefeituraStore } from '@/stores/prefeituraStore'
|
||||
import DocumentoInput from '@/components/auth/DocumentoInput.vue'
|
||||
import ServiceCard from '@/components/common/ServiceCard.vue'
|
||||
|
||||
import bgTutoia from '@/assets/images/bg-tutoia.jpeg'
|
||||
|
||||
const router = useRouter()
|
||||
const prefeitura = usePrefeituraStore()
|
||||
|
||||
const documento = ref('')
|
||||
const erro = ref('')
|
||||
|
||||
// ─── Hero background ─────────────────────────────────────────────────────────
|
||||
// Mapa template → imagem estática (Vite resolve na build).
|
||||
// Para adicionar novo município: importar a foto e adicionar a chave abaixo.
|
||||
const heroBgMap = {
|
||||
tutoia: bgTutoia,
|
||||
}
|
||||
|
||||
const heroBgUrl = computed(() => heroBgMap[prefeitura.template] ?? null)
|
||||
|
||||
const heroBgStyle = computed(() => {
|
||||
const url = heroBgUrl.value
|
||||
if (!url) return {}
|
||||
return {
|
||||
backgroundImage: `linear-gradient(to bottom right, rgba(15,23,42,0.70), rgba(15,23,42,0.50)), url(${url})`,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
}
|
||||
})
|
||||
|
||||
const heroHasPhoto = computed(() => !!heroBgUrl.value)
|
||||
|
||||
// ─── Avisos (carousel) ──────────────────────────────────────────────────────
|
||||
// Dados mockados — conectar ao endpoint /api/v1/publico/avisos/{dominio} futuramente
|
||||
const avisos = ref([
|
||||
{
|
||||
id: 1,
|
||||
tipo: 'prazo',
|
||||
icone: 'pi-calendar',
|
||||
titulo: 'IPTU 2025 — Parcela única com desconto',
|
||||
descricao: 'Pague até 31/07 e ganhe 10% de desconto. Emita seu boleto agora mesmo.',
|
||||
cor: 'amber',
|
||||
acao: { label: 'Emitir boleto', to: { name: 'iptu' } },
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
tipo: 'novidade',
|
||||
icone: 'pi-star',
|
||||
titulo: 'Novo serviço: Certidão Online Instantânea',
|
||||
descricao: 'Emita certidões negativas em segundos, sem sair de casa e com validade legal.',
|
||||
cor: 'green',
|
||||
acao: { label: 'Emitir agora', to: { name: 'certidao' } },
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
tipo: 'info',
|
||||
icone: 'pi-info-circle',
|
||||
titulo: 'Atualização cadastral obrigatória',
|
||||
descricao: 'Contribuintes com dados desatualizados devem regularizar até 30/08 para evitar multas.',
|
||||
cor: 'blue',
|
||||
acao: null,
|
||||
},
|
||||
])
|
||||
|
||||
const corAviso = {
|
||||
amber: { bg: 'bg-amber-50', borda: 'border-amber-200', icone: 'text-amber-600', tag: 'bg-amber-100 text-amber-700' },
|
||||
green: { bg: 'bg-emerald-50', borda: 'border-emerald-200', icone: 'text-emerald-600', tag: 'bg-emerald-100 text-emerald-700' },
|
||||
blue: { bg: 'bg-blue-50', borda: 'border-blue-200', icone: 'text-blue-600', tag: 'bg-blue-100 text-blue-700' },
|
||||
}
|
||||
|
||||
// ─── Serviços ────────────────────────────────────────────────────────────────
|
||||
const servicosPublicos = [
|
||||
{ icon: 'pi-file-check', titulo: 'Emissão de Certidão', descricao: 'Certidão negativa, positiva e situação fiscal.', to: { name: 'certidao' } },
|
||||
{ icon: 'pi-home', titulo: 'IPTU — Débitos e Carnê', descricao: 'Consulte débitos e imprima o carnê de pagamento.', to: { name: 'iptu' } },
|
||||
{ icon: 'pi-verified', titulo: 'Validar Documento', descricao: 'Verifique a autenticidade de certidões emitidas.', to: { name: 'servicos' } },
|
||||
]
|
||||
|
||||
const servicosAutenticados = [
|
||||
{ icon: 'pi-receipt', titulo: 'Débitos e Guias', descricao: 'Emita guias de pagamento de todos os seus tributos.', to: { name: 'debitos' } },
|
||||
{ icon: 'pi-file-edit', titulo: 'Certidões Ativas', descricao: 'Acesse e reemita certidões emitidas anteriormente.', to: { name: 'certidoes-portal' } },
|
||||
{ icon: 'pi-briefcase', titulo: 'Alvarás', descricao: 'Acompanhe processos e solicite novos alvarás.', to: { name: 'alvaras' } },
|
||||
{ icon: 'pi-credit-card', titulo: 'Pagamentos', descricao: 'Histórico completo com comprovantes para download.', to: { name: 'pagamentos' } },
|
||||
{ icon: 'pi-user', titulo: 'Dados Cadastrais', descricao: 'Visualize e mantenha seus dados sempre atualizados.', to: { name: 'dados' } },
|
||||
]
|
||||
|
||||
function continuar() {
|
||||
if (documento.value.replace(/\D/g, '').length < 11) {
|
||||
erro.value = 'Informe um CPF (11 dígitos) ou CNPJ (14 dígitos) válido.'
|
||||
return
|
||||
}
|
||||
erro.value = ''
|
||||
router.push({ name: 'login', query: { doc: documento.value } })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
|
||||
<!-- ─── HERO ─────────────────────────────────────────────────────── -->
|
||||
<section
|
||||
class="text-white relative overflow-hidden"
|
||||
:class="heroHasPhoto ? '' : 'bg-gradient-to-br from-slate-900 via-primary-900 to-slate-800'"
|
||||
:style="heroBgStyle"
|
||||
>
|
||||
<!-- Padrão geométrico sutil (visível só sem foto) -->
|
||||
<div
|
||||
v-if="!heroHasPhoto"
|
||||
class="absolute inset-0 opacity-5 pointer-events-none"
|
||||
style="background-image: radial-gradient(circle at 25px 25px, white 2px, transparent 0); background-size: 50px 50px;"
|
||||
/>
|
||||
|
||||
<!-- Gradiente lateral esquerdo — garante legibilidade dos textos
|
||||
independente do conteúdo da foto de fundo -->
|
||||
<div
|
||||
v-if="heroHasPhoto"
|
||||
class="absolute inset-0 pointer-events-none"
|
||||
style="background: linear-gradient(to right, rgba(15,23,42,0.88) 0%, rgba(15,23,42,0.65) 55%, rgba(15,23,42,0.15) 100%);"
|
||||
/>
|
||||
|
||||
<div class="relative max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-14 lg:py-20">
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-12 lg:gap-20 items-center">
|
||||
|
||||
<!-- Esquerda -->
|
||||
<div>
|
||||
<!-- Identidade do município -->
|
||||
<div class="flex items-center gap-3 mb-6">
|
||||
<img
|
||||
v-if="prefeitura.pathLogo"
|
||||
:src="prefeitura.pathLogo"
|
||||
alt="Logo"
|
||||
class="h-12 w-auto object-contain drop-shadow-md"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="w-12 h-12 bg-white/15 backdrop-blur-sm rounded-xl flex items-center justify-center"
|
||||
>
|
||||
<i class="pi pi-building text-white text-xl" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-white/60 text-xs uppercase tracking-widest font-medium">Portal do Contribuinte</p>
|
||||
<p class="text-white font-bold text-lg leading-tight">
|
||||
{{ prefeitura.nomePrefeitura || 'Prefeitura Municipal' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h1 class="text-3xl lg:text-[2.6rem] font-bold leading-tight mb-4 tracking-tight">
|
||||
Serviços municipais<br />
|
||||
<span class="text-white/80">na palma da mão</span>
|
||||
</h1>
|
||||
<p class="text-white/65 text-base mb-8 leading-relaxed max-w-md">
|
||||
Emita certidões, consulte débitos e acesse todos os serviços
|
||||
da prefeitura sem precisar sair de casa.
|
||||
</p>
|
||||
|
||||
<!-- Lista de serviços públicos -->
|
||||
<ul class="space-y-3 mb-8">
|
||||
<li
|
||||
v-for="s in servicosPublicos"
|
||||
:key="s.titulo"
|
||||
class="flex items-center gap-3 group cursor-pointer"
|
||||
@click="router.push(s.to)"
|
||||
>
|
||||
<div class="w-9 h-9 bg-white/10 rounded-lg flex items-center justify-center flex-shrink-0 group-hover:bg-white/20 transition-colors">
|
||||
<i :class="['pi', s.icon, 'text-white text-sm']" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-white group-hover:text-white/80 transition-colors">{{ s.titulo }}</p>
|
||||
<p class="text-xs text-white/50">{{ s.descricao }}</p>
|
||||
</div>
|
||||
<i class="pi pi-chevron-right text-white/30 text-xs ml-auto opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<RouterLink
|
||||
:to="{ name: 'servicos' }"
|
||||
class="inline-flex items-center gap-2 text-sm text-white/60 hover:text-white transition-colors font-medium"
|
||||
>
|
||||
Ver todos os serviços disponíveis
|
||||
<i class="pi pi-arrow-right text-xs" />
|
||||
</RouterLink>
|
||||
</div>
|
||||
|
||||
<!-- Direita — Card de acesso -->
|
||||
<div class="flex justify-center lg:justify-end">
|
||||
<div class="bg-white rounded-2xl shadow-2xl p-8 w-full max-w-sm backdrop-blur-sm">
|
||||
|
||||
<!-- Cabeçalho do card -->
|
||||
<div class="flex items-center gap-3 mb-7">
|
||||
<div class="w-11 h-11 bg-primary rounded-xl flex items-center justify-center shadow-sm">
|
||||
<i class="pi pi-lock-open text-white text-lg" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-bold text-slate-800 text-base">Área do Contribuinte</p>
|
||||
<p class="text-xs text-slate-500">Acesso seguro ao portal</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Formulário -->
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700 mb-1.5">
|
||||
CPF ou CNPJ
|
||||
</label>
|
||||
<DocumentoInput
|
||||
v-model="documento"
|
||||
@keyup.enter="continuar"
|
||||
/>
|
||||
<p v-if="erro" class="mt-1.5 text-xs text-red-600 flex items-center gap-1">
|
||||
<i class="pi pi-exclamation-circle text-xs" />
|
||||
{{ erro }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
label="Continuar"
|
||||
icon="pi pi-arrow-right"
|
||||
icon-pos="right"
|
||||
class="w-full"
|
||||
size="large"
|
||||
@click="continuar"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Divider>
|
||||
<span class="text-xs text-slate-400 font-normal">Primeiro acesso?</span>
|
||||
</Divider>
|
||||
|
||||
<div class="space-y-2.5 text-center">
|
||||
<RouterLink
|
||||
:to="{ name: 'primeiro-acesso' }"
|
||||
class="flex items-center justify-center gap-2 w-full px-4 py-2.5 rounded-lg border border-slate-200 text-sm text-slate-700 hover:bg-slate-50 hover:border-slate-300 transition-colors font-medium"
|
||||
>
|
||||
<i class="pi pi-key text-slate-500 text-sm" />
|
||||
Criar minha senha
|
||||
</RouterLink>
|
||||
<RouterLink
|
||||
:to="{ name: 'credenciamento' }"
|
||||
class="block text-xs text-slate-400 hover:text-slate-600 transition-colors"
|
||||
>
|
||||
Ainda não cadastrado? <span class="text-primary font-semibold">Credenciar-se</span>
|
||||
</RouterLink>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ─── CAROUSEL DE AVISOS ────────────────────────────────────────── -->
|
||||
<section class="bg-white border-b border-slate-100">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
<Carousel
|
||||
:value="avisos"
|
||||
:num-visible="1"
|
||||
:num-scroll="1"
|
||||
:autoplay-interval="6000"
|
||||
circular
|
||||
class="aviso-carousel"
|
||||
>
|
||||
<template #item="{ data: aviso }">
|
||||
<div
|
||||
:class="[
|
||||
'mx-2 rounded-xl border p-4 flex items-start gap-4',
|
||||
corAviso[aviso.cor].bg,
|
||||
corAviso[aviso.cor].borda,
|
||||
]"
|
||||
>
|
||||
<div
|
||||
:class="[
|
||||
'w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0 bg-white shadow-sm',
|
||||
]"
|
||||
>
|
||||
<i :class="['pi', aviso.icone, 'text-lg', corAviso[aviso.cor].icone]" />
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p class="font-semibold text-slate-800 text-sm">{{ aviso.titulo }}</p>
|
||||
<p class="text-xs text-slate-600 mt-0.5 leading-relaxed">{{ aviso.descricao }}</p>
|
||||
</div>
|
||||
<RouterLink
|
||||
v-if="aviso.acao"
|
||||
:to="aviso.acao.to"
|
||||
class="flex-shrink-0"
|
||||
>
|
||||
<Button
|
||||
:label="aviso.acao.label"
|
||||
size="small"
|
||||
outlined
|
||||
class="whitespace-nowrap"
|
||||
/>
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Carousel>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ─── SERVIÇOS AUTENTICADOS ─────────────────────────────────────── -->
|
||||
<section class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h2 class="text-xl font-bold text-slate-800">Área logada</h2>
|
||||
<p class="text-sm text-slate-500 mt-0.5">Serviços disponíveis após login</p>
|
||||
</div>
|
||||
<span class="hidden sm:flex items-center gap-1.5 bg-primary/8 text-primary text-xs font-semibold px-3 py-1.5 rounded-full border border-primary/15">
|
||||
<i class="pi pi-lock text-xs" />
|
||||
Requer login
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5 gap-4">
|
||||
<ServiceCard
|
||||
v-for="s in servicosAutenticados"
|
||||
:key="s.titulo"
|
||||
v-bind="s"
|
||||
:require-auth="true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- CTA credenciamento -->
|
||||
<div class="mt-10 bg-gradient-to-r from-primary/5 to-primary/10 border border-primary/15 rounded-2xl p-6 flex flex-col sm:flex-row items-center justify-between gap-5">
|
||||
<div>
|
||||
<p class="font-bold text-slate-800 text-base">Ainda não tem acesso ao portal?</p>
|
||||
<p class="text-sm text-slate-500 mt-1 max-w-md">
|
||||
Solicite seu credenciamento e passe a gerenciar todos os seus tributos municipais de forma online.
|
||||
</p>
|
||||
</div>
|
||||
<RouterLink :to="{ name: 'credenciamento' }">
|
||||
<Button label="Solicitar Credenciamento" icon="pi pi-user-plus" class="whitespace-nowrap" />
|
||||
</RouterLink>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Remove as setas de navegação padrão do Carousel no contexto dos avisos */
|
||||
.aviso-carousel :deep(.p-carousel-prev),
|
||||
.aviso-carousel :deep(.p-carousel-next) {
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
}
|
||||
</style>
|
||||
160
src/views/public/LoginView.vue
Normal file
160
src/views/public/LoginView.vue
Normal file
@ -0,0 +1,160 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const senha = ref('')
|
||||
const carregando = ref(false)
|
||||
const erro = ref('')
|
||||
|
||||
const docBruto = computed(() => (route.query.doc ?? '').replace(/\D/g, ''))
|
||||
|
||||
const docFormatado = computed(() => {
|
||||
const d = docBruto.value
|
||||
if (d.length <= 11) {
|
||||
return d
|
||||
.replace(/(\d{3})(\d)/, '$1.$2')
|
||||
.replace(/(\d{3})(\d)/, '$1.$2')
|
||||
.replace(/(\d{3})(\d{1,2})$/, '$1-$2')
|
||||
}
|
||||
return d
|
||||
.replace(/(\d{2})(\d)/, '$1.$2')
|
||||
.replace(/(\d{3})(\d)/, '$1.$2')
|
||||
.replace(/(\d{3})(\d)/, '$1/$2')
|
||||
.replace(/(\d{4})(\d{1,2})$/, '$1-$2')
|
||||
})
|
||||
|
||||
const tipoDoc = computed(() => docBruto.value.length > 11 ? 'CNPJ' : 'CPF')
|
||||
|
||||
function trocarDocumento() {
|
||||
router.push({ name: 'home' })
|
||||
}
|
||||
|
||||
async function entrar() {
|
||||
if (!senha.value) {
|
||||
erro.value = 'Informe a senha.'
|
||||
return
|
||||
}
|
||||
erro.value = ''
|
||||
carregando.value = true
|
||||
// Integração com Keycloak PKCE — a ser implementado
|
||||
setTimeout(() => {
|
||||
carregando.value = false
|
||||
erro.value = 'Integração com Keycloak ainda não configurada nesta fase.'
|
||||
}, 800)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-[calc(100vh-8rem)] flex items-center justify-center px-4 py-12">
|
||||
<div class="w-full max-w-md">
|
||||
|
||||
<!-- Card de login -->
|
||||
<div class="bg-white rounded-2xl shadow-lg border border-slate-200 overflow-hidden">
|
||||
|
||||
<!-- Cabeçalho azul -->
|
||||
<div class="bg-gradient-to-r from-blue-700 to-blue-800 px-8 py-6">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 bg-white/20 rounded-xl flex items-center justify-center">
|
||||
<i class="pi pi-lock text-white text-lg" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-white font-semibold">Acesso seguro</p>
|
||||
<p class="text-blue-200 text-xs">Portal do Contribuinte</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Conteúdo -->
|
||||
<div class="px-8 py-8 space-y-6">
|
||||
|
||||
<!-- Documento identificado -->
|
||||
<div>
|
||||
<p class="text-xs font-medium text-slate-500 uppercase tracking-wide mb-2">
|
||||
Entrando como
|
||||
</p>
|
||||
<div class="flex items-center justify-between bg-slate-50 border border-slate-200 rounded-xl px-4 py-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-8 h-8 bg-blue-100 rounded-lg flex items-center justify-center">
|
||||
<i class="pi pi-id-card text-blue-700 text-sm" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-mono font-medium text-slate-800 text-sm">{{ docFormatado }}</p>
|
||||
<p class="text-xs text-slate-500">{{ tipoDoc }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="text-xs text-blue-600 hover:text-blue-800 font-medium flex items-center gap-1 transition-colors"
|
||||
@click="trocarDocumento"
|
||||
>
|
||||
<i class="pi pi-pencil text-xs" />
|
||||
Trocar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Senha -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700 mb-1.5">
|
||||
Senha
|
||||
</label>
|
||||
<Password
|
||||
v-model="senha"
|
||||
:feedback="false"
|
||||
toggle-mask
|
||||
placeholder="Digite sua senha"
|
||||
class="w-full"
|
||||
input-class="w-full"
|
||||
size="large"
|
||||
@keyup.enter="entrar"
|
||||
/>
|
||||
<p v-if="erro" class="mt-1.5 text-xs text-red-600 flex items-center gap-1">
|
||||
<i class="pi pi-exclamation-circle" />
|
||||
{{ erro }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Botão entrar -->
|
||||
<Button
|
||||
label="Entrar"
|
||||
icon="pi pi-sign-in"
|
||||
class="w-full"
|
||||
size="large"
|
||||
:loading="carregando"
|
||||
@click="entrar"
|
||||
/>
|
||||
|
||||
<!-- Links secundários -->
|
||||
<div class="text-center space-y-2">
|
||||
<RouterLink
|
||||
:to="{ name: 'primeiro-acesso' }"
|
||||
class="block text-sm text-blue-600 hover:text-blue-800 transition-colors"
|
||||
>
|
||||
Esqueci minha senha
|
||||
</RouterLink>
|
||||
<RouterLink
|
||||
:to="{ name: 'credenciamento' }"
|
||||
class="block text-xs text-slate-500 hover:text-slate-700 transition-colors"
|
||||
>
|
||||
Ainda não tem acesso? Credenciar-se
|
||||
</RouterLink>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Voltar -->
|
||||
<div class="text-center mt-6">
|
||||
<button
|
||||
class="text-sm text-slate-500 hover:text-slate-700 transition-colors flex items-center gap-1.5 mx-auto"
|
||||
@click="router.push({ name: 'home' })"
|
||||
>
|
||||
<i class="pi pi-arrow-left text-xs" />
|
||||
Voltar à página inicial
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
17
src/views/public/PrimeiroAcessoView.vue
Normal file
17
src/views/public/PrimeiroAcessoView.vue
Normal file
@ -0,0 +1,17 @@
|
||||
<script setup>
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="max-w-2xl mx-auto px-4 py-16 text-center">
|
||||
<div class="w-16 h-16 bg-blue-100 rounded-2xl flex items-center justify-center mx-auto mb-6">
|
||||
<i class="pi pi-key text-blue-700 text-2xl" />
|
||||
</div>
|
||||
<h1 class="text-2xl font-bold text-slate-800 mb-3">Primeiro Acesso / Esqueci minha senha</h1>
|
||||
<p class="text-slate-500 mb-8">
|
||||
Fluxo de criação e recuperação de senha — a ser implementado.
|
||||
</p>
|
||||
<RouterLink :to="{ name: 'home' }">
|
||||
<Button label="Voltar ao início" severity="secondary" icon="pi pi-arrow-left" outlined />
|
||||
</RouterLink>
|
||||
</div>
|
||||
</template>
|
||||
17
src/views/servicos/CertidaoView.vue
Normal file
17
src/views/servicos/CertidaoView.vue
Normal file
@ -0,0 +1,17 @@
|
||||
<script setup>
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="max-w-3xl mx-auto px-4 py-12 text-center">
|
||||
<div class="w-16 h-16 bg-blue-100 rounded-2xl flex items-center justify-center mx-auto mb-6">
|
||||
<i class="pi pi-file-check text-blue-700 text-2xl" />
|
||||
</div>
|
||||
<h1 class="text-2xl font-bold text-slate-800 mb-3">Emissão de Certidão</h1>
|
||||
<p class="text-slate-500 mb-8">
|
||||
Consulta de situação fiscal e emissão de certidão em PDF — a ser implementado.
|
||||
</p>
|
||||
<RouterLink :to="{ name: 'servicos' }">
|
||||
<Button label="Voltar aos serviços" severity="secondary" icon="pi pi-arrow-left" outlined />
|
||||
</RouterLink>
|
||||
</div>
|
||||
</template>
|
||||
17
src/views/servicos/IptuView.vue
Normal file
17
src/views/servicos/IptuView.vue
Normal file
@ -0,0 +1,17 @@
|
||||
<script setup>
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="max-w-3xl mx-auto px-4 py-12 text-center">
|
||||
<div class="w-16 h-16 bg-blue-100 rounded-2xl flex items-center justify-center mx-auto mb-6">
|
||||
<i class="pi pi-home text-blue-700 text-2xl" />
|
||||
</div>
|
||||
<h1 class="text-2xl font-bold text-slate-800 mb-3">IPTU — Débitos e Carnê</h1>
|
||||
<p class="text-slate-500 mb-8">
|
||||
Consulta por inscrição imobiliária ou CPF/CNPJ e impressão do carnê — a ser implementado.
|
||||
</p>
|
||||
<RouterLink :to="{ name: 'servicos' }">
|
||||
<Button label="Voltar aos serviços" severity="secondary" icon="pi pi-arrow-left" outlined />
|
||||
</RouterLink>
|
||||
</div>
|
||||
</template>
|
||||
23
src/views/servicos/ServicosHubView.vue
Normal file
23
src/views/servicos/ServicosHubView.vue
Normal file
@ -0,0 +1,23 @@
|
||||
<script setup>
|
||||
import ServiceCard from '@/components/common/ServiceCard.vue'
|
||||
|
||||
const servicos = [
|
||||
{ icon: 'pi-file-check', titulo: 'Emissão de Certidão', descricao: 'Emita certidões de situação fiscal.', to: { name: 'certidao' } },
|
||||
{ icon: 'pi-home', titulo: 'IPTU — Débitos e Carnê', descricao: 'Consulte débitos e imprima o carnê.', to: { name: 'iptu' } },
|
||||
{ icon: 'pi-verified', titulo: 'Validar Documento', descricao: 'Valide certidões e documentos emitidos.' },
|
||||
{ icon: 'pi-map-marker', titulo: 'Consulta Cadastral', descricao: 'Consulte dados cadastrais do imóvel ou empresa.' },
|
||||
{ icon: 'pi-building', titulo: 'Alvará — Consulta Pública', descricao: 'Situação de alvarás pelo número do processo.' },
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||
<div class="mb-8">
|
||||
<h1 class="text-2xl font-bold text-slate-800">Todos os Serviços</h1>
|
||||
<p class="text-slate-500 mt-1">Serviços disponíveis sem necessidade de login.</p>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<ServiceCard v-for="s in servicos" :key="s.titulo" v-bind="s" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
21
vite.config.js
Normal file
21
vite.config.js
Normal file
@ -0,0 +1,21 @@
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import Components from 'unplugin-vue-components/vite'
|
||||
import { PrimeVueResolver } from '@primevue/auto-import-resolver'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
vue(),
|
||||
tailwindcss(),
|
||||
Components({
|
||||
resolvers: [PrimeVueResolver()],
|
||||
}),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
},
|
||||
},
|
||||
})
|
||||
Loading…
x
Reference in New Issue
Block a user