diff --git a/frontend/src/__apps/painel/main.js b/frontend/src/__apps/painel/main.js index b09491ae0..a96036546 100644 --- a/frontend/src/__apps/painel/main.js +++ b/frontend/src/__apps/painel/main.js @@ -1,6 +1,5 @@ import './scss/painel.scss' -// main.js (Vue 2) import Vue from 'vue' import Vuex from 'vuex' import { mapState } from 'vuex'; @@ -13,7 +12,7 @@ import PainelParlamentares from '../../components/painel/PainelParlamentares.vue import PainelOradores from '../../components/painel/PainelOradores.vue' import PainelMateria from '../../components/painel/PainelMateria.vue' import PainelResultado from '../../components/painel/PainelResultado.vue' -import alarm from '../../assets/audio/ring.mp3' +import PainelFooter from '../../components/painel/PainelFooter.vue' // register components Vue.component('painel-cronometro', Cronometro) @@ -23,6 +22,7 @@ Vue.component('painel-parlamentares', PainelParlamentares) Vue.component('painel-oradores', PainelOradores) Vue.component('painel-materia', PainelMateria) Vue.component('painel-resultado', PainelResultado) +Vue.component('painel-footer', PainelFooter) // global store Vue.use(Vuex) @@ -50,10 +50,22 @@ const store = new Vuex.Store({ }, updateParlamentares(state, votos_parlamentares) { if (votos_parlamentares) { - state.parlamentares.forEach((p)=>{ - if (p.parlamentar_id in votos_parlamentares) { - p.voto = votos_parlamentares[p.parlamentar_id].voto + let votosMap = votos_parlamentares; + if (Array.isArray(votos_parlamentares)) { + votosMap = {}; + votos_parlamentares.forEach((item) => { + if (item) { + Object.keys(item).forEach((key) => { + votosMap[key] = item[key]; + }); + } + }); + } + state.parlamentares = state.parlamentares.map((p) => { + if (p.parlamentar_id in votosMap) { + return { ...p, voto: votosMap[p.parlamentar_id].voto }; } + return p; }); } }, @@ -71,6 +83,9 @@ const store = new Vuex.Store({ }, setMostrarVoto(state, mostrar_voto) { state.mostrar_voto = mostrar_voto; + }, + setSessao(state, sessao) { + state.sessao = sessao; } }, actions: {}, @@ -104,8 +119,9 @@ new Vue({ this.connectWS() }, + computed: { - ...mapState(["painel_aberto", "sessao_aberta"]), + ...mapState(["painel_aberto", "sessao_aberta", "sessao"]), canRender () { return this.sessao_aberta && this.painel_aberto; }, @@ -113,7 +129,7 @@ new Vue({ methods: { ...mapMutations(['sessaoStatus', 'painelStatus','setParlamentares', 'updateParlamentares', 'setOradores', 'setMateria', - 'setResultado', 'setMessage', 'setMostrarVoto']), + 'setResultado', 'setMessage', 'setMostrarVoto', 'setSessao']), wsURL() { const proto = location.protocol === 'https:' ? 'wss' : 'ws' return `${proto}://${location.host}/ws/painel/${this.controllerId}/` @@ -165,6 +181,10 @@ new Vue({ this.painelStatus(data.painel_aberto); this.setMostrarVoto(data.mostrar_voto); + if (data.sessao) { + this.setSessao(data.sessao); + } + // PARLAMENTARES if (data.parlamentares) { // pre-popula para Vuex capturar mudanca de estado de 'voto' @@ -173,14 +193,14 @@ new Vue({ } // HEADER DO PAINEL - // SESSAO_PLENARIA - //TODO: group in a single SessaoPlenaria object const headerInstance = this.$refs.painelHeader; - //TODO: setup as child's props? headerInstance.sessao_plenaria = data.sessao.sessao_plenaria - headerInstance.sessao_plenaria_data = data.sessao.sessao_plenaria_data - headerInstance.sessao_plenaria_hora_inicio = data.sessao.sessao_plenaria_hora_inicio - headerInstance.brasao = data.sessao.brasao + + // FOOTER DO PAINEL (brasão + relógio) + const footerInstance = this.$refs.painelFooter; + if (footerInstance) { + footerInstance.brasao = data.sessao.brasao; + } if (data.message) { this.setMessage(data.message); @@ -194,15 +214,20 @@ new Vue({ // MATERIA if (data.materia) { this.setMateria(data.materia); - } - // RESULTADO - if (data.materia.resultado) { - this.setResultado(data.materia.resultado); - } + // RESULTADO + if (data.materia.resultado) { + this.setResultado(data.materia.resultado); - if (data.materia.resultado.votos_parlamentares) { - this.updateParlamentares(data.materia.resultado.votos_parlamentares); + if (data.materia.resultado.votos_parlamentares) { + this.updateParlamentares(data.materia.resultado.votos_parlamentares); + } + } else { + this.setResultado({}); + } + } else { + this.setMateria({}); + this.setResultado({}); } } catch (e) { console.error('Error', e); @@ -243,6 +268,11 @@ new Vue({ console.log(`Received ping from server: ${JSON.stringify(data)}`); } else if (data.type == 'stopwatch.update') { this.handleStopWatchEvent(data); + } else if (data.type == 'vote.update') { + console.log('Received vote update:', data); + this.updateParlamentares({ + [data.parlamentar_id]: { voto: data.voto } + }); } } catch (e) { console.error('WS parse error:', e); diff --git a/frontend/src/__apps/painel/scss/painel.scss b/frontend/src/__apps/painel/scss/painel.scss index aa3c060a1..d1ef31aef 100644 --- a/frontend/src/__apps/painel/scss/painel.scss +++ b/frontend/src/__apps/painel/scss/painel.scss @@ -1,43 +1,24 @@ +:root { + --bg-dark: #121212; + --bg-panel: #1e1e1e; + --text-main: #e0e0e0; + --text-highlight: #4fa64d; +} -.painel-principal { - background: #1c1b1b; - font-family: Verdana; - font-size: x-large; - .text-title { - color: #4fa64d; - margin: 0.5rem; - font-weight: bold; - } - .text-subtitle { - color: #459170; - font-weight: bold; - } - .data-hora { - font-size: 180%; - } +body { + background-color: var(--bg-dark); + color: var(--text-main); + font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; + margin-bottom: 0; +} - .text-value { - color: white; - } +.separador-vertical { + border-right: 1px solid #333; + min-height: 100vh; + margin: 0; +} - .logo-painel { - max-width: 100%; - } - .painels { - flex-wrap: wrap; - } - .painel{ - margin-top: 1rem; - table { - width: 100%; - } - h2 { - margin-bottom: 0.5rem; - } - #votacao, #oradores_list { - text-align: left; - display: inline-block; - margin-bottom: 1rem; - } - } +.text-subtitle { + color: #81c784; + font-weight: 600; } \ No newline at end of file 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/painel/Cronometro.vue b/frontend/src/components/painel/Cronometro.vue index a77e1d777..081b3404a 100644 --- a/frontend/src/components/painel/Cronometro.vue +++ b/frontend/src/components/painel/Cronometro.vue @@ -1,18 +1,19 @@ + + + diff --git a/frontend/src/components/painel/PainelFooter.vue b/frontend/src/components/painel/PainelFooter.vue new file mode 100644 index 000000000..c2ffea383 --- /dev/null +++ b/frontend/src/components/painel/PainelFooter.vue @@ -0,0 +1,62 @@ + + + + + \ No newline at end of file diff --git a/frontend/src/components/painel/PainelHeader.vue b/frontend/src/components/painel/PainelHeader.vue index 38b13bd6d..c4a3ff49e 100644 --- a/frontend/src/components/painel/PainelHeader.vue +++ b/frontend/src/components/painel/PainelHeader.vue @@ -1,29 +1,7 @@ -