From f0d284a60962d205371236e1f28f2463d5450938 Mon Sep 17 00:00:00 2001 From: NguyenND Date: Tue, 17 Mar 2026 15:28:54 -0400 Subject: [PATCH] March 17 2026: form editor - update functions --- app/routes/dashboard.py | 70 +++ app/routes/scheduled_reports.py | 70 +++ app/templates/dashboard.html | 130 +++++- app/templates/scheduled_reports/index.html | 517 ++++++++++++++++++--- app/utils/pdf_export.py | 176 +++++++ 5 files changed, 900 insertions(+), 63 deletions(-) diff --git a/app/routes/dashboard.py b/app/routes/dashboard.py index 70a4373..8e8cfe4 100644 --- a/app/routes/dashboard.py +++ b/app/routes/dashboard.py @@ -180,6 +180,16 @@ def index(): for r in perf_rows ] + # ── Facilities list for the trend-by-facility chart selector ──────────── + if is_privileged or is_project_manager: + all_facilities = Facility.query.filter_by(active=True).order_by(Facility.name).all() + elif is_customer and customer_facility_ids: + all_facilities = Facility.query.filter( + Facility.id.in_(customer_facility_ids), Facility.active == True + ).order_by(Facility.name).all() + else: + all_facilities = [] + return render_template( 'dashboard.html', today_inspections = today_inspections, @@ -197,4 +207,64 @@ def index(): facility_perf = facility_perf, customer_facilities = customer_facilities, pending_followups = pending_followups, + all_facilities = all_facilities, ) + + +# ── AJAX: facility score trend ──────────────────────────────────────────────── + +@bp.route('/facility-trend') +@login_required +def facility_trend(): + """Return daily avg-score data for a single facility over N days. + + Query params: + facility_id (int, required) + days (int, default 30 — allowed: 30, 60, 90) + + Response JSON: + { labels: ['2026-03-01', ...], data: [85.2, ...], facility: 'Name' } + """ + from flask import jsonify, request as req + + facility_id = req.args.get('facility_id', type=int) + days = req.args.get('days', 30, type=int) + if days not in (30, 60, 90): + days = 30 + + if not facility_id: + return jsonify({'labels': [], 'data': [], 'facility': ''}) + + # Scope check for customer users + if current_user.role == 'customer': + cids = get_customer_scope(current_user) or [] + if facility_id not in cids: + return jsonify({'labels': [], 'data': [], 'facility': ''}), 403 + + facility = Facility.query.get(facility_id) + if not facility: + return jsonify({'labels': [], 'data': [], 'facility': ''}) + + start = now_eastern() - timedelta(days=days) + + rows = ( + db.session.query( + func.date(Inspection.inspection_date).label('day'), + func.avg(Inspection.overall_score).label('avg'), + ) + .filter( + Inspection.facility_id == facility_id, + Inspection.status == 'completed', + Inspection.overall_score.isnot(None), + Inspection.inspection_date >= start, + ) + .group_by(func.date(Inspection.inspection_date)) + .order_by(func.date(Inspection.inspection_date)) + .all() + ) + + return jsonify({ + 'labels': [str(r.day) for r in rows], + 'data': [round(float(r.avg), 2) for r in rows], + 'facility': facility.name, + }) \ No newline at end of file diff --git a/app/routes/scheduled_reports.py b/app/routes/scheduled_reports.py index 6a9ad4c..2203d9c 100644 --- a/app/routes/scheduled_reports.py +++ b/app/routes/scheduled_reports.py @@ -230,6 +230,22 @@ def _send_report(report: ScheduledReport): fname = f'jqc_report_{report.frequency}_{start.strftime("%Y%m%d")}.csv' msg.attach(fname, 'text/csv', csv_bytes) + if report.include_pdf: + try: + from app.utils.pdf_export import generate_scheduled_report_pdf + pdf_bytes = generate_scheduled_report_pdf( + report_name = report.name, + frequency = report.frequency, + start = start, + end = end, + facility_name = report.facility.name if report.facility else None, + data = data, + ) + pdf_fname = f'jqc_report_{report.frequency}_{start.strftime("%Y%m%d")}.pdf' + msg.attach(pdf_fname, 'application/pdf', pdf_bytes) + except Exception as exc: + logger.error('SCHEDULED REPORT PDF FAILED | id=%s | error=%s', report.id, exc) + try: mail.send(msg) logger.info('SCHEDULED REPORT SENT | id=%s | name=%r | recipients=%s', @@ -340,6 +356,60 @@ def delete(report_id): return redirect(url_for('scheduled_reports.index')) +@bp.route('//preview') +@login_required +@supervisor_required +def preview(report_id): + """Render the scheduled report email in-browser for review.""" + report = ScheduledReport.query.get_or_404(report_id) + start, end = _date_window(report.frequency) + data = _build_report_data(report, start, end) + base_url = current_app.config.get('APP_BASE_URL', '').rstrip('/') + + current_app.logger.info( + 'SCHEDULED REPORT PREVIEW | id=%s | name=%r | by=%s', + report.id, report.name, current_user.username, + ) + + return render_template('scheduled_reports/email.html', + base_url=base_url, **data) + + +@bp.route('//preview-pdf') +@login_required +@supervisor_required +def preview_pdf(report_id): + """Generate and stream the PDF attachment for in-browser review.""" + from flask import Response + from app.utils.pdf_export import generate_scheduled_report_pdf + + report = ScheduledReport.query.get_or_404(report_id) + start, end = _date_window(report.frequency) + data = _build_report_data(report, start, end) + + pdf_bytes = generate_scheduled_report_pdf( + report_name = report.name, + frequency = report.frequency, + start = start, + end = end, + facility_name = report.facility.name if report.facility else None, + data = data, + ) + + filename = f'jqc_report_{report.frequency}_{start.strftime("%Y%m%d")}.pdf' + + current_app.logger.info( + 'SCHEDULED REPORT PDF PREVIEW | id=%s | name=%r | by=%s', + report.id, report.name, current_user.username, + ) + + return Response( + pdf_bytes, + mimetype='application/pdf', + headers={'Content-Disposition': f'inline; filename="{filename}"'}, + ) + + @bp.route('//send-now', methods=['POST']) @login_required @supervisor_required diff --git a/app/templates/dashboard.html b/app/templates/dashboard.html index 9d6cec5..4685d47 100644 --- a/app/templates/dashboard.html +++ b/app/templates/dashboard.html @@ -261,6 +261,47 @@ {% endif %} +{# ── Facility Score Trend (30/60/90 days) ─────────────────────────────────── #} +{% if all_facilities %} +
+
+
+
+ Facility Score Trend +
+ +
+ + + +
+
+
+
+
+ + Select a facility to view its score trend. +
+ + + +
+
+
+
+{% endif %} + {# ── Recent activity ─────────────────────────────────────────────────────── #}
@@ -314,8 +355,9 @@
-{% if trend_labels %} + +{% if trend_labels %} {% endif %} -{% endblock %} + +{# ── Facility trend chart (AJAX-driven) ── #} +{% if all_facilities %} + +{% endif %} +{% endblock %} \ No newline at end of file diff --git a/app/templates/scheduled_reports/index.html b/app/templates/scheduled_reports/index.html index e5716dc..4685d47 100644 --- a/app/templates/scheduled_reports/index.html +++ b/app/templates/scheduled_reports/index.html @@ -1,87 +1,482 @@ {% extends "base.html" %} -{% block title %}Scheduled Reports{% endblock %} +{% block title %}Dashboard{% endblock %} + {% block content %} -
-

Scheduled Reports

- - New Schedule - +
+
+

Welcome, {{ current_user.username }}!

+ + {{ current_user.role.replace('_',' ')|title }} + +
-{% if reports %} +{# ── Top stat cards ─────────────────────────────────────────────────────── #} + + +{# ── Pending Follow-ups alert (non-customer) ────────────────────────────── #} +{% if pending_followups and pending_followups > 0 and current_user.role != 'customer' %} + +{% endif %} + +{# ── SLA Summary ─────────────────────────────────────────────────────────── #} +{% if sla_breached > 0 or sla_at_risk > 0 %} +
+ {% if sla_breached > 0 %} + + {% endif %} + {% if sla_at_risk > 0 %} + + {% endif %} +
+{% endif %} + +{# ── Customer portal: scoped facilities panel ───────────────────────────── #} +{% if current_user.role == 'customer' %} +
+
+
+
+ Your Facilities +
+ {% if customer_facilities %} +
+
+ + + + + + + + + + + {% for f in customer_facilities %} + + + + + + + {% endfor %} + +
FacilityAddressProject
{{ f.name }}{{ f.address or '—' }}{{ f.project.name if f.project else '—' }} + + View + + + Report + +
+
+
+ {% else %} +
+ + No facilities have been assigned to your account yet. Please contact your administrator. +
+ {% endif %} +
+
+
+{% endif %} + +{# ── System quick stats (admin/supervisor) ──────────────────────────────── #} +{% if current_user.role in ['admin', 'supervisor'] %} +
+
+
+
+ +
{{ total_facilities }}
+
Active Facilities
+
+
+
+
+
+
+ +
{{ total_templates }}
+
Templates
+
+
+
+ {% if current_user.role == 'admin' %} +
+
+
+ +
{{ total_users }}
+
Users
+
+
+
+ {% endif %} +
+{% endif %} + +{# ── Score trend chart + Facility performance ───────────────────────────── #} +
+
+
+
+ Inspection Score Trend (Last 30 Days) +
+
+ {% if trend_labels %} + + {% else %} +
+ + No completed inspections with scores in the last 30 days. +
+ {% endif %} +
+
+
+ + {% if current_user.role in ['admin', 'supervisor'] and facility_perf %} +
+
+
+ Facility Performance (30d) +
+
+ + + + + + + + + + {% for f in facility_perf %} + + + + + + {% endfor %} + +
FacilityInspectionsAvg Score
{{ f.name }}{{ f.count }} + + {{ f.avg }}% + +
+
+
+
+ {% endif %} +
+ +{# ── Facility Score Trend (30/60/90 days) ─────────────────────────────────── #} +{% if all_facilities %} +
+
+
+
+ Facility Score Trend +
+ +
+ + + +
+
+
+
+
+ + Select a facility to view its score trend. +
+ + + +
+
+
+
+{% endif %} + +{# ── Recent activity ─────────────────────────────────────────────────────── #}
+
+ Recent Activity +
+ {% if recent_inspections %}
- - - + + + + {% if current_user.role != 'inspector' %}{% endif %} + + - {% for r in reports %} - - - - - + {% for insp in recent_inspections %} + + + + + {% if current_user.role != 'inspector' %}{% endif %} - - - {% endfor %}
NameTypeFrequencyFacilityRecipientsNext SendLast SentStatusDateFacilityAreaInspectorScoreStatus
{{ r.name }}{{ r.report_type|title }}{{ r.frequency|title }}{{ r.facility.name if r.facility else '— All —' }}
{{ insp.inspection_date.strftime('%Y-%m-%d %H:%M') }}{{ insp.facility.name }}{{ insp.area.name if insp.area else '—' }}{{ insp.inspector.username }} - - {{ r.recipient_list()|length }} recipient{{ 's' if r.recipient_list()|length != 1 else '' }} + {% if insp.overall_score %} + + {{ insp.overall_score }}% - - {{ r.next_send_at.strftime('%Y-%m-%d %H:%M') if r.next_send_at else '—' }} - - {{ r.last_sent_at.strftime('%Y-%m-%d %H:%M') if r.last_sent_at else 'Never' }} + {% else %} + + {% endif %} - {% if r.active %}Active - {% else %}Paused{% endif %} - - - - -
- - -
-
- - -
+ + {{ insp.status|title }} +
+ {% else %} +
+ No recent inspections. +
+ {% endif %}
-{% else %} -
-
- -

No scheduled reports configured yet.

- - Create First Schedule - -
-
+ + + +{% if trend_labels %} + {% endif %} -{% endblock %} + +{# ── Facility trend chart (AJAX-driven) ── #} +{% if all_facilities %} + +{% endif %} +{% endblock %} \ No newline at end of file diff --git a/app/utils/pdf_export.py b/app/utils/pdf_export.py index 441a371..cef0449 100644 --- a/app/utils/pdf_export.py +++ b/app/utils/pdf_export.py @@ -644,5 +644,181 @@ def generate_inspection_pdf(inspection, form_fields, form_data, issues, ])) story.append(sig_tbl) + doc.build(story, onFirstPage=_page_cb, onLaterPages=_page_cb) + return buf.getvalue() + + +# ══════════════════════════════════════════════════════════════════════════════ +# SCHEDULED REPORT PDF +# ══════════════════════════════════════════════════════════════════════════════ + +def generate_scheduled_report_pdf(report_name, frequency, start, end, + facility_name=None, data=None): + """Generate a PDF summary for a scheduled report email attachment. + + Parameters + ---------- + report_name : str — the ScheduledReport.name + frequency : str — 'daily' / 'weekly' / 'monthly' + start, end : datetime — the reporting window + facility_name : str | None — scoped facility name, or None for all + data : dict — the assembled report data from _build_report_data() + + Returns + ------- + bytes — the PDF content + """ + if data is None: + data = {} + + buf = io.BytesIO() + doc = SimpleDocTemplate( + buf, pagesize=letter, + leftMargin=0.65 * inch, rightMargin=0.65 * inch, + topMargin=1.0 * inch, bottomMargin=0.65 * inch, + ) + + generated_at = datetime.now().strftime('%Y-%m-%d %H:%M') + title_text = f'{frequency.title()} Report — {report_name}' + + def _page_cb(canvas, doc): + _on_page(canvas, doc, title_text, generated_at) + + story = [] + pw = letter[0] - 1.3 * inch # usable page width + + # ── Sub-header ──────────────────────────────────────────────────────── + period = f'{start.strftime("%b %d, %Y")} — {end.strftime("%b %d, %Y")}' + scope = f'Facility: {facility_name}' if facility_name else 'All Facilities' + story.append(Paragraph(f'{period} · {scope}', STYLES['ReportSub'])) + story.append(Spacer(1, 12)) + + # ── KPI cards (summary / facility report types) ─────────────────────── + total_insp = data.get('total_inspections', 0) + completed = data.get('completed', 0) + open_iss = data.get('open_issues', 0) + avg_score = data.get('avg_score') + + kpi_data = [[ + Paragraph('Inspections', STYLES['FieldLabel']), + Paragraph('Completed', STYLES['FieldLabel']), + Paragraph('Open Issues', STYLES['FieldLabel']), + Paragraph('Avg Score', STYLES['FieldLabel']), + ], [ + Paragraph(f'{total_insp}', STYLES['FieldValue']), + Paragraph(f'{completed}', STYLES['FieldValue']), + Paragraph(f'{open_iss}', STYLES['FieldValue']), + Paragraph( + f'{f"{avg_score:.1f}%" if avg_score else "—"}', + STYLES['FieldValue'], + ), + ]] + kpi_tbl = Table(kpi_data, colWidths=[pw * 0.25] * 4) + kpi_tbl.setStyle(TableStyle([ + ('BACKGROUND', (0, 0), (-1, -1), C_LIGHT), + ('ALIGN', (0, 0), (-1, -1), 'CENTER'), + ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'), + ('TOPPADDING', (0, 0), (-1, -1), 8), + ('BOTTOMPADDING', (0, 0), (-1, -1), 8), + ('BOX', (0, 0), (-1, -1), 0.5, C_BORDER), + ('INNERGRID', (0, 0), (-1, -1), 0.25, C_BORDER), + ('ROUNDEDCORNERS', [6, 6, 6, 6]), + ])) + story.append(kpi_tbl) + story.append(Spacer(1, 16)) + + # ── Facility scores table ───────────────────────────────────────────── + fac_scores = data.get('facility_scores', []) + if fac_scores: + story.append(Paragraph('Facility Scores', STYLES['SectionHead'])) + tbl_data = [['Facility', 'Inspections', 'Avg Score']] + for row in fac_scores: + sc = float(row.avg) if hasattr(row, 'avg') else float(row[1]) + cnt = row.count if hasattr(row, 'count') else row[2] + nm = row.name if hasattr(row, 'name') else row[0] + sc_color = C_GREEN if sc >= 90 else C_YELLOW if sc >= 70 else C_RED + tbl_data.append([ + Paragraph(str(nm), STYLES['FieldValue']), + Paragraph(str(cnt), STYLES['FieldValue']), + Paragraph(f'{sc:.1f}%', STYLES['FieldValue']), + ]) + fac_tbl = Table(tbl_data, colWidths=[pw * 0.50, pw * 0.25, pw * 0.25]) + fac_tbl.setStyle(TableStyle([ + ('BACKGROUND', (0, 0), (-1, 0), C_LIGHT), + ('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'), + ('FONTSIZE', (0, 0), (-1, -1), 8), + ('ALIGN', (1, 0), (-1, -1), 'CENTER'), + ('INNERGRID', (0, 0), (-1, -1), 0.25, C_BORDER), + ('BOX', (0, 0), (-1, -1), 0.5, C_BORDER), + ('TOPPADDING', (0, 0), (-1, -1), 4), + ('BOTTOMPADDING', (0, 0), (-1, -1), 4), + ])) + story.append(fac_tbl) + story.append(Spacer(1, 14)) + + # ── Critical / High issues ──────────────────────────────────────────── + crit_issues = data.get('critical_issues', []) + if crit_issues: + story.append(Paragraph('Open Critical / High Issues', STYLES['SectionHead'])) + tbl_data = [['#', 'Severity', 'Facility / Area', 'Description', 'Reported']] + for iss in crit_issues: + sev_c = SEVERITY_COLORS.get(iss.severity, C_SLATE) + tbl_data.append([ + Paragraph(f'#{iss.id}', STYLES['FieldValue']), + Paragraph(f'{iss.severity.title()}', STYLES['FieldValue']), + Paragraph(f'{iss.area.facility.name} / {iss.area.name}', STYLES['FieldValue']), + Paragraph(iss.description[:80] + ('…' if len(iss.description) > 80 else ''), STYLES['IssueDesc']), + Paragraph(iss.reported_at.strftime('%b %d'), STYLES['FieldValue']), + ]) + iss_tbl = Table(tbl_data, colWidths=[pw * 0.07, pw * 0.12, pw * 0.25, pw * 0.40, pw * 0.16]) + iss_tbl.setStyle(TableStyle([ + ('BACKGROUND', (0, 0), (-1, 0), colors.HexColor('#fef2f2')), + ('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'), + ('FONTSIZE', (0, 0), (-1, -1), 7.5), + ('INNERGRID', (0, 0), (-1, -1), 0.25, C_BORDER), + ('BOX', (0, 0), (-1, -1), 0.5, C_BORDER), + ('TOPPADDING', (0, 0), (-1, -1), 3), + ('BOTTOMPADDING', (0, 0), (-1, -1), 3), + ('VALIGN', (0, 0), (-1, -1), 'TOP'), + ])) + story.append(iss_tbl) + story.append(Spacer(1, 14)) + + # ── Open issues list (issues report type) ───────────────────────────── + all_issues = data.get('issues', []) + if all_issues and not crit_issues: + story.append(Paragraph(f'Open Issues ({len(all_issues)})', STYLES['SectionHead'])) + tbl_data = [['#', 'Severity', 'Facility / Area', 'Status', 'Description']] + for iss in all_issues: + sev_c = SEVERITY_COLORS.get(iss.severity, C_SLATE) + tbl_data.append([ + Paragraph(f'#{iss.id}', STYLES['FieldValue']), + Paragraph(f'{iss.severity.title()}', STYLES['FieldValue']), + Paragraph(f'{iss.area.facility.name} / {iss.area.name}', STYLES['FieldValue']), + Paragraph(iss.status.replace('_', ' ').title(), STYLES['FieldValue']), + Paragraph(iss.description[:70] + ('…' if len(iss.description) > 70 else ''), STYLES['IssueDesc']), + ]) + iss_tbl = Table(tbl_data, colWidths=[pw * 0.07, pw * 0.12, pw * 0.25, pw * 0.16, pw * 0.40]) + iss_tbl.setStyle(TableStyle([ + ('BACKGROUND', (0, 0), (-1, 0), C_LIGHT), + ('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'), + ('FONTSIZE', (0, 0), (-1, -1), 7.5), + ('INNERGRID', (0, 0), (-1, -1), 0.25, C_BORDER), + ('BOX', (0, 0), (-1, -1), 0.5, C_BORDER), + ('TOPPADDING', (0, 0), (-1, -1), 3), + ('BOTTOMPADDING', (0, 0), (-1, -1), 3), + ('VALIGN', (0, 0), (-1, -1), 'TOP'), + ])) + story.append(iss_tbl) + + # ── Footer note ─────────────────────────────────────────────────────── + story.append(Spacer(1, 20)) + story.append(HRFlowable(width='100%', thickness=0.5, color=C_BORDER)) + story.append(Spacer(1, 6)) + story.append(Paragraph( + 'Janitorial QC System — automated scheduled report', + STYLES['FooterStyle'], + )) + doc.build(story, onFirstPage=_page_cb, onLaterPages=_page_cb) return buf.getvalue() \ No newline at end of file