From 1f93ec5817f8593f6f6570bd2b86c2ac85179a70 Mon Sep 17 00:00:00 2001 From: KemuelAlves Date: Fri, 15 May 2026 16:57:03 -0300 Subject: [PATCH] =?UTF-8?q?Feat=20(Materias):=20Como=20cliente,=20quero=20?= =?UTF-8?q?poder=20imprimir=20diversos=20documentos=20de=20uma=20=C3=BAnic?= =?UTF-8?q?a=20vez=20[AB#1325]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- sapl/materia/urls.py | 3 + sapl/materia/views.py | 105 ++++++- sapl/static/js/materia_impressao_em_massa.js | 293 ++++++++++++++++++ .../materia/materialegislativa_filter.html | 251 ++++++++++++--- 4 files changed, 591 insertions(+), 61 deletions(-) create mode 100644 sapl/static/js/materia_impressao_em_massa.js diff --git a/sapl/materia/urls.py b/sapl/materia/urls.py index 007a7b7b6..f4201141c 100644 --- a/sapl/materia/urls.py +++ b/sapl/materia/urls.py @@ -34,6 +34,7 @@ from sapl.materia.views import (AcompanhamentoConfirmarView, MateriaPesquisaSimplesView, DespachoInicialMultiCreateView, get_zip_docacessorios, get_pdf_docacessorios, get_zip_completo, get_pdf_completo, + get_pdf_multiplos, configEtiquetaMateriaLegislativaCrud, PesquisarStatusTramitacaoView, HistoricoProposicaoView) from sapl.materia.onlyoffice_views import (onlyoffice_config, onlyoffice_download, @@ -163,6 +164,8 @@ urlpatterns_materia = [ name='zip_completo_materia'), url(r'^materia/pdf-completo/(?P\d+)$', get_pdf_completo, name='pdf_completo_materia'), + url(r'^materia/pdf-multiplos/$', get_pdf_multiplos, + name='pdf_multiplos_materias'), # OnlyOffice endpoints para Matéria Legislativa url(r'^materia/(?P\d+)/onlyoffice/editor$', materia_onlyoffice_editor, diff --git a/sapl/materia/views.py b/sapl/materia/views.py index bce2dc4a4..4bc6156b9 100644 --- a/sapl/materia/views.py +++ b/sapl/materia/views.py @@ -2800,17 +2800,26 @@ class MateriaLegislativaPesquisaView(MultiFormatOutputMixin, FilterView): status_assinatura = self.request.GET.get('status_assinatura') if status_assinatura == 'pendente' and context['show_results']: from django.db.models import Q as _Q - qs_lote = self.object_list.filter( - texto_original__isnull=False, - ).filter( - _Q(pdf_assinado__isnull=True) | _Q(pdf_assinado='') - ).exclude(texto_original='').select_related('tipo').values_list( - 'id', 'tipo__sigla', 'numero', 'ano' - )[:200] - context['materias_pendentes_lote'] = [ - {'id': pk, 'descricao': f'{sigla} {numero}/{ano}'} - for pk, sigla, numero, ano in qs_lote - ] + # object_list já foi filtrado pelo filter_status_assinatura — + # precisamos obter os IDs primeiro para evitar problemas com + # querysets compostos por union (|) que não suportam .filter() extra + try: + ids_lote = list(self.object_list.values_list('id', flat=True)[:200]) + from .models import MateriaLegislativa + qs_lote = MateriaLegislativa.objects.filter( + pk__in=ids_lote, + texto_original__isnull=False, + ).exclude(texto_original='').filter( + _Q(pdf_assinado__isnull=True) | _Q(pdf_assinado='') + ).select_related('tipo').values_list( + 'id', 'tipo__sigla', 'numero', 'ano' + ) + context['materias_pendentes_lote'] = [ + {'id': pk, 'descricao': f'{sigla} {numero}/{ano}'} + for pk, sigla, numero, ano in qs_lote + ] + except Exception: + context['materias_pendentes_lote'] = [] else: context['materias_pendentes_lote'] = [] @@ -3882,3 +3891,77 @@ def configEtiquetaMateriaLegislativaCrud(request): else: form = ConfigEtiquetaMateriaLegislativaForms(instance=config) return render(request, 'materia/config_etiqueta_materia.html', {'form': form}) + + +def get_pdf_multiplos(request): + """ + Gera PDF unificado com os documentos das matérias informadas via + GET ?ids=1,2,3 ou POST body JSON {"ids": [1,2,3]}. + Retorna o PDF inline para impressão direta no browser. + Limite: 50 matérias por chamada. + """ + logger_local = logging.getLogger(__name__) + username = 'Usuário anônimo' if request.user.is_anonymous else request.user.username + + if request.method == 'POST': + import json as _json + try: + body = _json.loads(request.body) + ids_raw = body.get('ids', '') + except Exception: + ids_raw = request.POST.get('ids', '') + else: + ids_raw = request.GET.get('ids', '') + + try: + import json as _json + if isinstance(ids_raw, list): + ids = [int(i) for i in ids_raw] + elif isinstance(ids_raw, str) and ids_raw.startswith('['): + ids = [int(i) for i in _json.loads(ids_raw)] + else: + ids = [int(i.strip()) for i in str(ids_raw).split(',') if i.strip().isdigit()] + except Exception: + return JsonResponse({'error': 'IDs invalidos'}, status=400) + + if not ids: + return JsonResponse({'error': 'Nenhum ID informado'}, status=400) + + ids = ids[:50] + + MEDIA_ROOT_local = settings.MEDIA_ROOT + materias = MateriaLegislativa.objects.filter(pk__in=ids).select_related('tipo') + + pdf_files = [] + for materia in materias: + if materia.pdf_assinado: + f = os.path.join(MEDIA_ROOT_local, str(materia.pdf_assinado)) + if os.path.exists(f) and f.lower().endswith('.pdf'): + pdf_files.append(f) + continue + if materia.texto_original: + f = os.path.join(MEDIA_ROOT_local, str(materia.texto_original)) + if os.path.exists(f) and f.lower().endswith('.pdf'): + pdf_files.append(f) + + if not pdf_files: + return JsonResponse({'error': 'Nenhum PDF disponivel para as materias selecionadas.'}, status=404) + + try: + merger = PdfFileMerger(strict=False) + for f in pdf_files: + merger.append(fileobj=f) + data = BytesIO() + merger.write(data) + merger.close() + pdf_bytes = data.getvalue() + except Exception as e: + logger_local.error("user={}. Erro ao gerar PDF multiplos: {}".format(username, str(e))) + return JsonResponse({'error': 'Erro ao gerar PDF: ' + str(e)}, status=500) + + logger_local.info("user={}. Gerou PDF multiplos ({} materias, {} PDFs)".format( + username, len(materias), len(pdf_files))) + + response = HttpResponse(pdf_bytes, content_type='application/pdf') + response['Content-Disposition'] = 'inline; filename="materias_selecionadas.pdf"' + return response diff --git a/sapl/static/js/materia_impressao_em_massa.js b/sapl/static/js/materia_impressao_em_massa.js new file mode 100644 index 000000000..71dacc058 --- /dev/null +++ b/sapl/static/js/materia_impressao_em_massa.js @@ -0,0 +1,293 @@ +/** + * materia_impressao_em_massa.js + * + * Impressão / download em massa de matérias legislativas. + * + * Funcionalidades: + * - Ativa modo de seleção ao clicar em "Imprimir Selecionados" (barra de ações) + * - Exibe checkbox em cada linha de resultado + * - Toolbar flutuante mostra contagem e ações (Imprimir / Baixar PDF / Limpar) + * - Botão "Todos" seleciona/desseleciona todos da página atual + * - Ao clicar Imprimir: chama /materia/pdf-multiplos/?ids=... → abre PDF no + * browser para impressão direta via window.print() em novo tab + * - Ao clicar Baixar PDF: mesmo endpoint mas força download via blob + */ + +(function () { + 'use strict'; + + var URL_PDF_MULTIPLOS = '/materia/pdf-multiplos/'; + var MAX_SELECAO = 200; + + var modoAtivo = false; + + // ── Elementos ──────────────────────────────────────────────────────────── + var btnImprimirSelecionados = null; // botão na barra de ações (topo) + var btnSelecionarTodos = null; + var toolbar = null; + var toolbarCount = null; + var toolbarBtnImprimir = null; + var toolbarBtnDownload = null; + var toolbarBtnLimpar = null; + var toolbarLoading = null; + + // ── Inicialização ───────────────────────────────────────────────────────── + function init() { + btnImprimirSelecionados = document.getElementById('btn-imprimir-selecionados'); + btnSelecionarTodos = document.getElementById('btn-selecionar-todos-print'); + toolbar = document.getElementById('print-toolbar'); + toolbarCount = document.getElementById('print-toolbar-count'); + toolbarBtnImprimir = document.getElementById('print-toolbar-btn-imprimir'); + toolbarBtnDownload = document.getElementById('print-toolbar-btn-download'); + toolbarBtnLimpar = document.getElementById('print-toolbar-btn-limpar'); + toolbarLoading = document.getElementById('print-toolbar-loading'); + + if (!btnImprimirSelecionados || !toolbar) return; // não está na página de resultados + + // Botões já visíveis no novo layout — garante estado inicial correto + btnImprimirSelecionados.style.display = ''; + if (btnSelecionarTodos) btnSelecionarTodos.style.display = 'none'; // aparece só quando modo ativo + + btnImprimirSelecionados.addEventListener('click', function () { + if (!modoAtivo) { + ativarModo(); + } else { + var ids = getIdsSelecionados(); + if (ids.length === 0) { + mostrarAlerta('Selecione ao menos um documento para imprimir.'); + return; + } + abrirPDF(ids, true); + } + }); + + btnSelecionarTodos.addEventListener('click', function () { + if (!modoAtivo) { ativarModo(); } + var checks = document.querySelectorAll('.print-chk'); + var todasMarcadas = Array.from(checks).every(function (c) { return c.checked; }); + checks.forEach(function (c) { c.checked = !todasMarcadas; }); + atualizarContagem(); + }); + + toolbarBtnImprimir.addEventListener('click', function () { + var ids = getIdsSelecionados(); + if (ids.length === 0) { mostrarAlerta('Selecione ao menos um documento.'); return; } + abrirPDF(ids, true); + }); + + toolbarBtnDownload.addEventListener('click', function () { + var ids = getIdsSelecionados(); + if (ids.length === 0) { mostrarAlerta('Selecione ao menos um documento.'); return; } + baixarPDF(ids); + }); + + toolbarBtnLimpar.addEventListener('click', function () { + desativarModo(); + }); + + // Delegação de eventos nos checkboxes (gerados dinamicamente) + document.addEventListener('change', function (e) { + if (e.target && e.target.classList.contains('print-chk')) { + atualizarContagem(); + } + }); + + // Clique na linha inteira (quando modo ativo) seleciona o checkbox + document.addEventListener('click', function (e) { + if (!modoAtivo) return; + var row = e.target.closest('.materia-row'); + if (!row) return; + // Evita toggle duplo se clicou direto no checkbox ou num link + if (e.target.classList.contains('print-chk')) return; + if (e.target.closest('a')) return; + var chk = row.querySelector('.print-chk'); + if (chk) { + chk.checked = !chk.checked; + atualizarContagem(); + } + }); + } + + // ── Modo de seleção ─────────────────────────────────────────────────────── + function ativarModo() { + modoAtivo = true; + // Mostra checkboxes em todas as linhas + document.querySelectorAll('.print-select-col').forEach(function (el) { + el.style.display = 'inline-block'; + }); + // Estilo visual nas linhas + document.querySelectorAll('.materia-row').forEach(function (row) { + row.style.cursor = 'pointer'; + }); + // Atualiza botão de ações — destaca em vermelho sólido + btnImprimirSelecionados.classList.replace('btn-outline-danger', 'btn-danger'); + // Mostra badge e botão Todos + var badge = document.getElementById('badge-print-total'); + if (badge) badge.style.display = ''; + if (btnSelecionarTodos) btnSelecionarTodos.style.display = ''; + + toolbar.style.display = 'block'; + atualizarContagem(); + } + + function desativarModo() { + modoAtivo = false; + // Desmarca todos e oculta checkboxes + document.querySelectorAll('.print-chk').forEach(function (c) { c.checked = false; }); + document.querySelectorAll('.print-select-col').forEach(function (el) { + el.style.display = 'none'; + }); + document.querySelectorAll('.materia-row').forEach(function (row) { + row.style.cursor = ''; + row.classList.remove('table-active'); + }); + // Restaura botão para outline + btnImprimirSelecionados.classList.replace('btn-danger', 'btn-outline-danger'); + // Oculta badge e botão Todos + var badge = document.getElementById('badge-print-total'); + if (badge) { badge.style.display = 'none'; badge.textContent = '0'; } + if (btnSelecionarTodos) btnSelecionarTodos.style.display = 'none'; + + toolbar.style.display = 'none'; + ocultarAlertaToolbar(); + } + + // ── Contagem ────────────────────────────────────────────────────────────── + function getIdsSelecionados() { + return Array.from(document.querySelectorAll('.print-chk:checked')) + .map(function (c) { return parseInt(c.getAttribute('data-materia-id'), 10); }) + .slice(0, MAX_SELECAO); + } + + function atualizarContagem() { + var ids = getIdsSelecionados(); + var n = ids.length; + + // Badge na toolbar flutuante + if (toolbarCount) toolbarCount.textContent = n; + + // Badge no botão de ações + var badge = document.getElementById('badge-print-total'); + if (badge) badge.textContent = n; + + // Destaque visual nas linhas selecionadas + document.querySelectorAll('.materia-row').forEach(function (row) { + var chk = row.querySelector('.print-chk'); + if (chk && chk.checked) { + row.classList.add('table-active'); + } else { + row.classList.remove('table-active'); + } + }); + + // Aviso de limite + if (n >= MAX_SELECAO) { + mostrarAlertaToolbar('Limite de ' + MAX_SELECAO + ' documentos atingido. Desmarque alguns para selecionar outros.'); + } else { + ocultarAlertaToolbar(); + } + } + + // ── PDF: imprimir ───────────────────────────────────────────────────────── + function abrirPDF(ids, imprimir) { + setLoading(true); + var url = URL_PDF_MULTIPLOS + '?ids=' + ids.join(','); + + // Abre em nova aba; quando carregado o browser oferece impressão + var win = window.open(url, '_blank'); + if (!win) { + // Pop-up bloqueado — fallback: link direto + var a = document.createElement('a'); + a.href = url; + a.target = '_blank'; + a.rel = 'noopener'; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + } + // Aguarda pequeno delay e remove loading (não temos evento de "carregou" na outra aba) + setTimeout(function () { setLoading(false); }, 2000); + } + + // ── PDF: baixar como arquivo ────────────────────────────────────────────── + function baixarPDF(ids) { + setLoading(true); + var url = URL_PDF_MULTIPLOS + '?ids=' + ids.join(','); + + fetch(url, { credentials: 'same-origin' }) + .then(function (resp) { + if (!resp.ok) { + return resp.json().then(function (d) { + throw new Error(d.error || ('Erro HTTP ' + resp.status)); + }); + } + return resp.blob(); + }) + .then(function (blob) { + var blobUrl = window.URL.createObjectURL(blob); + var a = document.createElement('a'); + a.href = blobUrl; + a.download = 'materias_selecionadas.pdf'; + a.style.display = 'none'; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + window.URL.revokeObjectURL(blobUrl); + setLoading(false); + }) + .catch(function (err) { + setLoading(false); + mostrarAlerta('Erro ao gerar PDF: ' + err.message); + }); + } + + // ── Helpers UI ──────────────────────────────────────────────────────────── + function setLoading(on) { + if (!toolbarLoading) return; + toolbarLoading.style.display = on ? 'block' : 'none'; + if (toolbarBtnImprimir) toolbarBtnImprimir.disabled = on; + if (toolbarBtnDownload) toolbarBtnDownload.disabled = on; + } + + function mostrarAlerta(msg) { + // Toast simples usando Bootstrap alert + var div = document.createElement('div'); + div.className = 'alert alert-warning alert-dismissible fade show'; + div.style.cssText = 'position:fixed;top:20px;right:20px;z-index:9999;min-width:300px;max-width:500px;'; + div.innerHTML = ' ' + escHtml(msg) + + ''; + document.body.appendChild(div); + setTimeout(function () { + if (div.parentNode) div.parentNode.removeChild(div); + }, 5000); + } + + function mostrarAlertaToolbar(msg) { + var existing = document.getElementById('print-toolbar-limit-alert'); + if (existing) return; + var div = document.createElement('div'); + div.id = 'print-toolbar-limit-alert'; + div.className = 'alert alert-warning py-1 px-2 mt-2 mb-0 small'; + div.innerHTML = ' ' + escHtml(msg); + if (toolbar) toolbar.appendChild(div); + } + + function ocultarAlertaToolbar() { + var el = document.getElementById('print-toolbar-limit-alert'); + if (el && el.parentNode) el.parentNode.removeChild(el); + } + + function escHtml(s) { + return String(s) + .replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); + } + + // ── Arranque ────────────────────────────────────────────────────────────── + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); + } else { + init(); + } + +})(); diff --git a/sapl/templates/materia/materialegislativa_filter.html b/sapl/templates/materia/materialegislativa_filter.html index cb3e58436..73f7cf8b0 100644 --- a/sapl/templates/materia/materialegislativa_filter.html +++ b/sapl/templates/materia/materialegislativa_filter.html @@ -7,61 +7,148 @@ {% load static %} {% block actions %} + + +
+ + {# ── Linha 1: ações + navegação ─────────────────────────────────── #} +
+ +
+ {% if show_results %} + {# Exportações (CSV, XLS, etc) #} + {% with 'sapl.materia:pesquisar_materia' as url_reverse %} + {% include "crud/format_options.html" %} + {% endwith %} + + {# Download todos os PDFs desta página #} + + +
+ + {# Impressão em massa #} + + + {% endif %} +
- {% if show_results %} -
- {% with 'sapl.materia:pesquisar_materia' as url_reverse %} - {% include "crud/format_options.html" %} +
+ {% switch "SOLR_SWITCH" %} + + + Pesquisa Textual + + {% endswitch %} + {% if perms.materia.add_materialegislativa %} + + + {% blocktrans with verbose_name=view.verbose_name %}Adicionar Matéria Legislativa{% endblocktrans %} + + {% endif %} + {% if show_results %} + + + {% trans 'Nova pesquisa' %} + + {% endif %} +
+ +
+ {# ── /Linha 1 ────────────────────────────────────────────────────── #} + + {# ── Linha 2: filtro de assinatura + assinar em lote ─────────────── #} +
+ + {% with status_atual=request.GET.status_assinatura %} + {% endwith %} - + {% endif %}
- {% endif %} + {# ── /Linha 2 ────────────────────────────────────────────────────── #} - {# ── Filtro rápido de assinatura ─────────────────────────────────── #} -
- - {% with status_atual=request.GET.status_assinatura %} - - {% endwith %} - {% if materias_pendentes_lote %} - - {% endif %}
- {# ─────────────────────────────────────────────────────────────────── #} - -
- {% switch "SOLR_SWITCH" %} - - Pesquisa Textual - - {% endswitch %} - {% if perms.materia.add_materialegislativa %} - - {% blocktrans with verbose_name=view.verbose_name %} Adicionar Matéria Legislativa {% endblocktrans %} - - {% endif %} - {% if show_results %} - {% trans 'Fazer nova pesquisa' %} - {% endif %} -
+ {% endblock %} {% block detail_content %} @@ -87,8 +174,16 @@ {% endif %} {% for m in page_obj %} - + + {# Checkbox para seleção de impressão em massa #} + {{m.tipo.sigla}} {{m.numero}}/{{m.ano}} - {{m.tipo}} Etiqueta Individual
@@ -292,11 +387,67 @@ {% include "paginacao.html" %} {% endif %} + + {# ── Toolbar flutuante de impressão em massa ─────────────────────────── #} + {% if show_results %} + + {% endif %} + {# ────────────────────────────────────────────────────────────────────── #} + {% endblock detail_content %} {% block table_content %} {% endblock table_content %} {% block extra_js %} + {% if materias_pendentes_lote %} {# ── Modal de Assinatura em Lote ─────────────────────────────────────── #}