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 @@
+
+
+ Ementa: {{ materia.ementa }}
+
+
+
+
+