From 07c9d4c29ede253a6abe6a574c5e58f6ba6866a9 Mon Sep 17 00:00:00 2001 From: RogerKoala Date: Thu, 2 Jul 2026 08:53:58 -0300 Subject: [PATCH] feat(votacao): refatora template votacao_v2 para usar componentes Vue --- frontend/src/__apps/votacao/main.js | 569 +++++++++++------- .../src/components/votacao/VotacaoMateria.vue | 25 + .../src/components/votacao/VotacaoNominal.vue | 76 ++- .../components/votacao/VotacaoObservacoes.vue | 84 +++ .../components/votacao/VotacaoResultado.vue | 49 ++ .../src/components/votacao/VotacaoVotos.vue | 44 ++ sapl/templates/sessao/votacao/votacao_v2.html | 65 +- 7 files changed, 637 insertions(+), 275 deletions(-) create mode 100644 frontend/src/components/votacao/VotacaoMateria.vue create mode 100644 frontend/src/components/votacao/VotacaoObservacoes.vue create mode 100644 frontend/src/components/votacao/VotacaoResultado.vue create mode 100644 frontend/src/components/votacao/VotacaoVotos.vue diff --git a/frontend/src/__apps/votacao/main.js b/frontend/src/__apps/votacao/main.js index 24622702c..ab188d5b6 100644 --- a/frontend/src/__apps/votacao/main.js +++ b/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() + } } }) diff --git a/frontend/src/components/votacao/VotacaoMateria.vue b/frontend/src/components/votacao/VotacaoMateria.vue new file mode 100644 index 000000000..e8f47b57d --- /dev/null +++ b/frontend/src/components/votacao/VotacaoMateria.vue @@ -0,0 +1,25 @@ + + + diff --git a/frontend/src/components/votacao/VotacaoNominal.vue b/frontend/src/components/votacao/VotacaoNominal.vue index dd569d78f..128a57ed9 100644 --- a/frontend/src/components/votacao/VotacaoNominal.vue +++ b/frontend/src/components/votacao/VotacaoNominal.vue @@ -1,11 +1,73 @@ +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'); + } +}; + \ No newline at end of file diff --git a/frontend/src/components/votacao/VotacaoObservacoes.vue b/frontend/src/components/votacao/VotacaoObservacoes.vue new file mode 100644 index 000000000..0b6d1b369 --- /dev/null +++ b/frontend/src/components/votacao/VotacaoObservacoes.vue @@ -0,0 +1,84 @@ + + + diff --git a/frontend/src/components/votacao/VotacaoResultado.vue b/frontend/src/components/votacao/VotacaoResultado.vue new file mode 100644 index 000000000..ee6befd5a --- /dev/null +++ b/frontend/src/components/votacao/VotacaoResultado.vue @@ -0,0 +1,49 @@ + + + diff --git a/frontend/src/components/votacao/VotacaoVotos.vue b/frontend/src/components/votacao/VotacaoVotos.vue new file mode 100644 index 000000000..734647cdc --- /dev/null +++ b/frontend/src/components/votacao/VotacaoVotos.vue @@ -0,0 +1,44 @@ + + + diff --git a/sapl/templates/sessao/votacao/votacao_v2.html b/sapl/templates/sessao/votacao/votacao_v2.html index f988f1aba..e3da10e04 100644 --- a/sapl/templates/sessao/votacao/votacao_v2.html +++ b/sapl/templates/sessao/votacao/votacao_v2.html @@ -7,62 +7,15 @@ {% block vue_content %} -
-
- [[ tipo_votacao_descricao ]] - [[ error_message ]] - - -
- Matéria: [[ materia ]] -
- Ementa: [[ ementa ]] -
-
-
- -
- Votos -
-
[[ p.nome_parlamentar ]]
-
[[ p.filiacao ]]
-
- - - -   - -
-
- [[ votos_parlamentares[p.parlamentar_id].voto ]] -
-
-
-
-
- Total de votos: -
-
[[ voto.tipo ]]: [[ voto.total ]]
-
-
- -
- Resultado da Votação: - -
- -
-
- Observações: - -
-
- -
+
+ +
{% endblock vue_content %}