Browse Source

Feat(Proposições): Melhorias na UI/UX do módulo de proposições do vereador

- Home: Adicionado card "Matérias Legislativas" e "Minhas Matérias" para vereadores
- OnlyOffice: Corrigido callback para salvar documentos corretamente na rede Docker
- OnlyOffice: Adicionado botão "Salvar e Voltar" no editor
- Listagem: Nova interface minimalista com cards, status visual e paginação
- Detalhes: Layout moderno com alertas contextuais e ações dinâmicas
- Documentação: Adicionado docs/melhorias-proposicoes-vereador.md
pull/3858/head
rangelbruno 8 months ago
parent
commit
46eabe298c
  1. 262
      docs/melhorias-proposicoes-vereador.md
  2. 5
      sapl/base/views.py
  3. 14
      sapl/materia/onlyoffice_views.py
  4. 116
      sapl/materia/views.py
  5. 27
      sapl/templates/index.html
  6. 56
      sapl/templates/materia/onlyoffice_editor.html
  7. 636
      sapl/templates/materia/proposicao_detail.html
  8. 306
      sapl/templates/materia/proposicao_list.html

262
docs/melhorias-proposicoes-vereador.md

@ -0,0 +1,262 @@
## Resumo das Alterações
- **Home do Vereador**: Novos cards de acesso rápido
- **OnlyOffice**: Correções no salvamento de documentos
- **Listagem de Proposições**: Nova UI minimalista com status visual
- **Detalhes da Proposição**: Layout moderno e intuitivo
---
## 1. Tela Home do Vereador
### Arquivos Alterados
- `sapl/templates/index.html`
- `sapl/base/views.py`
### Mudanças
**Novo card "Matérias Legislativas"**
- Adicionado na seção do vereador (`is_parlamentar`)
- Permite pesquisar projetos de lei e proposições
- Link para `/materia/pesquisar-materia`
**Novo card "Minhas Matérias"**
- Substitui o antigo card "Fluxo de Proposições"
- Filtra automaticamente as matérias pelo autor logado
- Permite ao vereador visualizar e assinar documentos de sua autoria
**Alteração na View**
- `IndexView` agora passa `autor_id` no contexto
- Usado para filtrar matérias por autor na pesquisa
---
## 2. Correções no OnlyOffice
### Arquivos Alterados
- `sapl/materia/onlyoffice_views.py`
- `sapl/templates/materia/onlyoffice_editor.html`
- `sapl/materia/views.py`
### Problema Resolvido
Ao tentar enviar uma proposição, aparecia o erro:
> "Proposição não possui nenhum tipo de Texto associado"
Isso ocorria porque o callback do OnlyOffice não conseguia salvar o documento corretamente.
### Soluções Implementadas
**1. Correção no Callback**
```python
# Antes: só substituía localhost:8001
if 'localhost:8001' in download_url:
download_url = download_url.replace('localhost:8001', 'onlyoffice:80')
# Depois: substitui qualquer URL externa
download_url = re.sub(r'https?://[^/]+', 'http://onlyoffice:80', download_url)
```
**2. Botão "Salvar e Voltar" no Editor**
- Força o fechamento do editor (triggering callback)
- Aguarda 3 segundos para o callback processar
- Redireciona para a página de detalhes
**3. Mensagem de Erro Melhorada**
- Orienta o usuário a usar o OnlyOffice
- Explica como salvar o documento antes de enviar
**4. Aviso no Template de Detalhes**
- Alerta quando a proposição não tem texto
- Instrui o usuário a criar o documento
---
## 3. Listagem de Proposições (`/proposicao/`)
### Arquivos Alterados
- `sapl/templates/materia/proposicao_list.html`
- `sapl/materia/views.py`
### Nova Interface
**Design Minimalista**
- Layout limpo com max-width de 900px
- Cards com bordas sutis ao invés de sombras pesadas
- Tipografia clara e espaçamento consistente
**Header com Estatísticas**
```
Proposições 5 total 2 em elaboração 1 enviadas
```
**Cards de Proposição**
Cada card mostra:
- Tipo (ex: Requerimento)
- Status com badge colorido
- Data de envio
- Ementa (truncada em 40 palavras)
- Alerta de devolução (se aplicável)
- Links: Abrir / Editar
**Status com Cores**
| Status | Cor | Descrição |
|--------|-----|-----------|
| Em Elaboração | Cinza | Proposição ainda não enviada |
| Aguardando | Azul | Enviada, aguardando recebimento |
| Incorporada | Verde | Recebida pela Mesa |
| Devolvida | Laranja | Devolvida com justificativa |
| Cancelada | Cinza escuro | Proposição cancelada |
**Paginação**
- 10 itens por página
- Navegação: Anterior / Números / Próxima
- Indicador "Página X de Y"
### Código da View
```python
class ListView(Crud.ListView):
paginate_by = 10
def get_context_data(self, **kwargs):
# Estatísticas
context['stats'] = {
'total': qs.count(),
'elaboracao': qs.filter(data_envio__isnull=True).count(),
# ...
}
# Lista processada com status
for obj in page_obj.object_list:
if obj.cancelado:
status = 'cancelada'
elif obj.data_devolucao:
status = 'devolvida'
# ...
# Paginação
context['is_paginated'] = paginator.num_pages > 1
context['page_obj'] = page_obj
context['page_range'] = make_pagination(...)
```
---
## 4. Detalhes da Proposição (`/proposicao/{id}`)
### Arquivos Alterados
- `sapl/templates/materia/proposicao_detail.html`
### Nova Interface
**Header**
- Link "Voltar para lista"
- Título: Tipo + Número/Ano ou "Rascunho"
- Badge de status colorido
**Alertas Contextuais**
- **Vermelho**: Proposição devolvida (mostra justificativa)
- **Azul**: Aguardando recebimento
- **Amarelo**: Documento não criado
**Barra de Ações**
Muda conforme o estado da proposição:
*Em Elaboração:*
- Editar Documento (OnlyOffice)
- Enviar Proposição (se tem texto)
- Editar Dados
- Excluir
*Enviada:*
- Ver Recibo
- Retornar Proposição (se não recebida)
- Baixar Documento
**Seções de Conteúdo**
1. **Ementa** - Texto descritivo em destaque
2. **Informações** - Grid com:
- Tipo
- Número
- Data de Envio
- Data de Recebimento
- Data de Devolução
- Status do Documento (Criado/Não criado)
3. **Observação** - Se houver
4. **Vínculos** - Links para:
- Matéria gerada
- Matéria anexadora
5. **Info do Sistema** (só admin) - Usuário, IP, Última edição, Hash
**Página de Acesso Negado**
- Ícone de cadeado
- Mensagem clara
- Botão para voltar à lista
---
## Fluxo do Vereador
```
1. Acessar Home
└── Card "Proposições" → Criar / Listar
2. Criar Proposição
└── Preencher tipo e ementa
└── Redirecionado para OnlyOffice
3. Editar Documento (OnlyOffice)
└── Escrever texto da proposição
└── Clicar "Salvar e Voltar"
4. Enviar Proposição
└── Verificar dados
└── Clicar "Enviar Proposição"
└── Aguardar recebimento
5. Acompanhar Status
└── Listagem mostra status atual
└── Ver detalhes para mais informações
└── Se devolvida, ver justificativa e corrigir
```
---
## Considerações Técnicas
### Compatibilidade Docker
O OnlyOffice roda em container separado. As URLs são convertidas:
- Browser → OnlyOffice: `localhost:8001`
- SAPL → OnlyOffice: `onlyoffice:80`
- OnlyOffice → SAPL: `sapl-dev:8000`
### Paginação
Usa a função `make_pagination` do CRUD base para gerar o range de páginas com reticências.
### CSS
Estilos inline no template usando o bloco `webpack_loader_chunks_css` para evitar conflitos com o CSS global.
---
## Arquivos Modificados (Resumo)
```
sapl/
├── base/
│ └── views.py # IndexView com autor_id
├── materia/
│ ├── views.py # ListView e mensagens de erro
│ └── onlyoffice_views.py # Correção do callback
└── templates/
├── index.html # Cards do vereador
└── materia/
├── proposicao_list.html # Nova listagem
├── proposicao_detail.html # Novos detalhes
└── onlyoffice_editor.html # Botão salvar e voltar
```
---

5
sapl/base/views.py

@ -71,9 +71,12 @@ class IndexView(TemplateView):
context = super().get_context_data(**kwargs)
# Verifica se o usuário é operador de algum Autor (Parlamentar)
if self.request.user.is_authenticated:
context['is_parlamentar'] = self.request.user.autor_set.exists()
autor = self.request.user.autor_set.first()
context['is_parlamentar'] = autor is not None
context['autor_id'] = autor.id if autor else None
else:
context['is_parlamentar'] = False
context['autor_id'] = None
return context

14
sapl/materia/onlyoffice_views.py

@ -220,10 +220,16 @@ def onlyoffice_callback(request, pk):
if status in [2, 6] and download_url:
logger.info(f"URL original recebida: {download_url}")
# Substitui localhost:8001 por onlyoffice:80 para acesso interno Docker
if 'localhost:8001' in download_url:
download_url = download_url.replace('localhost:8001', 'onlyoffice:80')
logger.info(f"URL substituída para rede Docker: {download_url}")
# Substitui URLs externas por URLs internas da rede Docker
# O OnlyOffice pode retornar localhost:8001 ou o host externo
import re
# Padrão para capturar qualquer host:porta antes do path
download_url = re.sub(
r'https?://[^/]+',
'http://onlyoffice:80',
download_url
)
logger.info(f"URL substituída para rede Docker: {download_url}")
logger.info(f"Iniciando download do documento de: {download_url}")
proposicao = get_object_or_404(Proposicao, pk=pk)

116
sapl/materia/views.py

@ -843,12 +843,11 @@ class ProposicaoCrud(Crud):
class BaseMixin(Crud.BaseMixin):
list_field_names = [
'data_envio',
'data_recebimento',
'descricao',
'status_proposicao',
'tipo',
'descricao',
'data_envio',
'conteudo_gerado_related',
'cancelado'
]
class BaseLocalMixin:
@ -916,7 +915,8 @@ class ProposicaoCrud(Crud):
msg_error = _('Proposição já foi enviada.')
elif not p.texto_original and not p.texto_articulado.exists():
msg_error = _(
'Proposição não possui nenhum tipo de Texto associado.')
'Proposição não possui texto. Use o botão "Editar com OnlyOffice" para criar o documento, '
'e depois clique em "Salvar e Voltar" para garantir que o texto seja salvo antes de enviar.')
else:
if p.texto_articulado.exists():
ta = p.texto_articulado.first()
@ -1199,24 +1199,112 @@ class ProposicaoCrud(Crud):
'-data_envio',
'descricao'
]
paginate_by = 10
def hook_header_status_proposicao(self):
return 'Status'
def get_context_data(self, **kwargs):
# Primeiro, buscar os dados antes de chamar super()
# pois get_rows modifica os objetos
qs = self.get_queryset()
stats = {
'total': qs.count(),
'elaboracao': qs.filter(data_envio__isnull=True, cancelado=False).count(),
'aguardando': qs.filter(data_envio__isnull=False, data_recebimento__isnull=True, data_devolucao__isnull=True, cancelado=False).count(),
'incorporada': qs.filter(data_recebimento__isnull=False, cancelado=False).count(),
'devolvida': qs.filter(data_devolucao__isnull=False, cancelado=False).count(),
}
# Processar proposições ANTES de chamar super() que chama get_rows
# Usar uma nova query para não conflitar
page_qs = self.get_queryset()
paginator = self.get_paginator(page_qs, self.paginate_by)
page = self.request.GET.get('page', 1)
try:
page_obj = paginator.page(page)
except:
page_obj = paginator.page(1)
proposicoes = []
for obj in page_obj.object_list:
# Determina o status
if obj.cancelado:
status = 'cancelada'
status_label = 'Cancelada'
status_icon = 'fa-ban'
elif obj.data_devolucao:
status = 'devolvida'
status_label = 'Devolvida'
status_icon = 'fa-undo'
elif obj.data_recebimento:
status = 'incorporada'
status_label = 'Incorporada'
status_icon = 'fa-check'
elif obj.data_envio:
status = 'aguardando'
status_label = 'Aguardando Recebimento'
status_icon = 'fa-clock-o'
else:
status = 'elaboracao'
status_label = 'Em Elaboração'
status_icon = 'fa-edit'
data_envio_fmt = None
if obj.data_envio:
data_envio_fmt = formats.date_format(
timezone.localtime(obj.data_envio), "SHORT_DATETIME_FORMAT"
)
data_devolucao_fmt = None
if obj.data_devolucao:
data_devolucao_fmt = formats.date_format(
timezone.localtime(obj.data_devolucao), "SHORT_DATETIME_FORMAT"
)
proposicoes.append({
'obj': obj,
'status': status,
'status_label': status_label,
'status_icon': status_icon,
'data_envio_fmt': data_envio_fmt,
'data_devolucao_fmt': data_devolucao_fmt,
})
context = super().get_context_data(**kwargs)
context['stats'] = stats
context['proposicoes'] = proposicoes
# Paginação
context['is_paginated'] = paginator.num_pages > 1
context['page_obj'] = page_obj
context['paginator'] = paginator
context['page_range'] = make_pagination(page_obj.number, paginator.num_pages)
return context
def get_rows(self, object_list):
for obj in object_list:
if obj.data_recebimento is None:
obj.data_recebimento = 'Não recebida' if obj.data_envio else 'Não enviada'
# Determina o status da proposição
if obj.cancelado:
obj.status_proposicao = '<span class="badge badge-dark"><i class="fa fa-ban"></i> Cancelada</span>'
elif obj.data_devolucao:
obj.status_proposicao = '<span class="badge badge-warning"><i class="fa fa-undo"></i> Devolvida</span>'
elif obj.data_recebimento:
obj.status_proposicao = '<span class="badge badge-success"><i class="fa fa-check"></i> Incorporada</span>'
elif obj.data_envio:
obj.status_proposicao = '<span class="badge badge-info"><i class="fa fa-clock-o"></i> Aguardando</span>'
else:
obj.data_recebimento = timezone.localtime(
obj.data_recebimento)
obj.data_recebimento = formats.date_format(
obj.data_recebimento, "DATETIME_FORMAT")
obj.status_proposicao = '<span class="badge badge-secondary"><i class="fa fa-edit"></i> Em Elaboração</span>'
# Formata a data de envio
if obj.data_envio is None:
obj.data_envio = 'Em elaboração...'
obj.data_envio = '-'
else:
obj.data_envio = timezone.localtime(obj.data_envio)
obj.data_envio = formats.date_format(
obj.data_envio, "DATETIME_FORMAT")
obj.data_envio, "SHORT_DATETIME_FORMAT")
return [self._as_row(obj) for obj in object_list]

27
sapl/templates/index.html

@ -23,6 +23,19 @@
</a>
</div>
</div>
<!-- Matérias Legislativas (Vereador) -->
<div class="homeBlock">
<div class="card-icon">
<i class="fas fa-file-alt"></i>
</div>
<h3 class="card-title">Matérias Legislativas</h3>
<p class="card-description">Pesquise projetos de lei e proposições</p>
<div class="card-actions">
<a href="{% url 'sapl.materia:pesquisar_materia' %}" class="btn-action" title="Pesquisar Matérias">
<i class="fas fa-search"></i> Pesquisar
</a>
</div>
</div>
{% endif %}
<!-- Mesa Diretora -->
@ -153,17 +166,17 @@
</div>
{% endif %}
<!-- Fluxo de Proposições -->
{% if is_parlamentar %}
<!-- Minhas Matérias (Documentos de autoria do Vereador) -->
{% if is_parlamentar and autor_id %}
<div class="homeBlock">
<div class="card-icon">
<i class="fas fa-paper-plane"></i>
<i class="fas fa-folder-open"></i>
</div>
<h3 class="card-title">Fluxo de Proposições</h3>
<p class="card-description">Como parlamentares criam e enviam proposições</p>
<h3 class="card-title">Minhas Matérias</h3>
<p class="card-description">Visualize e assine seus documentos de autoria</p>
<div class="card-actions">
<a href="{% url 'sapl.base:fluxo_proposicoes' %}" class="btn-action" title="Ver Fluxo de Proposições">
<i class="fas fa-route"></i> Visualizar
<a href="{% url 'sapl.materia:pesquisar_materia' %}?autoria__autor={{ autor_id }}" class="btn-action" title="Ver Minhas Matérias">
<i class="fas fa-eye"></i> Visualizar
</a>
</div>
</div>

56
sapl/templates/materia/onlyoffice_editor.html

@ -33,12 +33,15 @@
<div class="row mt-3">
<div class="col-12">
<button id="btn-salvar-voltar" class="btn btn-success" onclick="salvarEVoltar()">
<i class="fa fa-save"></i> {% trans "Salvar e Voltar" %}
</button>
<a href="{% url 'sapl.materia:proposicao_detail' proposicao.pk %}" class="btn btn-secondary">
<i class="fa fa-arrow-left"></i> {% trans "Voltar" %}
<i class="fa fa-arrow-left"></i> {% trans "Voltar sem Salvar" %}
</a>
<div class="alert alert-warning mt-3">
<div class="alert alert-info mt-3">
<i class="fa fa-info-circle"></i>
{% trans "O documento é salvo automaticamente enquanto você edita. Ao terminar, feche o editor e volte para a página da proposição." %}
{% trans "Use o botão 'Salvar e Voltar' para garantir que o documento seja salvo antes de sair. Se usar Ctrl+S, aguarde a confirmação de salvamento antes de sair." %}
</div>
</div>
</div>
@ -47,17 +50,34 @@
<script type="text/javascript" src="{{ onlyoffice_url }}/web-apps/apps/api/documents/api.js"></script>
<script type="text/javascript">
var docEditor = null;
var documentSaved = false;
var returnUrl = "{% url 'sapl.materia:proposicao_detail' proposicao.pk %}";
function salvarEVoltar() {
var btn = document.getElementById('btn-salvar-voltar');
btn.disabled = true;
btn.innerHTML = '<i class="fa fa-spinner fa-spin"></i> Salvando...';
if (docEditor) {
// Força o salvamento do documento
docEditor.requestClose();
}
// Aguarda um tempo para o callback ser processado e redireciona
setTimeout(function() {
window.location.href = returnUrl;
}, 3000);
}
// Carrega a configuração do OnlyOffice
fetch('{{ config_url }}')
.then(response => response.json())
.then(config => {
console.log('OnlyOffice Config:', config);
// Inicializa o editor
var docEditor = new DocsAPI.DocEditor("onlyoffice-placeholder", config);
// Eventos do editor
docEditor.events = {
// Adiciona eventos à configuração
config.events = {
'onDocumentReady': function() {
console.log('Documento pronto para edição');
},
@ -67,8 +87,19 @@
},
'onWarning': function(event) {
console.warn('Aviso do OnlyOffice:', event);
},
'onDocumentStateChange': function(event) {
// event.data = true quando há alterações não salvas
console.log('Estado do documento:', event.data ? 'Alterações pendentes' : 'Salvo');
documentSaved = !event.data;
},
'onRequestClose': function() {
console.log('Editor fechado, redirecionando...');
}
};
// Inicializa o editor
docEditor = new DocsAPI.DocEditor("onlyoffice-placeholder", config);
})
.catch(error => {
console.error('Erro ao carregar configuração:', error);
@ -78,5 +109,14 @@
'Verifique se o servidor OnlyOffice está rodando.' +
'</div>';
});
// Aviso antes de sair se houver alterações não salvas
window.addEventListener('beforeunload', function(e) {
if (!documentSaved && docEditor) {
e.preventDefault();
e.returnValue = 'Você tem alterações não salvas. Deseja realmente sair?';
return e.returnValue;
}
});
</script>
{% endblock %}

636
sapl/templates/materia/proposicao_detail.html

@ -1,239 +1,471 @@
{% extends "crud/detail.html" %}
{% extends "base.html" %}
{% load i18n common_tags %}
{% load tz %}
{% block sub_actions %}
{{block.super}}
{% if user in object.autor.operadores.all %}
<div class="actions btn-group btn-group-sm {%block sub_actions_pull%}{% endblock%}" role="group">
{% if object.texto_articulado.exists %}
<a class="btn btn-success" href="{% url 'sapl.materia:proposicao_ta' object.pk%}">{% trans "Texto Eletrônico" %}</a>
{% block webpack_loader_chunks_css %}
{{ block.super }}
<style>
.prop-detail {
max-width: 800px;
margin: 0 auto;
padding: 20px 0;
}
.prop-detail-header {
margin-bottom: 24px;
}
.prop-back {
display: inline-flex;
align-items: center;
gap: 6px;
color: #666;
text-decoration: none;
font-size: 0.9rem;
margin-bottom: 16px;
}
.prop-back:hover {
color: #333;
}
.prop-title-row {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
flex-wrap: wrap;
}
.prop-title {
font-size: 1.1rem;
font-weight: 500;
color: #333;
margin: 0;
}
.prop-title-tipo {
color: #666;
font-weight: 400;
}
.prop-status-badge {
padding: 6px 14px;
border-radius: 20px;
font-size: 0.8rem;
font-weight: 500;
white-space: nowrap;
}
.prop-status-badge.elaboracao { background: #f0f0f0; color: #666; }
.prop-status-badge.aguardando { background: #e3f2fd; color: #1976d2; }
.prop-status-badge.incorporada { background: #e8f5e9; color: #388e3c; }
.prop-status-badge.devolvida { background: #fff3e0; color: #f57c00; }
.prop-status-badge.cancelada { background: #f5f5f5; color: #999; }
.prop-alert {
padding: 14px 18px;
border-radius: 8px;
margin-bottom: 20px;
font-size: 0.9rem;
}
.prop-alert.warning {
background: #fff8e1;
border: 1px solid #ffe082;
color: #8d6e00;
}
.prop-alert.info {
background: #e3f2fd;
border: 1px solid #90caf9;
color: #1565c0;
}
.prop-alert.danger {
background: #ffebee;
border: 1px solid #ef9a9a;
color: #c62828;
}
.prop-alert strong {
display: block;
margin-bottom: 4px;
}
.prop-section {
background: #fff;
border: 1px solid #e5e5e5;
border-radius: 8px;
padding: 20px;
margin-bottom: 16px;
}
.prop-section-title {
font-size: 0.75rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.5px;
color: #999;
margin-bottom: 8px;
}
.prop-ementa {
font-size: 1rem;
line-height: 1.7;
color: #333;
}
.prop-meta {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 16px;
}
.prop-meta-item label {
display: block;
font-size: 0.75rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.5px;
color: #999;
margin-bottom: 4px;
}
.prop-meta-item span {
font-size: 0.95rem;
color: #333;
}
.prop-meta-item a {
color: #1976d2;
text-decoration: none;
}
.prop-meta-item a:hover {
text-decoration: underline;
}
.prop-actions-bar {
display: flex;
gap: 12px;
flex-wrap: wrap;
padding: 16px 20px;
background: #fafafa;
border: 1px solid #e5e5e5;
border-radius: 8px;
margin-bottom: 16px;
}
.prop-actions-bar .btn {
display: inline-flex;
align-items: center;
gap: 6px;
}
.prop-document-section {
background: #f8f9fa;
border: 2px dashed #dee2e6;
border-radius: 8px;
padding: 30px;
text-align: center;
margin-bottom: 16px;
}
.prop-document-section.has-document {
background: #fff;
border: 1px solid #e5e5e5;
border-style: solid;
}
.prop-document-section i {
font-size: 2.5rem;
color: #adb5bd;
margin-bottom: 12px;
}
.prop-document-section.has-document i {
color: #28a745;
}
.prop-document-section h4 {
font-size: 1rem;
font-weight: 500;
color: #333;
margin-bottom: 8px;
}
.prop-document-section p {
font-size: 0.9rem;
color: #666;
margin-bottom: 16px;
}
.prop-document-actions {
display: flex;
gap: 10px;
justify-content: center;
flex-wrap: wrap;
}
.prop-vinculo {
background: #f8f9fa;
border-radius: 6px;
padding: 12px 16px;
}
.prop-vinculo a {
color: #1976d2;
text-decoration: none;
font-weight: 500;
}
.prop-vinculo a:hover {
text-decoration: underline;
}
.prop-no-access {
text-align: center;
padding: 60px 20px;
color: #999;
}
.prop-no-access i {
font-size: 3rem;
margin-bottom: 16px;
}
.prop-observacao {
font-size: 0.9rem;
color: #666;
font-style: italic;
}
@media (max-width: 640px) {
.prop-title-row {
flex-direction: column;
}
.prop-actions-bar {
flex-direction: column;
}
.prop-actions-bar .btn {
width: 100%;
justify-content: center;
}
}
</style>
{% endblock webpack_loader_chunks_css %}
{% block base_content %}
<div class="prop-detail">
{% if user in object.autor.operadores.all %}
<!-- Header -->
<div class="prop-detail-header">
<a href="{% url 'sapl.materia:proposicao_list' %}" class="prop-back">
<i class="fa fa-arrow-left"></i> {% trans "Voltar para lista" %}
</a>
<div class="prop-title-row">
<h1 class="prop-title">
<span class="prop-title-tipo">{{ object.tipo }} —</span>
{% if object.numero_proposicao %}#{{ object.numero_proposicao }}/{{ object.ano }}{% else %}{% trans "Rascunho" %}{% endif %}
</h1>
{% if object.cancelado %}
<span class="prop-status-badge cancelada">Cancelada</span>
{% elif object.data_devolucao %}
<span class="prop-status-badge devolvida">Devolvida</span>
{% elif object.data_recebimento %}
<span class="prop-status-badge incorporada">Incorporada</span>
{% elif object.data_envio %}
<span class="prop-status-badge aguardando">Aguardando Recebimento</span>
{% else %}
<span class="prop-status-badge elaboracao">Em Elaboração</span>
{% endif %}
</div>
</div>
<!-- Alertas -->
{% if object.data_devolucao %}
<div class="prop-alert danger">
<strong><i class="fa fa-exclamation-circle"></i> {% trans "Proposição Devolvida" %}</strong>
{{ object.justificativa_devolucao }}
</div>
{% elif object.data_envio and not object.data_recebimento %}
<div class="prop-alert info">
<i class="fa fa-clock-o"></i> {% trans "Proposição enviada e aguardando recebimento pela Mesa Diretora." %}
</div>
{% elif not object.data_envio and not object.texto_original and not object.texto_articulado.exists %}
<div class="prop-alert warning">
<strong><i class="fa fa-file-text-o"></i> {% trans "Documento não criado" %}</strong>
{% trans "Use o botão 'Editar Documento' abaixo para criar o texto da proposição." %}
</div>
{% endif %}
<!-- Ações principais -->
<div class="prop-actions-bar">
{% if not object.data_envio %}
<a href="{% url 'sapl.materia:onlyoffice_editor' object.pk %}" class="btn btn-primary">
<i class="fa fa-edit"></i> {% trans "Editar Documento" %}
</a>
{% if object.texto_original or object.texto_articulado.exists %}
<a href="{{ view.detail_url }}?action=send" class="btn btn-success">
<i class="fa fa-paper-plane"></i> {% trans "Enviar Proposição" %}
</a>
{% endif %}
<a href="{{ view.update_url }}" class="btn btn-outline-secondary">
<i class="fa fa-pencil"></i> {% trans "Editar Dados" %}
</a>
<a href="{{ view.delete_url }}" class="btn btn-outline-danger">
<i class="fa fa-trash"></i> {% trans "Excluir" %}
</a>
{% else %}
<a href="#" onclick="window.open('{% url 'sapl.materia:recibo-proposicao' object.pk %}','Recibo','width=1100, height=600, scrollbars=yes'); return false;" class="btn btn-outline-primary">
<i class="fa fa-file-text-o"></i> {% trans "Ver Recibo" %}
</a>
{% if not object.data_recebimento %}
<a href="{% url 'sapl.materia:retornar-proposicao' object.pk %}" class="btn btn-outline-warning">
<i class="fa fa-undo"></i> {% trans "Retornar Proposição" %}
</a>
{% endif %}
{% endif %}
{% if object.texto_original %}
<a class="btn btn-success" href="{% url 'sapl.materia:proposicao_texto' object.pk %}">{% trans "Texto Original" %}</a>
{% endif %}
{% if not object.data_envio %}
<a class="btn btn-primary" href="{% url 'sapl.materia:onlyoffice_editor' object.pk %}">
<i class="fa fa-file-word-o"></i> {% trans "Editar com OnlyOffice" %}
<a href="{% url 'sapl.materia:proposicao_texto' object.pk %}" class="btn btn-outline-secondary" target="_blank">
<i class="fa fa-download"></i> {% trans "Baixar Documento" %}
</a>
{% endif %}
</div>
{% endif %}
{% endblock sub_actions%}
{% block editions %}
{% if user in object.autor.operadores.all %}
{% if object.data_envio %}
{% block editions_actions_return %}
<div class="actions btn-group" role="group">
<a class="btn btn-outline-primary" onclick="window.open('{% url 'sapl.materia:recibo-proposicao' object.pk %}','Recibo','width=1100, height=600, scrollbars=yes')">{% trans "Recibo de Envio" %}</a>
{% if not object.data_recebimento %}
<a href="{% url 'sapl.materia:retornar-proposicao' object.pk %}" class="btn btn-outline-primary btn-outline-danger">{% trans 'Retornar Proposição Enviada' %}</a>
{% endif %}
</div>
{% endblock %}
{% else %}
{% block editions_actions_send %}
<div class="actions btn-group" role="group">
<a href="{{ view.detail_url }}?action=send" class="btn btn-primary">{% trans 'Enviar' %}</a>
</div>
<div class="actions btn-group" role="group">
<a href="{{ view.update_url }}" class="btn btn-outline-primary">{% trans 'Editar' %}</a>
<a href="{{ view.delete_url }}" class="btn btn-outline-primary btn-outline-danger">{% trans 'Excluir' %}</a>
</div>
{% endblock %}
{% endif %}
{% endif %}
{% endblock editions %}
{% block detail_content %}
{% if user in object.autor.operadores.all %}
<h2 class="legend">{% model_verbose_name 'sapl.materia.models.Proposicao' %}</h2>
<div class="row">
<div class="col-sm-3">
<div id="div_id_tipo" class="form-group">
<p class="control-label">{%field_verbose_name object 'tipo'%}</p>
<div class="controls">
<div class="form-control-static">{{object.tipo}}</div>
</div>
<!-- Ementa -->
<div class="prop-section">
<div class="prop-section-title">{% trans "Ementa" %}</div>
<div class="prop-ementa">{{ object.descricao }}</div>
</div>
<!-- Informações -->
<div class="prop-section">
<div class="prop-section-title">{% trans "Informações" %}</div>
<div class="prop-meta">
<div class="prop-meta-item">
<label>{% trans "Tipo" %}</label>
<span>{{ object.tipo }}</span>
</div>
</div>
{% if object.data_devolucao %}
<div class="col-sm-9">
<div class="alert alert-danger alert-dismissible " role="alert">
<strong>{% trans "Proposição devolvida em:" %} {{ object.data_devolucao}}</strong>
<div >{% trans "Justificativa:" %} {{object.justificativa_devolucao}}</div>
</div>
{% if object.numero_proposicao %}
<div class="prop-meta-item">
<label>{% trans "Número" %}</label>
<span>{{ object.numero_proposicao }}/{{ object.ano }}</span>
</div>
{% else %}
{% endif %}
{% if object.data_envio %}
<div class="col-sm-3">
<div id="div_id_data_envio" class="form-group">
<p class="control-label">{%field_verbose_name object 'data_envio' %}</p>
<div class="controls">
<div class="form-control-static">{{object.data_envio}}</div>
</div>
</div>
</div>
<div class="prop-meta-item">
<label>{% trans "Data de Envio" %}</label>
<span>{{ object.data_envio|date:"d/m/Y H:i" }}</span>
</div>
{% endif %}
{% if object.data_recebimento %}
<div class="col-sm-3">
<div id="div_id_data_envio" class="form-group">
<p class="control-label">{%field_verbose_name object 'data_recebimento'%}</p>
<div class="controls">
<div class="form-control-static">{{object.data_recebimento}}</div>
</div>
</div>
</div>
{% elif object.data_envio %}
<div class="col-sm-6">
<div class="alert alert-info alert-dismissible " role="alert">
<div >{% trans "Proposição aguardando recebimento" %}</div>
</div>
</div>
<div class="prop-meta-item">
<label>{% trans "Data de Recebimento" %}</label>
<span>{{ object.data_recebimento|date:"d/m/Y H:i" }}</span>
</div>
{% endif %}
{% endif %}
</div>
<div class="row">
<div class="col-sm-12">
<div id="div_id_descricao" class="form-group">
<p class="control-label">{%field_verbose_name object 'descricao'%}</p>
<div class="controls">
<div class="form-control-static">{{object.descricao}}</div>
</div>
{% if object.data_devolucao %}
<div class="prop-meta-item">
<label>{% trans "Data de Devolução" %}</label>
<span>{{ object.data_devolucao|date:"d/m/Y H:i" }}</span>
</div>
{% endif %}
<div class="prop-meta-item">
<label>{% trans "Documento" %}</label>
<span>
{% if object.texto_original %}
<i class="fa fa-check-circle" style="color: #28a745;"></i> {% trans "Criado" %}
{% elif object.texto_articulado.exists %}
<i class="fa fa-check-circle" style="color: #28a745;"></i> {% trans "Texto Eletrônico" %}
{% else %}
<i class="fa fa-times-circle" style="color: #dc3545;"></i> {% trans "Não criado" %}
{% endif %}
</span>
</div>
</div>
</div>
<!-- Observação -->
{% if object.observacao %}
<div class="row">
<div class="col-sm-12">
<div id="div_id_obseracao" class="form-group">
<p class="control-label">{%field_verbose_name object 'observacao'%}</p>
<div class="controls">
<div class="form-control-static">{{object.observacao}}</div>
</div>
</div>
</div>
</div>
{% endif %}
{% if object.data_envio %}
<div class="row">
<div class="col-sm-12">
<div id="div_id_obseracao" class="form-group">
<p class="control-label">Enviada por</p>
<div class="controls">
<div class="form-control-static">
{# TODO remover condicional apos migrar as acoes para os devidos campos#}
{% if proposicao.usuario_envio %}
{{ proposicao.usuario_envio|format_user }}
{% else %}
{{ proposicao.user|format_user }}
{% endif %}
</div>
</div>
</div>
</div>
</div>
<div class="prop-section">
<div class="prop-section-title">{% trans "Observação" %}</div>
<div class="prop-observacao">{{ object.observacao }}</div>
</div>
{% endif %}
<div class="row">
<!-- Conteúdo Gerado / Vínculo -->
{% if object.conteudo_gerado_related or object.materia_de_vinculo %}
<div class="prop-section">
<div class="prop-section-title">{% trans "Vínculos" %}</div>
{% if object.conteudo_gerado_related %}
<div class="col-sm-6">
<h2 class="legend">{% trans "Conteúdo Gerado" %}</h2>
<div id="div_id_conteudo_gerado_related" class="form-group">
<div class="controls">
<a href="{% url object|urldetail_content_type:object.conteudo_gerado_related object.conteudo_gerado_related.id%}">
{{object.conteudo_gerado_related}}
</a>
</div>
</div>
<div class="prop-vinculo" style="margin-bottom: 10px;">
<label style="font-size: 0.8rem; color: #666;">{% trans "Matéria Gerada:" %}</label>
<a href="{% url object|urldetail_content_type:object.conteudo_gerado_related object.conteudo_gerado_related.id %}">
{{ object.conteudo_gerado_related }}
</a>
</div>
{% endif %}
{% if object.materia_de_vinculo %}
<div class="col-sm-6">
<h2 class="legend">{% trans "Vínculo com a Matéria Legislativa" %}</h2>
<div id="div_id_materia_de_vinculo" class="form-group">
<div class="controls">
Matéria</br>
&nbsp;&nbsp;&nbsp;&nbsp;
<a href="{% url object.materia_de_vinculo|urldetail object.materia_de_vinculo.id%}">
{{object.materia_de_vinculo}}
</a>
</br>
{% if object.materia_de_vinculo.autoria_set.all %}
Autores
{% for a in object.materia_de_vinculo.autoria_set.all %}
</br>&nbsp;&nbsp;&nbsp;&nbsp;{{a.autor}}
{% endfor %}
{% endif %}
</br>
Texto Original
</br>
{% if object.materia_de_vinculo.texto_original %}
<a href="{{object.materia_de_vinculo.texto_original.url}}">
{{object.materia_de_vinculo.texto_original| to_str | split:"/" | get_last_item_from_list:-1}}
</a>
{% endif %}
</br>
</div>
</div>
<div class="prop-vinculo">
<label style="font-size: 0.8rem; color: #666;">{% trans "Matéria Anexadora:" %}</label>
<a href="{% url object.materia_de_vinculo|urldetail object.materia_de_vinculo.id %}">
{{ object.materia_de_vinculo }}
</a>
</div>
{% endif %}
{% if not AppConfig.receber_recibo_proposicao %}
{% if object.hash_code %}
<div class="col-sm-12">
<div id="div_id_hash_code" class="form-group">
<p class="control-label">{%field_verbose_name object 'hash_code'%}</p>
<div class="controls">
<div class="form-control-static">{{object.hash_code}}</div>
</div>
</div>
</div>
{% endif %}
{% endif %}
</div>
{% endif %}
<!-- Info para superusuário -->
{% if user.is_superuser %}
<div class="row">
<div class="prop-section" style="background: #f8f9fa;">
<div class="prop-section-title">{% trans "Informações do Sistema" %}</div>
<div class="prop-meta">
{% if proposicao.user %}
<div class="col-sm-4">
<div id="div_id_user" class="form-group">
<p class="control-label">Usuário</p>
<div class="controls">
<div class="form-control-static">
<div class="dont-break-out">
<a href="{% url 'sapl.base:user_update' proposicao.user.pk %}">{{ proposicao.user }}</a>
</div>
</div>
</div>
</div>
</div>
<div class="prop-meta-item">
<label>{% trans "Usuário" %}</label>
<span><a href="{% url 'sapl.base:user_update' proposicao.user.pk %}">{{ proposicao.user }}</a></span>
</div>
{% endif %}
{% if proposicao.ip %}
<div class="col-sm-4">
<div id="div_ip_user" class="form-group">
<p class="control-label">IP</p>
<div class="controls">
<div class="form-control-static">
<div class="dont-break-out">{{ proposicao.ip }}</div>
</div>
</div>
</div>
</div>
<div class="prop-meta-item">
<label>{% trans "IP" %}</label>
<span>{{ proposicao.ip }}</span>
</div>
{% endif %}
{% if proposicao.ultima_edicao %}
<div class="col-sm-4">
<div id="div_ud_user" class="form-group">
<p class="control-label">Data e Hora da Edição</p>
<div class="controls">
<div class="form-control-static">
<div class="dont-break-out">{{ proposicao.ultima_edicao }}</div>
</div>
</div>
</div>
</div>
<div class="prop-meta-item">
<label>{% trans "Última Edição" %}</label>
<span>{{ proposicao.ultima_edicao }}</span>
</div>
{% endif %}
{% if object.hash_code %}
<div class="prop-meta-item">
<label>{% trans "Hash" %}</label>
<span style="font-size: 0.75rem; word-break: break-all;">{{ object.hash_code }}</span>
</div>
{% endif %}
</div>
</div>
{% endif %}
{% else %}
<br><br>
<div class="alert alert-danger alert-dismissible" role="alert">
<h3>Você não tem acesso a essa Proposição porque não é de sua autoria.</h3>
<!-- Sem acesso -->
<div class="prop-no-access">
<i class="fa fa-lock"></i>
<h3>{% trans "Acesso Negado" %}</h3>
<p>{% trans "Você não tem permissão para visualizar esta proposição." %}</p>
<a href="{% url 'sapl.materia:proposicao_list' %}" class="btn btn-outline-primary">
{% trans "Voltar para lista" %}
</a>
</div>
{% endif %}
{% endblock detail_content %}
</div>
{% endblock base_content %}

306
sapl/templates/materia/proposicao_list.html

@ -1,12 +1,302 @@
{% extends "crud/list.html" %}
{% load i18n %}
{% extends "base.html" %}
{% load i18n common_tags crispy_forms_tags %}
{% block webpack_loader_chunks_css %}
{{ block.super }}
<style>
.prop-page {
max-width: 900px;
margin: 0 auto;
}
.prop-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
padding-bottom: 16px;
border-bottom: 1px solid #e5e5e5;
}
.prop-header h2 {
margin: 0;
font-size: 1.5rem;
font-weight: 500;
color: #333;
}
.prop-stats {
display: flex;
gap: 16px;
font-size: 0.85rem;
color: #666;
}
.prop-stats span {
display: flex;
align-items: center;
gap: 4px;
}
.prop-actions {
margin-bottom: 20px;
}
.prop-card {
background: #fff;
border: 1px solid #e5e5e5;
border-radius: 8px;
padding: 16px 20px;
margin-bottom: 12px;
transition: border-color 0.15s;
}
.prop-card:hover {
border-color: #ccc;
}
.prop-card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.prop-card-meta {
display: flex;
align-items: center;
gap: 12px;
}
.prop-tipo {
font-size: 0.75rem;
font-weight: 500;
color: #666;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.prop-status {
font-size: 0.75rem;
padding: 3px 10px;
border-radius: 12px;
font-weight: 500;
}
.prop-status.elaboracao { background: #f0f0f0; color: #666; }
.prop-status.aguardando { background: #e3f2fd; color: #1976d2; }
.prop-status.incorporada { background: #e8f5e9; color: #388e3c; }
.prop-status.devolvida { background: #fff3e0; color: #f57c00; }
.prop-status.cancelada { background: #f5f5f5; color: #999; }
.prop-date {
font-size: 0.8rem;
color: #999;
}
.prop-ementa {
color: #333;
font-size: 0.95rem;
line-height: 1.5;
margin-bottom: 12px;
}
.prop-card-footer {
display: flex;
justify-content: space-between;
align-items: center;
}
.prop-info {
font-size: 0.8rem;
color: #999;
}
.prop-info a {
color: #1976d2;
text-decoration: none;
}
.prop-info a:hover {
text-decoration: underline;
}
.prop-card-actions a {
font-size: 0.85rem;
color: #1976d2;
text-decoration: none;
margin-left: 16px;
}
.prop-card-actions a:hover {
text-decoration: underline;
}
.prop-alert {
background: #fff8e1;
border-radius: 4px;
padding: 8px 12px;
margin-bottom: 12px;
font-size: 0.85rem;
color: #8d6e00;
}
.prop-empty {
text-align: center;
padding: 60px 20px;
color: #999;
}
.prop-empty i {
font-size: 3rem;
margin-bottom: 16px;
opacity: 0.5;
}
.prop-pagination {
margin-top: 24px;
padding-top: 16px;
border-top: 1px solid #e5e5e5;
}
.prop-pagination-info {
text-align: center;
font-size: 0.8rem;
color: #999;
margin-top: 8px;
}
@media (max-width: 640px) {
.prop-card-header {
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
.prop-date {
order: -1;
}
}
</style>
{% endblock webpack_loader_chunks_css %}
{% block base_content %}
{% block sub_actions %}
<div class="actions btn-group btn-group-sm" role="group">
<a href="{% url 'sapl.materia:historico-proposicao' %}" class="btn btn-outline-primary">
{% blocktrans with verbose_name=view.verbose_name %} Histórico de {{ verbose_name }} {% endblocktrans %}
<div class="prop-page">
<div class="prop-header">
<h2>{% trans "Proposições" %}</h2>
<div class="prop-stats">
<span><strong>{{ stats.total|default:"0" }}</strong> total</span>
<span><strong>{{ stats.elaboracao|default:"0" }}</strong> em elaboração</span>
<span><strong>{{ stats.aguardando|default:"0" }}</strong> enviadas</span>
</div>
</div>
<div class="prop-actions">
{% if view.create_url %}
<a href="{{ view.create_url }}" class="btn btn-primary btn-sm">
<i class="fa fa-plus"></i> {% trans "Nova Proposição" %}
</a>
{% endif %}
<a href="{% url 'sapl.materia:historico-proposicao' %}" class="btn btn-outline-secondary btn-sm">
<i class="fa fa-history"></i> {% trans "Histórico" %}
</a>
</div>
{% endblock sub_actions %}
{{ block.super }}
{% if not proposicoes %}
<div class="prop-empty">
<i class="fa fa-file-text-o"></i>
<p>{% trans "Nenhuma proposição encontrada" %}</p>
{% if view.create_url %}
<a href="{{ view.create_url }}" class="btn btn-primary">
{% trans "Criar primeira proposição" %}
</a>
{% endif %}
</div>
{% else %}
{% for prop in proposicoes %}
<div class="prop-card">
<div class="prop-card-header">
<div class="prop-card-meta">
<span class="prop-tipo">{{ prop.obj.tipo }}</span>
<span class="prop-status {{ prop.status }}">{{ prop.status_label }}</span>
</div>
<span class="prop-date">
{% if prop.data_envio_fmt %}
{{ prop.data_envio_fmt }}
{% else %}
{% endif %}
</span>
</div>
<div class="prop-ementa">
{{ prop.obj.descricao|truncatewords:40 }}
</div>
{% if prop.status == 'devolvida' and prop.obj.justificativa_devolucao %}
<div class="prop-alert">
<strong>{% trans "Devolvida:" %}</strong> {{ prop.obj.justificativa_devolucao }}
</div>
{% endif %}
<div class="prop-card-footer">
<div class="prop-info">
{% if prop.obj.numero_proposicao %}
#{{ prop.obj.numero_proposicao }}/{{ prop.obj.ano }}
{% endif %}
{% if prop.obj.conteudo_gerado_related %}
<a href="{% url prop.obj|urldetail_content_type:prop.obj.conteudo_gerado_related prop.obj.conteudo_gerado_related.id %}">
{{ prop.obj.conteudo_gerado_related }}
</a>
{% endif %}
</div>
<div class="prop-card-actions">
<a href="{% url 'sapl.materia:proposicao_detail' prop.obj.pk %}">
{% trans "Abrir" %}
</a>
{% if prop.status == 'elaboracao' %}
<a href="{% url 'sapl.materia:proposicao_update' prop.obj.pk %}">
{% trans "Editar" %}
</a>
{% endif %}
</div>
</div>
</div>
{% endfor %}
{% endif %}
</div>
{% if is_paginated %}
<div class="prop-pagination">
<nav>
<ul class="pagination pagination-sm justify-content-center">
{% if page_obj.has_previous %}
<li class="page-item">
<a class="page-link" href="?page={{ page_obj.previous_page_number }}">Anterior</a>
</li>
{% endif %}
{% for page in page_range %}
{% if page == None or page == 'None' %}
<li class="page-item disabled"><span class="page-link">...</span></li>
{% else %}
<li class="page-item {% if page == page_obj.number %}active{% endif %}">
<a class="page-link" href="?page={{ page }}">{{ page }}</a>
</li>
{% endif %}
{% endfor %}
{% if page_obj.has_next %}
<li class="page-item">
<a class="page-link" href="?page={{ page_obj.next_page_number }}">Próxima</a>
</li>
{% endif %}
</ul>
</nav>
<p class="prop-pagination-info">
Página {{ page_obj.number }} de {{ paginator.num_pages }}
</p>
</div>
{% endif %}
{% endblock %}
Loading…
Cancel
Save