Browse Source

Merge d1f107c7d7 into 2d6eaae489

pull/3840/merge
Heitor 1 week ago
committed by GitHub
parent
commit
4441336724
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 70
      frontend/src/__apps/painel/main.js
  2. 59
      frontend/src/__apps/painel/scss/painel.scss
  3. 569
      frontend/src/__apps/votacao/main.js
  4. 47
      frontend/src/components/painel/Cronometro.vue
  5. 89
      frontend/src/components/painel/CronometroList.vue
  6. 62
      frontend/src/components/painel/PainelFooter.vue
  7. 61
      frontend/src/components/painel/PainelHeader.vue
  8. 36
      frontend/src/components/painel/PainelMateria.vue
  9. 23
      frontend/src/components/painel/PainelOradores.vue
  10. 158
      frontend/src/components/painel/PainelParlamentares.vue
  11. 123
      frontend/src/components/painel/PainelResultado.vue
  12. 25
      frontend/src/components/votacao/VotacaoMateria.vue
  13. 76
      frontend/src/components/votacao/VotacaoNominal.vue
  14. 84
      frontend/src/components/votacao/VotacaoObservacoes.vue
  15. 49
      frontend/src/components/votacao/VotacaoResultado.vue
  16. 44
      frontend/src/components/votacao/VotacaoVotos.vue
  17. 66
      sapl/painel/consumers.py
  18. 11
      sapl/painel/urls.py
  19. 289
      sapl/painel/views.py
  20. 14
      sapl/sessao/migrations/0070_views_sessao_plenaria.py
  21. 73
      sapl/templates/painel/painel_v2.html
  22. 65
      sapl/templates/sessao/votacao/votacao_v2.html

70
frontend/src/__apps/painel/main.js

@ -1,6 +1,5 @@
import './scss/painel.scss'
// main.js (Vue 2)
import Vue from 'vue'
import Vuex from 'vuex'
import { mapState } from 'vuex';
@ -13,7 +12,7 @@ import PainelParlamentares from '../../components/painel/PainelParlamentares.vue
import PainelOradores from '../../components/painel/PainelOradores.vue'
import PainelMateria from '../../components/painel/PainelMateria.vue'
import PainelResultado from '../../components/painel/PainelResultado.vue'
import alarm from '../../assets/audio/ring.mp3'
import PainelFooter from '../../components/painel/PainelFooter.vue'
// register components
Vue.component('painel-cronometro', Cronometro)
@ -23,6 +22,7 @@ Vue.component('painel-parlamentares', PainelParlamentares)
Vue.component('painel-oradores', PainelOradores)
Vue.component('painel-materia', PainelMateria)
Vue.component('painel-resultado', PainelResultado)
Vue.component('painel-footer', PainelFooter)
// global store
Vue.use(Vuex)
@ -50,10 +50,22 @@ const store = new Vuex.Store({
},
updateParlamentares(state, votos_parlamentares) {
if (votos_parlamentares) {
state.parlamentares.forEach((p)=>{
if (p.parlamentar_id in votos_parlamentares) {
p.voto = votos_parlamentares[p.parlamentar_id].voto
let votosMap = votos_parlamentares;
if (Array.isArray(votos_parlamentares)) {
votosMap = {};
votos_parlamentares.forEach((item) => {
if (item) {
Object.keys(item).forEach((key) => {
votosMap[key] = item[key];
});
}
});
}
state.parlamentares = state.parlamentares.map((p) => {
if (p.parlamentar_id in votosMap) {
return { ...p, voto: votosMap[p.parlamentar_id].voto };
}
return p;
});
}
},
@ -71,6 +83,9 @@ const store = new Vuex.Store({
},
setMostrarVoto(state, mostrar_voto) {
state.mostrar_voto = mostrar_voto;
},
setSessao(state, sessao) {
state.sessao = sessao;
}
},
actions: {},
@ -104,8 +119,9 @@ new Vue({
this.connectWS()
},
computed: {
...mapState(["painel_aberto", "sessao_aberta"]),
...mapState(["painel_aberto", "sessao_aberta", "sessao"]),
canRender () {
return this.sessao_aberta && this.painel_aberto;
},
@ -113,7 +129,7 @@ new Vue({
methods: {
...mapMutations(['sessaoStatus', 'painelStatus','setParlamentares',
'updateParlamentares', 'setOradores', 'setMateria',
'setResultado', 'setMessage', 'setMostrarVoto']),
'setResultado', 'setMessage', 'setMostrarVoto', 'setSessao']),
wsURL() {
const proto = location.protocol === 'https:' ? 'wss' : 'ws'
return `${proto}://${location.host}/ws/painel/${this.controllerId}/`
@ -165,6 +181,10 @@ new Vue({
this.painelStatus(data.painel_aberto);
this.setMostrarVoto(data.mostrar_voto);
if (data.sessao) {
this.setSessao(data.sessao);
}
// PARLAMENTARES
if (data.parlamentares) {
// pre-popula para Vuex capturar mudanca de estado de 'voto'
@ -173,14 +193,14 @@ new Vue({
}
// HEADER DO PAINEL
// SESSAO_PLENARIA
//TODO: group in a single SessaoPlenaria object
const headerInstance = this.$refs.painelHeader;
//TODO: setup as child's props?
headerInstance.sessao_plenaria = data.sessao.sessao_plenaria
headerInstance.sessao_plenaria_data = data.sessao.sessao_plenaria_data
headerInstance.sessao_plenaria_hora_inicio = data.sessao.sessao_plenaria_hora_inicio
headerInstance.brasao = data.sessao.brasao
// FOOTER DO PAINEL (brasão + relógio)
const footerInstance = this.$refs.painelFooter;
if (footerInstance) {
footerInstance.brasao = data.sessao.brasao;
}
if (data.message) {
this.setMessage(data.message);
@ -194,15 +214,20 @@ new Vue({
// MATERIA
if (data.materia) {
this.setMateria(data.materia);
}
// RESULTADO
if (data.materia.resultado) {
this.setResultado(data.materia.resultado);
}
// RESULTADO
if (data.materia.resultado) {
this.setResultado(data.materia.resultado);
if (data.materia.resultado.votos_parlamentares) {
this.updateParlamentares(data.materia.resultado.votos_parlamentares);
if (data.materia.resultado.votos_parlamentares) {
this.updateParlamentares(data.materia.resultado.votos_parlamentares);
}
} else {
this.setResultado({});
}
} else {
this.setMateria({});
this.setResultado({});
}
} catch (e) {
console.error('Error', e);
@ -243,6 +268,11 @@ new Vue({
console.log(`Received ping from server: ${JSON.stringify(data)}`);
} else if (data.type == 'stopwatch.update') {
this.handleStopWatchEvent(data);
} else if (data.type == 'vote.update') {
console.log('Received vote update:', data);
this.updateParlamentares({
[data.parlamentar_id]: { voto: data.voto }
});
}
} catch (e) {
console.error('WS parse error:', e);

59
frontend/src/__apps/painel/scss/painel.scss

@ -1,43 +1,24 @@
:root {
--bg-dark: #121212;
--bg-panel: #1e1e1e;
--text-main: #e0e0e0;
--text-highlight: #4fa64d;
}
.painel-principal {
background: #1c1b1b;
font-family: Verdana;
font-size: x-large;
.text-title {
color: #4fa64d;
margin: 0.5rem;
font-weight: bold;
}
.text-subtitle {
color: #459170;
font-weight: bold;
}
.data-hora {
font-size: 180%;
}
body {
background-color: var(--bg-dark);
color: var(--text-main);
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
margin-bottom: 0;
}
.text-value {
color: white;
}
.separador-vertical {
border-right: 1px solid #333;
min-height: 100vh;
margin: 0;
}
.logo-painel {
max-width: 100%;
}
.painels {
flex-wrap: wrap;
}
.painel{
margin-top: 1rem;
table {
width: 100%;
}
h2 {
margin-bottom: 0.5rem;
}
#votacao, #oradores_list {
text-align: left;
display: inline-block;
margin-bottom: 1rem;
}
}
.text-subtitle {
color: #81c784;
font-weight: 600;
}

569
frontend/src/__apps/votacao/main.js

@ -1,238 +1,383 @@
import './scss/votacao.scss'
import Vue from 'vue'
import { FormSelectPlugin } from 'bootstrap-vue'
import Vuex from 'vuex'
import { mapState, mapMutations } from 'vuex'
import axios from 'axios'
// Import components
import VotacaoNominal from '../../components/votacao/VotacaoNominal.vue'
import VotacaoMateria from '../../components/votacao/VotacaoMateria.vue'
import VotacaoVotos from '../../components/votacao/VotacaoVotos.vue'
import VotacaoResultado from '../../components/votacao/VotacaoResultado.vue'
import VotacaoObservacoes from '../../components/votacao/VotacaoObservacoes.vue'
// Register components globally
Vue.component('votacao-nominal', VotacaoNominal)
Vue.component('votacao-materia', VotacaoMateria)
Vue.component('votacao-votos', VotacaoVotos)
Vue.component('votacao-resultado', VotacaoResultado)
Vue.component('votacao-observacoes', VotacaoObservacoes)
axios.defaults.xsrfCookieName = 'csrftoken'
axios.defaults.xsrfHeaderName = 'X-CSRFToken'
Vue.use(FormSelectPlugin)
Vue.use(Vuex)
console.log('votacao main.js carregado')
// Vuex store
const store = new Vuex.Store({
state: {
sessao_aberta: false,
painel_aberto: false,
mostrar_voto: false,
sessao: {},
parlamentares: [],
oradores: [],
materia: {},
resultado: {},
message: '',
tipos_resultado: [],
},
mutations: {
sessaoStatus (state, value) {
state.sessao_aberta = value
},
painelStatus (state, value) {
state.painel_aberto = value
},
setParlamentares (state, parlamentares) {
state.parlamentares = parlamentares
},
updateParlamentarVoto (state, { parlamentar_id, voto, voted }) {
const p = state.parlamentares.find(p => p.parlamentar_id === parlamentar_id)
if (p) {
Vue.set(p, 'voto', voto)
if (voted !== undefined) {
Vue.set(p, 'voted', voted)
}
}
},
setOradores (state, oradores) {
state.oradores = oradores
},
setMateria (state, materia) {
state.materia = materia
},
setResultado (state, resultado) {
state.resultado = resultado
},
setMessage (state, message) {
state.message = message
},
setMostrarVoto (state, mostrar_voto) {
state.mostrar_voto = mostrar_voto
},
setSessao (state, sessao) {
state.sessao = sessao
},
setTiposResultado (state, tipos) {
state.tipos_resultado = tipos
}
},
getters: {
totalVotos (state) {
const totals = [
{ tipo: 'Sim', total: 0 },
{ tipo: 'Não', total: 0 },
{ tipo: 'Abstenção', total: 0 },
{ tipo: 'Não Votou', total: 0 }
]
state.parlamentares.forEach(p => {
if (p.voto) {
const entry = totals.find(t => t.tipo === p.voto)
if (entry) entry.total++
}
})
return totals
},
numPresentes (state) {
return state.parlamentares.length
},
totalVotados (state) {
return state.parlamentares.filter(p => p.voto && p.voto !== 'Não Votou').length
}
}
})
const BACKOFF = 500
const PING_INTERVAL = 30000
const v = new Vue({ // eslint-disable-line
store,
delimiters: ['[[', ']]'],
el: '#votacao',
data () {
return {
votacao_aberta: true,
edit_votes: true,
disable_resultado: false,
resultado_selected: "",
observacoes: "",
error_message: "",
tipo_votacao: 2,
tipo_votacao_descricao: "Votação Nominal",
materia: "Projeto de Lei Ordinária nº 3 de 2025",
ementa: "Institui no Município de Pato Branco o Projeto Chá de Fralda Social. ",
parlamentares: [
{
"parlamentar_id": 197,
"nome_parlamentar": "Alexandre Zoche",
"filiacao": "PRD"
},
{
"parlamentar_id": 196,
"nome_parlamentar": "Anne Cristine Gomes da Silva Cavali",
"filiacao": "PSD"
},
{
"parlamentar_id": 194,
"nome_parlamentar": "Diogo Domingos Grando",
"filiacao": "PRD"
},
{
"parlamentar_id": 186,
"nome_parlamentar": "Eduardo Albani Dala Costa",
"filiacao": "Republicanos"
},
{
"parlamentar_id": 3,
"nome_parlamentar": "Fabricio Preis de Mello",
"filiacao": "PL"
},
{
"parlamentar_id": 6,
"nome_parlamentar": "Joecir Bernardi",
"filiacao": "PSD"
},
{
"parlamentar_id": 187,
"nome_parlamentar": "Lindomar Rodrigo Brandão",
"filiacao": "PP"
},
{
"parlamentar_id": 195,
"nome_parlamentar": "Rafael Foss",
"filiacao": "União"
},
{
"parlamentar_id": 11,
"nome_parlamentar": "Rodrigo José Correia",
"filiacao": "União"
},
{
"parlamentar_id": 192,
"nome_parlamentar": "Thania Maria Caminski Gehlen",
"filiacao": "PP"
}
],
//TODO: check if votos_parlamentares is null
votos_parlamentares: {
186: {
"voto": "Não",
"materia_id": 31919,
"parlamentar_id": 186,
"parlamentar_nome": "Eduardo Albani Dala Costa"
},
195: {
"voto": "Não",
"materia_id": 31919,
"parlamentar_id": 195,
"parlamentar_nome": "Rafael Foss"
},
196: {
"voto": "Não",
"materia_id": 31919,
"parlamentar_id": 196,
"parlamentar_nome": "Anne Cristine Gomes da Silva Cavali"
},
3: {
"voto": "Sim",
"materia_id": 31919,
"parlamentar_id": 3,
"parlamentar_nome": "Fabricio Preis de Mello"
},
11: {
"voto": "Não",
"materia_id": 31919,
"parlamentar_id": 11,
"parlamentar_nome": "Rodrigo José Correia"
},
194: {
"voto": "Não",
"materia_id": 31919,
"parlamentar_id": 194,
"parlamentar_nome": "Diogo Domingos Grando"
},
197: {
"voto": "Não",
"materia_id": 31919,
"parlamentar_id": 197,
"parlamentar_nome": "Alexandre Zoche"
},
6: {
"voto": "Não",
"materia_id": 31919,
"parlamentar_id": 6,
"parlamentar_nome": "Joecir Bernardi"
},
187: {
"voto": "Abstenção",
"materia_id": 31919,
"parlamentar_id": 187,
"parlamentar_nome": "Lindomar Rodrigo Brandão"
},
192: {
"voto": "Sim",
"materia_id": 31919,
"parlamentar_id": 192,
"parlamentar_nome": "Thania Maria Caminski Gehlen"
}
},
options: [
{ text: 'Sim', value: 'voto_sim' },
{ text: 'Não', value: 'voto_nao' },
{ text: 'Abstenção', value: 'abstencao' },
{ text: 'Não Votou', value: 'nao_votou' },
],
tipos_resultados: [
{
"id": 13,
"nome": "Aprovada a retirada de pauta"
},
{
"id": 10,
"nome": "Aprovada por dois terços"
},
{
"id": 2,
"nome": "Aprovada por maioria absoluta"
},
{
"id": 1,
"nome": "Aprovada por maioria simples - conforme o art. 37 do RI o presidente não vota"
},
{
"id": 8,
"nome": "Aprovada por maioria simples - conforme o art. 37 do RI o presidente votou pelo desempate"
},
{
"id": 15,
"nome": "Aprovada."
},
{
"id": 7,
"nome": "Empate - conforme o art. 37 do RI o presidente vota para desempate"
},
{
"id": 16,
"nome": "IMPROCEDENTE"
},
{
"id": 12,
"nome": "Leitura em Plenário"
},
{
"id": 17,
"nome": "PROCEDENTE"
},
{
"id": 11,
"nome": "Prejudicada"
},
{
"id": 5,
"nome": "Rejeitada"
},
{
"id": 14,
"nome": "Rejeitada a retirada de pauta"
},
{
"id": 9,
"nome": "Rejeitada por maioria simples - conforme o art. 37 do RI o presidente votou pelo desempate"
}
],
controllerId: null,
ws: null,
isOpen: false,
error: null,
pingTimer: null,
reconnectTimer: null,
error_message: '',
}
},
watch: {},
computed: {
total_votos() {
// TODO: use number index instead of string ("sim", "não") as keys.
var groupedVotes = Map.groupBy(Object.values(this.votos_parlamentares), ({ voto }) => voto )
// initialize total_votos
total_votos = [
{"tipo": "Sim", "total": 0},
{"tipo": "Não", "total": 0},
{"tipo": "Abstenção", "total": 0},
{"tipo": "Não Votou", total: 0}
]
for (const [key, value] of groupedVotes.entries()) {
const index = total_votos.findIndex(item => item.tipo === key);
total_votos[index].total = value.length
}
return total_votos
...mapState([
'sessao_aberta', 'painel_aberto', 'sessao',
'parlamentares', 'materia', 'resultado', 'message', 'mostrar_voto'
]),
},
mounted () {
console.log('Votacao app mounted!')
const el = this.$el
this.controllerId = el.dataset.controllerId || window.controllerId
console.log(`Votacao ControllerId: ${this.controllerId}`)
if (this.controllerId) {
this.connectWS()
} else {
this.error_message = 'Erro: controller_id não definido. Não é possível conectar ao WebSocket.'
console.error('No controllerId found — WebSocket will not connect.')
}
},
created () {},
methods: {
...mapMutations([
'sessaoStatus', 'painelStatus', 'setParlamentares',
'updateParlamentarVoto', 'setOradores', 'setMateria',
'setResultado', 'setMessage', 'setMostrarVoto', 'setSessao',
'setTiposResultado'
]),
methods: {},
wsURL () {
const proto = location.protocol === 'https:' ? 'wss' : 'ws'
return `${proto}://${location.host}/ws/painel/${this.controllerId}/`
},
mounted () {
console.log("Votacao app mounted!")
voteURL () {
return `/v2/painel/controller/${this.controllerId}/vote`
},
castVote ({ parlamentar_id, voto }) {
console.log(`Casting vote: parlamentar=${parlamentar_id}, voto=${voto}`)
axios.post(this.voteURL(), {
parlamentar_id: parlamentar_id,
voto: voto
}, {
headers: { 'Content-Type': 'application/json' }
})
.then(response => {
console.log('Vote cast successfully:', response.data)
})
.catch(error => {
console.error('Error casting vote:', error.response ? error.response.data : error)
this.error_message = 'Erro ao registrar voto. Tente novamente.'
})
},
cancelURL () {
return `/v2/painel/controller/${this.controllerId}/cancel`
},
closeURL () {
return `/v2/painel/controller/${this.controllerId}/close`
},
onCancelar () {
console.log('Votação cancelada')
axios.post(this.cancelURL(), {}, {
headers: { 'Content-Type': 'application/json' }
})
.then(response => {
console.log('Votação cancelada com sucesso:', response.data)
if (response.data.redirect_url) {
window.location.href = response.data.redirect_url
}
})
.catch(error => {
console.error('Erro ao cancelar votação:', error.response ? error.response.data : error)
const msg = error.response && error.response.data && error.response.data.message
? error.response.data.message
: 'Erro ao cancelar votação. Tente novamente.'
this.error_message = msg
})
},
onFechar (data) {
console.log('Fechar votação:', data)
if (!data.resultado_selected) {
this.error_message = 'Não é possível finalizar a votação sem nenhum resultado da votação.'
return
}
axios.post(this.closeURL(), {
resultado_id: data.resultado_selected,
observacoes: data.observacoes || ''
}, {
headers: { 'Content-Type': 'application/json' }
})
.then(response => {
console.log('Votação finalizada com sucesso:', response.data)
this.error_message = ''
if (response.data.redirect_url) {
window.location.href = response.data.redirect_url
}
})
.catch(error => {
console.error('Erro ao fechar votação:', error.response ? error.response.data : error)
const msg = error.response && error.response.data && error.response.data.message
? error.response.data.message
: 'Erro ao fechar votação. Tente novamente.'
this.error_message = msg
})
},
updateState (data) {
try {
this.sessaoStatus(data.sessao_aberta)
this.painelStatus(data.painel_aberto)
this.setMostrarVoto(data.mostrar_voto)
if (data.sessao) {
this.setSessao(data.sessao)
}
if (data.parlamentares) {
data.parlamentares.forEach(p => {
p.voto = p.voto || ''
})
this.setParlamentares(data.parlamentares)
}
if (data.message) {
this.setMessage(data.message)
}
if (data.oradores) {
this.setOradores(data.oradores)
}
if (data.materia) {
this.setMateria(data.materia)
if (data.materia.resultado) {
this.setResultado(data.materia.resultado)
// If there are existing votes, apply them to parlamentares
if (data.materia.resultado.votos_parlamentares) {
const votos = data.materia.resultado.votos_parlamentares
if (Array.isArray(votos)) {
// Array format: [{parlamentar_id: X, voto: "Sim"}, ...]
votos.forEach(v => {
if (v && v.parlamentar_id && v.voto) {
this.updateParlamentarVoto({
parlamentar_id: v.parlamentar_id,
voto: v.voto
})
}
})
} else if (typeof votos === 'object') {
// Object format from DB view: {"5": {"voto": "Sim", "parlamentar_id": 5, ...}, ...}
Object.keys(votos).forEach(key => {
const v = votos[key]
if (v && v.voto) {
this.updateParlamentarVoto({
parlamentar_id: parseInt(key),
voto: v.voto
})
}
})
}
}
} else {
this.setResultado({})
}
} else {
this.setMateria({})
this.setResultado({})
}
if (data.tipos_resultado) {
this.setTiposResultado(data.tipos_resultado)
}
} catch (e) {
console.error('Error updating state:', e)
}
},
handleVoteUpdate (data) {
console.log('Received vote update via WebSocket:', data)
this.updateParlamentarVoto({
parlamentar_id: data.parlamentar_id,
voto: data.voto
})
},
connectWS () {
const url = this.wsURL()
this.ws = new WebSocket(url)
this.ws.addEventListener('open', () => {
this.isOpen = true
this.error = null
this.error_message = ''
console.log(`✅ Votação WebSocket connected to ${url}`)
this.ws.send(JSON.stringify({ type: 'echo', message: 'Votacao client connected' }))
this.pingTimer = setInterval(() => {
this.ws.send(JSON.stringify({ type: 'ping', ts: Date.now() }))
}, PING_INTERVAL)
})
this.ws.addEventListener('message', (message) => {
try {
const data = JSON.parse(message.data)
console.debug('Votacao WS message:', data)
if (data.type === 'data') {
this.updateState(data)
} else if (data.type === 'vote.update') {
this.handleVoteUpdate(data)
} else if (data.type === 'echo') {
console.log('Votacao echo:', data)
} else if (data.type === 'ping') {
// ignore pings
}
} catch (e) {
console.error('Votacao WS parse error:', e)
}
})
this.ws.addEventListener('close', (e) => {
console.log('❌ Votação WebSocket closed:', e)
this.isOpen = false
this.reconnectTimer = setTimeout(() => this.connectWS(), BACKOFF)
})
this.ws.addEventListener('error', (e) => {
this.error = e
console.error('❌ Votação WebSocket error:', e)
})
},
closeWS () {
try {
this.ws && this.ws.close()
} catch (_) {
console.log('Error closing WS')
}
},
beforeDestroy () {
if (this.pingTimer) {
clearInterval(this.pingTimer)
}
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer)
}
this.closeWS()
}
}
})

47
frontend/src/components/painel/Cronometro.vue

@ -1,18 +1,19 @@
<template>
<div>
<audio
ref="player"
:src="audioSrc"
preload="auto"
></audio>
<span ref="time">{{ title }}: {{ formatTime(time) }}<br/></span>
</div>
<tr :id="'row_' + id" v-show="visible">
<td :class="['fs-3', titleColorClass]">{{ title }}</td>
<td class="text-end">
<audio ref="player" :src="audioSrc" preload="auto"></audio>
<span :id="'cronometro_' + id"
:class="['fw-bold', 'font-monospace', 'fs-1', titleColorClass]"
ref="time">{{ formatTime(time) }}</span>
</td>
</tr>
</template>
<script>
export default {
name: 'Cronometro',
props: ['id', 'title'],
props: ['id', 'title', 'visible', 'colorClass'],
data() {
return {
time: 300,
@ -22,17 +23,21 @@ export default {
audioSrc: require('@/assets/audio/ring.mp3'),
}
},
mounted() {
console.log('Cronometro mounted');
console.log(this.audioSrc);
this.$emit('child-mounted'); // Emit a custom event
},
computed: {
titleColorClass() {
return this.colorClass || '';
}
},
mounted() {
console.log('Cronometro mounted');
this.$emit('child-mounted');
},
methods: {
changeFontSize(value) {
const el = this.$refs.time;
if (!el) return;
let fontSize = window.getComputedStyle(el).fontSize;
fontSize = parseFloat(fontSize); // safely convert "16px" 16
fontSize = parseFloat(fontSize);
el.style.fontSize = (fontSize + value) + 'px';
},
handleStartStop() {
@ -42,8 +47,7 @@ export default {
this.intervalId = setInterval(() => {
if (this.time > 0) {
this.time--;
// play buzz at 00:00:30
if (this.time == 30000) {
if (this.time == 30) {
this.playSound();
}
} else {
@ -66,19 +70,14 @@ export default {
playSound() {
const audio = this.$refs.player
if (!audio) return
const playPromise = audio.play()
audio.play()
},
formatTime(seconds) {
const hrs = Math.floor(seconds / 3600);
const mins = Math.floor((seconds % 3600) / 60);
const secs = seconds % 60;
if (hrs > 0) {
return `${hrs.toString().padStart(2, '0')}:${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
}
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
return `${hrs.toString().padStart(2, '0')}:${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
}
},
watch: {

89
frontend/src/components/painel/CronometroList.vue

@ -1,16 +1,50 @@
.<template>
<div class="col-md-6 text-left painel" v-if="canRender">
<div class="d-flex align-items-left justify-content-left mb-2">
<h2 class="text-subtitle mb-0">Cronômetros</h2>
</div>
<div class="text-value" id="box_cronometros">
<Cronometro v-for="(title, idx) in titles" :key="idx" :title="title" :ref="'childRef_' + idx" @child-mounted="handleChildMounted"/>
<template>
<div class="painel d-flex flex-column" v-if="canRender">
<div id="box_cronometros" class="w-100">
<h2 class="text-center text-subtitle mb-3">Cronômetro</h2>
<div class="d-flex align-items-center justify-content-center" style="height: 80px">
<table class="table-custom w-100 mb-0">
<tbody>
<Cronometro
ref="childRef_0"
id="discurso"
title="Discurso"
:visible="visibleCronometro === 'discurso'"
color-class=""
@child-mounted="handleChildMounted"
/>
<Cronometro
ref="childRef_1"
id="aparte"
title="Aparte"
:visible="visibleCronometro === 'aparte'"
color-class="text-warning"
@child-mounted="handleChildMounted"
/>
<Cronometro
ref="childRef_2"
id="ordem"
title="Questão de Ordem"
:visible="visibleCronometro === 'ordem'"
color-class="text-info"
@child-mounted="handleChildMounted"
/>
<Cronometro
ref="childRef_3"
id="consideracoes"
title="Consid. Finais"
:visible="visibleCronometro === 'consideracoes'"
color-class=""
@child-mounted="handleChildMounted"
/>
</tbody>
</table>
</div>
</div>
</div>
</template>
<script>
import { ref, onMounted } from 'vue';
import { mapState } from 'vuex';
import Cronometro from './Cronometro.vue';
@ -21,8 +55,7 @@
},
data() {
return {
titles: ["Discurso", "Aparte", "Questão de Ordem", "Considerações Finais"],
itemRefs: ref([]), // An array to store the refs
visibleCronometro: 'discurso',
}
},
mounted() {
@ -37,15 +70,39 @@
methods: {
handleStartStop() {
console.log("start/stop stopwatch");
//console.log(this.$refs.itemRefs);
},
handleChildMounted() {
console.log('ChildComponent has finished mounting in the parent!');
// Perform actions in the parent that depend on the child being fully mounted
const childId = 0;
const childComponent = this.$refs['childRef_' + childId];
childComponent[0].handleStartStop();
console.log('Cronometro child mounted');
},
updateVisibility() {
// Show priority: ordem > aparte > consideracoes > discurso
const refs = [
{ key: 'ordem', ref: 'childRef_2' },
{ key: 'aparte', ref: 'childRef_1' },
{ key: 'consideracoes', ref: 'childRef_3' },
{ key: 'discurso', ref: 'childRef_0' },
];
for (const item of refs) {
const comp = this.$refs[item.ref];
if (comp && comp.isRunning) {
this.visibleCronometro = item.key;
return;
}
}
// Default to discurso if none running
this.visibleCronometro = 'discurso';
}
},
};
</script>
<style scoped>
.table-custom {
color: #ddd;
}
::v-deep .table-custom tbody td {
padding: 8px;
font-size: 1.1rem;
}
</style>

62
frontend/src/components/painel/PainelFooter.vue

@ -0,0 +1,62 @@
<template>
<div class="painel position-relative bottom-0 hora-brasao">
<div class="row justify-content-center align-items-center">
<div class="col-4 text-center" id="logo-container" v-if="brasao">
<img :src="brasao" id="logo-painel" class="logo-painel" alt="Brasão" />
</div>
<div :class="['text-center', brasao ? 'col-4' : 'col-12 mt-5']" id="relogio-container">
<span class="fs-1 fw-bold" id="relogio">{{ relogio }}</span>
</div>
</div>
</div>
</template>
<script>
import { mapState } from 'vuex';
export default {
name: 'PainelFooter',
props: {
},
data() {
return {
brasao: "",
data_atual: "",
relogio: "",
currentDateTimeId: null,
}
},
methods: {
startCurrentDateTime() {
this.data_atual = moment().utcOffset(-3).format("DD/MM/YY");
this.currentDateTimeId = setInterval(() => {
this.relogio = moment.utc().utcOffset(-3).format("HH:mm:ss");
}, 500);
},
},
beforeDestroy() {
if (this.currentDateTimeId) {
clearInterval(this.currentDateTimeId);
console.log('currentDateTimeId Interval cleared.');
}
},
computed: {
...mapState(["sessao_aberta", "painel_aberto"])
},
mounted() {
console.log('PainelFooter component mounted');
this.startCurrentDateTime();
}
}
</script>
<style scoped>
.logo-painel {
max-height: 150px;
width: auto;
}
.hora-brasao {
border-top: 1px solid #333;
}
</style>

61
frontend/src/components/painel/PainelHeader.vue

@ -1,29 +1,7 @@
<template v-if="sessao_aberta">
<template>
<div>
<div class="d-flex justify-content-center">
<h1 id="sessao_plenaria" class="title text-title">{{ sessao_plenaria }} </h1>
</div>
<div class="row ">
<div class="col text-center">
<span id="sessao_plenaria_data" class="text-value"> Data Início: {{ sessao_plenaria_data }} </span>
</div>
<div class="col text-center">
<span id="sessao_plenaria_hora_inicio" class="text-value"> Hora Início: {{ sessao_plenaria_hora_inicio }} </span>
</div>
</div>
<div class="row justify-content-center">
<div class="col-1">
<img v-bind:src="brasao" id="logo-painel" class="logo-painel" alt=""/>
</div>
</div>
<div class="row justify-content-center">
<h2 class="text-danger"><span id="message">{{ message }}</span></h2>
</div>
<div class="row">
<div class="col text-center"><span class="text-value data-hora" id="date">{{ data_atual }}</span></div>
<div class="col text-center"><span class="text-value data-hora" id="relogio">{{ relogio }}</span></div>
</div>
<h1 id="sessao_plenaria" class="text-title fw-bold text-uppercase">{{ sessao_plenaria }}</h1>
<h2 class="text-danger" v-if="message"><span id="message">{{ message }}</span></h2>
</div>
</template>
@ -36,37 +14,24 @@ export default {
data() {
return {
sessao_plenaria: "Sessao Plenaria Teste",
sessao_plenaria_data: "22/10/2025",
sessao_plenaria_hora_inicio: "13:30",
brasao: "",
data_atual: "",
relogio: "",
currentDateTimeId: null, // stores the id returned by setInterval()
sessao_plenaria: "",
}
},
methods: {
startCurrentDateTime() {
this.data_atual = moment().utcOffset(-3).format("DD/MM/YY"); // RECOVER UTC OFFSET!!!!
this.currentDateTimeId = setInterval(() => {
this.relogio = moment.utc().utcOffset(-3).format("HH:mm:ss");
}, 500);
},
beforeDestroy() {
// Clear the interval before the component is destroyed
if (this.currentDateTimeId) {
clearInterval(this.currentDateTimeId);
console.log('currentDateTimeId Interval cleared.');
}
},
},
computed: {
...mapState(["sessao_aberta", "painel_aberto", "message", "mostrar_voto"])
...mapState(["sessao_aberta", "message"])
},
mounted() {
console.log('PainelHeader component mounted');
this.startCurrentDateTime();
}
}
</script>
</script>
<style scoped>
.text-title {
color: var(--text-highlight);
letter-spacing: 1px;
}
</style>

36
frontend/src/components/painel/PainelMateria.vue

@ -1,11 +1,21 @@
<template>
<div class="col-md-6 text-center painel" id="obs_materia_div" v-if="canRender">
<h2 class="text-subtitle" id="mat_em_votacao">Matéria em Votação</h2>
<span id="materia_legislativa_texto" class="text-value">{{ materia.texto }}</span>
<br>
<span id="materia_legislativa_ementa" class="text-value">{{ materia.ementa }} </span>
<br>
<span id="observacao_materia" class="text-value">{{ materia.observacao }}</span>
<div class="painel p-3 d-flex flex-column" id="obs_materia_div" v-if="canRender">
<div class="flex-grow-1">
<h2 id="mat_em_votacao" class="text-subtitle text-center mb-3 pb-1">
Matéria em Votação
</h2>
<span id="materia_legislativa_texto"
class="fs-4 text-white d-block mb-1">{{ materia.texto }}</span>
<span id="materia_legislativa_ementa"
class="fs-6 fst-italic opacity-75 text-white">{{ materia.ementa }}</span>
<div class="mt-2 text-warning" style="font-size: 0.9rem">
<span id="observacao_materia">{{ materia.observacao }}</span>
</div>
<div id="resultado_votacao"
class="text-title mt-auto text-center d-block fs-2">{{ materia.resultado_votacao }}</div>
</div>
</div>
</template>
@ -15,13 +25,6 @@ export default {
name: 'PainelMateria',
data() {
return {
/*
materia: {
texto: '',
ementa: '',
observacao: '',
}
*/
};
},
mounted() {
@ -38,5 +41,8 @@ export default {
</script>
<style scoped>
/* Optional styling */
.text-title {
color: var(--text-highlight);
letter-spacing: 1px;
}
</style>

23
frontend/src/components/painel/PainelOradores.vue

@ -1,11 +1,15 @@
<template>
<div class="col-md-6 text-center painel" id="aparecer_oradores" v-if="canRender">
<div class="text-center painel" id="aparecer_oradores" v-if="canRender && oradores.length > 0">
<h2 class="text-subtitle">Oradores</h2>
<table id="oradores_list">
<tr v-for="o in oradores" :key="o.ordem_pronunciamento"><td style="padding-right:20px; color:white">
{{ o.ordem_pronunciamento }}º &nbsp {{ o.nome_parlamentar }}</td>
</tr>
</table>
<div id="orador">
<table id="oradores_list">
<tr v-for="o in oradores" :key="o.ordem_pronunciamento">
<td style="padding-right:20px; color:white">
{{ o.ordem_pronunciamento }}º - {{ o.nome_parlamentar }}
</td>
</tr>
</table>
</div>
</div>
</template>
@ -15,7 +19,6 @@ export default {
name: 'PainelOradores',
data() {
return {
// oradores: [],
};
},
mounted() {
@ -32,5 +35,9 @@ export default {
</script>
<style scoped>
/* Optional styling */
#oradores_list tr td {
font-size: 1.2rem;
padding: 5px;
text-align: start;
}
</style>

158
frontend/src/components/painel/PainelParlamentares.vue

@ -1,26 +1,40 @@
<template>
<div class="col-md-4" v-if="canRender">
<div class="text-center painel">
<h2 class="text-subtitle">Parlamentares</h2>
<span id="parlamentares" class="text-value text-center"></span>
<table id="parlamentares_list">
<tr v-for="p in parlamentares" :key="p.parlamentar_id" class="text-value text-center">
<td style="padding-right:20px; color:yellow" > {{ p.nome_parlamentar }}</td>
<td style="padding-right:20px; color:yellow"> {{ p.filiacao }}</td>
<td style="padding-right:20px; color:yellow" v-if="mostrar_voto">{{ p.voto }}</td>
</tr>
</table>
<div id="parlamentares">
<div class="container-fluid" id="parlamentares_container" v-if="canRender">
<div class="parlamentares-grid" id="parlamentares_row">
<div v-for="p in parlamentares"
:key="p.parlamentar_id"
:class="['parlamentar-card', 'mt-3', votoClass(p)]">
<div class="voto-faixa" v-if="votoText(p)">
<p>{{ votoText(p) }}</p>
</div>
<div class="foto-container">
<img :src="p.fotografia || defaultAvatar" :alt="p.nome_parlamentar">
</div>
<div class="mt-2 text-center">
<div class="nome">{{ p.nome_parlamentar }}</div>
<div class="partido">{{ p.filiacao }}</div>
</div>
</div>
</div>
</div>
<span class="text-white" v-else>
<center>A listagem de parlamentares aparecerá quando o painel estiver aberto.</center>
</span>
</div>
</template>
<script>
import { mapState } from 'vuex';
import defaultAvatarImg from '@/assets/img/avatar.png';
export default {
name: 'PainelParlamentares',
data() {
return {
//parlamentares: [],
defaultAvatar: defaultAvatarImg,
};
},
mounted() {
@ -33,9 +47,127 @@ export default {
},
...mapState(["painel_aberto", "sessao_aberta", "parlamentares", "mostrar_voto"])
},
methods: {
votoClass(parlamentar) {
const voto = parlamentar.voto;
if (!voto) return '';
if (this.mostrar_voto) {
if (voto === 'Sim') return 'voto-sim';
if (voto === 'Não') return 'voto-nao';
if (voto === 'Abstenção') return 'voto-abstencao';
} else {
if (voto === 'Sim' || voto === 'Não' || voto === 'Abstenção' || voto === 'Voto Informado') {
return 'voto-informado';
}
}
return '';
},
votoText(parlamentar) {
const voto = parlamentar.voto;
if (!voto) return '';
if (this.mostrar_voto) {
if (voto === 'Sim') return 'S';
if (voto === 'Não') return 'N';
if (voto === 'Abstenção') return 'A';
} else {
if (voto === 'Sim' || voto === 'Não' || voto === 'Abstenção' || voto === 'Voto Informado') {
return 'I';
}
}
return '';
},
},
};
</script>
<style scoped>
/* Optional styling */
.parlamentares-grid {
display: grid;
grid-template-columns: repeat(auto-fill, 190px);
width: 100%;
}
.parlamentar-card {
width: 150px;
border-radius: 3px;
overflow: hidden;
position: relative;
}
.foto-container {
border-radius: 3px;
overflow: hidden;
height: 150px;
}
.parlamentar-card img {
width: 100%;
height: 150px;
display: block;
object-fit: fill;
}
.parlamentar-card .nome {
font-weight: bold;
font-size: 0.95rem;
color: #fff;
line-height: 1.1;
text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.8);
margin-bottom: 2px;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.parlamentar-card .partido {
font-size: 0.8rem;
color: #ddd;
font-weight: 600;
text-transform: uppercase;
text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.8);
}
.voto-faixa {
position: absolute;
width: 90px;
height: 90px;
top: -45px;
right: -45px;
transform: rotate(45deg);
display: flex;
align-items: flex-end;
justify-content: center;
color: #fff;
font-weight: 700;
box-shadow: -2px 2px 5px rgba(0, 0, 0, 0.2);
z-index: 10;
line-height: 1;
}
.voto-faixa p {
transform: rotate(-45deg);
font-size: 2rem;
text-transform: uppercase;
}
.voto-sim .voto-faixa {
background-color: #27ae60;
}
.voto-nao .voto-faixa {
background-color: #c0392b;
}
.voto-abstencao .voto-faixa {
background-color: #f39c12;
}
.voto-informado .voto-faixa {
background-color: #d35400;
}
.voto-ausente .voto-faixa {
background-color: #7f8c8d;
}
</style>

123
frontend/src/components/painel/PainelResultado.vue

@ -1,23 +1,30 @@
<template>
<div class="col-md-6 text-left painel" id="resultado_votacao_div" v-if="canRender">
<div class="d-flex align-items-left justify-content-left mb-2">
<h2 class="text-subtitle mb-0">Resultado</h2>
<button class="btn btn-sm btn-secondary ms-2" v-on:click="changeFontSize(-1)">
A-
</button>
<button class="btn btn-sm btn-secondary ms-2" v-on:click="changeFontSize(1)">
A+
</button>
</div>
<div ref="votacao" id="box_votacao">
<div id="votacao" class="text-value">
<li>Sim: {{ resultado.numero_votos.votos_sim }}</li>
<li>Não: {{ resultado.numero_votos.votos_nao }}</li>
<li>Abstenções: {{ resultado.numero_votos.abstencoes }}</li>
<li>Presentes: {{ resultado.numero_votos.num_presentes }}</li>
<li>Total votos: {{ resultado.numero_votos.total_votos }}</li>
</div>
<div id="resultado_votacao" class="text-title">{{ resultado.resultado_votacao }}</div>
<div class="painel p-3 d-flex flex-column" id="resultado_votacao_div" v-if="canRender">
<div id="box_votacao" class="w-100">
<table class="table-custom w-100 mb-0" id="tabela_resultados">
<tbody id="votacao">
<tr>
<td>Presentes</td>
<td>{{ numPresentes }}</td>
</tr>
<tr>
<td>Sim</td>
<td class="table-sim">{{ votosSim }}</td>
</tr>
<tr>
<td>Não</td>
<td class="table-nao">{{ votosNao }}</td>
</tr>
<tr>
<td>Abstenções</td>
<td class="table-abstencao">{{ votosAbstencao }}</td>
</tr>
<tr>
<td><strong>Total votos</strong></td>
<td class="table-total"><strong>{{ totalVotos }}</strong></td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
@ -28,18 +35,6 @@ export default {
name: 'PainelResultado',
data() {
return {
/*
resultado: {
numero_votos: {
votos_sim: 0,
votos_nao: 0,
abstencoes: 0,
total_votos: 0,
num_presentes: 0,
},
resultado_votacao: '',
}
*/
};
},
mounted() {
@ -50,20 +45,64 @@ export default {
canRender () {
return this.sessao_aberta && this.painel_aberto;
},
...mapState(["painel_aberto", "sessao_aberta", "resultado"])
},
methods: {
changeFontSize(value) {
const el = this.$refs.votacao;
if (!el) return;
let fontSize = window.getComputedStyle(el).fontSize;
fontSize = parseFloat(fontSize); // safely convert "16px" 16
el.style.fontSize = (fontSize + value) + 'px';
...mapState(["painel_aberto", "sessao_aberta", "resultado", "parlamentares"]),
numPresentes() {
if (this.resultado && this.resultado.numero_votos && typeof this.resultado.numero_votos.num_presentes !== 'undefined' && this.resultado.numero_votos.num_presentes !== null) {
return this.resultado.numero_votos.num_presentes;
}
return this.parlamentares ? this.parlamentares.length : 0;
},
votosSim() {
if (this.resultado && this.resultado.numero_votos && typeof this.resultado.numero_votos.votos_sim !== 'undefined' && this.resultado.numero_votos.votos_sim !== null && this.resultado.numero_votos.votos_sim > 0) {
return this.resultado.numero_votos.votos_sim;
}
return this.parlamentares ? this.parlamentares.filter(p => p.voto === 'Sim').length : 0;
},
}
votosNao() {
if (this.resultado && this.resultado.numero_votos && typeof this.resultado.numero_votos.votos_nao !== 'undefined' && this.resultado.numero_votos.votos_nao !== null && this.resultado.numero_votos.votos_nao > 0) {
return this.resultado.numero_votos.votos_nao;
}
return this.parlamentares ? this.parlamentares.filter(p => p.voto === 'Não').length : 0;
},
votosAbstencao() {
if (this.resultado && this.resultado.numero_votos && typeof this.resultado.numero_votos.abstencoes !== 'undefined' && this.resultado.numero_votos.abstencoes !== null && this.resultado.numero_votos.abstencoes > 0) {
return this.resultado.numero_votos.abstencoes;
}
return this.parlamentares ? this.parlamentares.filter(p => p.voto === 'Abstenção').length : 0;
},
totalVotos() {
if (this.resultado && this.resultado.numero_votos && typeof this.resultado.numero_votos.total_votos !== 'undefined' && this.resultado.numero_votos.total_votos !== null && this.resultado.numero_votos.total_votos > 0) {
return this.resultado.numero_votos.total_votos;
}
return this.votosSim + this.votosNao + this.votosAbstencao;
}
},
};
</script>
<style scoped>
/* Optional styling */
.table-custom {
color: #ddd;
}
.table-custom tbody td {
padding: 8px;
font-size: 1.1rem;
}
.table-sim {
color: #27ae60;
}
.table-nao {
color: #c0392b;
}
.table-abstencao {
color: #f39c12;
}
.table-total {
color: #194BFA;
}
</style>

25
frontend/src/components/votacao/VotacaoMateria.vue

@ -0,0 +1,25 @@
<template>
<div v-if="materia && materia.texto">
<div>
Matéria: {{ materia.texto }}
<br />
Ementa: {{ materia.ementa }}
</div>
</div>
<div v-else class="alert alert-info">
Nenhuma matéria aberta para votação.
</div>
</template>
<script>
import { mapState } from 'vuex';
export default {
name: 'VotacaoMateria',
computed: {
...mapState(['materia'])
},
mounted() {
console.log('VotacaoMateria mounted');
}
};
</script>

76
frontend/src/components/votacao/VotacaoNominal.vue

@ -1,11 +1,73 @@
<template>
<div class="VotacaoNominal">
<pre v-text="$attrs"/>
</div>
<fieldset>
<legend>Votação Nominal</legend>
<div v-if="error_message" class="alert alert-danger">
{{ error_message }}
</div>
<votacao-materia></votacao-materia>
<br />
<votacao-votos @cast-vote="onCastVote" ref="votos"></votacao-votos>
<votacao-resultado ref="resultado"></votacao-resultado>
<votacao-observacoes
:observacoes.sync="observacoes"
:resultado-selected.sync="resultado_selected"
:disabled="disable_resultado"
@cancelar="onCancelar"
@fechar="onFechar"
ref="observacoesComp">
</votacao-observacoes>
</fieldset>
</template>
<script>
export default {
name: 'Votacaonominal'
};
</script>
import { mapState } from 'vuex';
export default {
name: 'VotacaoNominal',
props: {
isOpen: {
type: Boolean,
default: false
},
errorMessage: {
type: String,
default: ''
}
},
data() {
return {
edit_votes: true,
disable_resultado: false,
resultado_selected: '',
observacoes: '',
}
},
computed: {
...mapState(['parlamentares', 'materia']),
error_message() {
return this.errorMessage;
}
},
methods: {
onCastVote({ parlamentar_id, voto }) {
this.$emit('cast-vote', { parlamentar_id, voto });
},
onCancelar() {
this.$emit('cancelar');
},
onFechar() {
this.$emit('fechar', {
resultado_selected: this.resultado_selected,
observacoes: this.observacoes
});
}
},
mounted() {
console.log('VotacaoNominal mounted');
}
};
</script>

84
frontend/src/components/votacao/VotacaoObservacoes.vue

@ -0,0 +1,84 @@
<template>
<div v-if="parlamentares && parlamentares.length > 0">
<div class="row">
<div class="col-md-12">
<label for="resultado_votacao"><strong>Resultado da Votação</strong></label>
<select v-model="resultadoSelecionado" id="resultado_votacao" class="select form-control" :disabled="disabled">
<option value="">---------</option>
<option v-for="tipo in tipos_resultado"
:key="tipo.id"
:value="tipo.id">
{{ tipo.nome }}
</option>
</select>
</div>
</div>
<br />
<div class="row">
<div class="col-md-12">
Observações<br/>
<textarea id="observacao" v-model="textoObservacoes" style="width:100%;" rows="7"></textarea>
</div>
</div>
<br /><br />
<div class="row">
<div class="col-md-12">
<div class="form-group row justify-content-between">
<input type="button" id="cancelar-votacao" value="Cancelar Votação"
class="btn btn-warning" @click="$emit('cancelar')" />
<input type="button" id="salvar-votacao" value="Fechar Votação"
class="btn btn-primary" @click="$emit('fechar')" />
</div>
</div>
</div>
</div>
</template>
<script>
import { mapState } from 'vuex';
export default {
name: 'VotacaoObservacoes',
props: {
disabled: {
type: Boolean,
default: false
},
observacoes: {
type: String,
default: ''
},
resultadoSelected: {
type: [String, Number],
default: ''
}
},
data() {
return {
textoObservacoes: this.observacoes,
resultadoSelecionado: this.resultadoSelected,
}
},
watch: {
observacoes(val) {
this.textoObservacoes = val;
},
textoObservacoes(val) {
this.$emit('update:observacoes', val);
},
resultadoSelected(val) {
this.resultadoSelecionado = val;
},
resultadoSelecionado(val) {
this.$emit('update:resultadoSelected', val);
}
},
computed: {
...mapState(['parlamentares', 'tipos_resultado'])
},
mounted() {
console.log('VotacaoObservacoes mounted');
}
};
</script>

49
frontend/src/components/votacao/VotacaoResultado.vue

@ -0,0 +1,49 @@
<template>
<div v-if="parlamentares && parlamentares.length > 0">
<legend>Situação da Votação:</legend>
<div id="soma_votos">
<div class="row">
<div class="col-md-12">Sim: {{ votosSim }}</div>
</div>
<div class="row">
<div class="col-md-12">Não: {{ votosNao }}</div>
</div>
<div class="row">
<div class="col-md-12">Abstenções: {{ votosAbstencao }}</div>
</div>
<div class="row">
<div class="col-md-12">Ainda não votaram: {{ naoVotou }}</div>
</div>
</div>
</div>
</template>
<script>
import { mapGetters, mapState } from 'vuex';
export default {
name: 'VotacaoResultado',
computed: {
...mapState(['parlamentares']),
...mapGetters(['totalVotos']),
votosSim() {
const entry = this.totalVotos.find(t => t.tipo === 'Sim');
return entry ? entry.total : 0;
},
votosNao() {
const entry = this.totalVotos.find(t => t.tipo === 'Não');
return entry ? entry.total : 0;
},
votosAbstencao() {
const entry = this.totalVotos.find(t => t.tipo === 'Abstenção');
return entry ? entry.total : 0;
},
naoVotou() {
const entry = this.totalVotos.find(t => t.tipo === 'Não Votou');
return entry ? entry.total : 0;
}
},
mounted() {
console.log('VotacaoResultado mounted');
}
};
</script>

44
frontend/src/components/votacao/VotacaoVotos.vue

@ -0,0 +1,44 @@
<template>
<fieldset class="form-group" v-if="parlamentares && parlamentares.length > 0">
<legend>Votos</legend>
<div class="row">
<template v-for="p in parlamentares">
<div class="col-md-4" id="styleparlamentar" :key="'nome_' + p.parlamentar_id">
{{ p.nome_parlamentar }}
</div>
<div class="col-md-5" :key="'voto_' + p.parlamentar_id">
<select class="form-control"
:name="'voto_parlamentar_' + p.parlamentar_id"
:value="p.voto || 'Não Votou'"
@change="onVoteChange(p.parlamentar_id, $event.target.value)">
<option value="Não Votou">Não Votou</option>
<option value="Sim">Sim</option>
<option value="Não">Não</option>
<option value="Abstenção">Abstenção</option>
</select>
</div>
</template>
</div>
</fieldset>
<div class="alert alert-info alert-dismissible" role="alert" v-else>
<div>Não existe nenhum parlamentar presente para que a votação ocorra.</div>
</div>
</template>
<script>
import { mapState } from 'vuex';
export default {
name: 'VotacaoVotos',
computed: {
...mapState(['parlamentares'])
},
methods: {
onVoteChange(parlamentar_id, voto) {
this.$emit('cast-vote', { parlamentar_id, voto });
}
},
mounted() {
console.log('VotacaoVotos mounted');
}
};
</script>

66
sapl/painel/consumers.py

@ -12,16 +12,28 @@ from sapl.sessao.models import SessaoPlenaria, SessaoPresencasView, \
def get_materia_votacao(votacao):
if not votacao:
return {
"materia_id": "",
"texto": "",
"ementa": "",
"resultado": {
"resultado_votacao": "",
"resultado": "",
"numero_votos": {},
"votos_parlamentares": [],
},
}
materia = votacao.materia
return {
# TOO UGLY! FIX THIS if-else
"materia_id": votacao.materia.id if votacao and votacao.materia else "",
"texto": str(votacao.materia) if votacao and votacao.materia else "",
"ementa": votacao.materia.ementa if votacao and votacao.materia and votacao.materia.ementa else "",
"materia_id": materia.id if materia else "",
"texto": str(materia) if materia else "",
"ementa": materia.ementa if materia and materia.ementa else "",
"resultado": {
"resultado_votacao": votacao.resultado_votacao if votacao and votacao.resultado_votacao else "",
"resultado": votacao.resultado if votacao and votacao.resultado else "",
"numero_votos": votacao.numero_votos if votacao and votacao.numero_votos else {},
"votos_parlamentares": votacao.votos_parlamentares if votacao and votacao.votos_parlamentares else [],
"resultado_votacao": votacao.resultado_votacao or "",
"resultado": votacao.resultado or "",
"numero_votos": votacao.numero_votos or {},
"votos_parlamentares": votacao.votos_parlamentares or [],
},
}
@ -65,6 +77,29 @@ def get_dados_painel(sessao_plenaria_id: int) -> dict:
'nome_parlamentar',
'filiacao', )
parlamentares = [dict(zip(['parlamentar_id', 'nome_parlamentar', 'filiacao'], p)) for p in presentes]
# Adicionar fotografia dos parlamentares
from sapl.parlamentares.models import Parlamentar
from image_cropping.utils import get_backend
parlamentar_ids = [p['parlamentar_id'] for p in parlamentares]
parlamentares_db = {p.id: p for p in Parlamentar.objects.filter(id__in=parlamentar_ids)}
for p in parlamentares:
par = parlamentares_db.get(p['parlamentar_id'])
if par and par.fotografia:
try:
p['fotografia'] = get_backend().get_thumbnail_url(
par.fotografia,
{
'size': (128, 128),
'box': par.cropping,
'crop': True,
'detail': True,
}
)
except Exception:
p['fotografia'] = None
else:
p['fotografia'] = None
if materia_votacao and materia_votacao.numero_votos:
materia_votacao.numero_votos.update({"num_presentes": len(parlamentares)})
@ -84,6 +119,12 @@ def get_dados_painel(sessao_plenaria_id: int) -> dict:
"elapsed_ms": 5320
}
# Tipos de Resultado de Votação (for the resultado dropdown)
from sapl.sessao.models import TipoResultadoVotacao
tipos_resultado = list(
TipoResultadoVotacao.objects.all().values('id', 'nome')
)
dados_sessao = {
"type": "data",
"sessao_aberta": sessao.iniciada and not sessao.finalizada,
@ -103,6 +144,7 @@ def get_dados_painel(sessao_plenaria_id: int) -> dict:
"parlamentares": parlamentares,
"oradores": oradores,
"materia": get_materia_votacao(materia_votacao),
"tipos_resultado": tipos_resultado,
"stopwatch": [stopwatch], # TODO: array of stopwatches
}
@ -170,11 +212,15 @@ class PainelConsumer(AsyncJsonWebsocketConsumer):
return
await self.send_json({"type": "error", "message": "Misformed message"})
return
async def stopwatch_update(self, event):
await self.send_json(event)
return
async def vote_update(self, event):
"""Broadcast vote updates to all connected clients (painel + votacao).
Called automatically by Channels when group_send is invoked with type='vote.update'.
"""
await self.send_json(event)
return
@database_sync_to_async
def user_can_view(self, user_id, controller_id) -> bool:
# Replace with your ACL check (ORM must be in a sync wrapper)

11
sapl/painel/urls.py

@ -3,8 +3,9 @@ from django.conf.urls import url
from .apps import AppConfig
from .views import (cronometro_painel, get_dados_painel, painel_mensagem_view,
painel_parlamentar_view, painel_view, painel_votacao_view,
switch_painel, verifica_painel, votante_view, websocket_view, stopwatch_controller)
switch_painel, verifica_painel, votante_view, websocket_view,
stopwatch_controller, vote_controller, cancel_voting,
close_voting)
from django.urls import path
app_name = AppConfig.name
@ -30,4 +31,10 @@ urlpatterns = [
path("v2/painel/<int:controller_id>", websocket_view, name='painel_websocket'),
path("v2/painel/controller/<int:controller_id>/stopwatch",
stopwatch_controller, name='painel_controller'),
path("v2/painel/controller/<int:controller_id>/vote",
vote_controller, name='vote_controller'),
path("v2/painel/controller/<int:controller_id>/cancel",
cancel_voting, name='cancel_voting'),
path("v2/painel/controller/<int:controller_id>/close",
close_voting, name='close_voting'),
]

289
sapl/painel/views.py

@ -13,6 +13,7 @@ from django.shortcuts import render
from django.utils import timezone
from django.utils.translation import ugettext_lazy as _
from sapl import settings
from sapl.base.models import AppConfig as ConfiguracoesAplicacao
from sapl.base.models import CasaLegislativa
from sapl.crud.base import Crud
@ -23,6 +24,7 @@ from sapl.sessao.models import (ExpedienteMateria, OradorExpediente, OrdemDia,
SessaoPlenaria, SessaoPlenariaPresenca,
VotoParlamentar, RegistroLeitura)
from sapl.utils import filiacao_data, get_client_ip, sort_lista_chave
from image_cropping.utils import get_backend
from .models import Cronometro
@ -402,10 +404,24 @@ def get_presentes(pk, response, materia):
else:
partido = filiacao
if p.parlamentar.fotografia:
thumbnail_url = get_backend().get_thumbnail_url(
p.parlamentar.fotografia,
{
'size': (128, 128),
'box': p.parlamentar.cropping,
'crop': True,
'detail': True,
}
)
else:
thumbnail_url = False
presentes_list.append(
{'id': p.id,
'parlamentar_id': p.parlamentar.id,
'nome': p.parlamentar.nome_parlamentar,
'fotografia':thumbnail_url,
'partido': partido,
'voto': ''
})
@ -691,6 +707,279 @@ def stopwatch_controller(request, controller_id):
async_to_sync(layer.group_send)(group, notification)
return JsonResponse(notification)
VALID_VOTE_VALUES = ["Sim", "Não", "Abstenção", "Não Votou"]
@user_passes_test(check_permission)
def vote_controller(request, controller_id):
"""
HTTP endpoint to cast/update a vote and broadcast to all connected WebSocket clients.
Follows the same pattern as stopwatch_controller.
POST /v2/painel/controller/<sessao_id>/vote
Body (form-encoded or JSON):
parlamentar_id: int
voto: str ("Sim", "Não", "Abstenção", "Não Votou")
"""
from asgiref.sync import async_to_sync
from channels.layers import get_channel_layer
logger = logging.getLogger(__name__)
if request.method != 'POST':
return JsonResponse({"type": "error", "message": "Only POST allowed"}, status=405)
# Parse body: support both form-encoded and JSON
if request.content_type and 'json' in request.content_type:
try:
body = json.loads(request.body)
except json.JSONDecodeError:
return JsonResponse({"type": "error", "message": "Invalid JSON"}, status=400)
parlamentar_id = body.get("parlamentar_id")
voto = body.get("voto")
else:
parlamentar_id = request.POST.get("parlamentar_id")
voto = request.POST.get("voto")
# Validate
if not parlamentar_id:
return JsonResponse({"type": "error", "message": "parlamentar_id is required"}, status=400)
try:
parlamentar_id = int(parlamentar_id)
except (ValueError, TypeError):
return JsonResponse({"type": "error", "message": "parlamentar_id must be an integer"}, status=400)
if voto not in VALID_VOTE_VALUES:
return JsonResponse({
"type": "error",
"message": f"Invalid vote value: {voto}. Must be one of: {VALID_VOTE_VALUES}"
}, status=400)
# Verify session exists
try:
sessao = SessaoPlenaria.objects.get(id=controller_id)
except SessaoPlenaria.DoesNotExist:
return JsonResponse({"type": "error", "message": "Session not found"}, status=404)
# Find open materia (OrdemDia or ExpedienteMateria)
ordem_dia = get_materia_aberta(controller_id)
expediente = get_materia_expediente_aberta(controller_id)
materia_aberta = ordem_dia or expediente
if not materia_aberta:
return JsonResponse({"type": "error", "message": "No open materia for voting"}, status=400)
if materia_aberta.tipo_votacao != VOTACAO_NOMINAL:
return JsonResponse({"type": "error", "message": "Materia is not nominal voting type"}, status=400)
# Verify parlamentar exists
try:
parlamentar = Parlamentar.objects.get(id=parlamentar_id)
except Parlamentar.DoesNotExist:
return JsonResponse({"type": "error", "message": f"Parlamentar {parlamentar_id} not found"}, status=404)
# Save/update vote
vote_kwargs = {
'parlamentar': parlamentar,
'voto': voto,
'user': request.user,
'ip': get_client_ip(request),
}
if ordem_dia:
vote_kwargs['ordem'] = ordem_dia
voto_obj, created = VotoParlamentar.objects.update_or_create(
parlamentar=parlamentar,
ordem=ordem_dia,
defaults={'voto': voto, 'user': request.user, 'ip': get_client_ip(request)}
)
else:
vote_kwargs['expediente'] = expediente
voto_obj, created = VotoParlamentar.objects.update_or_create(
parlamentar=parlamentar,
expediente=expediente,
defaults={'voto': voto, 'user': request.user, 'ip': get_client_ip(request)}
)
logger.info(f"Vote {'created' if created else 'updated'}: parlamentar={parlamentar_id}, voto={voto}, sessao={controller_id}")
# Broadcast vote to all connected WebSocket clients
layer = get_channel_layer()
group = f"controller_{controller_id}"
notification = {
"type": "vote.update",
"parlamentar_id": parlamentar_id,
"voto": voto,
"created": created,
}
async_to_sync(layer.group_send)(group, notification)
return JsonResponse({"ok": True, **notification})
@user_passes_test(check_permission)
def cancel_voting(request, controller_id):
"""
HTTP endpoint to cancel the current open voting and discard all votes.
POST /v2/painel/controller/<sessao_id>/cancel
"""
from asgiref.sync import async_to_sync
from channels.layers import get_channel_layer
from sapl.sessao.views import fechar_votacao_materia
logger = logging.getLogger(__name__)
if request.method != 'POST':
return JsonResponse({"type": "error", "message": "Only POST allowed"}, status=405)
# Find the open materia
ordem_dia = get_materia_aberta(controller_id)
expediente = get_materia_expediente_aberta(controller_id)
materia_aberta = ordem_dia or expediente
if not materia_aberta:
return JsonResponse({"type": "error", "message": "No open materia for voting"}, status=400)
# Build redirect URL before closing (materia_aberta fields change after close)
materia_id = materia_aberta.materia_id
if ordem_dia:
redirect_url = reverse('sapl.sessao:ordemdia_list',
kwargs={'pk': controller_id}) + f'#id{materia_id}'
else:
redirect_url = reverse('sapl.sessao:expedientemateria_list',
kwargs={'pk': controller_id}) + f'#id{materia_id}'
# Cancel: delete votes + RegistroVotacao, close materia
fechar_votacao_materia(materia_aberta)
logger.info(f"Voting cancelled for sessao={controller_id}, materia={materia_aberta.id}")
# Broadcast updated state to all WebSocket clients
layer = get_channel_layer()
group = f"controller_{controller_id}"
from sapl.painel.consumers import get_dados_painel
updated_data = get_dados_painel(controller_id)
async_to_sync(layer.group_send)(group, updated_data)
return JsonResponse({"ok": True, "message": "Votação cancelada com sucesso.", "redirect_url": redirect_url})
@user_passes_test(check_permission)
def close_voting(request, controller_id):
"""
HTTP endpoint to close the current voting and save the result.
POST /v2/painel/controller/<sessao_id>/close
Body (JSON):
resultado_id: int (TipoResultadoVotacao id)
observacoes: str (optional)
"""
from asgiref.sync import async_to_sync
from channels.layers import get_channel_layer
from sapl.sessao.models import TipoResultadoVotacao
logger = logging.getLogger(__name__)
if request.method != 'POST':
return JsonResponse({"type": "error", "message": "Only POST allowed"}, status=405)
# Parse body
if request.content_type and 'json' in request.content_type:
try:
body = json.loads(request.body)
except json.JSONDecodeError:
return JsonResponse({"type": "error", "message": "Invalid JSON"}, status=400)
else:
body = request.POST
resultado_id = body.get("resultado_id")
observacoes = body.get("observacoes", "")
if not resultado_id:
return JsonResponse({
"type": "error",
"message": "Não é possível finalizar a votação sem nenhum resultado da votação"
}, status=400)
# Validate resultado
try:
tipo_resultado = TipoResultadoVotacao.objects.get(id=resultado_id)
except TipoResultadoVotacao.DoesNotExist:
return JsonResponse({"type": "error", "message": "Tipo de resultado não encontrado"}, status=404)
# Find the open materia
ordem_dia = get_materia_aberta(controller_id)
expediente = get_materia_expediente_aberta(controller_id)
materia_aberta = ordem_dia or expediente
if not materia_aberta:
return JsonResponse({"type": "error", "message": "No open materia for voting"}, status=400)
# Count votes from VotoParlamentar
if ordem_dia:
votos = VotoParlamentar.objects.filter(ordem=ordem_dia)
else:
votos = VotoParlamentar.objects.filter(expediente=expediente)
votos_sim = votos.filter(voto='Sim').count()
votos_nao = votos.filter(voto='Não').count()
abstencoes = votos.filter(voto='Abstenção').count()
nao_votou = votos.filter(voto='Não Votou').count()
# All votes must not be "Não Votou"
total_votados = votos_sim + votos_nao + abstencoes
if total_votados == 0:
return JsonResponse({
"type": "error",
"message": "Não é possível finalizar a votação sem nenhum voto"
}, status=400)
# Remove old RegistroVotacao if exists
if ordem_dia:
RegistroVotacao.objects.filter(ordem=ordem_dia).delete()
else:
RegistroVotacao.objects.filter(expediente=expediente).delete()
# Create RegistroVotacao
registro = RegistroVotacao()
registro.numero_votos_sim = votos_sim
registro.numero_votos_nao = votos_nao
registro.numero_abstencoes = abstencoes
registro.observacao = observacoes
registro.user = request.user
registro.ip = get_client_ip(request)
registro.materia = materia_aberta.materia
registro.tipo_resultado_votacao = tipo_resultado
if ordem_dia:
registro.ordem = ordem_dia
else:
registro.expediente = expediente
registro.save()
# Link VotoParlamentar records to RegistroVotacao and update user/ip
for voto_obj in votos:
voto_obj.votacao = registro
voto_obj.user = request.user
voto_obj.ip = get_client_ip(request)
voto_obj.save()
# Build redirect URL before closing
materia_id = materia_aberta.materia_id
if ordem_dia:
redirect_url = reverse('sapl.sessao:ordemdia_list',
kwargs={'pk': controller_id}) + f'#id{materia_id}'
else:
redirect_url = reverse('sapl.sessao:expedientemateria_list',
kwargs={'pk': controller_id}) + f'#id{materia_id}'
# Close materia with resultado
materia_aberta.resultado = tipo_resultado.nome
materia_aberta.votacao_aberta = False
materia_aberta.save()
# Clean up orphan VotoParlamentar (without votacao)
if ordem_dia:
VotoParlamentar.objects.filter(ordem=ordem_dia, votacao__isnull=True).delete()
else:
VotoParlamentar.objects.filter(expediente=expediente, votacao__isnull=True).delete()
logger.info(
f"Voting closed for sessao={controller_id}: "
f"sim={votos_sim}, nao={votos_nao}, abstencoes={abstencoes}, "
f"resultado={tipo_resultado.nome}"
)
# Broadcast updated state to all WebSocket clients
layer = get_channel_layer()
group = f"controller_{controller_id}"
from sapl.painel.consumers import get_dados_painel
updated_data = get_dados_painel(controller_id)
async_to_sync(layer.group_send)(group, updated_data)
return JsonResponse({"ok": True, "message": "Votação finalizada com sucesso.", "redirect_url": redirect_url})
# def push_to_me(request):
# from asgiref.sync import async_to_sync

14
sapl/sessao/migrations/0070_views_sessao_plenaria.py

@ -186,14 +186,14 @@ class Migration(migrations.Migration):
LEFT JOIN LATERAL (
SELECT em.sessao_plenaria_id,
em.numero_ordem,
jsonb_agg(jsonb_build_object(
jsonb_object_agg(
vp.parlamentar_id,
jsonb_build_object(
'materia_id', em.materia_id,
'parlamentar_id', vp.parlamentar_id,
'parlamentar_nome', p.nome_parlamentar,
'voto', vp.voto
)) ORDER BY p.nome_parlamentar) as votos_parlamentares
)) as votos_parlamentares
FROM sessao_votoparlamentar vp
JOIN parlamentares_parlamentar p ON (vp.parlamentar_id = p.id)
WHERE vp.expediente_id = em.id AND em.tipo_votacao != 4
@ -227,7 +227,7 @@ class Migration(migrations.Migration):
FROM sessao_ordemdia od
JOIN materia_materialegislativa ml ON (od.materia_id = ml.id)
JOIN materia_tipomaterialegislativa tm ON (ml.tipo_id = tm.id)
LEFT JOIN sessao_registroleitura rl on (od.id = rl.expediente_id)
LEFT JOIN sessao_registroleitura rl on (od.id = rl.ordem_id)
LEFT JOIN LATERAL (
SELECT jsonb_build_object(
'votos_sim', coalesce(rv.numero_votos_sim, 0),
@ -238,21 +238,21 @@ class Migration(migrations.Migration):
trv.nome resultado_votacao
FROM sessao_registrovotacao rv
JOIN sessao_tiporesultadovotacao trv on (rv.tipo_resultado_votacao_id = trv.id)
WHERE rv.expediente_id = od.id AND tipo_votacao != 4) rv ON TRUE
WHERE rv.ordem_id = od.id AND tipo_votacao != 4) rv ON TRUE
LEFT JOIN LATERAL (
SELECT od.sessao_plenaria_id,
od.numero_ordem,
jsonb_agg(jsonb_build_object(
jsonb_object_agg(
vp.parlamentar_id,
jsonb_build_object(
'materia_id', od.materia_id,
'parlamentar_id', vp.parlamentar_id,
'parlamentar_nome', p.nome_parlamentar,
'voto', vp.voto
)) ORDER BY p.nome_parlamentar) as votos_parlamentares
)) as votos_parlamentares
FROM sessao_votoparlamentar vp
JOIN parlamentares_parlamentar p ON (vp.parlamentar_id = p.id)
WHERE vp.expediente_id = od.id AND od.tipo_votacao != 4
WHERE vp.ordem_id = od.id AND od.tipo_votacao != 4
GROUP BY od.sessao_plenaria_id, od.numero_ordem
ORDER BY od.sessao_plenaria_id, od.numero_ordem
) vp ON TRUE

73
sapl/templates/painel/painel_v2.html

@ -5,86 +5,57 @@
{% load webpack_static from webpack_loader %}
<!DOCTYPE HTML>
<!--[if IE 8]>
<html class="no-js lt-ie9" lang="pt-br"> <![endif]-->
<!--[if gt IE 8]><!-->
<html lang="pt-br">
<!--<![endif]-->
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<!-- TODO: does it need this head_title here? -->
<title>{% block head_title %}{% trans 'SAPL - Sistema de Apoio ao Processo Legislativo' %}{% endblock %}</title>
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
<link
href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css"
rel="stylesheet"
/>
{% block webpack_loader_css %}
{% render_chunk_vendors 'css' %}
{% render_bundle 'global' 'css' %}
{% render_bundle 'painel' 'css' %}
{% endblock webpack_loader_css %}
<style type="text/css">
html, body {
max-width: 100%;
overflow-x: hidden;
}
@media screen {
ul, li {
list-style-type: none;
}
}
</style>
</head>
<body class="painel-principal">
<body class="min-vh-100 d-flex flex-column p-2">
{% block vue_content %}
<style>[v-cloak]{display:none}</style>
<div id="painel" v-cloak class="col text-center" data-controller-id="{{ controller_id }}">
<div id="painel" v-cloak class="container-fluid" data-controller-id="{{ controller_id }}">
<div class="row g-3">
<painel-header ref="painelHeader"></painel-header>
<div class="row justify-content-center">
<div class="col-md-9 separador-vertical pe-4">
<div class="painel p-3 h-100 d-flex flex-column">
<painel-header ref="painelHeader"></painel-header>
<painel-parlamentares ref="parlamentares"></painel-parlamentares>
</div>
</div>
<div class="d-flex justify-content-start">
<painel-parlamentares ref="parlamentares"></painel-parlamentares>
<div class="col-md-3 d-flex flex-column gap-3">
<painel-materia ref="materia"></painel-materia>
<div class="d-flex col-md-8 painels">
<painel-oradores ref="oradores"></painel-oradores>
<div class="col-md-6 text-left painel" v-if="canRender">
<div class="d-flex align-items-left justify-content-left mb-2">
<h2 class="text-subtitle mb-0">Cronômetros</h2>
</div>
<div class="text-value" id="box_cronometros">
<button class="btn btn-sm btn-secondary ms-2" v-on:click="changeFontSize(-1)">
A-
</button>
<button class="btn btn-sm btn-secondary ms-2" v-on:click="changeFontSize(1)">
A+
</button>
<painel-cronometro ref="sw:discurso" id="sw:discurso" title="Discurso"></painel-cronometro>
<painel-cronometro ref="sw:aparte" id="sw:aparte" title="Aparte"></painel-cronometro>
<painel-cronometro ref="sw:questao" id="sw:questao" title="Questão de Ordem"></painel-cronometro>
<painel-cronometro ref="sw:consideracoes" id="sw:consideracoes" title="Considerações Finais"></painel-cronometro>
</div>
</div>
<!-- <painel-cronometro-list ref="cronometro_list"></painel-cronometro-list>-->
<painel-cronometro-list ref="cronometro_list"></painel-cronometro-list>
<painel-resultado ref="resultado"></painel-resultado>
<painel-materia ref="materia"></painel-materia>
<div class="col-md-6 text-center painel" id="tema_solene_div" style="display: none">
<div class="painel p-3 d-flex flex-column" id="tema_solene_div" v-if="sessao && sessao.sessao_solene">
<h2 class="text-subtitle">Tema da Sessão Solene</h2>
<span id="sessao_solene_tema" class="text-value"></span>
<span id="sessao_solene_tema" class="text-value">[[ sessao.tema_solene ]]</span>
</div>
<painel-footer ref="painelFooter"></painel-footer>
</div>
</div>
</div>
{% endblock %}
</body>
{% block webpack_loader_js %}

65
sapl/templates/sessao/votacao/votacao_v2.html

@ -7,62 +7,15 @@
{% block vue_content %}
<style>[v-cloak]{display:none}</style>
<div id="votacao">
<fieldset class="form-group">
<legend>[[ tipo_votacao_descricao ]]</legend>
[[ error_message ]]
<!-- Component 1 -->
<div>
<b>Matéria:</b> [[ materia ]]
<br/>
<b>Ementa:</b> [[ ementa ]]
<br/>
</div>
<br/>
<!-- Votacao Nominal -->
<fieldset class="form-group" v-if="tipo_votacao == 1">
<legend><strong>Votos</strong></legend>
<div class="row" v-for="p in parlamentares">
<div class="col-md-4" id="nome_parlamentar">[[ p.nome_parlamentar ]]</div>
<div class="col-md-2" id="filiacao">[[ p.filiacao ]]</div>
<div class="col-md-4" v-if="edit_votes">
<span v-for="o in options" :key="o.value">
<input type="radio" :id="o.value" :value="o.text" :name="p.parlamentar_id" v-model="votos_parlamentares[p.parlamentar_id].voto"/>
<label :for="o.value"> [[ o.text ]]</label>
&nbsp;
</span>
</div>
<div v-else>
[[ votos_parlamentares[p.parlamentar_id].voto ]]
</div>
</div>
<br/>
<br/>
</fieldset>
<legend><strong>Total de votos:</strong></legend>
<div id="total_votos" v-for="voto in total_votos">
<div class='row'><div class='col-md-12'>[[ voto.tipo ]]: [[ voto.total ]]</div></div>
</div>
<br/>
<!-- component -->
<div class="col-md-6">
<b>Resultado da Votação: </b>
<select v-model="resultado_selected" class="select form-control" :disabled="disable_resultado" >
<option :key="t.id" :value="t.id" v-for="t in tipos_resultados">[[ t.nome ]]</option>
</select>
</div>
<!-- component -->
<div class="row">
<div class="col-md-12">
Observações:
<textarea v-model="observacoes" cols="10" rows="10" class="form-control"></textarea>
</div>
</div>
<div class="alert alert-info alert-dismissible " role="alert" v-else>
<div>Não existe nenhum parlamentar presente para que a votação ocorra.</div>
</div>
</fieldset>
<div id="votacao" v-cloak data-controller-id="{{ pk }}">
<votacao-nominal
:is-open="isOpen"
:error-message="error_message"
@cast-vote="castVote"
@cancelar="onCancelar"
@fechar="onFechar"
ref="votacaoNominal">
</votacao-nominal>
</div>
{% endblock vue_content %}

Loading…
Cancel
Save