From 8cc1770c10b5032166e3c2bb468618c9309ab53f Mon Sep 17 00:00:00 2001 From: NguyenND Date: Fri, 12 Jun 2026 12:47:56 -0400 Subject: [PATCH] 06/12 Redesign Reports page --- app/routes/reports.py | 617 +++++++++++++++++- app/templates/reports/_subnav.html | 36 + app/templates/reports/followup_closure.html | 186 ++++++ app/templates/reports/index.html | 15 +- .../reports/inspector_performance.html | 13 +- app/templates/reports/issues_aging.html | 175 +++++ app/templates/reports/scorecard.html | 4 + app/templates/reports/sla_compliance.html | 149 +++++ app/utils/pdf_export.py | 161 +++++ 9 files changed, 1329 insertions(+), 27 deletions(-) create mode 100644 app/templates/reports/_subnav.html create mode 100644 app/templates/reports/followup_closure.html create mode 100644 app/templates/reports/issues_aging.html create mode 100644 app/templates/reports/sla_compliance.html diff --git a/app/routes/reports.py b/app/routes/reports.py index bc92ad7..3a34b29 100644 --- a/app/routes/reports.py +++ b/app/routes/reports.py @@ -1003,4 +1003,619 @@ def export_inspector_performance(): buf.read(), mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', headers={'Content-Disposition': f'attachment; filename="{filename}"'}, - ) \ No newline at end of file + ) + + +# ── Issues Aging ────────────────────────────────────────────────────────────── + +def _age_bucket(age_h): + if age_h < 24: return '<24h' + if age_h < 72: return '1–3 days' + if age_h < 168: return '3–7 days' + if age_h < 720: return '1–4 weeks' + return '>4 weeks' + +AGING_BUCKETS = ['<24h', '1–3 days', '3–7 days', '1–4 weeks', '>4 weeks'] + + +def _load_open_issues_scoped(customer_facility_ids, severity_filter, facility_id_filter): + """Return open issues with area+facility+assigned_user loaded, filters applied.""" + q = Issue.query.options( + joinedload(Issue.area).joinedload(Area.facility), + joinedload(Issue.assigned_user), + ).filter(Issue.status != 'resolved') + + if customer_facility_ids is not None: + if not customer_facility_ids: + return [] + q = q.outerjoin(Area, Issue.area_id == Area.id).filter( + db.or_(Issue.facility_id.in_(customer_facility_ids), + Area.facility_id.in_(customer_facility_ids)) + ) + if severity_filter: + q = q.filter(Issue.severity == severity_filter) + + issues = q.order_by(Issue.reported_at).all() + + if facility_id_filter: + issues = [i for i in issues if + (i.facility_id == facility_id_filter) or + (i.area and i.area.facility_id == facility_id_filter)] + return issues + + +@bp.route('/issues-aging') +@login_required +def issues_aging(): + from app.utils.sla import sla_status + now = now_eastern() + customer_facility_ids = get_customer_scope(current_user) + severity_filter = request.args.get('severity', '') + facility_id_filter = request.args.get('facility_id', type=int) + + issues = _load_open_issues_scoped(customer_facility_ids, severity_filter, facility_id_filter) + + buckets = {b: [] for b in AGING_BUCKETS} + for issue in issues: + age_h = (now - issue.reported_at).total_seconds() / 3600 + buckets[_age_bucket(age_h)].append({ + 'issue': issue, + 'age_h': round(age_h, 1), + 'sla': sla_status(issue), + }) + + sla_breached = sum(1 for i in issues if sla_status(i) == 'breached') + sla_at_risk = sum(1 for i in issues if sla_status(i) == 'at_risk') + + if customer_facility_ids is not None: + facilities = (Facility.query.filter(Facility.id.in_(customer_facility_ids), Facility.active == True) + .order_by(Facility.name).all()) if customer_facility_ids else [] + else: + facilities = Facility.query.filter_by(active=True).order_by(Facility.name).all() + + return render_template('reports/issues_aging.html', + now=now, + buckets=buckets, + bucket_labels=AGING_BUCKETS, + total=len(issues), + sla_breached=sla_breached, + sla_at_risk=sla_at_risk, + severity_filter=severity_filter, + facility_id_filter=facility_id_filter, + facilities=facilities, + ) + + +@bp.route('/export/issues-aging') +@login_required +def export_issues_aging(): + try: + from openpyxl import Workbook + from openpyxl.styles import Font, PatternFill, Alignment, Border, Side + from openpyxl.utils import get_column_letter + except ImportError: + abort(501) + + from app.utils.sla import sla_status + now = now_eastern() + customer_facility_ids = get_customer_scope(current_user) + severity_filter = request.args.get('severity', '') + facility_id_filter = request.args.get('facility_id', type=int) + + issues = _load_open_issues_scoped(customer_facility_ids, severity_filter, facility_id_filter) + log_action(ACTION_EXPORT, 'Issue', None, 'Issues Aging Excel', + f'severity={severity_filter or "all"} facility={facility_id_filter or "all"}') + + wb = Workbook() + ws = wb.active + ws.title = 'Issues Aging' + ws.freeze_panes = 'A2' + + hdr_font = Font(bold=True, color='FFFFFF', size=11) + hdr_fill = PatternFill('solid', fgColor='DC2626') + good_fill = PatternFill('solid', fgColor='D1FAE5') + warn_fill = PatternFill('solid', fgColor='FEF3C7') + bad_fill = PatternFill('solid', fgColor='FEE2E2') + sub_fill = PatternFill('solid', fgColor='FFF5F5') + thin = Side(style='thin', color='D1D5DB') + bdr = Border(left=thin, right=thin, top=thin, bottom=thin) + ctr = Alignment(horizontal='center', vertical='center') + lft = Alignment(horizontal='left', vertical='center') + + hdrs = ['#', 'Reported', 'Age (h)', 'Age Bucket', 'Facility', 'Area', + 'Severity', 'Description', 'Status', 'SLA', 'Assigned To'] + widths = [6, 18, 10, 14, 28, 20, 12, 45, 20, 14, 22] + for ci, (h, w) in enumerate(zip(hdrs, widths), 1): + cell = ws.cell(row=1, column=ci, value=h) + cell.font = hdr_font; cell.fill = hdr_fill; cell.border = bdr + cell.alignment = Alignment(horizontal='center', vertical='center', wrap_text=True) + ws.column_dimensions[get_column_letter(ci)].width = w + ws.row_dimensions[1].height = 22 + + sev_fill = {'critical': bad_fill, 'high': bad_fill, 'medium': warn_fill, 'low': sub_fill} + sla_fill = {'breached': bad_fill, 'at_risk': warn_fill, 'ok': good_fill} + + for ri, issue in enumerate(issues, 2): + age_h = (now - issue.reported_at).total_seconds() / 3600 + sla = sla_status(issue) + fac = issue.resolved_facility + stripe = sub_fill if ri % 2 == 0 else None + + row = [issue.id, + issue.reported_at.strftime('%Y-%m-%d %H:%M'), + round(age_h, 1), + _age_bucket(age_h), + fac.name if fac else '—', + issue.area.name if issue.area else '—', + issue.severity.title() if issue.severity else '—', + issue.description, + issue.status.replace('_', ' ').title(), + (sla or '—').replace('_', ' ').title(), + issue.assigned_user.display_name if issue.assigned_user else '—'] + + for ci, val in enumerate(row, 1): + cell = ws.cell(row=ri, column=ci, value=val) + cell.border = bdr + cell.alignment = lft if ci in (5, 6, 8, 11) else ctr + if ci not in (7, 10) and stripe: + cell.fill = stripe + + sf = sev_fill.get(issue.severity) + if sf: ws.cell(row=ri, column=7).fill = sf + slaf = sla_fill.get(sla) + if slaf: ws.cell(row=ri, column=10).fill = slaf + ws.row_dimensions[ri].height = 16 + + buf = io.BytesIO(); wb.save(buf); buf.seek(0) + fname = f'issues_aging_{now.strftime("%Y%m%d")}.xlsx' + return Response(buf.read(), + mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + headers={'Content-Disposition': f'attachment; filename="{fname}"'}) + + +# ── SLA Compliance Summary ──────────────────────────────────────────────────── + +def _sla_within(issue): + from app.utils.sla import SLA_HOURS + if not issue.resolved_at or not issue.reported_at: + return False + elapsed_h = (issue.resolved_at - issue.reported_at).total_seconds() / 3600 + return elapsed_h <= SLA_HOURS.get(issue.severity, 9999) + + +@bp.route('/sla-compliance') +@login_required +def sla_compliance(): + from app.utils.sla import SLA_HOURS + start, end = _date_range() + customer_facility_ids = get_customer_scope(current_user) + facility_id_filter = request.args.get('facility_id', type=int) + + q = Issue.query.options( + joinedload(Issue.area).joinedload(Area.facility), + ).filter( + Issue.status == 'resolved', + Issue.reported_at >= start, + Issue.reported_at <= end, + ) + if customer_facility_ids is not None: + if not customer_facility_ids: + q = q.filter(False) + else: + q = q.outerjoin(Area, Issue.area_id == Area.id).filter( + db.or_(Issue.facility_id.in_(customer_facility_ids), + Area.facility_id.in_(customer_facility_ids)) + ) + issues = q.all() + if facility_id_filter: + issues = [i for i in issues if + (i.facility_id == facility_id_filter) or + (i.area and i.area.facility_id == facility_id_filter)] + + total = len(issues) + met = sum(1 for i in issues if _sla_within(i)) + overall_pct = round(met / total * 100, 1) if total else None + + by_severity = {} + for sev in ('critical', 'high', 'medium', 'low'): + sub = [i for i in issues if i.severity == sev] + n = len(sub) + m = sum(1 for i in sub if _sla_within(i)) + by_severity[sev] = { + 'total': n, 'met': m, + 'pct': round(m / n * 100, 1) if n else None, + 'sla_hours': SLA_HOURS.get(sev, 0), + } + + fac_map = {} + for issue in issues: + fac = issue.resolved_facility + if not fac: + continue + fid = fac.id + if fid not in fac_map: + fac_map[fid] = {'name': fac.name, 'total': 0, 'met': 0} + fac_map[fid]['total'] += 1 + if _sla_within(issue): + fac_map[fid]['met'] += 1 + by_facility = sorted([ + {'name': d['name'], 'total': d['total'], 'met': d['met'], + 'pct': round(d['met'] / d['total'] * 100, 1) if d['total'] else None} + for d in fac_map.values() + ], key=lambda x: (x['pct'] is None, -(x['pct'] or 0))) + + if customer_facility_ids is not None: + facilities = (Facility.query.filter(Facility.id.in_(customer_facility_ids), Facility.active == True) + .order_by(Facility.name).all()) if customer_facility_ids else [] + else: + facilities = Facility.query.filter_by(active=True).order_by(Facility.name).all() + + return render_template('reports/sla_compliance.html', + start=start, end=end, + total=total, met=met, overall_pct=overall_pct, + by_severity=by_severity, + by_facility=by_facility, + facility_id_filter=facility_id_filter, + facilities=facilities, + ) + + +@bp.route('/export/sla-compliance') +@login_required +def export_sla_compliance(): + try: + from openpyxl import Workbook + from openpyxl.styles import Font, PatternFill, Alignment, Border, Side + from openpyxl.utils import get_column_letter + except ImportError: + abort(501) + + from app.utils.sla import SLA_HOURS + start, end = _date_range() + customer_facility_ids = get_customer_scope(current_user) + facility_id_filter = request.args.get('facility_id', type=int) + + q = Issue.query.options( + joinedload(Issue.area).joinedload(Area.facility), + ).filter(Issue.status == 'resolved', Issue.reported_at >= start, Issue.reported_at <= end) + if customer_facility_ids is not None: + if not customer_facility_ids: + q = q.filter(False) + else: + q = q.outerjoin(Area, Issue.area_id == Area.id).filter( + db.or_(Issue.facility_id.in_(customer_facility_ids), + Area.facility_id.in_(customer_facility_ids)) + ) + issues = q.all() + if facility_id_filter: + issues = [i for i in issues if + (i.facility_id == facility_id_filter) or + (i.area and i.area.facility_id == facility_id_filter)] + + log_action(ACTION_EXPORT, 'Issue', None, 'SLA Compliance Excel', + f'range={start.strftime("%Y-%m-%d")} to {end.strftime("%Y-%m-%d")}') + + wb = Workbook() + thin = Side(style='thin', color='D1D5DB') + bdr = Border(left=thin, right=thin, top=thin, bottom=thin) + hdr_font = Font(bold=True, color='FFFFFF', size=11) + hdr_fill = PatternFill('solid', fgColor='1A56DB') + good_fill = PatternFill('solid', fgColor='D1FAE5') + warn_fill = PatternFill('solid', fgColor='FEF3C7') + bad_fill = PatternFill('solid', fgColor='FEE2E2') + sub_fill = PatternFill('solid', fgColor='EFF6FF') + ctr = Alignment(horizontal='center', vertical='center') + lft = Alignment(horizontal='left', vertical='center') + + def _pct_fill(pct): + if pct is None: return None + return good_fill if pct >= 90 else warn_fill if pct >= 70 else bad_fill + + def _hdr(ws, row, headers, widths, fill=None): + fill = fill or hdr_fill + for ci, (h, w) in enumerate(zip(headers, widths), 1): + cell = ws.cell(row=row, column=ci, value=h) + cell.font = hdr_font; cell.fill = fill; cell.border = bdr + cell.alignment = Alignment(horizontal='center', vertical='center', wrap_text=True) + ws.column_dimensions[get_column_letter(ci)].width = w + ws.row_dimensions[row].height = 22 + + # ── Sheet 1: Severity Summary ──────────────────────────────────────── + ws1 = wb.active + ws1.title = 'By Severity' + ws1.freeze_panes = 'A2' + _hdr(ws1, 1, ['Severity', 'SLA Window', 'Total Resolved', 'Met SLA', 'Compliance %'], + [14, 14, 16, 12, 16]) + for ri, sev in enumerate(['critical', 'high', 'medium', 'low'], 2): + sub = [i for i in issues if i.severity == sev] + n = len(sub) + m = sum(1 for i in sub if _sla_within(i)) + pct = round(m / n * 100, 1) if n else None + row = [sev.title(), f'{SLA_HOURS.get(sev)}h', n, m, + f'{pct}%' if pct is not None else '—'] + for ci, val in enumerate(row, 1): + cell = ws1.cell(row=ri, column=ci, value=val) + cell.border = bdr; cell.alignment = lft if ci == 1 else ctr + if ri % 2 == 0: cell.fill = sub_fill + pf = _pct_fill(pct) + if pf: ws1.cell(row=ri, column=5).fill = pf + + # Overall totals row + tr = 6 + total = len(issues); met = sum(1 for i in issues if _sla_within(i)) + op = round(met / total * 100, 1) if total else None + for ci, val in enumerate(['OVERALL', '—', total, met, + f'{op}%' if op is not None else '—'], 1): + cell = ws1.cell(row=tr, column=ci, value=val) + cell.font = Font(bold=True); cell.border = bdr; cell.alignment = lft if ci == 1 else ctr + cell.fill = PatternFill('solid', fgColor='DBEAFE') + pf = _pct_fill(op) + if pf: ws1.cell(row=tr, column=5).fill = pf + + # ── Sheet 2: Facility Breakdown ────────────────────────────────────── + ws2 = wb.create_sheet('By Facility') + ws2.freeze_panes = 'A2' + _hdr(ws2, 1, ['Facility', 'Total Resolved', 'Met SLA', 'Compliance %'], [30, 16, 12, 16]) + fac_map = {} + for issue in issues: + fac = issue.resolved_facility + if not fac: continue + fid = fac.id + if fid not in fac_map: + fac_map[fid] = {'name': fac.name, 'total': 0, 'met': 0} + fac_map[fid]['total'] += 1 + if _sla_within(issue): fac_map[fid]['met'] += 1 + rows_sorted = sorted(fac_map.values(), key=lambda x: ( + (round(x['met']/x['total']*100,1) if x['total'] else None) is None, + -(round(x['met']/x['total']*100,1) if x['total'] else 0) + )) + for ri, d in enumerate(rows_sorted, 2): + pct = round(d['met'] / d['total'] * 100, 1) if d['total'] else None + row = [d['name'], d['total'], d['met'], f'{pct}%' if pct is not None else '—'] + for ci, val in enumerate(row, 1): + cell = ws2.cell(row=ri, column=ci, value=val) + cell.border = bdr; cell.alignment = lft if ci == 1 else ctr + if ri % 2 == 0: cell.fill = sub_fill + pf = _pct_fill(pct) + if pf: ws2.cell(row=ri, column=4).fill = pf + + buf = io.BytesIO(); wb.save(buf); buf.seek(0) + fname = f'sla_compliance_{start.strftime("%Y%m%d")}_{end.strftime("%Y%m%d")}.xlsx' + return Response(buf.read(), + mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + headers={'Content-Disposition': f'attachment; filename="{fname}"'}) + + +# ── Follow-up Closure Rate ──────────────────────────────────────────────────── + +@bp.route('/followup-closure') +@login_required +@supervisor_required +def followup_closure(): + start, end = _date_range() + + flagged = Inspection.query.options( + joinedload(Inspection.facility), + joinedload(Inspection.inspector), + joinedload(Inspection.template), + ).filter( + Inspection.follow_up_required == True, + Inspection.inspection_date >= start, + Inspection.inspection_date <= end, + ).order_by(Inspection.inspection_date.desc()).all() + + flagged_ids = [i.id for i in flagged] + followed_up_ids = set() + if flagged_ids: + followed_up_ids = { + row[0] for row in + db.session.query(Inspection.parent_inspection_id) + .filter(Inspection.parent_inspection_id.in_(flagged_ids)) + .distinct() + if row[0] is not None + } + + for insp in flagged: + insp._has_followup = insp.id in followed_up_ids + + total = len(flagged) + closed = sum(1 for i in flagged if i._has_followup) + rate = round(closed / total * 100, 1) if total else None + + fac_map = {} + for insp in flagged: + fac = insp.facility + if not fac: continue + fid = fac.id + if fid not in fac_map: + fac_map[fid] = {'name': fac.name, 'total': 0, 'closed': 0} + fac_map[fid]['total'] += 1 + if insp._has_followup: + fac_map[fid]['closed'] += 1 + by_facility = sorted([ + {'name': d['name'], 'total': d['total'], 'closed': d['closed'], + 'rate': round(d['closed'] / d['total'] * 100, 1) if d['total'] else 0} + for d in fac_map.values() + ], key=lambda x: -x['total']) + + return render_template('reports/followup_closure.html', + start=start, end=end, + flagged=flagged, + total=total, closed=closed, rate=rate, + by_facility=by_facility, + followed_up_ids=followed_up_ids, + ) + + +@bp.route('/export/followup-closure') +@login_required +@supervisor_required +def export_followup_closure(): + try: + from openpyxl import Workbook + from openpyxl.styles import Font, PatternFill, Alignment, Border, Side + from openpyxl.utils import get_column_letter + except ImportError: + abort(501) + + start, end = _date_range() + flagged = Inspection.query.options( + joinedload(Inspection.facility), + joinedload(Inspection.inspector), + joinedload(Inspection.template), + ).filter( + Inspection.follow_up_required == True, + Inspection.inspection_date >= start, + Inspection.inspection_date <= end, + ).order_by(Inspection.inspection_date.desc()).all() + + flagged_ids = [i.id for i in flagged] + followed_up_ids = set() + if flagged_ids: + followed_up_ids = { + row[0] for row in + db.session.query(Inspection.parent_inspection_id) + .filter(Inspection.parent_inspection_id.in_(flagged_ids)) + .distinct() + if row[0] is not None + } + for insp in flagged: + insp._has_followup = insp.id in followed_up_ids + + log_action(ACTION_EXPORT, 'Inspection', None, 'Follow-up Closure Excel', + f'range={start.strftime("%Y-%m-%d")} to {end.strftime("%Y-%m-%d")}') + + wb = Workbook() + ws = wb.active + ws.title = 'Follow-up Detail' + ws.freeze_panes = 'A2' + + thin = Side(style='thin', color='D1D5DB') + bdr = Border(left=thin, right=thin, top=thin, bottom=thin) + hdr_font = Font(bold=True, color='FFFFFF', size=11) + hdr_fill = PatternFill('solid', fgColor='7C3AED') + good_fill = PatternFill('solid', fgColor='D1FAE5') + bad_fill = PatternFill('solid', fgColor='FEE2E2') + sub_fill = PatternFill('solid', fgColor='F5F3FF') + ctr = Alignment(horizontal='center', vertical='center') + lft = Alignment(horizontal='left', vertical='center') + + hdrs = ['Inspection ID', 'Date', 'Facility', 'Area', 'Template', 'Inspector', + 'Score (%)', 'Follow-up Note', 'Followed Up?'] + widths = [14, 18, 28, 20, 24, 22, 12, 40, 14] + for ci, (h, w) in enumerate(zip(hdrs, widths), 1): + cell = ws.cell(row=1, column=ci, value=h) + cell.font = hdr_font; cell.fill = hdr_fill; cell.border = bdr + cell.alignment = Alignment(horizontal='center', vertical='center', wrap_text=True) + ws.column_dimensions[get_column_letter(ci)].width = w + ws.row_dimensions[1].height = 22 + + for ri, insp in enumerate(flagged, 2): + stripe = sub_fill if ri % 2 == 0 else None + insp_name = insp.inspector.display_name if insp.inspector else '—' + row = [insp.id, + insp.inspection_date.strftime('%Y-%m-%d') if insp.inspection_date else '', + insp.facility.name if insp.facility else '—', + insp.area.name if insp.area else '—', + insp.template.name if insp.template else '—', + insp_name, + insp.overall_score, + insp.follow_up_note or '', + 'Yes' if insp._has_followup else 'No'] + for ci, val in enumerate(row, 1): + cell = ws.cell(row=ri, column=ci, value=val) + cell.border = bdr + cell.alignment = lft if ci in (3, 4, 5, 6, 8) else ctr + if stripe and ci != 9: cell.fill = stripe + # Colour the Follow-up column + fu_cell = ws.cell(row=ri, column=9) + fu_cell.fill = good_fill if insp._has_followup else bad_fill + fu_cell.font = Font(bold=True) + ws.row_dimensions[ri].height = 16 + + buf = io.BytesIO(); wb.save(buf); buf.seek(0) + fname = f'followup_closure_{start.strftime("%Y%m%d")}_{end.strftime("%Y%m%d")}.xlsx' + return Response(buf.read(), + mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + headers={'Content-Disposition': f'attachment; filename="{fname}"'}) + + +# ── Customer Facility Summary PDF ──────────────────────────────────────────── + +@bp.route('/facility//summary-pdf') +@login_required +def facility_summary_pdf(facility_id): + """One-click customer-facing PDF summary for a facility.""" + from app.utils.pdf_export import generate_facility_summary_pdf + from app.utils.sla import SLA_HOURS + + facility = db.session.get(Facility, facility_id) + if facility is None: + abort(404) + + # Access control + if current_user.role == 'customer': + cids = get_customer_scope(current_user) or [] + if facility_id not in cids: + abort(403) + elif current_user.role == 'inspector': + has = Inspection.query.filter_by(facility_id=facility_id, + inspector_id=current_user.id).first() + if not has: + abort(403) + + days = request.args.get('days', 90, type=int) + if days not in (30, 60, 90, 180, 365): + days = 90 + now = now_eastern() + start = now - timedelta(days=days) + + completed_insp = Inspection.query.filter( + Inspection.facility_id == facility_id, + Inspection.inspection_date >= start, + Inspection.status == 'completed', + ).order_by(Inspection.inspection_date.desc()).all() + + area_scores = db.session.query( + Area.name, + func.avg(Inspection.overall_score).label('avg'), + func.count(Inspection.id).label('count'), + ).join(Inspection, Area.id == Inspection.area_id).filter( + Inspection.facility_id == facility_id, + Inspection.inspection_date >= start, + Inspection.status == 'completed', + Inspection.overall_score.isnot(None), + ).group_by(Area.id, Area.name).order_by(func.avg(Inspection.overall_score).desc()).all() + + open_issues = Issue.query.join(Area).filter( + Area.facility_id == facility_id, + Issue.status != 'resolved', + ).options(joinedload(Issue.area)).order_by(Issue.severity, Issue.reported_at).all() + + resolved_count = Issue.query.join(Area).filter( + Area.facility_id == facility_id, + Issue.status == 'resolved', + Issue.resolved_at >= start, + ).count() + + scores = [float(i.overall_score) for i in completed_insp if i.overall_score is not None] + avg_score = round(sum(scores) / len(scores), 1) if scores else None + + log_action(ACTION_EXPORT, 'Facility', facility_id, facility.name, + f'Customer Summary PDF days={days}') + + pdf_bytes = generate_facility_summary_pdf( + facility=facility, + days=days, + start=start, + now=now, + total_inspections=len(completed_insp), + avg_score=avg_score, + area_scores=area_scores, + open_issues=open_issues, + resolved_count=resolved_count, + ) + fname = f'{facility.name.replace(" ", "_")}_summary_{now.strftime("%Y%m%d")}.pdf' + return Response(pdf_bytes, + mimetype='application/pdf', + headers={'Content-Disposition': f'attachment; filename="{fname}"'}) \ No newline at end of file diff --git a/app/templates/reports/_subnav.html b/app/templates/reports/_subnav.html new file mode 100644 index 0000000..7bfa4a9 --- /dev/null +++ b/app/templates/reports/_subnav.html @@ -0,0 +1,36 @@ + diff --git a/app/templates/reports/followup_closure.html b/app/templates/reports/followup_closure.html new file mode 100644 index 0000000..eff0502 --- /dev/null +++ b/app/templates/reports/followup_closure.html @@ -0,0 +1,186 @@ +{% extends "base.html" %} +{% block title %}Follow-up Closure Rate{% endblock %} +{% block content %} + +{# ── Sub-nav ── #} +{% include 'reports/_subnav.html' %} + +
+
+

Follow-up Closure Rate

+

+ {{ start.strftime('%b %d, %Y') }} — {{ end.strftime('%b %d, %Y') }} +  ·  Inspections flagged for follow-up and whether a re-inspection was completed. +

+
+ + Export Excel + +
+ +{# ── Date filter ── #} +
+
+
+
+ + +
+
+ + +
+
+ + Reset +
+
+
+
+ +{# ── KPI row ── #} +
+
+
+
+
Flagged for Follow-up
+
{{ total }}
+
inspections in period
+
+
+
+
+
+
+
Re-inspected
+
{{ closed }}
+
follow-up completed
+
+
+
+
+
+
+
Closure Rate
+
+ {{ rate|round(1) if rate is not none else '—' }}{% if rate is not none %}%{% endif %} +
+
of flagged inspections re-done
+
+
+
+
+ +{% if total == 0 %} +
+ No inspections were flagged for follow-up in this period. +
+{% else %} + +{# ── By facility ── #} +{% if by_facility %} +
+
+ Closure Rate by Facility +
+
+ + + + + + + + + + + + {% for row in by_facility %} + + + + + + + + {% endfor %} + +
FacilityFlaggedRe-inspectedClosure RateProgress
{{ row.name }}{{ row.total }}{{ row.closed }} + + {{ row.rate }}% + + +
+
+
+
+
+
+{% endif %} + +{# ── Detail table ── #} +
+
+ Inspection Detail + {{ total }} flagged +
+
+ + + + + + + + + + + + + + + + {% for insp in flagged %} + + + + + + + + + + + + {% endfor %} + +
IDDateFacilityTemplateInspectorScoreNoteRe-inspected?
#{{ insp.id }}{{ insp.inspection_date.strftime('%Y-%m-%d') if insp.inspection_date else '—' }}{{ insp.facility.name if insp.facility else '—' }}{{ insp.template.name if insp.template else '—' }}{{ insp.inspector.display_name if insp.inspector else '—' }} + {% if insp.overall_score %} + + {{ insp.overall_score|round(1) }}% + + {% else %}—{% endif %} + + {{ insp.follow_up_note[:60] if insp.follow_up_note else '—' }} + {% if insp.follow_up_note and insp.follow_up_note|length > 60 %}…{% endif %} + + {% if insp._has_followup %} + Yes + {% else %} + No + {% endif %} + + + + +
+
+
+ +{% endif %}{# end total == 0 #} +{% endblock %} diff --git a/app/templates/reports/index.html b/app/templates/reports/index.html index e9323c5..c33ed0e 100644 --- a/app/templates/reports/index.html +++ b/app/templates/reports/index.html @@ -13,20 +13,7 @@ {% block content %} {# ── Sub-nav ── #} - +{% include 'reports/_subnav.html' %} {# ── Header + date filter ── #}
diff --git a/app/templates/reports/inspector_performance.html b/app/templates/reports/inspector_performance.html index 757c9ff..a85ddf7 100644 --- a/app/templates/reports/inspector_performance.html +++ b/app/templates/reports/inspector_performance.html @@ -16,18 +16,7 @@ {% block content %} {# ── Sub-nav ── #} - +{% include 'reports/_subnav.html' %} {# ── Header ── #}
diff --git a/app/templates/reports/issues_aging.html b/app/templates/reports/issues_aging.html new file mode 100644 index 0000000..d654a1b --- /dev/null +++ b/app/templates/reports/issues_aging.html @@ -0,0 +1,175 @@ +{% extends "base.html" %} +{% block title %}Issues Aging{% endblock %} +{% block content %} + +{# ── Sub-nav ── #} +{% include 'reports/_subnav.html' %} + +
+
+

Issues Aging

+

All currently open issues grouped by how long they have been waiting.

+
+ + Export Excel + +
+ +{# ── Filters ── #} +
+
+
+
+ + +
+
+ + +
+
+ + Clear +
+
+
+
+ +{# ── KPI row ── #} +
+
+
+
+
Total Open
+
{{ total }}
+
unresolved issues
+
+
+
+
+
+
+
SLA Breached
+
{{ sla_breached }}
+
past resolution deadline
+
+
+
+
+
+
+
SLA At Risk
+
{{ sla_at_risk }}
+
approaching deadline
+
+
+
+
+ +{# ── Bucket accordions ── #} +
+{% for label in bucket_labels %} +{% set bucket = buckets[label] %} +{% set bucket_id = 'bucket-' ~ loop.index %} +{% set is_danger = label in ['>4 weeks', '1–4 weeks'] %} +{% set is_warning = label == '3–7 days' %} +
+

+ +

+
+
+ {% if bucket %} +
+ + + + + + + + + + + + + + + + {% for item in bucket %} + {% set issue = item.issue %} + {% set sla = item.sla %} + + + + + + + + + + + + {% endfor %} + +
#AgeSeverityFacility / AreaDescriptionStatusSLAAssigned
#{{ issue.id }}{{ item.age_h }}h + + {{ issue.severity|title }} + + + {{ issue.resolved_facility.name if issue.resolved_facility else '—' }}
+ {{ issue.area.name if issue.area else '—' }} +
{{ issue.description[:70] }}{% if issue.description|length > 70 %}…{% endif %} + + {{ issue.status|replace('_',' ')|title }} + + + {% if sla == 'breached' %}Breached + {% elif sla == 'at_risk' %}At Risk + {% elif sla == 'ok' %}OK + {% else %}{% endif %} + {{ issue.assigned_user.display_name if issue.assigned_user else '—' }} + + + +
+
+ {% else %} +
No issues in this age range.
+ {% endif %} +
+
+
+{% endfor %} +
+ +{% if total == 0 %} +
+ No open issues match the selected filters. +
+{% endif %} + +{% endblock %} diff --git a/app/templates/reports/scorecard.html b/app/templates/reports/scorecard.html index 962392f..4912b00 100644 --- a/app/templates/reports/scorecard.html +++ b/app/templates/reports/scorecard.html @@ -29,6 +29,10 @@ class="btn btn-sm btn-outline-secondary"> Full Report + + PDF Summary + Reports diff --git a/app/templates/reports/sla_compliance.html b/app/templates/reports/sla_compliance.html new file mode 100644 index 0000000..186850d --- /dev/null +++ b/app/templates/reports/sla_compliance.html @@ -0,0 +1,149 @@ +{% extends "base.html" %} +{% block title %}SLA Compliance{% endblock %} +{% block extra_css %} + +{% endblock %} +{% block content %} + +{# ── Sub-nav ── #} +{% include 'reports/_subnav.html' %} + +
+
+

SLA Compliance

+

{{ start.strftime('%b %d, %Y') }} — {{ end.strftime('%b %d, %Y') }}

+
+ + Export Excel + +
+ +{# ── Filters ── #} +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + Reset +
+
+
+
+ +{% if total == 0 %} +
+ No resolved issues found for this period and filter. +
+{% else %} + +{# ── Overall KPI ── #} +
+
+
+
+
Overall Compliance
+
+ {{ overall_pct|round(1) if overall_pct is not none else '—' }}{% if overall_pct is not none %}%{% endif %} +
+
{{ met }}/{{ total }} resolved on time
+
+
+
+ + {# ── Per-severity compliance cards ── #} + {% for sev, color_cls in [('critical','danger'),('high','danger'),('medium','warning'),('low','secondary')] %} + {% set d = by_severity[sev] %} +
+
+
+
+ {{ sev|title }} +
+
SLA: {{ d.sla_hours }}h window
+
+ {{ d.pct|round(1) if d.pct is not none else '—' }}{% if d.pct is not none %}%{% endif %} +
+
{{ d.met }}/{{ d.total }}
+ {% if d.total > 0 %} +
+
+
+ {% endif %} +
+
+
+ {% endfor %} +
+ +{# ── Facility breakdown ── #} +{% if by_facility %} +
+
+ Compliance by Facility + {{ by_facility|length }} +
+
+ + + + + + + + + + + + {% for row in by_facility %} + {% set pct_val = row.pct or 0 %} + {% set bar_class = 'bg-success' if pct_val >= 90 else 'bg-warning' if pct_val >= 70 else 'bg-danger' %} + + + + + + + + {% endfor %} + +
FacilityResolved IssuesMet SLACompliance %Progress
{{ row.name }}{{ row.total }}{{ row.met }} + {% if row.pct is not none %} + + {{ row.pct }}% + + {% else %}—{% endif %} + +
+
+
+
+
+
+{% endif %} + +{% endif %}{# end total == 0 #} +{% endblock %} diff --git a/app/utils/pdf_export.py b/app/utils/pdf_export.py index 850440a..38032d5 100644 --- a/app/utils/pdf_export.py +++ b/app/utils/pdf_export.py @@ -93,6 +93,27 @@ def _build_styles(): add('FooterStyle', parent='Normal', fontSize=7, textColor=C_SLATE, fontName='Helvetica', alignment=TA_CENTER) + # ── Summary PDF styles ──────────────────────────────────────────────── + add('SummaryTitle', parent='Normal', + fontSize=18, textColor=C_DARK, fontName='Helvetica-Bold', spaceAfter=2) + add('ReportSubtitle', parent='Normal', + fontSize=11, textColor=C_SLATE, fontName='Helvetica', spaceAfter=2) + add('Meta', parent='Normal', + fontSize=8, textColor=C_SLATE, fontName='Helvetica', spaceAfter=1) + add('ScoreValue', parent='Normal', + fontSize=22, textColor=C_BLUE, fontName='Helvetica-Bold', + alignment=TA_CENTER, spaceAfter=0) + add('ScoreLabel', parent='Normal', + fontSize=7.5, textColor=C_SLATE, fontName='Helvetica', + alignment=TA_CENTER, spaceAfter=0) + add('SectionHeader', parent='Normal', + fontSize=10, textColor=C_DARK, fontName='Helvetica-Bold', + spaceBefore=8, spaceAfter=4) + add('TableHeader', parent='Normal', + fontSize=8.5, textColor=C_WHITE, fontName='Helvetica-Bold', + alignment=TA_CENTER, spaceAfter=0) + add('TableCell', parent='Normal', + fontSize=8.5, textColor=C_DARK, fontName='Helvetica', spaceAfter=0) return base @@ -1385,4 +1406,144 @@ def generate_inspections_list_pdf(inspections, filter_summary: str = '') -> byte story.append(tbl) doc.build(story, onFirstPage=_page_cb, onLaterPages=_page_cb) + + +# ── Facility Customer Summary PDF ───────────────────────────────────────────── + +def generate_facility_summary_pdf(facility, days, start, now, + total_inspections, avg_score, + area_scores, open_issues, resolved_count): + """Customer-facing one-page PDF summary for a facility.""" + styles = _build_styles() + buf = io.BytesIO() + doc = SimpleDocTemplate( + buf, pagesize=letter, + leftMargin=0.75 * inch, rightMargin=0.75 * inch, + topMargin=0.75 * inch, bottomMargin=0.75 * inch, + ) + page_w = letter[0] - 1.5 * inch + story = [] + + story.append(Paragraph(facility.name, styles['SummaryTitle'])) + story.append(Paragraph('Facility Performance Summary', styles['ReportSubtitle'])) + story.append(Paragraph( + 'Period: {} to {} ({} days)'.format( + start.strftime('%b %d, %Y'), now.strftime('%b %d, %Y'), days), + styles['Meta'], + )) + if getattr(facility, 'address', None): + story.append(Paragraph(facility.address, styles['Meta'])) + story.append(Spacer(1, 0.15 * inch)) + story.append(HRFlowable(width='100%', thickness=1, color=C_BORDER)) + story.append(Spacer(1, 0.15 * inch)) + + score_str = '{}%'.format(avg_score) if avg_score is not None else '—' + score_color = (C_GREEN if avg_score and avg_score >= 80 + else C_YELLOW if avg_score and avg_score >= 60 else C_RED) + + def _kpi(label, value, col=C_BLUE): + hex_str = '%06x' % (col.hexval() & 0xFFFFFF) + return [Paragraph('' + '{}'.format(hex_str, value), + styles['ScoreValue']), + Paragraph(label, styles['ScoreLabel'])] + + kpi_tbl = Table([[ + _kpi('Inspections Completed', str(total_inspections)), + _kpi('Avg Score', score_str, score_color), + _kpi('Open Issues', str(len(open_issues)), + C_RED if open_issues else C_GREEN), + _kpi('Resolved This Period', str(resolved_count), C_GREEN), + ]], colWidths=[page_w / 4] * 4) + kpi_tbl.setStyle(TableStyle([ + ('ALIGN', (0, 0), (-1, -1), 'CENTER'), + ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'), + ('INNERGRID', (0, 0), (-1, -1), 0.5, C_BORDER), + ('BOX', (0, 0), (-1, -1), 0.5, C_BORDER), + ('BACKGROUND', (0, 0), (-1, -1), C_LIGHT), + ('TOPPADDING', (0, 0), (-1, -1), 8), + ('BOTTOMPADDING', (0, 0), (-1, -1), 8), + ])) + story.append(kpi_tbl) + story.append(Spacer(1, 0.2 * inch)) + + _P = lambda t: Paragraph(t, styles['TableCell']) + _H = lambda t: Paragraph('{}'.format(t), styles['TableHeader']) + + if area_scores: + story.append(Paragraph('Score by Area', styles['SectionHeader'])) + story.append(Spacer(1, 0.05 * inch)) + tbl_data = [[_H('Area'), _H('Avg Score'), _H('Inspections')]] + for a in area_scores: + avg = round(float(a.avg), 1) + col = C_GREEN if avg >= 80 else C_YELLOW if avg >= 60 else C_RED + hex_str = '%06x' % (col.hexval() & 0xFFFFFF) + tbl_data.append([ + _P(a.name), + Paragraph('' + '{}%'.format(hex_str, avg), + styles['TableCell']), + _P(str(a.count)), + ]) + area_tbl = Table(tbl_data, + colWidths=[page_w * 0.55, page_w * 0.25, page_w * 0.20], + repeatRows=1) + area_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), + ('ALIGN', (1, 0), (-1, -1), 'CENTER'), + ('TOPPADDING', (0, 0), (-1, -1), 4), + ('BOTTOMPADDING', (0, 0), (-1, -1), 4), + ('LEFTPADDING', (0, 0), (-1, -1), 6), + ('RIGHTPADDING', (0, 0), (-1, -1), 6), + ])) + story.append(area_tbl) + story.append(Spacer(1, 0.2 * inch)) + + story.append(Paragraph('Open Issues', styles['SectionHeader'])) + story.append(Spacer(1, 0.05 * inch)) + if open_issues: + tbl_data = [[_H('Severity'), _H('Area'), _H('Description'), _H('Reported')]] + for issue in open_issues: + sev_col = SEVERITY_COLORS.get(issue.severity, C_SLATE) + hex_str = '%06x' % (sev_col.hexval() & 0xFFFFFF) + desc = issue.description or '' + tbl_data.append([ + Paragraph('' + '{}'.format(hex_str, (issue.severity or '').title()), + styles['TableCell']), + _P(issue.area.name if issue.area else '—'), + _P(desc[:100] + ('...' if len(desc) > 100 else '')), + _P(issue.reported_at.strftime('%Y-%m-%d') if issue.reported_at else '—'), + ]) + iss_tbl = Table(tbl_data, + colWidths=[page_w * 0.14, page_w * 0.20, + page_w * 0.50, page_w * 0.16], + repeatRows=1) + iss_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), 6), + ('RIGHTPADDING', (0, 0), (-1, -1), 6), + ])) + story.append(iss_tbl) + else: + story.append(Paragraph('No open issues — all clear.', styles['Meta'])) + + story.append(Spacer(1, 0.2 * inch)) + story.append(HRFlowable(width='100%', thickness=0.5, color=C_BORDER)) + story.append(Spacer(1, 0.05 * inch)) + story.append(Paragraph( + 'Generated {} — Confidential'.format(now.strftime('%B %d, %Y %I:%M %p')), + styles['Meta'], + )) + doc.build(story) + return buf.getvalue() return buf.getvalue() \ No newline at end of file