Browse Source

Merge d1f107c7d7 into 2d6eaae489

pull/3840/merge
Heitor 2 weeks 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. 59
      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. 74
      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' import './scss/painel.scss'
// main.js (Vue 2)
import Vue from 'vue' import Vue from 'vue'
import Vuex from 'vuex' import Vuex from 'vuex'
import { mapState } 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 PainelOradores from '../../components/painel/PainelOradores.vue'
import PainelMateria from '../../components/painel/PainelMateria.vue' import PainelMateria from '../../components/painel/PainelMateria.vue'
import PainelResultado from '../../components/painel/PainelResultado.vue' import PainelResultado from '../../components/painel/PainelResultado.vue'
import alarm from '../../assets/audio/ring.mp3' import PainelFooter from '../../components/painel/PainelFooter.vue'
// register components // register components
Vue.component('painel-cronometro', Cronometro) Vue.component('painel-cronometro', Cronometro)
@ -23,6 +22,7 @@ Vue.component('painel-parlamentares', PainelParlamentares)
Vue.component('painel-oradores', PainelOradores) Vue.component('painel-oradores', PainelOradores)
Vue.component('painel-materia', PainelMateria) Vue.component('painel-materia', PainelMateria)
Vue.component('painel-resultado', PainelResultado) Vue.component('painel-resultado', PainelResultado)
Vue.component('painel-footer', PainelFooter)
// global store // global store
Vue.use(Vuex) Vue.use(Vuex)
@ -50,10 +50,22 @@ const store = new Vuex.Store({
}, },
updateParlamentares(state, votos_parlamentares) { updateParlamentares(state, votos_parlamentares) {
if (votos_parlamentares) { if (votos_parlamentares) {
state.parlamentares.forEach((p)=>{ let votosMap = votos_parlamentares;
if (p.parlamentar_id in votos_parlamentares) { if (Array.isArray(votos_parlamentares)) {
p.voto = votos_parlamentares[p.parlamentar_id].voto 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) { setMostrarVoto(state, mostrar_voto) {
state.mostrar_voto = mostrar_voto; state.mostrar_voto = mostrar_voto;
},
setSessao(state, sessao) {
state.sessao = sessao;
} }
}, },
actions: {}, actions: {},
@ -104,8 +119,9 @@ new Vue({
this.connectWS() this.connectWS()
}, },
computed: { computed: {
...mapState(["painel_aberto", "sessao_aberta"]), ...mapState(["painel_aberto", "sessao_aberta", "sessao"]),
canRender () { canRender () {
return this.sessao_aberta && this.painel_aberto; return this.sessao_aberta && this.painel_aberto;
}, },
@ -113,7 +129,7 @@ new Vue({
methods: { methods: {
...mapMutations(['sessaoStatus', 'painelStatus','setParlamentares', ...mapMutations(['sessaoStatus', 'painelStatus','setParlamentares',
'updateParlamentares', 'setOradores', 'setMateria', 'updateParlamentares', 'setOradores', 'setMateria',
'setResultado', 'setMessage', 'setMostrarVoto']), 'setResultado', 'setMessage', 'setMostrarVoto', 'setSessao']),
wsURL() { wsURL() {
const proto = location.protocol === 'https:' ? 'wss' : 'ws' const proto = location.protocol === 'https:' ? 'wss' : 'ws'
return `${proto}://${location.host}/ws/painel/${this.controllerId}/` return `${proto}://${location.host}/ws/painel/${this.controllerId}/`
@ -165,6 +181,10 @@ new Vue({
this.painelStatus(data.painel_aberto); this.painelStatus(data.painel_aberto);
this.setMostrarVoto(data.mostrar_voto); this.setMostrarVoto(data.mostrar_voto);
if (data.sessao) {
this.setSessao(data.sessao);
}
// PARLAMENTARES // PARLAMENTARES
if (data.parlamentares) { if (data.parlamentares) {
// pre-popula para Vuex capturar mudanca de estado de 'voto' // pre-popula para Vuex capturar mudanca de estado de 'voto'
@ -173,14 +193,14 @@ new Vue({
} }
// HEADER DO PAINEL // HEADER DO PAINEL
// SESSAO_PLENARIA
//TODO: group in a single SessaoPlenaria object
const headerInstance = this.$refs.painelHeader; const headerInstance = this.$refs.painelHeader;
//TODO: setup as child's props?
headerInstance.sessao_plenaria = data.sessao.sessao_plenaria 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 // FOOTER DO PAINEL (brasão + relógio)
headerInstance.brasao = data.sessao.brasao const footerInstance = this.$refs.painelFooter;
if (footerInstance) {
footerInstance.brasao = data.sessao.brasao;
}
if (data.message) { if (data.message) {
this.setMessage(data.message); this.setMessage(data.message);
@ -194,15 +214,20 @@ new Vue({
// MATERIA // MATERIA
if (data.materia) { if (data.materia) {
this.setMateria(data.materia); this.setMateria(data.materia);
}
// RESULTADO // RESULTADO
if (data.materia.resultado) { if (data.materia.resultado) {
this.setResultado(data.materia.resultado); this.setResultado(data.materia.resultado);
}
if (data.materia.resultado.votos_parlamentares) { if (data.materia.resultado.votos_parlamentares) {
this.updateParlamentares(data.materia.resultado.votos_parlamentares); this.updateParlamentares(data.materia.resultado.votos_parlamentares);
}
} else {
this.setResultado({});
}
} else {
this.setMateria({});
this.setResultado({});
} }
} catch (e) { } catch (e) {
console.error('Error', e); console.error('Error', e);
@ -243,6 +268,11 @@ new Vue({
console.log(`Received ping from server: ${JSON.stringify(data)}`); console.log(`Received ping from server: ${JSON.stringify(data)}`);
} else if (data.type == 'stopwatch.update') { } else if (data.type == 'stopwatch.update') {
this.handleStopWatchEvent(data); 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) { } catch (e) {
console.error('WS parse error:', 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 { body {
background: #1c1b1b; background-color: var(--bg-dark);
font-family: Verdana; color: var(--text-main);
font-size: x-large; font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
.text-title { margin-bottom: 0;
color: #4fa64d; }
margin: 0.5rem;
font-weight: bold;
}
.text-subtitle {
color: #459170;
font-weight: bold;
}
.data-hora {
font-size: 180%;
}
.text-value { .separador-vertical {
color: white; border-right: 1px solid #333;
} min-height: 100vh;
margin: 0;
}
.logo-painel { .text-subtitle {
max-width: 100%; color: #81c784;
} font-weight: 600;
.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;
}
}
} }

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

@ -1,238 +1,383 @@
import './scss/votacao.scss' import './scss/votacao.scss'
import Vue from 'vue' import Vue from 'vue'
import { FormSelectPlugin } from 'bootstrap-vue' import Vuex from 'vuex'
import { mapState, mapMutations } from 'vuex'
import axios from 'axios' 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.xsrfCookieName = 'csrftoken'
axios.defaults.xsrfHeaderName = 'X-CSRFToken' axios.defaults.xsrfHeaderName = 'X-CSRFToken'
Vue.use(FormSelectPlugin) Vue.use(Vuex)
console.log('votacao main.js carregado') 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 const v = new Vue({ // eslint-disable-line
store,
delimiters: ['[[', ']]'], delimiters: ['[[', ']]'],
el: '#votacao', el: '#votacao',
data () { data () {
return { return {
votacao_aberta: true, controllerId: null,
edit_votes: true, ws: null,
disable_resultado: false, isOpen: false,
resultado_selected: "", error: null,
observacoes: "", pingTimer: null,
error_message: "", reconnectTimer: null,
tipo_votacao: 2, error_message: '',
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"
}
],
} }
}, },
watch: {},
computed: { computed: {
total_votos() { ...mapState([
// TODO: use number index instead of string ("sim", "não") as keys. 'sessao_aberta', 'painel_aberto', 'sessao',
var groupedVotes = Map.groupBy(Object.values(this.votos_parlamentares), ({ voto }) => voto ) 'parlamentares', 'materia', 'resultado', 'message', 'mostrar_voto'
// initialize total_votos ]),
total_votos = [ },
{"tipo": "Sim", "total": 0},
{"tipo": "Não", "total": 0}, mounted () {
{"tipo": "Abstenção", "total": 0}, console.log('Votacao app mounted!')
{"tipo": "Não Votou", total: 0} const el = this.$el
] this.controllerId = el.dataset.controllerId || window.controllerId
for (const [key, value] of groupedVotes.entries()) { console.log(`Votacao ControllerId: ${this.controllerId}`)
const index = total_votos.findIndex(item => item.tipo === key); if (this.controllerId) {
total_votos[index].total = value.length this.connectWS()
} } else {
return total_votos 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 () { voteURL () {
console.log("Votacao app mounted!") 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> <template>
<div> <tr :id="'row_' + id" v-show="visible">
<audio <td :class="['fs-3', titleColorClass]">{{ title }}</td>
ref="player" <td class="text-end">
:src="audioSrc" <audio ref="player" :src="audioSrc" preload="auto"></audio>
preload="auto" <span :id="'cronometro_' + id"
></audio> :class="['fw-bold', 'font-monospace', 'fs-1', titleColorClass]"
<span ref="time">{{ title }}: {{ formatTime(time) }}<br/></span> ref="time">{{ formatTime(time) }}</span>
</div> </td>
</tr>
</template> </template>
<script> <script>
export default { export default {
name: 'Cronometro', name: 'Cronometro',
props: ['id', 'title'], props: ['id', 'title', 'visible', 'colorClass'],
data() { data() {
return { return {
time: 300, time: 300,
@ -22,17 +23,21 @@ export default {
audioSrc: require('@/assets/audio/ring.mp3'), audioSrc: require('@/assets/audio/ring.mp3'),
} }
}, },
mounted() { computed: {
console.log('Cronometro mounted'); titleColorClass() {
console.log(this.audioSrc); return this.colorClass || '';
this.$emit('child-mounted'); // Emit a custom event }
}, },
mounted() {
console.log('Cronometro mounted');
this.$emit('child-mounted');
},
methods: { methods: {
changeFontSize(value) { changeFontSize(value) {
const el = this.$refs.time; const el = this.$refs.time;
if (!el) return; if (!el) return;
let fontSize = window.getComputedStyle(el).fontSize; let fontSize = window.getComputedStyle(el).fontSize;
fontSize = parseFloat(fontSize); // safely convert "16px" 16 fontSize = parseFloat(fontSize);
el.style.fontSize = (fontSize + value) + 'px'; el.style.fontSize = (fontSize + value) + 'px';
}, },
handleStartStop() { handleStartStop() {
@ -42,8 +47,7 @@ export default {
this.intervalId = setInterval(() => { this.intervalId = setInterval(() => {
if (this.time > 0) { if (this.time > 0) {
this.time--; this.time--;
// play buzz at 00:00:30 if (this.time == 30) {
if (this.time == 30000) {
this.playSound(); this.playSound();
} }
} else { } else {
@ -66,19 +70,14 @@ export default {
playSound() { playSound() {
const audio = this.$refs.player const audio = this.$refs.player
if (!audio) return if (!audio) return
audio.play()
const playPromise = audio.play()
}, },
formatTime(seconds) { formatTime(seconds) {
const hrs = Math.floor(seconds / 3600); const hrs = Math.floor(seconds / 3600);
const mins = Math.floor((seconds % 3600) / 60); const mins = Math.floor((seconds % 3600) / 60);
const secs = seconds % 60; const secs = seconds % 60;
return `${hrs.toString().padStart(2, '0')}:${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
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')}`;
} }
}, },
watch: { watch: {

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

@ -1,16 +1,50 @@
.<template> <template>
<div class="col-md-6 text-left painel" v-if="canRender"> <div class="painel d-flex flex-column" v-if="canRender">
<div class="d-flex align-items-left justify-content-left mb-2"> <div id="box_cronometros" class="w-100">
<h2 class="text-subtitle mb-0">Cronômetros</h2> <h2 class="text-center text-subtitle mb-3">Cronômetro</h2>
</div> <div class="d-flex align-items-center justify-content-center" style="height: 80px">
<div class="text-value" id="box_cronometros"> <table class="table-custom w-100 mb-0">
<Cronometro v-for="(title, idx) in titles" :key="idx" :title="title" :ref="'childRef_' + idx" @child-mounted="handleChildMounted"/> <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>
</div> </div>
</template> </template>
<script> <script>
import { ref, onMounted } from 'vue';
import { mapState } from 'vuex'; import { mapState } from 'vuex';
import Cronometro from './Cronometro.vue'; import Cronometro from './Cronometro.vue';
@ -21,8 +55,7 @@
}, },
data() { data() {
return { return {
titles: ["Discurso", "Aparte", "Questão de Ordem", "Considerações Finais"], visibleCronometro: 'discurso',
itemRefs: ref([]), // An array to store the refs
} }
}, },
mounted() { mounted() {
@ -37,15 +70,39 @@
methods: { methods: {
handleStartStop() { handleStartStop() {
console.log("start/stop stopwatch"); console.log("start/stop stopwatch");
//console.log(this.$refs.itemRefs);
}, },
handleChildMounted() { handleChildMounted() {
console.log('ChildComponent has finished mounting in the parent!'); console.log('Cronometro child mounted');
// 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();
}, },
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> </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>

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

@ -1,29 +1,7 @@
<template v-if="sessao_aberta"> <template>
<div> <div>
<div class="d-flex justify-content-center"> <h1 id="sessao_plenaria" class="text-title fw-bold text-uppercase">{{ sessao_plenaria }}</h1>
<h1 id="sessao_plenaria" class="title text-title">{{ sessao_plenaria }} </h1> <h2 class="text-danger" v-if="message"><span id="message">{{ message }}</span></h2>
</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>
</div> </div>
</template> </template>
@ -36,37 +14,24 @@ export default {
data() { data() {
return { return {
sessao_plenaria: "Sessao Plenaria Teste", sessao_plenaria: "",
sessao_plenaria_data: "22/10/2025",
sessao_plenaria_hora_inicio: "13:30",
brasao: "",
data_atual: "",
relogio: "",
currentDateTimeId: null, // stores the id returned by setInterval()
} }
}, },
methods: { 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: { computed: {
...mapState(["sessao_aberta", "painel_aberto", "message", "mostrar_voto"]) ...mapState(["sessao_aberta", "message"])
}, },
mounted() { mounted() {
console.log('PainelHeader component 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> <template>
<div class="col-md-6 text-center painel" id="obs_materia_div" v-if="canRender"> <div class="painel p-3 d-flex flex-column" id="obs_materia_div" v-if="canRender">
<h2 class="text-subtitle" id="mat_em_votacao">Matéria em Votação</h2> <div class="flex-grow-1">
<span id="materia_legislativa_texto" class="text-value">{{ materia.texto }}</span> <h2 id="mat_em_votacao" class="text-subtitle text-center mb-3 pb-1">
<br> Matéria em Votação
<span id="materia_legislativa_ementa" class="text-value">{{ materia.ementa }} </span> </h2>
<br> <span id="materia_legislativa_texto"
<span id="observacao_materia" class="text-value">{{ materia.observacao }}</span> 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> </div>
</template> </template>
@ -15,13 +25,6 @@ export default {
name: 'PainelMateria', name: 'PainelMateria',
data() { data() {
return { return {
/*
materia: {
texto: '',
ementa: '',
observacao: '',
}
*/
}; };
}, },
mounted() { mounted() {
@ -38,5 +41,8 @@ export default {
</script> </script>
<style scoped> <style scoped>
/* Optional styling */ .text-title {
color: var(--text-highlight);
letter-spacing: 1px;
}
</style> </style>

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

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

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

@ -1,26 +1,40 @@
<template> <template>
<div class="col-md-4" v-if="canRender"> <div id="parlamentares">
<div class="text-center painel"> <div class="container-fluid" id="parlamentares_container" v-if="canRender">
<h2 class="text-subtitle">Parlamentares</h2> <div class="parlamentares-grid" id="parlamentares_row">
<span id="parlamentares" class="text-value text-center"></span> <div v-for="p in parlamentares"
<table id="parlamentares_list"> :key="p.parlamentar_id"
<tr v-for="p in parlamentares" :key="p.parlamentar_id" class="text-value text-center"> :class="['parlamentar-card', 'mt-3', votoClass(p)]">
<td style="padding-right:20px; color:yellow" > {{ p.nome_parlamentar }}</td> <div class="voto-faixa" v-if="votoText(p)">
<td style="padding-right:20px; color:yellow"> {{ p.filiacao }}</td> <p>{{ votoText(p) }}</p>
<td style="padding-right:20px; color:yellow" v-if="mostrar_voto">{{ p.voto }}</td> </div>
</tr>
</table> <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>
</div> </div>
<span class="text-white" v-else>
<center>A listagem de parlamentares aparecerá quando o painel estiver aberto.</center>
</span>
</div>
</template> </template>
<script> <script>
import { mapState } from 'vuex'; import { mapState } from 'vuex';
import defaultAvatarImg from '@/assets/img/avatar.png';
export default { export default {
name: 'PainelParlamentares', name: 'PainelParlamentares',
data() { data() {
return { return {
//parlamentares: [], defaultAvatar: defaultAvatarImg,
}; };
}, },
mounted() { mounted() {
@ -33,9 +47,127 @@ export default {
}, },
...mapState(["painel_aberto", "sessao_aberta", "parlamentares", "mostrar_voto"]) ...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> </script>
<style scoped> <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> </style>

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

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

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

@ -1,11 +1,73 @@
<template> <template>
<div class="VotacaoNominal"> <fieldset>
<pre v-text="$attrs"/> <legend>Votação Nominal</legend>
</div>
<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> </template>
<script> <script>
export default { import { mapState } from 'vuex';
name: 'Votacaonominal' 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> </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): 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 { return {
# TOO UGLY! FIX THIS if-else "materia_id": materia.id if materia else "",
"materia_id": votacao.materia.id if votacao and votacao.materia else "", "texto": str(materia) if materia else "",
"texto": str(votacao.materia) if votacao and votacao.materia else "", "ementa": materia.ementa if materia and materia.ementa else "",
"ementa": votacao.materia.ementa if votacao and votacao.materia and votacao.materia.ementa else "",
"resultado": { "resultado": {
"resultado_votacao": votacao.resultado_votacao if votacao and votacao.resultado_votacao else "", "resultado_votacao": votacao.resultado_votacao or "",
"resultado": votacao.resultado if votacao and votacao.resultado else "", "resultado": votacao.resultado or "",
"numero_votos": votacao.numero_votos if votacao and votacao.numero_votos else {}, "numero_votos": votacao.numero_votos or {},
"votos_parlamentares": votacao.votos_parlamentares if votacao and votacao.votos_parlamentares else [], "votos_parlamentares": votacao.votos_parlamentares or [],
}, },
} }
@ -65,6 +77,29 @@ def get_dados_painel(sessao_plenaria_id: int) -> dict:
'nome_parlamentar', 'nome_parlamentar',
'filiacao', ) 'filiacao', )
parlamentares = [dict(zip(['parlamentar_id', 'nome_parlamentar', 'filiacao'], p)) for p in presentes] 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: if materia_votacao and materia_votacao.numero_votos:
materia_votacao.numero_votos.update({"num_presentes": len(parlamentares)}) 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 "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 = { dados_sessao = {
"type": "data", "type": "data",
"sessao_aberta": sessao.iniciada and not sessao.finalizada, "sessao_aberta": sessao.iniciada and not sessao.finalizada,
@ -103,6 +144,7 @@ def get_dados_painel(sessao_plenaria_id: int) -> dict:
"parlamentares": parlamentares, "parlamentares": parlamentares,
"oradores": oradores, "oradores": oradores,
"materia": get_materia_votacao(materia_votacao), "materia": get_materia_votacao(materia_votacao),
"tipos_resultado": tipos_resultado,
"stopwatch": [stopwatch], # TODO: array of stopwatches "stopwatch": [stopwatch], # TODO: array of stopwatches
} }
@ -170,11 +212,15 @@ class PainelConsumer(AsyncJsonWebsocketConsumer):
return return
await self.send_json({"type": "error", "message": "Misformed message"}) await self.send_json({"type": "error", "message": "Misformed message"})
return return
async def stopwatch_update(self, event): async def stopwatch_update(self, event):
await self.send_json(event) await self.send_json(event)
return 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 @database_sync_to_async
def user_can_view(self, user_id, controller_id) -> bool: def user_can_view(self, user_id, controller_id) -> bool:
# Replace with your ACL check (ORM must be in a sync wrapper) # 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 .apps import AppConfig
from .views import (cronometro_painel, get_dados_painel, painel_mensagem_view, from .views import (cronometro_painel, get_dados_painel, painel_mensagem_view,
painel_parlamentar_view, painel_view, painel_votacao_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 from django.urls import path
app_name = AppConfig.name app_name = AppConfig.name
@ -30,4 +31,10 @@ urlpatterns = [
path("v2/painel/<int:controller_id>", websocket_view, name='painel_websocket'), path("v2/painel/<int:controller_id>", websocket_view, name='painel_websocket'),
path("v2/painel/controller/<int:controller_id>/stopwatch", path("v2/painel/controller/<int:controller_id>/stopwatch",
stopwatch_controller, name='painel_controller'), 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 import timezone
from django.utils.translation import ugettext_lazy as _ 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 AppConfig as ConfiguracoesAplicacao
from sapl.base.models import CasaLegislativa from sapl.base.models import CasaLegislativa
from sapl.crud.base import Crud from sapl.crud.base import Crud
@ -23,6 +24,7 @@ from sapl.sessao.models import (ExpedienteMateria, OradorExpediente, OrdemDia,
SessaoPlenaria, SessaoPlenariaPresenca, SessaoPlenaria, SessaoPlenariaPresenca,
VotoParlamentar, RegistroLeitura) VotoParlamentar, RegistroLeitura)
from sapl.utils import filiacao_data, get_client_ip, sort_lista_chave from sapl.utils import filiacao_data, get_client_ip, sort_lista_chave
from image_cropping.utils import get_backend
from .models import Cronometro from .models import Cronometro
@ -402,10 +404,24 @@ def get_presentes(pk, response, materia):
else: else:
partido = filiacao 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( presentes_list.append(
{'id': p.id, {'id': p.id,
'parlamentar_id': p.parlamentar.id, 'parlamentar_id': p.parlamentar.id,
'nome': p.parlamentar.nome_parlamentar, 'nome': p.parlamentar.nome_parlamentar,
'fotografia':thumbnail_url,
'partido': partido, 'partido': partido,
'voto': '' 'voto': ''
}) })
@ -691,6 +707,279 @@ def stopwatch_controller(request, controller_id):
async_to_sync(layer.group_send)(group, notification) async_to_sync(layer.group_send)(group, notification)
return JsonResponse(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): # def push_to_me(request):
# from asgiref.sync import async_to_sync # 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 ( LEFT JOIN LATERAL (
SELECT em.sessao_plenaria_id, SELECT em.sessao_plenaria_id,
em.numero_ordem, em.numero_ordem,
jsonb_agg(jsonb_build_object( jsonb_object_agg(
vp.parlamentar_id, vp.parlamentar_id,
jsonb_build_object( jsonb_build_object(
'materia_id', em.materia_id, 'materia_id', em.materia_id,
'parlamentar_id', vp.parlamentar_id, 'parlamentar_id', vp.parlamentar_id,
'parlamentar_nome', p.nome_parlamentar, 'parlamentar_nome', p.nome_parlamentar,
'voto', vp.voto 'voto', vp.voto
)) ORDER BY p.nome_parlamentar) as votos_parlamentares )) as votos_parlamentares
FROM sessao_votoparlamentar vp FROM sessao_votoparlamentar vp
JOIN parlamentares_parlamentar p ON (vp.parlamentar_id = p.id) JOIN parlamentares_parlamentar p ON (vp.parlamentar_id = p.id)
WHERE vp.expediente_id = em.id AND em.tipo_votacao != 4 WHERE vp.expediente_id = em.id AND em.tipo_votacao != 4
@ -227,7 +227,7 @@ class Migration(migrations.Migration):
FROM sessao_ordemdia od FROM sessao_ordemdia od
JOIN materia_materialegislativa ml ON (od.materia_id = ml.id) JOIN materia_materialegislativa ml ON (od.materia_id = ml.id)
JOIN materia_tipomaterialegislativa tm ON (ml.tipo_id = tm.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 ( LEFT JOIN LATERAL (
SELECT jsonb_build_object( SELECT jsonb_build_object(
'votos_sim', coalesce(rv.numero_votos_sim, 0), 'votos_sim', coalesce(rv.numero_votos_sim, 0),
@ -238,21 +238,21 @@ class Migration(migrations.Migration):
trv.nome resultado_votacao trv.nome resultado_votacao
FROM sessao_registrovotacao rv FROM sessao_registrovotacao rv
JOIN sessao_tiporesultadovotacao trv on (rv.tipo_resultado_votacao_id = trv.id) 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 ( LEFT JOIN LATERAL (
SELECT od.sessao_plenaria_id, SELECT od.sessao_plenaria_id,
od.numero_ordem, od.numero_ordem,
jsonb_agg(jsonb_build_object( jsonb_object_agg(
vp.parlamentar_id, vp.parlamentar_id,
jsonb_build_object( jsonb_build_object(
'materia_id', od.materia_id, 'materia_id', od.materia_id,
'parlamentar_id', vp.parlamentar_id, 'parlamentar_id', vp.parlamentar_id,
'parlamentar_nome', p.nome_parlamentar, 'parlamentar_nome', p.nome_parlamentar,
'voto', vp.voto 'voto', vp.voto
)) ORDER BY p.nome_parlamentar) as votos_parlamentares )) as votos_parlamentares
FROM sessao_votoparlamentar vp FROM sessao_votoparlamentar vp
JOIN parlamentares_parlamentar p ON (vp.parlamentar_id = p.id) 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 GROUP BY od.sessao_plenaria_id, od.numero_ordem
ORDER BY od.sessao_plenaria_id, od.numero_ordem ORDER BY od.sessao_plenaria_id, od.numero_ordem
) vp ON TRUE ) vp ON TRUE

73
sapl/templates/painel/painel_v2.html

@ -5,86 +5,57 @@
{% load webpack_static from webpack_loader %} {% load webpack_static from webpack_loader %}
<!DOCTYPE HTML> <!DOCTYPE HTML>
<!--[if IE 8]>
<html class="no-js lt-ie9" lang="pt-br"> <![endif]-->
<!--[if gt IE 8]><!-->
<html lang="pt-br"> <html lang="pt-br">
<!--<![endif]-->
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <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> <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 %} {% block webpack_loader_css %}
{% render_chunk_vendors 'css' %} {% render_chunk_vendors 'css' %}
{% render_bundle 'global' 'css' %} {% render_bundle 'global' 'css' %}
{% render_bundle 'painel' 'css' %} {% render_bundle 'painel' 'css' %}
{% endblock webpack_loader_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> </head>
<body class="painel-principal"> <body class="min-vh-100 d-flex flex-column p-2">
{% block vue_content %} {% block vue_content %}
<style>[v-cloak]{display:none}</style> <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="col-md-9 separador-vertical pe-4">
<div class="painel p-3 h-100 d-flex flex-column">
<div class="row justify-content-center"> <painel-header ref="painelHeader"></painel-header>
<painel-parlamentares ref="parlamentares"></painel-parlamentares>
</div>
</div>
<div class="d-flex justify-content-start"> <div class="col-md-3 d-flex flex-column gap-3">
<painel-parlamentares ref="parlamentares"></painel-parlamentares> <painel-materia ref="materia"></painel-materia>
<div class="d-flex col-md-8 painels">
<painel-oradores ref="oradores"></painel-oradores> <painel-oradores ref="oradores"></painel-oradores>
<div class="col-md-6 text-left painel" v-if="canRender"> <painel-cronometro-list ref="cronometro_list"></painel-cronometro-list>
<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-resultado ref="resultado"></painel-resultado> <painel-resultado ref="resultado"></painel-resultado>
<painel-materia ref="materia"></painel-materia> <div class="painel p-3 d-flex flex-column" id="tema_solene_div" v-if="sessao && sessao.sessao_solene">
<div class="col-md-6 text-center painel" id="tema_solene_div" style="display: none">
<h2 class="text-subtitle">Tema da Sessão Solene</h2> <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> </div>
<painel-footer ref="painelFooter"></painel-footer>
</div> </div>
</div> </div>
</div> </div>
{% endblock %} {% endblock %}
</body> </body>
{% block webpack_loader_js %} {% block webpack_loader_js %}

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

@ -7,62 +7,15 @@
{% block vue_content %} {% block vue_content %}
<style>[v-cloak]{display:none}</style> <style>[v-cloak]{display:none}</style>
<div id="votacao"> <div id="votacao" v-cloak data-controller-id="{{ pk }}">
<fieldset class="form-group"> <votacao-nominal
<legend>[[ tipo_votacao_descricao ]]</legend> :is-open="isOpen"
[[ error_message ]] :error-message="error_message"
@cast-vote="castVote"
<!-- Component 1 --> @cancelar="onCancelar"
<div> @fechar="onFechar"
<b>Matéria:</b> [[ materia ]] ref="votacaoNominal">
<br/> </votacao-nominal>
<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> </div>
{% endblock vue_content %} {% endblock vue_content %}

Loading…
Cancel
Save