Browse Source

Merge ed29b9b4e2 into 2d6eaae489

pull/3840/merge
Heitor 6 days ago
committed by GitHub
parent
commit
0ad6c77d51
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 62
      frontend/src/__apps/painel/main.js
  2. 59
      frontend/src/__apps/painel/scss/painel.scss
  3. 47
      frontend/src/components/painel/Cronometro.vue
  4. 89
      frontend/src/components/painel/CronometroList.vue
  5. 62
      frontend/src/components/painel/PainelFooter.vue
  6. 61
      frontend/src/components/painel/PainelHeader.vue
  7. 36
      frontend/src/components/painel/PainelMateria.vue
  8. 23
      frontend/src/components/painel/PainelOradores.vue
  9. 158
      frontend/src/components/painel/PainelParlamentares.vue
  10. 123
      frontend/src/components/painel/PainelResultado.vue
  11. 23
      sapl/painel/consumers.py
  12. 16
      sapl/painel/views.py
  13. 14
      sapl/sessao/migrations/0070_views_sessao_plenaria.py
  14. 73
      sapl/templates/painel/painel_v2.html

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

@ -13,6 +13,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 PainelFooter from '../../components/painel/PainelFooter.vue'
import alarm from '../../assets/audio/ring.mp3' import alarm from '../../assets/audio/ring.mp3'
// register components // register components
@ -23,6 +24,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 +52,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 +85,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: {},
@ -105,7 +122,7 @@ new Vue({
}, },
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 +130,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 +182,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 +194,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 +215,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);

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;
}
}
} }

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>

61
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>

23
sapl/painel/consumers.py

@ -65,6 +65,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)})

16
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': ''
}) })

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 %}

Loading…
Cancel
Save