04/06 implement creating ticket on behalf of another user for IT staff

This commit is contained in:
2026-04-06 17:13:53 -04:00
parent 3b17705911
commit bc236a3d19
8 changed files with 738 additions and 14 deletions
+6
View File
@@ -117,6 +117,12 @@ class Ticket(db.Model):
internal_notes = db.Column(db.Text)
resolution_notes = db.Column(db.Text)
# When an IT staff member creates a ticket on behalf of an employee
# (e.g. from a phone call or email), this column records who filed it.
# NULL means the ticket was self-submitted by the employee.
created_by_staff_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True)
filed_by_staff = db.relationship('User', foreign_keys='Ticket.created_by_staff_id')
# Relationships
comments = db.relationship('Comment', backref='ticket', lazy='dynamic', cascade='all, delete-orphan')
attachments = db.relationship('Attachment', backref='ticket', lazy='dynamic', cascade='all, delete-orphan')
+42
View File
@@ -146,3 +146,45 @@ def on_leave_ticket(data):
if current_user.is_authenticated:
ticket_id = data.get('ticket_id')
leave_room(f'ticket_{ticket_id}')
# ─── User Search API (IT Only) ────────────────────────────────────────────────
@api_bp.route('/users/search')
@login_required
def search_users():
"""Return active employees matching a search query.
Used by the 'create on behalf' form to populate the employee selector.
Restricted to IT staff to prevent employees from enumerating all users.
Query params
------------
q : str search term matched against full_name, email, department
limit : int max results (default 20, max 50)
"""
if not current_user.is_it_staff:
return jsonify({'error': 'Forbidden'}), 403
q = request.args.get('q', '').strip()
limit = min(request.args.get('limit', 20, type=int), 50)
from app.models import User, UserRole
query = User.query.filter(User.is_active == True)
if q:
query = query.filter(
User.full_name.ilike(f'%{q}%') |
User.email.ilike(f'%{q}%') |
User.department.ilike(f'%{q}%')
)
users = query.order_by(User.full_name).limit(limit).all()
return jsonify({'users': [
{
'id' : u.id,
'full_name' : u.full_name,
'email' : u.email,
'department': u.department or '',
'role' : u.role,
}
for u in users
]})
+103
View File
@@ -188,6 +188,109 @@ def create_ticket():
categories=_categories(), priorities=_priorities())
# ─── Create Ticket on Behalf of Employee (IT Staff Only) ─────────────────────
@tickets_bp.route('/tickets/behalf', methods=['GET', 'POST'])
@login_required
def create_ticket_behalf():
"""Allow IT staff to create a ticket on behalf of an employee.
Use case: an employee calls or emails to report an issue and cannot or
does not create a ticket themselves. IT staff completes the form,
selecting the employee from a searchable dropdown.
Data model
----------
ticket.created_by_id = employee's user ID (ticket shows as theirs)
ticket.created_by_staff_id = IT staff's user ID (audit trail)
The employee sees this ticket in their own dashboard and receives the
same new-ticket confirmation notification they would if self-submitted.
"""
if not current_user.is_it_staff:
abort(403)
employees = User.query.filter(
User.is_active == True,
).order_by(User.full_name).all()
if request.method == 'POST':
employee_id = request.form.get('employee_id', type=int)
title = request.form.get('title', '').strip()
description = request.form.get('description', '').strip()
category = request.form.get('category', TicketCategory.OTHER)
priority = request.form.get('priority', TicketPriority.MEDIUM)
location = request.form.get('location', '').strip()
asset_tag = request.form.get('asset_tag', '').strip()
# Validate employee selection
employee = db.session.get(User, employee_id) if employee_id else None
if not employee or not employee.is_active:
flash('Please select a valid active employee.', 'danger')
return render_template('tickets/create_behalf.html',
employees=employees,
categories=_categories(),
priorities=_priorities())
if not title or not description:
flash('Title and description are required.', 'danger')
return render_template('tickets/create_behalf.html',
employees=employees,
categories=_categories(),
priorities=_priorities(),
selected_employee_id=employee_id)
ticket = Ticket(
title = title,
description = description,
category = category,
priority = priority,
location = location,
asset_tag = asset_tag,
created_by_id = employee.id, # ticket belongs to the employee
created_by_staff_id = current_user.id, # IT staff who filed it
status = TicketStatus.OPEN,
)
ticket.ticket_number = ticket.generate_ticket_number()
db.session.add(ticket)
db.session.flush() # get ticket.id before attachments
# Handle file uploads
for f in request.files.getlist('attachments'):
if f and f.filename:
file_error = validate_file(f, ALLOWED_EXT)
if file_error:
logger.warning(
f'[BEHALF UPLOAD REJECTED] {file_error} '
f'filename="{f.filename}" staff_id={current_user.id}'
)
continue
save_attachment(f, ticket_id=ticket.id, uploader_id=current_user.id)
log_action(
current_user.id, 'ticket_create_behalf', 'ticket', ticket.id,
f'ticket_number={ticket.ticket_number} on_behalf_of=user_id:{employee.id} '
f'({employee.full_name}) priority={priority} category={category}'
)
db.session.commit()
logger.info(
f'[TICKET CREATE BEHALF] ticket_id={ticket.id} '
f'number={ticket.ticket_number} '
f'employee_id={employee.id} staff_id={current_user.id}'
)
notify_new_ticket(ticket)
flash(
f'Ticket {ticket.ticket_number} created on behalf of {employee.full_name}.',
'success'
)
return redirect(url_for('tickets.ticket_detail', ticket_id=ticket.id))
return render_template('tickets/create_behalf.html',
employees=employees,
categories=_categories(),
priorities=_priorities())
# ─── Ticket List ──────────────────────────────────────────────────────────────
@tickets_bp.route('/tickets')
+6 -1
View File
@@ -51,10 +51,15 @@
</div>
<div class="card">
<div class="card-header">
<div class="card-header d-flex align-items-center justify-content-between">
<div>
<i class="bi bi-collection me-2"></i>Tickets
<span style="font-size:12px;color:var(--muted);font-family:'Space Mono',monospace;">({{ tickets.total }})</span>
</div>
<a href="{{ url_for('tickets.create_ticket_behalf') }}" class="btn btn-primary btn-sm">
<i class="bi bi-person-plus me-1"></i>File on Behalf
</a>
</div>
<div class="card-body p-0">
{% if tickets.items %}
<table class="table mb-0">
+259 -6
View File
@@ -7,7 +7,13 @@
<link href="https://fonts.googleapis.com/css2?family=Space+Mono:wght@400;700&family=DM+Sans:opsz,wght@9..40,400;9..40,500;9..40,600&display=swap" rel="stylesheet"/>
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css" rel="stylesheet"/>
<style>
:root{--bg:#f0f4f8;--surface:#ffffff;--border:#e2e8f0;--border2:#cbd5e1;--accent:#2563eb;--accent-h:#1d4ed8;--text:#0f172a;--text2:#334155;--muted:#64748b;}
:root{
--bg:#f0f4f8;--surface:#ffffff;--border:#e2e8f0;--border2:#cbd5e1;
--accent:#2563eb;--accent-h:#1d4ed8;--text:#0f172a;--text2:#334155;--muted:#64748b;
--success:#059669;--success-bg:#ecfdf5;--success-border:#a7f3d0;
--warning:#d97706;--warning-bg:#fffbeb;
--danger:#dc2626;--danger-bg:#fef2f2;
}
*{box-sizing:border-box;margin:0;padding:0;}
body{font-family:'DM Sans',sans-serif;background:var(--bg);color:var(--text);min-height:100vh;display:flex;align-items:center;justify-content:center;padding:24px;}
.bg-grid{position:fixed;inset:0;background-image:linear-gradient(var(--border) 1px,transparent 1px),linear-gradient(90deg,var(--border) 1px,transparent 1px);background-size:48px 48px;opacity:.6;pointer-events:none;}
@@ -22,8 +28,69 @@
input,select{width:100%;background:#fff;border:1px solid var(--border);color:var(--text);border-radius:8px;padding:10px 13px;font-size:14px;font-family:inherit;transition:border-color .15s,box-shadow .15s;box-shadow:0 1px 3px rgba(0,0,0,.05);}
input:focus,select:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px rgba(37,99,235,.1);}
input::placeholder{color:#94a3b8;}
input.valid{border-color:var(--success);box-shadow:0 0 0 3px rgba(5,150,105,.08);}
input.invalid{border-color:var(--danger);box-shadow:0 0 0 3px rgba(220,38,38,.08);}
/* ── Password input wrapper (for show/hide toggle) ── */
.pw-wrap{position:relative;}
.pw-wrap input{padding-right:42px;}
.pw-toggle{
position:absolute;right:12px;top:50%;transform:translateY(-50%);
background:none;border:none;color:var(--muted);cursor:pointer;
font-size:16px;padding:2px;line-height:1;
transition:color .15s;
}
.pw-toggle:hover{color:var(--accent);}
/* ── Strength bar ── */
.strength-wrap{margin-top:8px;}
.strength-bar-track{
height:4px;background:var(--border);border-radius:2px;
overflow:hidden;margin-bottom:6px;
}
.strength-bar-fill{
height:100%;border-radius:2px;width:0%;
transition:width .3s ease,background .3s ease;
}
.strength-label{
font-size:11px;font-weight:600;letter-spacing:.3px;
font-family:'Space Mono',monospace;
transition:color .3s;
display:flex;align-items:center;gap:5px;
}
/* ── Rule checklist ── */
.rules{
margin-top:8px;display:grid;grid-template-columns:1fr 1fr;gap:3px 12px;
background:var(--bg);border:1px solid var(--border);border-radius:8px;
padding:10px 12px;
}
.rule{
display:flex;align-items:center;gap:5px;
font-size:11.5px;color:var(--muted);
transition:color .2s;
}
.rule .ri{
font-size:13px;width:14px;text-align:center;
transition:color .2s, transform .15s;
}
.rule.met{color:var(--success);}
.rule.met .ri{color:var(--success);transform:scale(1.1);}
.rule.unmet .ri{color:var(--border2);}
/* ── Confirm match indicator ── */
.match-msg{
font-size:11.5px;margin-top:6px;display:flex;align-items:center;gap:5px;
font-family:'Space Mono',monospace;font-weight:600;letter-spacing:.2px;
min-height:18px;transition:color .2s;
}
.match-msg.ok{color:var(--success);}
.match-msg.fail{color:var(--danger);}
/* ── Submit ── */
.btn{width:100%;background:var(--accent);border:none;color:#fff;border-radius:9px;padding:12px;font-size:14px;font-weight:600;cursor:pointer;transition:background .15s;font-family:inherit;margin-top:4px;box-shadow:0 2px 6px rgba(37,99,235,.3);}
.btn:hover{background:var(--accent-h);}
.btn:disabled{background:#93c5fd;cursor:not-allowed;box-shadow:none;}
.links{text-align:center;margin-top:18px;font-size:13px;color:var(--muted);}
.links a{color:var(--accent);}
.alert{border-radius:8px;padding:10px 13px;font-size:13px;margin-bottom:16px;}
@@ -53,7 +120,7 @@
{% endfor %}
{% endwith %}
<form method="POST">
<form method="POST" id="reg-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<div class="row">
<div class="form-group">
@@ -79,19 +146,205 @@
<input type="text" name="phone" placeholder="+1 555 000 0000"/>
</div>
</div>
<div class="row">
<!-- ── Password with strength meter ── -->
<div class="form-group">
<label>Password *</label>
<input type="password" name="password" required placeholder="Min. 8 characters"/>
<div class="pw-wrap">
<input type="password" name="password" id="pw" required
placeholder="Min. 8 characters"
autocomplete="new-password"
oninput="onPasswordInput()"/>
<button type="button" class="pw-toggle" id="pw-toggle"
onclick="toggleVis('pw','pw-toggle')"
title="Show / hide password"
tabindex="-1">
<i class="bi bi-eye"></i>
</button>
</div>
<!-- Strength bar -->
<div class="strength-wrap" id="strength-wrap" style="display:none;">
<div class="strength-bar-track">
<div class="strength-bar-fill" id="strength-bar"></div>
</div>
<div class="strength-label" id="strength-label"></div>
</div>
<!-- Rule checklist — mirrors server-side validate_password() rules -->
<div class="rules" id="rules-box" style="display:none;">
<div class="rule unmet" id="rule-len">
<span class="ri"><i class="bi bi-circle"></i></span>
<span>8+ characters</span>
</div>
<div class="rule unmet" id="rule-upper">
<span class="ri"><i class="bi bi-circle"></i></span>
<span>Uppercase letter</span>
</div>
<div class="rule unmet" id="rule-digit">
<span class="ri"><i class="bi bi-circle"></i></span>
<span>Number</span>
</div>
<div class="rule unmet" id="rule-special">
<span class="ri"><i class="bi bi-circle"></i></span>
<span>Special character</span>
</div>
</div>
</div>
<!-- ── Confirm password with match indicator ── -->
<div class="form-group">
<label>Confirm Password *</label>
<input type="password" name="confirm_password" required placeholder="Repeat password"/>
<div class="pw-wrap">
<input type="password" name="confirm_password" id="pw2" required
placeholder="Repeat password"
autocomplete="new-password"
oninput="onConfirmInput()"/>
<button type="button" class="pw-toggle" id="pw2-toggle"
onclick="toggleVis('pw2','pw2-toggle')"
title="Show / hide password"
tabindex="-1">
<i class="bi bi-eye"></i>
</button>
</div>
<div class="match-msg" id="match-msg"></div>
</div>
<button type="submit" class="btn"><i class="bi bi-person-plus me-2"></i>Create Account</button>
<button type="submit" class="btn" id="submit-btn">
<i class="bi bi-person-plus" style="margin-right:6px;"></i>Create Account
</button>
</form>
<div class="links">Already have an account? <a href="{{ url_for('auth.login') }}">Sign in</a></div>
</div>
<script>
// ── Mirrors server-side validate_password() rules in validation_service.py ──
// Any change to the server rules must be reflected here too.
const RULES = {
len : pw => pw.length >= 8,
upper : pw => /[A-Z]/.test(pw),
digit : pw => /\d/.test(pw),
special: pw => /[!@#$%^&*()\-_=+\[\]{};:'",.<>?/\\|`~]/.test(pw),
};
// Strength scoring: each rule = 1 point; bonus point for length >= 16
function scorePassword(pw) {
let score = 0;
if (RULES.len(pw)) score++;
if (RULES.upper(pw)) score++;
if (RULES.digit(pw)) score++;
if (RULES.special(pw)) score++;
if (pw.length >= 16) score++; // bonus for extra length
return score; // 05
}
const LEVELS = [
{ label: 'Very Weak', color: '#ef4444', pct: 12 },
{ label: 'Weak', color: '#f97316', pct: 30 },
{ label: 'Fair', color: '#eab308', pct: 52 },
{ label: 'Good', color: '#22c55e', pct: 76 },
{ label: 'Strong', color: '#059669', pct: 100 },
];
function setRule(id, met) {
const el = document.getElementById('rule-' + id);
if (!el) return;
el.className = 'rule ' + (met ? 'met' : 'unmet');
el.querySelector('.ri').innerHTML = met
? '<i class="bi bi-check-circle-fill"></i>'
: '<i class="bi bi-circle"></i>';
}
function onPasswordInput() {
const pw = document.getElementById('pw').value;
const wrap = document.getElementById('strength-wrap');
const rules = document.getElementById('rules-box');
const bar = document.getElementById('strength-bar');
const lbl = document.getElementById('strength-label');
if (pw.length === 0) {
wrap.style.display = 'none';
rules.style.display = 'none';
document.getElementById('pw').classList.remove('valid','invalid');
updateSubmitState();
onConfirmInput();
return;
}
wrap.style.display = 'block';
rules.style.display = 'grid';
// Update rule checklist
setRule('len', RULES.len(pw));
setRule('upper', RULES.upper(pw));
setRule('digit', RULES.digit(pw));
setRule('special', RULES.special(pw));
// Update strength bar
const score = scorePassword(pw);
const lvl = LEVELS[Math.max(0, score - 1)] || LEVELS[0];
bar.style.width = lvl.pct + '%';
bar.style.background = lvl.color;
lbl.style.color = lvl.color;
lbl.innerHTML = `<i class="bi bi-shield${score >= 4 ? '-fill' : score >= 2 ? '-half' : ''}"></i> ${lvl.label}`;
// Border feedback on password field
const allMet = RULES.len(pw) && RULES.upper(pw) && RULES.digit(pw) && RULES.special(pw);
document.getElementById('pw').classList.toggle('valid', allMet);
document.getElementById('pw').classList.toggle('invalid', !allMet);
onConfirmInput();
updateSubmitState();
}
function onConfirmInput() {
const pw = document.getElementById('pw').value;
const pw2 = document.getElementById('pw2').value;
const msg = document.getElementById('match-msg');
const el2 = document.getElementById('pw2');
if (pw2.length === 0) {
msg.textContent = '';
msg.className = 'match-msg';
el2.classList.remove('valid','invalid');
updateSubmitState();
return;
}
if (pw === pw2) {
msg.innerHTML = '<i class="bi bi-check-circle-fill"></i> Passwords match';
msg.className = 'match-msg ok';
el2.classList.add('valid');
el2.classList.remove('invalid');
} else {
msg.innerHTML = '<i class="bi bi-x-circle-fill"></i> Passwords do not match';
msg.className = 'match-msg fail';
el2.classList.add('invalid');
el2.classList.remove('valid');
}
updateSubmitState();
}
function updateSubmitState() {
const pw = document.getElementById('pw').value;
const pw2 = document.getElementById('pw2').value;
const btn = document.getElementById('submit-btn');
const allMet = RULES.len(pw) && RULES.upper(pw) && RULES.digit(pw) && RULES.special(pw);
const match = pw === pw2 && pw2.length > 0;
// Disable only when user has started typing in either field and criteria
// are not met — never disable before they've interacted with the fields.
const hasStarted = pw.length > 0 || pw2.length > 0;
btn.disabled = hasStarted && !(allMet && match);
}
function toggleVis(inputId, btnId) {
const inp = document.getElementById(inputId);
const btn = document.getElementById(btnId);
const isPassword = inp.type === 'password';
inp.type = isPassword ? 'text' : 'password';
btn.querySelector('i').className = isPassword ? 'bi bi-eye-slash' : 'bi bi-eye';
btn.title = isPassword ? 'Hide password' : 'Show password';
}
</script>
</body>
</html>
+3
View File
@@ -329,6 +329,9 @@
<li><a href="{{ url_for('admin.all_tickets') }}" class="{{ 'active' if request.endpoint == 'admin.all_tickets' }}">
<i class="bi bi-collection"></i> All Tickets
</a></li>
<li><a href="{{ url_for('tickets.create_ticket_behalf') }}" class="{{ 'active' if request.endpoint == 'tickets.create_ticket_behalf' }}">
<i class="bi bi-person-plus"></i> File on Behalf
</a></li>
<li><a href="{{ url_for('admin.kb_list') }}" class="{{ 'active' if 'admin.kb' in request.endpoint }}">
<i class="bi bi-journal-text"></i> Manage KB
</a></li>
+304
View File
@@ -0,0 +1,304 @@
{% extends "base.html" %}
{% block title %}File Ticket on Behalf{% endblock %}
{% block page_title %}File Ticket on Behalf of Employee{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-lg-8">
<!-- Context banner -->
<div class="alert alert-info mb-4" style="font-size:13px;">
<i class="bi bi-info-circle me-2"></i>
<strong>Filing on behalf</strong> — Use this form when an employee reports an issue by phone or email
and cannot submit a ticket themselves. The ticket will appear in their dashboard and they will
receive a confirmation notification.
</div>
<div class="card">
<div class="card-header">
<i class="bi bi-person-plus me-2"></i>New Ticket — Filed by IT Staff
</div>
<div class="card-body">
<form method="POST" enctype="multipart/form-data" id="behalf-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<!-- ── Employee selector ── -->
<div class="mb-4" style="background:var(--surface2);border:1px solid var(--border);border-radius:10px;padding:18px;">
<div style="font-size:12px;font-weight:700;text-transform:uppercase;letter-spacing:.5px;color:var(--muted);margin-bottom:12px;">
<i class="bi bi-person-badge me-1"></i> Reporting Employee *
</div>
<!-- Live search input (visible) -->
<div style="position:relative;">
<input type="text" id="employee-search"
class="form-control"
placeholder="Search by name, email, or department…"
autocomplete="off"
style="padding-left:36px;"/>
<i class="bi bi-search" style="position:absolute;left:12px;top:50%;transform:translateY(-50%);color:var(--muted);font-size:14px;pointer-events:none;"></i>
<!-- Dropdown results -->
<div id="employee-results"
style="display:none;position:absolute;top:calc(100% + 4px);left:0;right:0;
background:#fff;border:1px solid var(--border);border-radius:8px;
box-shadow:0 8px 24px rgba(0,0,0,.12);z-index:100;
max-height:260px;overflow-y:auto;">
</div>
</div>
<!-- Hidden field that actually gets submitted -->
<input type="hidden" name="employee_id" id="employee-id"
value="{{ selected_employee_id or '' }}"/>
<!-- Selected employee display card -->
<div id="selected-employee" style="display:none;margin-top:10px;
background:#fff;border:1px solid var(--border);border-radius:8px;padding:12px 14px;
display:flex;align-items:center;gap:12px;">
<div id="sel-avatar"
style="width:36px;height:36px;border-radius:50%;background:var(--accent);
display:flex;align-items:center;justify-content:center;
font-size:14px;font-weight:700;color:#fff;flex-shrink:0;">
</div>
<div style="flex:1;min-width:0;">
<div id="sel-name" style="font-size:14px;font-weight:600;color:var(--text);"></div>
<div id="sel-meta" style="font-size:12px;color:var(--muted);margin-top:1px;"></div>
</div>
<button type="button" id="clear-employee"
class="btn btn-secondary btn-sm" title="Change employee">
<i class="bi bi-x-lg"></i>
</button>
</div>
<div id="employee-hint" style="font-size:11px;color:var(--muted);margin-top:6px;">
Start typing to search all active users — employees, IT staff, and admins.
</div>
</div>
<!-- ── Ticket fields (identical to create.html) ── -->
<div class="row g-3">
<div class="col-12">
<label class="form-label">Issue Title *</label>
<input type="text" class="form-control" name="title" required
placeholder="Brief description of the issue (e.g. 'Cannot connect to VPN')"/>
</div>
<div class="col-md-6">
<label class="form-label">Category *</label>
<select class="form-select" name="category" required>
{% for cat in categories %}
<option value="{{ cat }}">{{ cat.replace('_',' ').title() }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-6">
<label class="form-label">Priority *</label>
<select class="form-select" name="priority" required>
{% for p in priorities %}
<option value="{{ p }}" {% if p == 'medium' %}selected{% endif %}>{{ p.upper() }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-6">
<label class="form-label">Location / Floor</label>
<input type="text" class="form-control" name="location" placeholder="e.g. 3rd Floor, Room 302"/>
</div>
<div class="col-md-6">
<label class="form-label">Asset Tag / Device Serial</label>
<input type="text" class="form-control" name="asset_tag" placeholder="e.g. ASSET-1234"/>
</div>
<div class="col-12">
<label class="form-label">Detailed Description *</label>
<textarea class="form-control" name="description" rows="6" required
placeholder="Please describe the issue in detail:&#10;- What were you trying to do?&#10;- What happened?&#10;- Any error messages?&#10;- When did it start?"></textarea>
</div>
<div class="col-12">
<label class="form-label">Attachments (screenshots, logs, etc.)</label>
<input type="file" class="form-control" name="attachments" multiple
accept=".png,.jpg,.jpeg,.gif,.pdf,.doc,.docx,.txt,.zip,.log"/>
<div style="font-size:11px;color:var(--muted);margin-top:5px;">
Accepted: PNG, JPG, PDF, DOC, DOCX, TXT, ZIP, LOG. Max 16 MB per file.
</div>
</div>
<!-- Priority guide -->
<div class="col-12">
<div style="background:var(--surface2);border:1px solid var(--border);border-radius:8px;padding:14px 16px;">
<div style="font-size:12px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.5px;margin-bottom:10px;">Priority Guide</div>
<div class="row g-2" style="font-size:12px;">
<div class="col-sm-3"><span class="badge badge-low me-1">LOW</span> Minor inconvenience, no work stoppage</div>
<div class="col-sm-3"><span class="badge badge-medium me-1">MEDIUM</span> Impacting productivity, workaround available</div>
<div class="col-sm-3"><span class="badge badge-high me-1">HIGH</span> Significant impact, no workaround</div>
<div class="col-sm-3"><span class="badge badge-critical me-1">CRITICAL</span> Complete outage or security incident</div>
</div>
</div>
</div>
</div>
<div class="d-flex gap-2 mt-4">
<button type="submit" class="btn btn-primary" id="submit-btn">
<i class="bi bi-send me-2"></i>Submit Ticket
</button>
<a href="{{ url_for('admin.all_tickets') }}" class="btn btn-secondary">Cancel</a>
</div>
</form>
</div>
</div>
</div>
<!-- ── Sidebar tips ── -->
<div class="col-lg-4 d-none d-lg-block">
<div class="card mb-3">
<div class="card-header"><i class="bi bi-shield-check me-2"></i>Audit Trail</div>
<div class="card-body" style="font-size:13px;color:var(--muted);">
<p>The ticket will be registered under the <strong>employee's account</strong>.
Your name is recorded in the activity log as the filing IT staff member.</p>
<p class="mt-2">The employee will receive a notification and can view, comment on,
and track the ticket from their dashboard.</p>
</div>
</div>
<div class="card">
<div class="card-header"><i class="bi bi-lightbulb me-2"></i>Tips</div>
<div class="card-body" style="font-size:13px;color:var(--muted);">
<p>✅ Confirm the employee's name and department before submitting</p>
<p>✅ Include verbatim error messages they described</p>
<p>✅ Note the contact method (phone / email) in the description</p>
<p>✅ Attach any files the employee sent you</p>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
// ── Employee live search ──────────────────────────────────────────────────────
const searchInput = document.getElementById('employee-search');
const resultsBox = document.getElementById('employee-results');
const employeeIdEl = document.getElementById('employee-id');
const selectedCard = document.getElementById('selected-employee');
const selAvatar = document.getElementById('sel-avatar');
const selName = document.getElementById('sel-name');
const selMeta = document.getElementById('sel-meta');
const clearBtn = document.getElementById('clear-employee');
const hintEl = document.getElementById('employee-hint');
const submitBtn = document.getElementById('submit-btn');
let debounceTimer = null;
// Disable submit until an employee is chosen
function refreshSubmitState() {
submitBtn.disabled = !employeeIdEl.value;
}
refreshSubmitState();
searchInput.addEventListener('input', () => {
clearTimeout(debounceTimer);
const q = searchInput.value.trim();
if (q.length < 1) { resultsBox.style.display = 'none'; return; }
debounceTimer = setTimeout(() => fetchUsers(q), 250);
});
searchInput.addEventListener('focus', () => {
if (searchInput.value.trim().length >= 1) fetchUsers(searchInput.value.trim());
});
document.addEventListener('click', e => {
if (!e.target.closest('#employee-search') && !e.target.closest('#employee-results')) {
resultsBox.style.display = 'none';
}
});
async function fetchUsers(q) {
try {
const r = await fetch(`/api/users/search?q=${encodeURIComponent(q)}&limit=20`);
const data = await r.json();
renderResults(data.users || []);
} catch {
resultsBox.style.display = 'none';
}
}
function renderResults(users) {
if (users.length === 0) {
resultsBox.innerHTML = '<div style="padding:12px 16px;font-size:13px;color:var(--muted);">No users found.</div>';
resultsBox.style.display = 'block';
return;
}
resultsBox.innerHTML = users.map(u => `
<div class="employee-option" data-id="${u.id}" data-name="${u.full_name}"
data-email="${u.email}" data-dept="${u.department}" data-role="${u.role}"
style="padding:10px 14px;cursor:pointer;border-bottom:1px solid var(--border);
display:flex;align-items:center;gap:10px;transition:background .1s;">
<div style="width:30px;height:30px;border-radius:50%;background:var(--accent);flex-shrink:0;
display:flex;align-items:center;justify-content:center;
font-size:12px;font-weight:700;color:#fff;">
${u.full_name[0].toUpperCase()}
</div>
<div style="flex:1;min-width:0;">
<div style="font-size:13px;font-weight:600;color:var(--text);">${u.full_name}</div>
<div style="font-size:11px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">
${u.email}${u.department ? ' · ' + u.department : ''}
${u.role !== 'employee' ? ' · <span style="color:var(--accent);">' + u.role.replace('_',' ') + '</span>' : ''}
</div>
</div>
</div>
`).join('');
resultsBox.querySelectorAll('.employee-option').forEach(el => {
el.addEventListener('mouseenter', () => el.style.background = 'var(--surface2)');
el.addEventListener('mouseleave', () => el.style.background = '');
el.addEventListener('click', () => selectEmployee(el.dataset));
});
resultsBox.style.display = 'block';
}
function selectEmployee(data) {
employeeIdEl.value = data.id;
searchInput.value = '';
resultsBox.style.display = 'none';
// Populate selected card
selAvatar.textContent = data.name[0].toUpperCase();
selName.textContent = data.name;
const metaParts = [data.email];
if (data.dept) metaParts.push(data.dept);
if (data.role && data.role !== 'employee') metaParts.push(data.role.replace(/_/g,' '));
selMeta.textContent = metaParts.join(' · ');
selectedCard.style.display = 'flex';
searchInput.style.display = 'none';
hintEl.style.display = 'none';
refreshSubmitState();
}
clearBtn.addEventListener('click', () => {
employeeIdEl.value = '';
selectedCard.style.display = 'none';
searchInput.style.display = '';
searchInput.value = '';
hintEl.style.display = '';
searchInput.focus();
refreshSubmitState();
});
// Pre-select employee if form re-rendered after validation error
(function() {
const preselId = '{{ selected_employee_id or "" }}';
if (!preselId) return;
fetch(`/api/users/search?q=&limit=50`)
.then(r => r.json())
.then(data => {
const u = (data.users || []).find(x => String(x.id) === preselId);
if (u) selectEmployee({
id: String(u.id), name: u.full_name,
email: u.email, dept: u.department || '', role: u.role
});
})
.catch(() => {});
})();
</script>
{% endblock %}
+8
View File
@@ -19,6 +19,11 @@
<div class="d-flex flex-wrap gap-3" style="font-size:12px;color:var(--muted);">
<span><i class="bi bi-hash me-1"></i><span class="mono">{{ ticket.ticket_number }}</span></span>
<span><i class="bi bi-person me-1"></i>{{ ticket.creator.full_name }}</span>
{% if ticket.filed_by_staff %}
<span style="background:var(--info-bg);color:var(--info);border:1px solid #bae6fd;border-radius:6px;padding:2px 8px;font-size:11px;font-weight:600;">
<i class="bi bi-person-badge me-1"></i>Filed by {{ ticket.filed_by_staff.full_name }}
</span>
{% endif %}
<span><i class="bi bi-tag me-1"></i>{{ ticket.category.replace('_',' ').title() }}</span>
<span><i class="bi bi-calendar3 me-1"></i>{{ ticket.created_at.strftime('%b %d, %Y %H:%M') }}</span>
{% if ticket.location %}<span><i class="bi bi-geo-alt me-1"></i>{{ ticket.location }}</span>{% endif %}
@@ -596,6 +601,9 @@ function buildCommentEl(c) {
{{ info_row('bi-hash', 'Number', '<span class="mono" style="font-size:11px;color:var(--accent3);">'~ticket.ticket_number~'</span>') }}
{{ info_row('bi-tag', 'Category', ticket.category.replace('_',' ').title()) }}
{{ info_row('bi-person', 'Submitted by', ticket.creator.full_name) }}
{% if ticket.filed_by_staff %}
{{ info_row('bi-person-badge', 'Filed by (IT)', '<span style="color:var(--accent);font-weight:600;">' ~ ticket.filed_by_staff.full_name ~ '</span>') }}
{% endif %}
{{ info_row('bi-person-check', 'Assigned to', ticket.assignee.full_name if ticket.assignee else '—') }}
{{ info_row('bi-calendar3', 'Created', ticket.created_at.strftime('%b %d, %Y')) }}
{{ info_row('bi-calendar-check', 'Updated', ticket.updated_at.strftime('%b %d, %Y')) }}