diff --git a/app/routes/issues.py b/app/routes/issues.py
index 61f8141..ea98427 100644
--- a/app/routes/issues.py
+++ b/app/routes/issues.py
@@ -2,7 +2,7 @@ import os
import logging
from app.utils.time_utils import now_eastern
from flask import (Blueprint, render_template, redirect, url_for,
- flash, request, current_app, jsonify, abort)
+ flash, request, current_app, jsonify, abort, Response)
from flask_login import login_required, current_user
from app import db
from app.models.issue import Issue, IssueComment, IssueFollower
@@ -16,7 +16,8 @@ from app.models.notification import (
from app.utils.forms import IssueForm, IssueUpdateForm
from app.utils.decorators import supervisor_required
from app.utils.notifications import notify, notify_customers_for_facility, notify_by_matrix
-from app.utils.audit import log_action, ACTION_CREATE, ACTION_UPDATE, ACTION_DELETE
+from app.utils.audit import log_action, ACTION_CREATE, ACTION_UPDATE, ACTION_DELETE, ACTION_EXPORT
+from app.utils.pdf_export import generate_issues_list_pdf
from app.utils.scope import get_customer_scope, get_inspector_scope
from app.utils.sla import sla_status
from sqlalchemy.orm import joinedload, contains_eager
@@ -67,6 +68,104 @@ class _SLAFilteredPage:
return iter([1])
+@bp.route('/export-list-pdf')
+@login_required
+def export_list_pdf():
+ """Generate and stream a PDF of the currently filtered issue list."""
+ q = (
+ Issue.query
+ .outerjoin(Area, Issue.area_id == Area.id)
+ .options(
+ contains_eager(Issue.area),
+ joinedload(Issue.facility),
+ joinedload(Issue.assigned_user),
+ )
+ .order_by(Issue.reported_at.desc())
+ )
+
+ if current_user.role == 'inspector':
+ fids = get_inspector_scope(current_user)
+ if not fids:
+ q = q.filter(False)
+ else:
+ q = q.filter(db.or_(
+ Issue.facility_id.in_(fids),
+ db.and_(Issue.area_id.isnot(None), Area.facility_id.in_(fids)),
+ ))
+ elif current_user.role == 'customer':
+ customer_facility_ids = get_customer_scope(current_user)
+ if not customer_facility_ids:
+ q = q.filter(False)
+ else:
+ q = q.filter(db.or_(
+ Issue.facility_id.in_(customer_facility_ids),
+ db.and_(Issue.area_id.isnot(None), Area.facility_id.in_(customer_facility_ids)),
+ ))
+
+ issue_id_filter = request.args.get('issue_id', '').strip()
+ severity_filter = request.args.get('severity', '')
+ status_filter = request.args.get('status', '')
+ sla_filter = request.args.get('sla', '')
+ facility_filter = request.args.get('facility_id', '')
+ contract_filter = request.args.get('contract_id', '')
+
+ if issue_id_filter.isdigit():
+ q = q.filter(Issue.id == int(issue_id_filter))
+ if severity_filter:
+ q = q.filter(Issue.severity == severity_filter)
+ if status_filter:
+ q = q.filter(Issue.status == status_filter)
+ if contract_filter.isdigit():
+ _contract_fids = [
+ f.id for f in Facility.query.filter_by(project_id=int(contract_filter)).all()
+ ]
+ q = q.filter(db.or_(
+ Issue.facility_id.in_(_contract_fids),
+ Area.facility_id.in_(_contract_fids),
+ )) if _contract_fids else q.filter(False)
+ if facility_filter:
+ fid = int(facility_filter)
+ q = q.filter(db.or_(Issue.facility_id == fid, Area.facility_id == fid))
+
+ all_issues = q.all()
+ if sla_filter:
+ all_issues = [i for i in all_issues if sla_status(i) == sla_filter]
+
+ # Human-readable filter summary for the PDF header
+ filter_parts = []
+ if issue_id_filter:
+ filter_parts.append(f'Issue #: {issue_id_filter}')
+ if severity_filter:
+ filter_parts.append(f'Severity: {severity_filter.title()}')
+ if status_filter:
+ filter_parts.append(f'Status: {status_filter.replace("_", " ").title()}')
+ if sla_filter:
+ filter_parts.append(f'SLA: {sla_filter.replace("_", " ").title()}')
+ if contract_filter.isdigit():
+ from app.models.project import Project
+ p = db.session.get(Project, int(contract_filter))
+ if p:
+ filter_parts.append(f'Contract: {p.name}')
+ if facility_filter:
+ f = db.session.get(Facility, int(facility_filter))
+ if f:
+ filter_parts.append(f'Facility: {f.name}')
+
+ filter_summary = ' | '.join(filter_parts) if filter_parts else 'All issues'
+
+ pdf_bytes = generate_issues_list_pdf(all_issues, filter_summary)
+ filename = f'issues_list_{now_eastern().strftime("%Y%m%d_%H%M")}.pdf'
+
+ log_action(ACTION_EXPORT, 'Issue', None, 'Issues List',
+ f'format=pdf; filters={filter_summary}; count={len(all_issues)}')
+
+ return Response(
+ pdf_bytes,
+ mimetype='application/pdf',
+ headers={'Content-Disposition': f'attachment; filename="{filename}"'},
+ )
+
+
@bp.route('/')
@login_required
def index():
diff --git a/app/templates/issues/list.html b/app/templates/issues/list.html
index 83635a7..4f5fc38 100644
--- a/app/templates/issues/list.html
+++ b/app/templates/issues/list.html
@@ -63,9 +63,13 @@
{% endfor %}
-
@@ -194,7 +198,7 @@
{% if p %}
{{ p }}
+ href="{{ url_for('issues.index', page=p, issue_id=issue_id_filter, severity=severity_filter, status=status_filter, sla=sla_filter, contract_id=contract_filter, facility_id=facility_filter) }}">{{ p }}
{% else %}…{% endif %}
{% endfor %}
diff --git a/app/utils/pdf_export.py b/app/utils/pdf_export.py
index e095a40..850440a 100644
--- a/app/utils/pdf_export.py
+++ b/app/utils/pdf_export.py
@@ -1096,6 +1096,161 @@ def generate_scheduled_report_pdf(report_name, frequency, start, end,
return buf.getvalue()
+# ══════════════════════════════════════════════════════════════════════════════
+# ISSUES LIST PDF
+# ══════════════════════════════════════════════════════════════════════════════
+
+def generate_issues_list_pdf(issues, filter_summary: str = '') -> bytes:
+ """Return a PDF byte-string for a filtered list of issues.
+
+ Parameters
+ ----------
+ issues : list of Issue model instances
+ filter_summary : human-readable string describing active filters (optional)
+ """
+ buf = io.BytesIO()
+ generated_at = datetime.now().strftime('%B %d, %Y %I:%M %p ET')
+ report_title = 'Issues List'
+
+ page_size = landscape(letter)
+ doc = SimpleDocTemplate(
+ buf,
+ pagesize=page_size,
+ leftMargin=0.65 * inch,
+ rightMargin=0.65 * inch,
+ topMargin=1.1 * inch,
+ bottomMargin=0.75 * inch,
+ title=report_title,
+ author='Janitorial QC System',
+ )
+
+ def _page_cb(canvas, doc):
+ _on_page(canvas, doc, report_title, generated_at)
+
+ pw = page_size[0] - 1.3 * inch # usable page width (~9.7 in)
+
+ story = []
+
+ if filter_summary:
+ story.append(Paragraph(f'Filters: {filter_summary}', STYLES['ReportSub']))
+ story.append(Paragraph(f'Total records: {len(issues)}', STYLES['ReportSub']))
+ story.append(Spacer(1, 10))
+
+ if not issues:
+ story.append(Paragraph('No issues match the selected filters.', STYLES['FieldValue']))
+ doc.build(story, onFirstPage=_page_cb, onLaterPages=_page_cb)
+ return buf.getvalue()
+
+ # ── Column widths ─────────────────────────────────────────────────────────
+ # #, Reported, Severity, Contract, Facility / Area, Description, Status, SLA, Assigned
+ col_w = [
+ pw * 0.05, # #
+ pw * 0.10, # Reported
+ pw * 0.08, # Severity
+ pw * 0.13, # Contract
+ pw * 0.14, # Facility / Area
+ pw * 0.28, # Description
+ pw * 0.10, # Status
+ pw * 0.07, # SLA
+ pw * 0.05, # Assigned (truncated)
+ ]
+
+ hdr_style = ParagraphStyle('ILH', fontName='Helvetica-Bold', fontSize=7.5,
+ textColor=C_WHITE, leading=9)
+ val_style = ParagraphStyle('ILV', fontName='Helvetica', fontSize=7.5,
+ textColor=C_DARK, leading=9)
+
+ def _h(text):
+ return Paragraph(text, hdr_style)
+
+ def _v(text, color=None):
+ if color:
+ return Paragraph(f'{text}', val_style)
+ return Paragraph(text, val_style)
+
+ tbl_data = [[
+ _h('#'), _h('Reported'), _h('Severity'), _h('Contract'),
+ _h('Facility / Area'), _h('Description'), _h('Status'), _h('SLA'), _h('Assigned'),
+ ]]
+
+ for iss in issues:
+ reported_str = iss.reported_at.strftime('%Y-%m-%d %H:%M') if iss.reported_at else '—'
+
+ sev = iss.severity or 'low'
+ sev_color = SEVERITY_COLORS.get(sev, C_SLATE)
+
+ contract_str = '—'
+ if iss.resolved_facility and iss.resolved_facility.project:
+ contract_str = iss.resolved_facility.project.name
+ facility_str = iss.resolved_facility.name if iss.resolved_facility else '—'
+ area_str = iss.area.name if iss.area else '—'
+ fac_area_str = f'{facility_str}\n{area_str}'
+
+ desc_str = iss.description or ''
+ if len(desc_str) > 90:
+ desc_str = desc_str[:90] + '...'
+
+ status_raw = iss.status or ''
+ if status_raw == 'resolved':
+ status_str = 'Resolved'
+ status_color = C_GREEN
+ elif status_raw == 'pending_verification':
+ status_str = 'Pending Verif.'
+ status_color = colors.HexColor('#0ea5e9')
+ elif status_raw == 'in_progress':
+ status_str = 'In Progress'
+ status_color = C_YELLOW
+ else:
+ status_str = 'Open'
+ status_color = C_RED
+
+ # Compute SLA inline (avoids circular import — same logic as sla.sla_status)
+ from app.utils.sla import sla_status as _sla_status
+ sla = _sla_status(iss)
+ if sla == 'breached':
+ sla_str, sla_color = 'Breached', C_RED
+ elif sla == 'at_risk':
+ sla_str, sla_color = 'At Risk', C_YELLOW
+ elif sla == 'ok':
+ sla_str, sla_color = 'OK', C_GREEN
+ else:
+ sla_str, sla_color = '—', C_SLATE
+
+ assigned_str = '—'
+ if iss.assigned_user:
+ n = iss.assigned_user.display_name
+ assigned_str = n[:18] + ('...' if len(n) > 18 else '')
+
+ tbl_data.append([
+ _v(f'#{iss.id}'),
+ _v(reported_str),
+ _v(sev.title(), color=sev_color),
+ _v(contract_str[:28] + ('...' if len(contract_str) > 28 else '')),
+ Paragraph(f'{facility_str[:28]}\n{area_str[:24]}', val_style),
+ _v(desc_str),
+ _v(status_str, color=status_color),
+ _v(sla_str, color=sla_color),
+ _v(assigned_str),
+ ])
+
+ tbl = Table(tbl_data, colWidths=col_w, repeatRows=1)
+ tbl.setStyle(TableStyle([
+ ('BACKGROUND', (0, 0), (-1, 0), C_DARK),
+ ('ROWBACKGROUNDS', (0, 1), (-1, -1), [C_WHITE, C_LIGHT]),
+ ('INNERGRID', (0, 0), (-1, -1), 0.25, C_BORDER),
+ ('BOX', (0, 0), (-1, -1), 0.5, C_BORDER),
+ ('VALIGN', (0, 0), (-1, -1), 'TOP'),
+ ('TOPPADDING', (0, 0), (-1, -1), 4),
+ ('BOTTOMPADDING', (0, 0), (-1, -1), 4),
+ ('LEFTPADDING', (0, 0), (-1, -1), 4),
+ ('RIGHTPADDING', (0, 0), (-1, -1), 4),
+ ]))
+ story.append(tbl)
+
+ doc.build(story, onFirstPage=_page_cb, onLaterPages=_page_cb)
+ return buf.getvalue()
+
+
# ══════════════════════════════════════════════════════════════════════════════
# INSPECTIONS LIST PDF
# ══════════════════════════════════════════════════════════════════════════════