06/12 Redesign Reports page

This commit is contained in:
2026-06-12 12:47:56 -04:00
parent f1413e0a5d
commit 8cc1770c10
9 changed files with 1329 additions and 27 deletions
+615
View File
@@ -1004,3 +1004,618 @@ def export_inspector_performance():
mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
headers={'Content-Disposition': f'attachment; filename="{filename}"'},
)
# ── Issues Aging ──────────────────────────────────────────────────────────────
def _age_bucket(age_h):
if age_h < 24: return '<24h'
if age_h < 72: return '13 days'
if age_h < 168: return '37 days'
if age_h < 720: return '14 weeks'
return '>4 weeks'
AGING_BUCKETS = ['<24h', '13 days', '37 days', '14 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/<int:facility_id>/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}"'})
+36
View File
@@ -0,0 +1,36 @@
<ul class="nav nav-pills mb-4 flex-wrap gap-1">
<li class="nav-item">
<a class="nav-link {{ 'active' if request.endpoint == 'reports.index' else '' }}"
href="{{ url_for('reports.index') }}">
<i class="bi bi-bar-chart me-1"></i>Overview &amp; Trends
</a>
</li>
<li class="nav-item">
<a class="nav-link {{ 'active' if request.endpoint == 'reports.issues_aging' else '' }}"
href="{{ url_for('reports.issues_aging') }}">
<i class="bi bi-clock-history me-1"></i>Issues Aging
</a>
</li>
<li class="nav-item">
<a class="nav-link {{ 'active' if request.endpoint == 'reports.sla_compliance' else '' }}"
href="{{ url_for('reports.sla_compliance') }}">
<i class="bi bi-shield-check me-1"></i>SLA Compliance
</a>
</li>
{% if current_user.role in ['admin', 'director', 'project_manager'] %}
<li class="nav-item">
<a class="nav-link {{ 'active' if request.endpoint == 'reports.followup_closure' else '' }}"
href="{{ url_for('reports.followup_closure') }}">
<i class="bi bi-arrow-repeat me-1"></i>Follow-up Closure
</a>
</li>
{% endif %}
{% if current_user.role in ['admin', 'director'] %}
<li class="nav-item">
<a class="nav-link {{ 'active' if request.endpoint == 'reports.inspector_performance' else '' }}"
href="{{ url_for('reports.inspector_performance') }}">
<i class="bi bi-person-lines-fill me-1"></i>Inspector Performance
</a>
</li>
{% endif %}
</ul>
+186
View File
@@ -0,0 +1,186 @@
{% extends "base.html" %}
{% block title %}Follow-up Closure Rate{% endblock %}
{% block content %}
{# ── Sub-nav ── #}
{% include 'reports/_subnav.html' %}
<div class="d-flex justify-content-between align-items-start mb-4">
<div>
<h2><i class="bi bi-arrow-repeat text-purple me-2" style="color:#7c3aed;"></i>Follow-up Closure Rate</h2>
<p class="text-muted mb-0">
{{ start.strftime('%b %d, %Y') }} — {{ end.strftime('%b %d, %Y') }}
&nbsp;·&nbsp; Inspections flagged for follow-up and whether a re-inspection was completed.
</p>
</div>
<a href="{{ url_for('reports.export_followup_closure', start=start.strftime('%Y-%m-%d'), end=end.strftime('%Y-%m-%d')) }}"
class="btn btn-sm btn-outline-success">
<i class="bi bi-file-earmark-excel me-1"></i>Export Excel
</a>
</div>
{# ── Date filter ── #}
<div class="card shadow-sm mb-4">
<div class="card-body py-2">
<form method="get" class="row g-2 align-items-end">
<div class="col-md-3">
<label class="form-label small mb-1">From</label>
<input type="date" name="start" class="form-control form-control-sm" value="{{ start.strftime('%Y-%m-%d') }}">
</div>
<div class="col-md-3">
<label class="form-label small mb-1">To</label>
<input type="date" name="end" class="form-control form-control-sm" value="{{ end.strftime('%Y-%m-%d') }}">
</div>
<div class="col-auto">
<button class="btn btn-sm btn-primary">Apply</button>
<a href="{{ url_for('reports.followup_closure') }}" class="btn btn-sm btn-outline-secondary">Reset</a>
</div>
</form>
</div>
</div>
{# ── KPI row ── #}
<div class="row g-3 mb-4">
<div class="col-6 col-md-4">
<div class="card shadow-sm h-100" style="border-left:5px solid #7c3aed;">
<div class="card-body">
<div class="text-muted small fw-semibold mb-1">Flagged for Follow-up</div>
<div class="fs-1 fw-bold" style="color:#7c3aed;">{{ total }}</div>
<div class="text-muted small">inspections in period</div>
</div>
</div>
</div>
<div class="col-6 col-md-4">
<div class="card shadow-sm h-100" style="border-left:5px solid #16a34a;">
<div class="card-body">
<div class="text-muted small fw-semibold mb-1">Re-inspected</div>
<div class="fs-1 fw-bold text-success">{{ closed }}</div>
<div class="text-muted small">follow-up completed</div>
</div>
</div>
</div>
<div class="col-6 col-md-4">
<div class="card shadow-sm h-100"
style="border-left:5px solid {{ '#16a34a' if rate and rate >= 80 else '#d97706' if rate and rate >= 50 else '#dc2626' }};">
<div class="card-body">
<div class="text-muted small fw-semibold mb-1">Closure Rate</div>
<div class="fs-1 fw-bold
{{ 'text-success' if rate and rate >= 80 else 'text-warning' if rate and rate >= 50 else 'text-danger' if rate is not none else 'text-muted' }}">
{{ rate|round(1) if rate is not none else '—' }}{% if rate is not none %}%{% endif %}
</div>
<div class="text-muted small">of flagged inspections re-done</div>
</div>
</div>
</div>
</div>
{% if total == 0 %}
<div class="alert alert-info">
<i class="bi bi-info-circle me-2"></i>No inspections were flagged for follow-up in this period.
</div>
{% else %}
{# ── By facility ── #}
{% if by_facility %}
<div class="card shadow-sm mb-4">
<div class="card-header bg-light fw-semibold">
<i class="bi bi-building me-1"></i>Closure Rate by Facility
</div>
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th>Facility</th>
<th class="text-center">Flagged</th>
<th class="text-center">Re-inspected</th>
<th class="text-center">Closure Rate</th>
<th>Progress</th>
</tr>
</thead>
<tbody>
{% for row in by_facility %}
<tr>
<td class="fw-semibold">{{ row.name }}</td>
<td class="text-center">{{ row.total }}</td>
<td class="text-center">{{ row.closed }}</td>
<td class="text-center">
<span class="badge bg-{{ 'success' if row.rate >= 80 else 'warning text-dark' if row.rate >= 50 else 'danger' }} px-3">
{{ row.rate }}%
</span>
</td>
<td style="min-width:120px;">
<div class="progress" style="height:8px;">
<div class="progress-bar bg-{{ 'success' if row.rate >= 80 else 'warning' if row.rate >= 50 else 'danger' }}"
style="width:{{ row.rate }}%;"></div>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}
{# ── Detail table ── #}
<div class="card shadow-sm">
<div class="card-header bg-light d-flex justify-content-between">
<span class="fw-semibold"><i class="bi bi-list-ul me-1"></i>Inspection Detail</span>
<span class="text-muted small">{{ total }} flagged</span>
</div>
<div class="table-responsive">
<table class="table table-hover table-sm mb-0">
<thead class="table-light">
<tr>
<th>ID</th>
<th>Date</th>
<th>Facility</th>
<th>Template</th>
<th>Inspector</th>
<th class="text-center">Score</th>
<th>Note</th>
<th class="text-center">Re-inspected?</th>
<th></th>
</tr>
</thead>
<tbody>
{% for insp in flagged %}
<tr class="{{ '' if insp._has_followup else 'table-warning' }}">
<td class="text-muted small">#{{ insp.id }}</td>
<td class="small">{{ insp.inspection_date.strftime('%Y-%m-%d') if insp.inspection_date else '—' }}</td>
<td class="small">{{ insp.facility.name if insp.facility else '—' }}</td>
<td class="small">{{ insp.template.name if insp.template else '—' }}</td>
<td class="small">{{ insp.inspector.display_name if insp.inspector else '—' }}</td>
<td class="text-center">
{% if insp.overall_score %}
<span class="badge bg-{{ 'success' if insp.overall_score >= 80 else 'warning text-dark' if insp.overall_score >= 60 else 'danger' }}">
{{ insp.overall_score|round(1) }}%
</span>
{% else %}—{% endif %}
</td>
<td class="small text-muted">
{{ insp.follow_up_note[:60] if insp.follow_up_note else '—' }}
{% if insp.follow_up_note and insp.follow_up_note|length > 60 %}…{% endif %}
</td>
<td class="text-center">
{% if insp._has_followup %}
<span class="badge bg-success"><i class="bi bi-check-circle me-1"></i>Yes</span>
{% else %}
<span class="badge bg-danger"><i class="bi bi-x-circle me-1"></i>No</span>
{% endif %}
</td>
<td>
<a href="{{ url_for('inspections.view', inspection_id=insp.id) }}"
class="btn btn-sm btn-outline-secondary">
<i class="bi bi-eye"></i>
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}{# end total == 0 #}
{% endblock %}
+1 -14
View File
@@ -13,20 +13,7 @@
{% block content %}
{# ── Sub-nav ── #}
<ul class="nav nav-pills mb-4">
<li class="nav-item">
<a class="nav-link active" href="{{ url_for('reports.index') }}">
<i class="bi bi-bar-chart me-1"></i>Overview
</a>
</li>
{% if current_user.role in ['admin', 'director'] %}
<li class="nav-item">
<a class="nav-link" href="{{ url_for('reports.inspector_performance') }}">
<i class="bi bi-person-lines-fill me-1"></i>Inspector Performance
</a>
</li>
{% endif %}
</ul>
{% include 'reports/_subnav.html' %}
{# ── Header + date filter ── #}
<div class="d-flex justify-content-between align-items-start mb-4">
@@ -16,18 +16,7 @@
{% block content %}
{# ── Sub-nav ── #}
<ul class="nav nav-pills mb-4">
<li class="nav-item">
<a class="nav-link" href="{{ url_for('reports.index', start=start.strftime('%Y-%m-%d'), end=end.strftime('%Y-%m-%d')) }}">
<i class="bi bi-bar-chart me-1"></i>Overview
</a>
</li>
<li class="nav-item">
<a class="nav-link active" href="{{ url_for('reports.inspector_performance', start=start.strftime('%Y-%m-%d'), end=end.strftime('%Y-%m-%d')) }}">
<i class="bi bi-person-lines-fill me-1"></i>Inspector Performance
</a>
</li>
</ul>
{% include 'reports/_subnav.html' %}
{# ── Header ── #}
<div class="d-flex justify-content-between align-items-start mb-3">
+175
View File
@@ -0,0 +1,175 @@
{% extends "base.html" %}
{% block title %}Issues Aging{% endblock %}
{% block content %}
{# ── Sub-nav ── #}
{% include 'reports/_subnav.html' %}
<div class="d-flex justify-content-between align-items-start mb-4">
<div>
<h2><i class="bi bi-clock-history text-danger me-2"></i>Issues Aging</h2>
<p class="text-muted mb-0">All currently open issues grouped by how long they have been waiting.</p>
</div>
<a href="{{ url_for('reports.export_issues_aging', severity=severity_filter, facility_id=facility_id_filter or '') }}"
class="btn btn-sm btn-outline-success">
<i class="bi bi-file-earmark-excel me-1"></i>Export Excel
</a>
</div>
{# ── Filters ── #}
<div class="card shadow-sm mb-4">
<div class="card-body py-2">
<form method="get" class="row g-2 align-items-end">
<div class="col-md-2">
<label class="form-label small mb-1">Severity</label>
<select name="severity" class="form-select form-select-sm">
<option value="">All</option>
{% for s in ['critical','high','medium','low'] %}
<option value="{{ s }}" {{ 'selected' if severity_filter == s }}>{{ s|title }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-4">
<label class="form-label small mb-1">Facility</label>
<select name="facility_id" class="form-select form-select-sm">
<option value="">All Facilities</option>
{% for f in facilities %}
<option value="{{ f.id }}" {{ 'selected' if facility_id_filter == f.id }}>{{ f.name }}</option>
{% endfor %}
</select>
</div>
<div class="col-auto">
<button class="btn btn-sm btn-primary">Apply</button>
<a href="{{ url_for('reports.issues_aging') }}" class="btn btn-sm btn-outline-secondary">Clear</a>
</div>
</form>
</div>
</div>
{# ── KPI row ── #}
<div class="row g-3 mb-4">
<div class="col-6 col-md-4">
<div class="card shadow-sm border-0 h-100" style="background:#fff7ed;">
<div class="card-body">
<div class="small fw-semibold text-muted mb-1">Total Open</div>
<div class="fs-1 fw-bold" style="color:#ea580c;">{{ total }}</div>
<div class="small text-muted">unresolved issues</div>
</div>
</div>
</div>
<div class="col-6 col-md-4">
<div class="card shadow-sm border-0 h-100" style="background:#fef2f2;">
<div class="card-body">
<div class="small fw-semibold text-muted mb-1">SLA Breached</div>
<div class="fs-1 fw-bold text-danger">{{ sla_breached }}</div>
<div class="small text-muted">past resolution deadline</div>
</div>
</div>
</div>
<div class="col-6 col-md-4">
<div class="card shadow-sm border-0 h-100" style="background:#fefce8;">
<div class="card-body">
<div class="small fw-semibold text-muted mb-1">SLA At Risk</div>
<div class="fs-1 fw-bold text-warning">{{ sla_at_risk }}</div>
<div class="small text-muted">approaching deadline</div>
</div>
</div>
</div>
</div>
{# ── Bucket accordions ── #}
<div class="accordion" id="agingAccordion">
{% for label in bucket_labels %}
{% set bucket = buckets[label] %}
{% set bucket_id = 'bucket-' ~ loop.index %}
{% set is_danger = label in ['>4 weeks', '14 weeks'] %}
{% set is_warning = label == '37 days' %}
<div class="accordion-item mb-2 shadow-sm border-0">
<h2 class="accordion-header">
<button class="accordion-button {{ 'collapsed' if loop.index > 1 else '' }} fw-semibold"
type="button" data-bs-toggle="collapse"
data-bs-target="#{{ bucket_id }}">
<span class="badge rounded-pill me-2
{{ 'bg-danger' if is_danger else 'bg-warning text-dark' if is_warning else 'bg-secondary' }}">
{{ bucket|length }}
</span>
{{ label }}
{% if is_danger and bucket|length > 0 %}
<span class="ms-2 badge bg-danger bg-opacity-25 text-danger" style="font-size:.7rem;">Needs attention</span>
{% endif %}
</button>
</h2>
<div id="{{ bucket_id }}" class="accordion-collapse collapse {{ 'show' if loop.index == 1 else '' }}"
data-bs-parent="#agingAccordion">
<div class="accordion-body p-0">
{% if bucket %}
<div class="table-responsive">
<table class="table table-hover table-sm mb-0">
<thead class="table-light">
<tr>
<th>#</th>
<th>Age</th>
<th>Severity</th>
<th>Facility / Area</th>
<th>Description</th>
<th>Status</th>
<th>SLA</th>
<th>Assigned</th>
<th></th>
</tr>
</thead>
<tbody>
{% for item in bucket %}
{% set issue = item.issue %}
{% set sla = item.sla %}
<tr class="{{ 'table-danger' if sla == 'breached' else 'table-warning' if sla == 'at_risk' else '' }}">
<td class="text-muted small">#{{ issue.id }}</td>
<td class="text-nowrap small">{{ item.age_h }}h</td>
<td>
<span class="badge bg-{{ 'danger' if issue.severity in ['critical','high'] else 'warning text-dark' if issue.severity == 'medium' else 'secondary' }}">
{{ issue.severity|title }}
</span>
</td>
<td class="small">
{{ issue.resolved_facility.name if issue.resolved_facility else '—' }}<br>
<span class="text-muted">{{ issue.area.name if issue.area else '—' }}</span>
</td>
<td class="small">{{ issue.description[:70] }}{% if issue.description|length > 70 %}…{% endif %}</td>
<td>
<span class="badge bg-{{ 'info text-dark' if issue.status == 'pending_verification' else 'warning text-dark' if issue.status == 'in_progress' else 'secondary' }}">
{{ issue.status|replace('_',' ')|title }}
</span>
</td>
<td>
{% if sla == 'breached' %}<span class="badge bg-danger"><i class="bi bi-alarm me-1"></i>Breached</span>
{% elif sla == 'at_risk' %}<span class="badge bg-warning text-dark">At Risk</span>
{% elif sla == 'ok' %}<span class="badge bg-secondary">OK</span>
{% else %}<span class="text-muted small"></span>{% endif %}
</td>
<td class="small">{{ issue.assigned_user.display_name if issue.assigned_user else '—' }}</td>
<td>
<a href="{{ url_for('issues.view', issue_id=issue.id) }}" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-eye"></i>
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="p-3 text-muted small">No issues in this age range.</div>
{% endif %}
</div>
</div>
</div>
{% endfor %}
</div>
{% if total == 0 %}
<div class="alert alert-success mt-3">
<i class="bi bi-check-circle me-2"></i>No open issues match the selected filters.
</div>
{% endif %}
{% endblock %}
+4
View File
@@ -29,6 +29,10 @@
class="btn btn-sm btn-outline-secondary">
<i class="bi bi-file-text"></i> Full Report
</a>
<a href="{{ url_for('reports.facility_summary_pdf', facility_id=facility.id, days=days) }}"
class="btn btn-sm btn-outline-danger" title="Download customer-facing PDF summary">
<i class="bi bi-file-earmark-pdf"></i> PDF Summary
</a>
<a href="{{ url_for('reports.index') }}" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-arrow-left"></i> Reports
</a>
+149
View File
@@ -0,0 +1,149 @@
{% extends "base.html" %}
{% block title %}SLA Compliance{% endblock %}
{% block extra_css %}
<style>
.compliance-ring { position:relative; display:inline-flex; align-items:center; justify-content:center; width:120px; height:120px; }
</style>
{% endblock %}
{% block content %}
{# ── Sub-nav ── #}
{% include 'reports/_subnav.html' %}
<div class="d-flex justify-content-between align-items-start mb-4">
<div>
<h2><i class="bi bi-shield-check text-success me-2"></i>SLA Compliance</h2>
<p class="text-muted mb-0">{{ start.strftime('%b %d, %Y') }} — {{ end.strftime('%b %d, %Y') }}</p>
</div>
<a href="{{ url_for('reports.export_sla_compliance', start=start.strftime('%Y-%m-%d'), end=end.strftime('%Y-%m-%d'), facility_id=facility_id_filter or '') }}"
class="btn btn-sm btn-outline-success">
<i class="bi bi-file-earmark-excel me-1"></i>Export Excel
</a>
</div>
{# ── Filters ── #}
<div class="card shadow-sm mb-4">
<div class="card-body py-2">
<form method="get" class="row g-2 align-items-end">
<div class="col-md-3">
<label class="form-label small mb-1">From</label>
<input type="date" name="start" class="form-control form-control-sm" value="{{ start.strftime('%Y-%m-%d') }}">
</div>
<div class="col-md-3">
<label class="form-label small mb-1">To</label>
<input type="date" name="end" class="form-control form-control-sm" value="{{ end.strftime('%Y-%m-%d') }}">
</div>
<div class="col-md-4">
<label class="form-label small mb-1">Facility</label>
<select name="facility_id" class="form-select form-select-sm">
<option value="">All Facilities</option>
{% for f in facilities %}
<option value="{{ f.id }}" {{ 'selected' if facility_id_filter == f.id }}>{{ f.name }}</option>
{% endfor %}
</select>
</div>
<div class="col-auto">
<button class="btn btn-sm btn-primary">Apply</button>
<a href="{{ url_for('reports.sla_compliance') }}" class="btn btn-sm btn-outline-secondary">Reset</a>
</div>
</form>
</div>
</div>
{% if total == 0 %}
<div class="alert alert-info">
<i class="bi bi-info-circle me-2"></i>No resolved issues found for this period and filter.
</div>
{% else %}
{# ── Overall KPI ── #}
<div class="row g-3 mb-4 align-items-stretch">
<div class="col-md-3">
<div class="card shadow-sm h-100 text-center"
style="border-left:6px solid {{ '#16a34a' if overall_pct and overall_pct >= 90 else '#d97706' if overall_pct and overall_pct >= 70 else '#dc2626' }};">
<div class="card-body d-flex flex-column align-items-center justify-content-center py-4">
<div class="text-muted small fw-semibold mb-1">Overall Compliance</div>
<div class="display-4 fw-bold
{{ 'text-success' if overall_pct and overall_pct >= 90 else 'text-warning' if overall_pct and overall_pct >= 70 else 'text-danger' }}">
{{ overall_pct|round(1) if overall_pct is not none else '—' }}{% if overall_pct is not none %}%{% endif %}
</div>
<div class="text-muted small mt-1">{{ met }}/{{ total }} resolved on time</div>
</div>
</div>
</div>
{# ── Per-severity compliance cards ── #}
{% for sev, color_cls in [('critical','danger'),('high','danger'),('medium','warning'),('low','secondary')] %}
{% set d = by_severity[sev] %}
<div class="col-6 col-md">
<div class="card shadow-sm h-100">
<div class="card-body text-center">
<div class="mb-1"><span class="badge bg-{{ color_cls }} {{ 'text-dark' if color_cls == 'warning' else '' }} px-2">
{{ sev|title }}
</span></div>
<div class="small text-muted mb-1">SLA: {{ d.sla_hours }}h window</div>
<div class="fs-3 fw-bold
{{ 'text-success' if d.pct and d.pct >= 90 else 'text-warning' if d.pct and d.pct >= 70 else 'text-danger' if d.pct is not none else 'text-muted' }}">
{{ d.pct|round(1) if d.pct is not none else '—' }}{% if d.pct is not none %}%{% endif %}
</div>
<div class="small text-muted">{{ d.met }}/{{ d.total }}</div>
{% if d.total > 0 %}
<div class="progress mt-2" style="height:4px;">
<div class="progress-bar bg-{{ 'success' if d.pct and d.pct >= 90 else 'warning' if d.pct and d.pct >= 70 else 'danger' }}"
style="width:{{ d.pct or 0 }}%;"></div>
</div>
{% endif %}
</div>
</div>
</div>
{% endfor %}
</div>
{# ── Facility breakdown ── #}
{% if by_facility %}
<div class="card shadow-sm">
<div class="card-header bg-light fw-semibold">
<i class="bi bi-building me-1"></i>Compliance by Facility
<span class="badge bg-secondary ms-1">{{ by_facility|length }}</span>
</div>
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th>Facility</th>
<th class="text-center">Resolved Issues</th>
<th class="text-center">Met SLA</th>
<th class="text-center">Compliance %</th>
<th>Progress</th>
</tr>
</thead>
<tbody>
{% 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' %}
<tr>
<td class="fw-semibold">{{ row.name }}</td>
<td class="text-center">{{ row.total }}</td>
<td class="text-center">{{ row.met }}</td>
<td class="text-center">
{% if row.pct is not none %}
<span class="badge bg-{{ 'success' if row.pct >= 90 else 'warning text-dark' if row.pct >= 70 else 'danger' }} px-3">
{{ row.pct }}%
</span>
{% else %}—{% endif %}
</td>
<td style="min-width:120px;">
<div class="progress" style="height:8px;">
<div class="progress-bar {{ bar_class }}" style="width:{{ row.pct or 0 }}%;"></div>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}
{% endif %}{# end total == 0 #}
{% endblock %}
+161
View File
@@ -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('<font color="#{}">'
'<b>{}</b></font>'.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('<b>{}</b>'.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('<font color="#{}">'
'<b>{}%</b></font>'.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('<font color="#{}">'
'<b>{}</b></font>'.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()