diff --git a/docs/melhorias-proposicoes-vereador.md b/docs/melhorias-proposicoes-vereador.md new file mode 100644 index 000000000..a9367fca7 --- /dev/null +++ b/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 +``` + +--- diff --git a/sapl/base/views.py b/sapl/base/views.py index c5fad7f13..34ccab936 100644 --- a/sapl/base/views.py +++ b/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 diff --git a/sapl/materia/onlyoffice_views.py b/sapl/materia/onlyoffice_views.py index caa8b6c37..35d679b9d 100644 --- a/sapl/materia/onlyoffice_views.py +++ b/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) diff --git a/sapl/materia/views.py b/sapl/materia/views.py index de995c563..de6db87bb 100644 --- a/sapl/materia/views.py +++ b/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 = ' Cancelada' + elif obj.data_devolucao: + obj.status_proposicao = ' Devolvida' + elif obj.data_recebimento: + obj.status_proposicao = ' Incorporada' + elif obj.data_envio: + obj.status_proposicao = ' Aguardando' else: - obj.data_recebimento = timezone.localtime( - obj.data_recebimento) - obj.data_recebimento = formats.date_format( - obj.data_recebimento, "DATETIME_FORMAT") + obj.status_proposicao = ' Em Elaboração' + + # 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] diff --git a/sapl/templates/index.html b/sapl/templates/index.html index 086950973..2d5a9f745 100644 --- a/sapl/templates/index.html +++ b/sapl/templates/index.html @@ -23,6 +23,19 @@ + +
{% endif %} @@ -153,17 +166,17 @@ {% endif %} - - {% if is_parlamentar %} + + {% if is_parlamentar and autor_id %}Como parlamentares criam e enviam proposições
+Visualize e assine seus documentos de autoria
{%field_verbose_name object 'tipo'%}
-{%field_verbose_name object 'data_envio' %}
-{%field_verbose_name object 'data_recebimento'%}
-{%field_verbose_name object 'descricao'%}
-{%field_verbose_name object 'observacao'%}
-Enviada por
-{%field_verbose_name object 'hash_code'%}
-{% trans "Você não tem permissão para visualizar esta proposição." %}
+ + {% trans "Voltar para lista" %} +{% trans "Nenhuma proposição encontrada" %}
+ {% if view.create_url %} + + {% trans "Criar primeira proposição" %} + + {% endif %} ++ Página {{ page_obj.number }} de {{ paginator.num_pages }} +
+