July 4 - Update Vendor Work-Order Workflow
This commit is contained in:
@@ -202,6 +202,7 @@ def create_app(config_name='default'):
|
||||
from app.routes import customers # Phase 5 — Customer management
|
||||
from app.routes import scheduled_reports # Phase 6 — Scheduled reports
|
||||
from app.routes import inspection_schedules # phase34 — recurring inspections
|
||||
from app.routes import work_orders # phase36 — vendor work orders (public tokenized)
|
||||
from app.routes import support # Support chat + admin tickets
|
||||
from app.routes import broadcast # Admin broadcast messages
|
||||
from app.routes import devices # Admin device management
|
||||
@@ -223,6 +224,7 @@ def create_app(config_name='default'):
|
||||
app.register_blueprint(customers.bp)
|
||||
app.register_blueprint(scheduled_reports.bp)
|
||||
app.register_blueprint(inspection_schedules.bp)
|
||||
app.register_blueprint(work_orders.bp)
|
||||
app.register_blueprint(support.bp)
|
||||
app.register_blueprint(broadcast.bp)
|
||||
app.register_blueprint(devices.bp)
|
||||
|
||||
@@ -6,4 +6,5 @@ from app.models.issue import Issue
|
||||
from app.models.project import Project, CustomerAssignment
|
||||
from app.models.api_token import RefreshToken, DeviceToken
|
||||
from app.models.notification_matrix import NotificationMatrix
|
||||
from app.models.inspection_schedule import InspectionSchedule
|
||||
from app.models.inspection_schedule import InspectionSchedule
|
||||
from app.models.work_order import IssueWorkOrder
|
||||
@@ -33,6 +33,10 @@ EVENT_ADMIN_BROADCAST = 'admin_broadcast' # bulk messages sent by admin to all
|
||||
# notifies the assigned inspector (phase34).
|
||||
EVENT_INSPECTION_SCHEDULED = 'inspection_scheduled'
|
||||
|
||||
# Fired when an external contractor acknowledges or completes a vendor work
|
||||
# order via the tokenized public link (phase36).
|
||||
EVENT_WORK_ORDER = 'work_order_update'
|
||||
|
||||
ALL_EVENT_TYPES = {
|
||||
EVENT_ISSUE_ASSIGNED: 'Issue assigned to me',
|
||||
EVENT_ISSUE_STATUS: 'Issue status changed',
|
||||
@@ -43,6 +47,7 @@ ALL_EVENT_TYPES = {
|
||||
EVENT_SLA_ALERT: 'SLA at-risk / breached alerts',
|
||||
EVENT_ADMIN_BROADCAST: 'Admin broadcast (system announcements)',
|
||||
EVENT_INSPECTION_SCHEDULED: 'Scheduled inspection due (assigned to me)',
|
||||
EVENT_WORK_ORDER: 'Contractor updated a work order',
|
||||
# Customer-facing — only relevant for customer role accounts
|
||||
EVENT_CUSTOMER_INSPECTION_DONE: 'Inspection completed at my facility (portal)',
|
||||
EVENT_CUSTOMER_ISSUE_UPDATED: 'Issue created or updated at my facility (portal)',
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
"""
|
||||
app/models/work_order.py
|
||||
------------------------
|
||||
Vendor work orders (phase36).
|
||||
|
||||
A work order dispatches an existing Issue to an external contractor by email.
|
||||
The contractor opens a tokenized public link (no account required) to see the
|
||||
scoped issue details and to Acknowledge and then mark the work Completed. Staff
|
||||
see the work-order status on the issue and are notified on each vendor action.
|
||||
|
||||
Builds on the existing free-text `vendor_*` fields on Issue (phase26) — a work
|
||||
order is the "send it and track it" layer on top of that.
|
||||
"""
|
||||
|
||||
import secrets
|
||||
|
||||
from app import db
|
||||
from app.utils.time_utils import now_eastern
|
||||
|
||||
|
||||
class IssueWorkOrder(db.Model):
|
||||
__tablename__ = 'issue_work_orders'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
issue_id = db.Column(
|
||||
db.Integer, db.ForeignKey('issues.id', ondelete='CASCADE'),
|
||||
nullable=False, index=True
|
||||
)
|
||||
vendor_name = db.Column(db.String(150), nullable=False)
|
||||
vendor_email = db.Column(db.String(255), nullable=False)
|
||||
|
||||
# Unguessable public access token (URL path segment). Never exposed to
|
||||
# anyone but the vendor who receives the emailed link.
|
||||
token = db.Column(db.String(64), nullable=False, unique=True, index=True)
|
||||
|
||||
status = db.Column(
|
||||
db.Enum('sent', 'acknowledged', 'completed'),
|
||||
nullable=False, default='sent'
|
||||
)
|
||||
message = db.Column(db.Text, nullable=True) # staff → vendor instructions
|
||||
vendor_note = db.Column(db.Text, nullable=True) # vendor → staff completion note
|
||||
|
||||
sent_at = db.Column(db.DateTime, nullable=False, default=now_eastern)
|
||||
acknowledged_at = db.Column(db.DateTime, nullable=True)
|
||||
completed_at = db.Column(db.DateTime, nullable=True)
|
||||
|
||||
created_by = db.Column(
|
||||
db.Integer, db.ForeignKey('users.id', ondelete='SET NULL'), nullable=True
|
||||
)
|
||||
created_at = db.Column(db.DateTime, nullable=False, default=now_eastern)
|
||||
|
||||
# Relationships
|
||||
issue = db.relationship('Issue', backref=db.backref(
|
||||
'work_orders', lazy='dynamic', cascade='all, delete-orphan',
|
||||
order_by='IssueWorkOrder.created_at.desc()'))
|
||||
creator = db.relationship('User', foreign_keys=[created_by])
|
||||
|
||||
@staticmethod
|
||||
def new_token():
|
||||
"""Return a fresh unguessable token (43 url-safe chars ≈ 256 bits)."""
|
||||
return secrets.token_urlsafe(32)
|
||||
|
||||
@property
|
||||
def status_label(self):
|
||||
return {'sent': 'Sent', 'acknowledged': 'Acknowledged',
|
||||
'completed': 'Completed'}.get(self.status, self.status)
|
||||
|
||||
def __repr__(self):
|
||||
return f'<IssueWorkOrder {self.id} issue={self.issue_id} {self.status}>'
|
||||
+47
-2
@@ -15,7 +15,7 @@ from app.models.notification import (
|
||||
EVENT_CUSTOMER_ISSUE_UPDATED,
|
||||
)
|
||||
from app.utils.forms import IssueForm, IssueUpdateForm
|
||||
from app.utils.decorators import supervisor_required
|
||||
from app.utils.decorators import supervisor_required, project_manager_required
|
||||
from app.utils.notifications import notify, notify_customers_for_facility, notify_by_matrix
|
||||
from app.utils.audit import log_action, ACTION_CREATE, ACTION_UPDATE, ACTION_DELETE, ACTION_EXPORT
|
||||
from app.tenancy.gates import quota_soft_check
|
||||
@@ -1080,4 +1080,49 @@ def export_pdf(issue_id):
|
||||
resp = make_response(pdf_bytes)
|
||||
resp.headers['Content-Type'] = 'application/pdf'
|
||||
resp.headers['Content-Disposition'] = f'attachment; filename="issue_{issue.id}.pdf"'
|
||||
return resp
|
||||
return resp
|
||||
|
||||
|
||||
# ── Vendor work order dispatch (phase36) ─────────────────────────────────────
|
||||
|
||||
@bp.route('/<int:issue_id>/work-order', methods=['POST'])
|
||||
@login_required
|
||||
@project_manager_required
|
||||
def dispatch_work_order(issue_id):
|
||||
"""Send this issue to an external contractor as a tokenized work order."""
|
||||
import re as _re
|
||||
from app.models.work_order import IssueWorkOrder
|
||||
from app.routes.work_orders import send_work_order_email
|
||||
|
||||
issue = db.session.get(Issue, issue_id)
|
||||
if issue is None:
|
||||
abort(404)
|
||||
|
||||
vendor_name = (request.form.get('vendor_name', '') or '').strip()
|
||||
vendor_email = (request.form.get('vendor_email', '') or '').strip()
|
||||
message = (request.form.get('message', '') or '').strip() or None
|
||||
|
||||
if not vendor_name or not _re.match(r'^[^@\s]+@[^@\s]+\.[^@\s]+$', vendor_email):
|
||||
flash('A contractor name and a valid email are required to send a work order.',
|
||||
'warning')
|
||||
return redirect(url_for('issues.view', issue_id=issue.id))
|
||||
|
||||
wo = IssueWorkOrder(
|
||||
issue_id=issue.id, vendor_name=vendor_name, vendor_email=vendor_email,
|
||||
token=IssueWorkOrder.new_token(), status='sent', message=message,
|
||||
sent_at=now_eastern(), created_by=current_user.id, created_at=now_eastern(),
|
||||
)
|
||||
# Keep the issue's free-text vendor fields in sync when they're still blank.
|
||||
if not issue.vendor_name:
|
||||
issue.vendor_name = vendor_name
|
||||
if not issue.vendor_contact:
|
||||
issue.vendor_contact = vendor_email
|
||||
if issue.status == 'open':
|
||||
issue.status = 'in_progress'
|
||||
db.session.add(wo)
|
||||
db.session.commit()
|
||||
log_action(ACTION_UPDATE, 'Issue', issue.id, f'Issue #{issue.id}',
|
||||
f'dispatched work order to {vendor_name} <{vendor_email}>')
|
||||
send_work_order_email(wo, issue, request.host_url)
|
||||
flash(f'Work order sent to {vendor_name}.', 'success')
|
||||
return redirect(url_for('issues.view', issue_id=issue.id))
|
||||
@@ -0,0 +1,175 @@
|
||||
"""
|
||||
app/routes/work_orders.py
|
||||
-------------------------
|
||||
Vendor work orders (phase36).
|
||||
|
||||
Two surfaces:
|
||||
|
||||
* Public, tokenized, NO login — the contractor's view of a single work order.
|
||||
`GET /work-orders/<token>` → scoped issue details + action buttons
|
||||
`POST /work-orders/<token>` → acknowledge / complete
|
||||
|
||||
In multi-tenant mode this resolves to the right tenant by Host (the emailed
|
||||
link is built from the tenant's own domain), so no tenant exemption is needed.
|
||||
The token is the authorization — unguessable (256-bit), single work order.
|
||||
|
||||
* A staff dispatch helper + email sender, imported by the issues blueprint's
|
||||
`POST /issues/<id>/work-order` route.
|
||||
|
||||
Staff are notified (in-app + email per matrix/prefs) whenever a contractor
|
||||
acknowledges or completes a work order.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import threading
|
||||
|
||||
from flask import (Blueprint, render_template, request, redirect, url_for,
|
||||
flash, abort, current_app)
|
||||
from flask_mail import Message
|
||||
|
||||
from app import db, mail, limiter
|
||||
from app.models.work_order import IssueWorkOrder
|
||||
from app.models.issue import Issue
|
||||
from app.models.notification import EVENT_WORK_ORDER
|
||||
from app.utils.notifications import notify
|
||||
from app.utils.audit import log_action, ACTION_UPDATE
|
||||
from app.utils.time_utils import now_eastern
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
bp = Blueprint('work_orders', __name__, url_prefix='/work-orders')
|
||||
|
||||
|
||||
# ── Email + notification helpers (imported by the issues blueprint) ───────────
|
||||
|
||||
def send_work_order_email(work_order, issue, base_url):
|
||||
"""Email the contractor their tokenized work-order link (background thread)."""
|
||||
if not current_app.config.get('MAIL_SERVER'):
|
||||
logger.warning('WORK ORDER EMAIL SKIPPED | id=%s | no MAIL_SERVER', work_order.id)
|
||||
return
|
||||
try:
|
||||
link = f"{base_url.rstrip('/')}/work-orders/{work_order.token}"
|
||||
facility = issue.resolved_facility
|
||||
fac_name = facility.name if facility else 'a facility'
|
||||
sender = current_app.config.get(
|
||||
'MAIL_DEFAULT_SENDER',
|
||||
current_app.config.get('MAIL_USERNAME', 'noreply@janitorialqc.local'))
|
||||
html = render_template('work_orders/email.html',
|
||||
work_order=work_order, issue=issue,
|
||||
facility_name=fac_name, link=link)
|
||||
text = (f"You have a new work order for {fac_name}.\n\n"
|
||||
f"Issue: {issue.description}\n"
|
||||
f"Severity: {issue.severity}\n\n"
|
||||
f"{work_order.message or ''}\n\n"
|
||||
f"Open your work order to acknowledge and mark it complete:\n{link}\n")
|
||||
msg = Message(subject=f'[Work Order] {fac_name} — action requested',
|
||||
sender=sender, recipients=[work_order.vendor_email],
|
||||
body=text, html=html)
|
||||
except Exception as exc:
|
||||
logger.error('WORK ORDER EMAIL BUILD FAILED | id=%s | err=%s', work_order.id, exc)
|
||||
return
|
||||
|
||||
app = current_app._get_current_object()
|
||||
to = work_order.vendor_email
|
||||
|
||||
def _send():
|
||||
with app.app_context():
|
||||
try:
|
||||
mail.send(msg)
|
||||
logger.info('WORK ORDER EMAIL SENT | to=%s', to)
|
||||
except Exception as exc:
|
||||
logger.error('WORK ORDER EMAIL FAILED | to=%s | err=%s', to, exc)
|
||||
|
||||
threading.Thread(target=_send, daemon=True).start()
|
||||
|
||||
|
||||
def _notify_staff(issue, title, body):
|
||||
"""Notify the issue's reporter, assignee, and followers of a vendor action."""
|
||||
from app.models.issue import IssueFollower
|
||||
recipient_ids = set()
|
||||
if issue.reported_by:
|
||||
recipient_ids.add(issue.reported_by)
|
||||
if issue.assigned_to:
|
||||
recipient_ids.add(issue.assigned_to)
|
||||
for f in IssueFollower.query.filter_by(issue_id=issue.id).all():
|
||||
recipient_ids.add(f.user_id)
|
||||
|
||||
from app.models.user import User
|
||||
link = url_for('issues.view', issue_id=issue.id)
|
||||
for uid in recipient_ids:
|
||||
user = db.session.get(User, uid)
|
||||
if user is None or not user.active:
|
||||
continue
|
||||
notify(user, title=title, body=body, link=link,
|
||||
issue_id=issue.id, event_type=EVENT_WORK_ORDER)
|
||||
db.session.commit() # notify() adds rows but leaves the commit to the caller
|
||||
|
||||
|
||||
# ── Public vendor pages (tokenized, no login) ─────────────────────────────────
|
||||
|
||||
def _load_or_404(token):
|
||||
wo = IssueWorkOrder.query.filter_by(token=token).first()
|
||||
if wo is None:
|
||||
abort(404)
|
||||
return wo
|
||||
|
||||
|
||||
@bp.route('/<token>')
|
||||
@limiter.limit('60 per hour')
|
||||
def view(token):
|
||||
wo = _load_or_404(token)
|
||||
issue = db.session.get(Issue, wo.issue_id)
|
||||
if issue is None:
|
||||
abort(404)
|
||||
facility = issue.resolved_facility
|
||||
return render_template('work_orders/view.html', wo=wo, issue=issue,
|
||||
facility=facility,
|
||||
area=issue.area if issue.area_id else None)
|
||||
|
||||
|
||||
@bp.route('/<token>', methods=['POST'])
|
||||
@limiter.limit('20 per hour')
|
||||
def update(token):
|
||||
wo = _load_or_404(token)
|
||||
issue = db.session.get(Issue, wo.issue_id)
|
||||
if issue is None:
|
||||
abort(404)
|
||||
|
||||
action = request.form.get('action', '')
|
||||
now = now_eastern()
|
||||
|
||||
if action == 'acknowledge' and wo.status == 'sent':
|
||||
wo.status = 'acknowledged'
|
||||
wo.acknowledged_at = now
|
||||
db.session.commit()
|
||||
log_action(ACTION_UPDATE, 'IssueWorkOrder', wo.id,
|
||||
f'Issue #{issue.id}', f'vendor acknowledged ({wo.vendor_name})')
|
||||
_notify_staff(issue,
|
||||
title=f'Contractor acknowledged work order — Issue #{issue.id}',
|
||||
body=f'{wo.vendor_name} acknowledged the work order and is on it.')
|
||||
flash('Thank you — the work order has been acknowledged.', 'success')
|
||||
|
||||
elif action == 'complete' and wo.status in ('sent', 'acknowledged'):
|
||||
note = (request.form.get('vendor_note', '') or '').strip()
|
||||
wo.status = 'completed'
|
||||
wo.completed_at = now
|
||||
if wo.acknowledged_at is None:
|
||||
wo.acknowledged_at = now
|
||||
wo.vendor_note = note or None
|
||||
# Move the issue into the verification queue so staff sign off the fix.
|
||||
if issue.status not in ('resolved',):
|
||||
issue.status = 'pending_verification'
|
||||
db.session.commit()
|
||||
log_action(ACTION_UPDATE, 'IssueWorkOrder', wo.id,
|
||||
f'Issue #{issue.id}', f'vendor completed ({wo.vendor_name})')
|
||||
_notify_staff(issue,
|
||||
title=f'Contractor completed work order — Issue #{issue.id}',
|
||||
body=(f'{wo.vendor_name} marked the work complete. '
|
||||
f'It is now pending your verification.'
|
||||
+ (f'\n\nContractor note: {note}' if note else '')))
|
||||
flash('Thank you — the work has been marked complete. The team will verify it.',
|
||||
'success')
|
||||
else:
|
||||
flash('That action is no longer available for this work order.', 'warning')
|
||||
|
||||
return redirect(url_for('work_orders.view', token=token))
|
||||
@@ -404,6 +404,53 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# ── Vendor Work Orders (phase36) ───────────────────────────────────── #}
|
||||
{% if current_user.role in ['admin','director','project_manager'] %}
|
||||
<div class="card shadow-sm mt-3">
|
||||
<div class="card-header bg-light">
|
||||
<h6 class="mb-0"><i class="bi bi-send me-1"></i>Contractor Work Orders</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% set wos = issue.work_orders.all() %}
|
||||
{% if wos %}
|
||||
<ul class="list-unstyled small mb-3">
|
||||
{% for wo in wos %}
|
||||
{% set b = {'sent':'secondary','acknowledged':'info','completed':'success'}[wo.status] %}
|
||||
<li class="d-flex justify-content-between align-items-center border-bottom py-1">
|
||||
<span class="text-truncate me-2">{{ wo.vendor_name }}
|
||||
<span class="text-muted d-block" style="font-size:.75rem;">{{ wo.vendor_email }}</span>
|
||||
</span>
|
||||
<span class="badge bg-{{ b }}">{{ wo.status_label }}</span>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
<form method="POST" action="{{ url_for('issues.dispatch_work_order', issue_id=issue.id) }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="mb-2">
|
||||
<label class="form-label small fw-semibold mb-1">Contractor name</label>
|
||||
<input type="text" name="vendor_name" class="form-control form-control-sm"
|
||||
value="{{ issue.vendor_name or '' }}" placeholder="e.g. Ace Plumbing" required>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<label class="form-label small fw-semibold mb-1">Contractor email</label>
|
||||
<input type="email" name="vendor_email" class="form-control form-control-sm"
|
||||
placeholder="name@contractor.com" required>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<label class="form-label small fw-semibold mb-1">Message <span class="text-muted">(optional)</span></label>
|
||||
<textarea name="message" class="form-control form-control-sm" rows="2"
|
||||
placeholder="Any specific instructions…"></textarea>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-outline-primary btn-sm w-100">
|
||||
<i class="bi bi-envelope-paper me-1"></i> Send Work Order
|
||||
</button>
|
||||
<div class="form-text">Emails the contractor a private link to acknowledge & complete — no account needed.</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
</div>{# /col-lg-4 #}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<body style="margin:0;padding:0;background:#f1f5f9;font-family:-apple-system,Segoe UI,Roboto,Arial,sans-serif;color:#1f2937;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#f1f5f9;padding:24px 0;">
|
||||
<tr><td align="center">
|
||||
<table role="presentation" width="600" cellpadding="0" cellspacing="0" style="max-width:600px;width:100%;background:#ffffff;border-radius:12px;overflow:hidden;">
|
||||
<tr><td style="background:#1a56db;color:#ffffff;padding:20px 28px;font-size:18px;font-weight:700;">
|
||||
New Work Order
|
||||
</td></tr>
|
||||
<tr><td style="padding:24px 28px;">
|
||||
<p style="margin:0 0 12px;">Hello {{ work_order.vendor_name }},</p>
|
||||
<p style="margin:0 0 16px;">You've been assigned a work order at <strong>{{ facility_name }}</strong>.</p>
|
||||
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="border:1px solid #e5e7eb;border-radius:8px;margin-bottom:16px;">
|
||||
<tr><td style="padding:14px 16px;">
|
||||
<div style="font-size:12px;text-transform:uppercase;color:#6b7280;margin-bottom:4px;">What needs doing</div>
|
||||
<div style="margin-bottom:10px;">{{ issue.description }}</div>
|
||||
<div style="font-size:13px;color:#6b7280;">Severity: {{ issue.severity }}</div>
|
||||
{% if work_order.message %}
|
||||
<div style="margin-top:12px;padding-top:12px;border-top:1px solid #eee;font-size:14px;">
|
||||
<strong>Note:</strong> {{ work_order.message }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</td></tr>
|
||||
</table>
|
||||
|
||||
<p style="margin:0 0 20px;">Open your work order to acknowledge it and mark it complete when done:</p>
|
||||
<p style="text-align:center;margin:0 0 8px;">
|
||||
<a href="{{ link }}" style="display:inline-block;background:#1a56db;color:#ffffff;text-decoration:none;padding:12px 28px;border-radius:8px;font-weight:600;">
|
||||
Open Work Order
|
||||
</a>
|
||||
</p>
|
||||
<p style="font-size:12px;color:#9aa3af;text-align:center;margin:12px 0 0;word-break:break-all;">{{ link }}</p>
|
||||
</td></tr>
|
||||
<tr><td style="padding:16px 28px;background:#f8fafc;color:#9aa3af;font-size:12px;">
|
||||
This link is private to you — please don't forward it.
|
||||
</td></tr>
|
||||
</table>
|
||||
</td></tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,100 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Work Order — {{ facility.name if facility else 'JQC' }}</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css">
|
||||
<style>
|
||||
body { background:#f1f5f9; color:#1f2937; }
|
||||
.wo-wrap { max-width:640px; margin:2rem auto; padding:0 1rem; }
|
||||
.sev-critical{background:#dc2626}.sev-high{background:#ea580c}
|
||||
.sev-medium{background:#d97706}.sev-low{background:#64748b}
|
||||
@media (max-width:576px){ .wo-wrap{ margin:1rem auto; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wo-wrap">
|
||||
|
||||
<div class="d-flex align-items-center gap-2 mb-3">
|
||||
<i class="bi bi-clipboard-check-fill fs-3 text-primary"></i>
|
||||
<div>
|
||||
<div class="fw-bold">Contractor Work Order</div>
|
||||
<div class="text-muted small">{{ facility.name if facility else 'Facility' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% for category, message in messages %}
|
||||
<div class="alert alert-{{ 'success' if category == 'success' else 'warning' if category == 'warning' else 'info' }}">
|
||||
{{ message }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endwith %}
|
||||
|
||||
<div class="card shadow-sm mb-3">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-start mb-2">
|
||||
<span class="badge sev-{{ issue.severity }} text-white text-uppercase">{{ issue.severity }}</span>
|
||||
{% set badge = {'sent':'secondary','acknowledged':'info','completed':'success'}[wo.status] %}
|
||||
<span class="badge bg-{{ badge }}">{{ wo.status_label }}</span>
|
||||
</div>
|
||||
|
||||
<table class="table table-sm mb-3">
|
||||
<tr><th class="text-muted" style="width:120px;">Facility</th><td>{{ facility.name if facility else '—' }}</td></tr>
|
||||
{% if area %}<tr><th class="text-muted">Area</th><td>{{ area.name }}</td></tr>{% endif %}
|
||||
<tr><th class="text-muted">Reported</th><td>{{ issue.reported_at.strftime('%b %d, %Y') if issue.reported_at else '—' }}</td></tr>
|
||||
</table>
|
||||
|
||||
<div class="mb-2">
|
||||
<div class="text-muted small text-uppercase mb-1">What needs doing</div>
|
||||
<div>{{ issue.description }}</div>
|
||||
</div>
|
||||
|
||||
{% if wo.message %}
|
||||
<div class="mt-3 p-2 bg-light rounded">
|
||||
<div class="text-muted small text-uppercase mb-1">Note from the team</div>
|
||||
<div>{{ wo.message }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if wo.status == 'completed' %}
|
||||
<div class="alert alert-success">
|
||||
<i class="bi bi-check-circle-fill me-1"></i>
|
||||
You marked this complete on {{ wo.completed_at.strftime('%b %d, %Y') if wo.completed_at else 'file' }}.
|
||||
The team will verify the work. No further action is needed.
|
||||
</div>
|
||||
{% else %}
|
||||
{% if wo.status == 'sent' %}
|
||||
<form method="POST" action="{{ url_for('work_orders.update', token=wo.token) }}" class="mb-2">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="action" value="acknowledge">
|
||||
<button type="submit" class="btn btn-outline-primary w-100">
|
||||
<i class="bi bi-hand-thumbs-up me-1"></i> Acknowledge — I'll handle this
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body">
|
||||
<form method="POST" action="{{ url_for('work_orders.update', token=wo.token) }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="action" value="complete">
|
||||
<label class="form-label fw-semibold">Mark work complete</label>
|
||||
<textarea name="vendor_note" class="form-control mb-2" rows="3"
|
||||
placeholder="Optional: describe what you did…"></textarea>
|
||||
<button type="submit" class="btn btn-success w-100">
|
||||
<i class="bi bi-check2-circle me-1"></i> Mark as Completed
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<p class="text-center text-muted small mt-4">This link is private to you. Please don't share it.</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user