04/07 updated password reset, ticket templates, and satisfaction survey

This commit is contained in:
2026-04-07 17:32:11 -04:00
parent 36945f5976
commit 60db5c3f9a
18 changed files with 1309 additions and 105 deletions
@@ -0,0 +1,121 @@
{% extends "base.html" %}
{% block title %}Satisfaction Report{% endblock %}
{% block page_title %}Satisfaction Report{% endblock %}
{% block content %}
<!-- KPI row -->
<div class="row g-3 mb-4">
<div class="col-sm-4">
<div class="stat-card">
<div class="stat-icon" style="background:rgba(251,191,36,.1);color:#f59e0b;">
<i class="bi bi-star-fill"></i>
</div>
<div>
<div class="stat-value">
{% if avg_rating %}{{ avg_rating }} <span style="font-size:16px;">/ 5</span>{% else %}—{% endif %}
</div>
<div class="stat-label">Average Rating</div>
</div>
</div>
</div>
<div class="col-sm-4">
<div class="stat-card">
<div class="stat-icon" style="background:rgba(37,99,235,.1);color:var(--accent);">
<i class="bi bi-clipboard-check"></i>
</div>
<div>
<div class="stat-value">{{ total }}</div>
<div class="stat-label">Responses Received</div>
</div>
</div>
</div>
<div class="col-sm-4">
<div class="stat-card">
<div class="stat-icon" style="background:rgba(100,116,139,.1);color:var(--muted);">
<i class="bi bi-hourglass-split"></i>
</div>
<div>
<div class="stat-value">{{ pending }}</div>
<div class="stat-label">Awaiting Response</div>
</div>
</div>
</div>
</div>
<!-- Rating distribution -->
{% if total %}
<div class="card mb-4">
<div class="card-header"><i class="bi bi-bar-chart-fill me-2"></i>Rating Distribution</div>
<div class="card-body">
{% for star in [5,4,3,2,1] %}
{% set count = dist[star] %}
{% set pct = ((count / total) * 100)|round(1) if total else 0 %}
<div class="d-flex align-items-center gap-3 mb-2">
<div style="width:60px;text-align:right;font-size:13px;font-weight:600;color:var(--text2);flex-shrink:0;">
{{ star }} ★
</div>
<div style="flex:1;background:var(--surface2);border-radius:4px;height:18px;overflow:hidden;">
<div style="width:{{ pct }}%;height:100%;background:{% if star >= 4 %}var(--success){% elif star == 3 %}var(--warning){% else %}var(--danger){% endif %};border-radius:4px;transition:width .4s;"></div>
</div>
<div style="width:70px;font-size:12px;color:var(--muted);font-family:'Space Mono',monospace;">
{{ count }} ({{ pct }}%)
</div>
</div>
{% endfor %}
</div>
</div>
{% endif %}
<!-- Individual responses -->
<div class="card">
<div class="card-header"><i class="bi bi-chat-square-text me-2"></i>Individual Responses</div>
<div class="card-body p-0">
{% if surveys %}
<table class="table mb-0">
<thead>
<tr>
<th>Ticket</th>
<th>Submitted By</th>
<th>Rating</th>
<th>Comment</th>
<th>Submitted</th>
</tr>
</thead>
<tbody>
{% for s in surveys %}
<tr>
<td>
<a href="{{ url_for('tickets.ticket_detail', ticket_id=s.ticket_id) }}"
class="mono" style="font-size:11px;color:var(--accent3);">
{{ s.ticket.ticket_number }}
</a>
<div style="font-size:12px;color:var(--muted);max-width:180px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">
{{ s.ticket.title }}
</div>
</td>
<td style="font-size:13px;">{{ s.user.full_name }}</td>
<td>
<span style="font-size:18px;color:#f59e0b;">
{% for i in range(s.rating) %}★{% endfor %}{% for i in range(5 - s.rating) %}<span style="color:var(--border2);"></span>{% endfor %}
</span>
</td>
<td style="font-size:12px;color:var(--muted);max-width:220px;">
{{ s.comment or '—' }}
</td>
<td style="font-size:11px;color:var(--muted);">
{{ s.submitted_at | localtime("%b %d, %Y") }}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="p-5 text-center" style="color:var(--muted);">
<i class="bi bi-star" style="font-size:40px;display:block;margin-bottom:12px;"></i>
No satisfaction responses yet. Surveys are sent automatically when tickets are resolved.
</div>
{% endif %}
</div>
</div>
{% endblock %}
@@ -0,0 +1,119 @@
{% extends "base.html" %}
{% block title %}{{ 'Edit' if t else 'New' }} Template{% endblock %}
{% block page_title %}{{ 'Edit' if t else 'New' }} Ticket Template{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-lg-7">
<div class="card">
<div class="card-header">
<i class="bi bi-layout-text-window-reverse me-2"></i>
{{ 'Edit: ' + t.name if t else 'New Template' }}
</div>
<div class="card-body">
<form method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<div class="mb-3">
<label class="form-label">Template Name *</label>
<input type="text" class="form-control" name="name" required
value="{{ t.name if t else '' }}"
placeholder="e.g. VPN Access Issue"/>
</div>
<div class="row g-3 mb-3">
<div class="col-md-6">
<label class="form-label">Category</label>
<select class="form-select" name="category">
{% for cat in categories %}
<option value="{{ cat }}" {% if t and t.category == cat %}selected{% endif %}>
{{ cat.replace('_',' ').title() }}
</option>
{% endfor %}
</select>
</div>
<div class="col-md-6">
<label class="form-label">Default Priority</label>
<select class="form-select" name="priority">
{% for p in priorities %}
<option value="{{ p }}" {% if t and t.priority == p %}selected{% elif not t and p == 'medium' %}selected{% endif %}>
{{ p.upper() }}
</option>
{% endfor %}
</select>
</div>
</div>
<div class="mb-3">
<label class="form-label">Title Hint
<span style="font-size:11px;color:var(--muted);font-weight:400;">
— pre-fills the ticket title field
</span>
</label>
<input type="text" class="form-control" name="title_hint"
value="{{ t.title_hint if t else '' }}"
placeholder="e.g. Cannot connect to VPN"/>
</div>
<div class="mb-3">
<label class="form-label">Description Template
<span style="font-size:11px;color:var(--muted);font-weight:400;">
— pre-fills the description textarea (use newlines as prompts)
</span>
</label>
<textarea class="form-control" name="description" rows="7"
placeholder="Steps to reproduce:&#10;1. &#10;&#10;Error message:&#10;&#10;Device / OS:">{{ t.description if t else '' }}</textarea>
</div>
<div class="row g-3 mb-3">
<div class="col-md-6">
<label class="form-label">Bootstrap Icon class
<span style="font-size:11px;color:var(--muted);font-weight:400;">
— e.g. bi-shield-lock
</span>
</label>
<div class="input-group">
<span class="input-group-text" id="icon-preview" style="font-size:18px;color:var(--accent);">
<i class="bi {{ t.icon if t else 'bi-file-text' }}" id="icon-el"></i>
</span>
<input type="text" class="form-control" name="icon" id="icon-input"
value="{{ t.icon if t else 'bi-file-text' }}"
oninput="document.getElementById('icon-el').className='bi '+this.value"/>
</div>
<div class="form-text">
Browse icons at
<a href="https://icons.getbootstrap.com/" target="_blank" style="color:var(--accent3);">
icons.getbootstrap.com
</a>
</div>
</div>
<div class="col-md-3">
<label class="form-label">Sort Order</label>
<input type="number" class="form-control" name="sort_order" min="0"
value="{{ t.sort_order if t else 0 }}"/>
<div class="form-text">Lower = shown first</div>
</div>
<div class="col-md-3 d-flex align-items-center" style="padding-top:28px;">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" role="switch"
name="is_active" id="is-active"
{% if not t or t.is_active %}checked{% endif %}
style="width:40px;height:22px;cursor:pointer;"/>
<label class="form-check-label ms-2" for="is-active"
style="font-size:13px;font-weight:600;">Active</label>
</div>
</div>
</div>
<div class="d-flex gap-2 mt-4">
<button type="submit" class="btn btn-primary">
<i class="bi bi-check2 me-2"></i>{{ 'Save Changes' if t else 'Create Template' }}
</button>
<a href="{{ url_for('admin.ticket_templates') }}" class="btn btn-secondary">Cancel</a>
</div>
</form>
</div>
</div>
</div>
</div>
{% endblock %}
+78
View File
@@ -0,0 +1,78 @@
{% extends "base.html" %}
{% block title %}Ticket Templates{% endblock %}
{% block page_title %}Ticket Templates{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<p style="font-size:13px;color:var(--muted);margin:0;">
Pre-filled ticket scaffolds that employees can select on the New Ticket form to speed up submission.
</p>
<a href="{{ url_for('admin.ticket_template_new') }}" class="btn btn-primary btn-sm">
<i class="bi bi-plus-lg me-1"></i>New Template
</a>
</div>
<div class="card">
<div class="card-header">
<i class="bi bi-layout-text-window-reverse me-2"></i>Templates
<span style="font-size:12px;color:var(--muted);font-family:'Space Mono',monospace;">({{ templates|length }})</span>
</div>
<div class="card-body p-0">
{% if templates %}
<table class="table mb-0">
<thead>
<tr>
<th style="width:36px;"></th>
<th>Name</th>
<th>Category</th>
<th>Priority</th>
<th>Title Hint</th>
<th>Order</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody>
{% for t in templates %}
<tr>
<td style="color:var(--accent);font-size:18px;"><i class="bi {{ t.icon }}"></i></td>
<td style="font-weight:600;font-size:13px;">{{ t.name }}</td>
<td style="font-size:12px;color:var(--muted);">{{ t.category.replace('_',' ').title() }}</td>
<td><span class="badge badge-{{ t.priority }}">{{ t.priority.upper() }}</span></td>
<td style="font-size:12px;color:var(--muted);max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">
{{ t.title_hint or '—' }}
</td>
<td style="font-size:12px;color:var(--muted);">{{ t.sort_order }}</td>
<td>
{% if t.is_active %}
<span style="font-size:12px;color:var(--success);"><i class="bi bi-check-circle-fill"></i> Active</span>
{% else %}
<span style="font-size:12px;color:var(--muted);"><i class="bi bi-dash-circle"></i> Inactive</span>
{% endif %}
</td>
<td>
<div class="d-flex gap-1">
<a href="{{ url_for('admin.ticket_template_edit', tmpl_id=t.id) }}"
class="btn btn-secondary btn-sm" title="Edit"><i class="bi bi-pencil"></i></a>
<form method="POST" action="{{ url_for('admin.ticket_template_delete', tmpl_id=t.id) }}"
onsubmit="return confirm('Delete template &quot;{{ t.name }}&quot;?');" style="margin:0;">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<button type="submit" class="btn btn-sm"
style="background:rgba(248,113,113,.1);border:1px solid rgba(248,113,113,.2);color:var(--danger);"
title="Delete"><i class="bi bi-trash"></i></button>
</form>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="p-5 text-center" style="color:var(--muted);">
<i class="bi bi-layout-text-window-reverse" style="font-size:40px;display:block;margin-bottom:12px;"></i>
No templates yet. <a href="{{ url_for('admin.ticket_template_new') }}" style="color:var(--accent3);">Create your first template →</a>
</div>
{% endif %}
</div>
</div>
{% endblock %}
+65
View File
@@ -0,0 +1,65 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/><meta name="viewport" content="width=device-width,initial-scale=1.0"/>
<title>Forgot Password — {{ branding.app_name }}</title>
<style>:root { --accent: {{ branding.primary_color }}; --accent-h: {{ branding.primary_color }}; }</style>
<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:#fff;--border:#e2e8f0;--accent:#2563eb;--accent-h:#1d4ed8;--text:#0f172a;--text2:#334155;--muted:#64748b;}
*{box-sizing:border-box;margin:0;padding:0;}
body{font-family:'DM Sans',sans-serif;background:var(--bg);min-height:100vh;display:flex;align-items:center;justify-content:center;}
.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;}
.card{background:#fff;border:1px solid var(--border);border-radius:16px;padding:40px;width:100%;max-width:420px;position:relative;z-index:1;box-shadow:0 4px 24px rgba(0,0,0,.08);}
.brand{text-align:center;margin-bottom:28px;}
.logo{width:52px;height:52px;background:var(--accent);border-radius:12px;display:flex;align-items:center;justify-content:center;font-family:'Space Mono',monospace;font-weight:700;font-size:17px;color:#fff;margin:0 auto 12px;box-shadow:0 2px 10px rgba(37,99,235,.35);}
h1{font-size:20px;font-weight:700;color:var(--text);}
.subtitle{font-size:13px;color:var(--muted);margin-top:4px;}
.form-group{margin-bottom:18px;}
label{display:block;font-size:12px;font-weight:600;letter-spacing:.4px;color:var(--text2);text-transform:uppercase;margin-bottom:7px;}
input{width:100%;background:#fff;border:1px solid var(--border);color:var(--text);border-radius:9px;padding:11px 14px;font-size:14px;font-family:inherit;transition:border-color .15s,box-shadow .15s;box-shadow:0 1px 3px rgba(0,0,0,.06);}
input:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px rgba(37,99,235,.1);}
input::placeholder{color:#94a3b8;}
.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;box-shadow:0 2px 6px rgba(37,99,235,.3);}
.btn:hover{background:var(--accent-h);}
.links{text-align:center;margin-top:18px;font-size:13px;color:var(--muted);}
.links a{color:var(--accent);}
.alert{border-radius:8px;padding:11px 14px;font-size:13px;margin-bottom:18px;}
.alert-info{background:#f0f9ff;color:#0284c7;border:1px solid #bae6fd;}
.alert-danger{background:#fef2f2;color:#dc2626;border:1px solid #fecaca;}
</style>
</head>
<body>
<div class="bg-grid"></div>
<div class="card">
<div class="brand">
{% if branding.logo_stored_name %}
<img src="{{ url_for('auth.serve_logo', filename=branding.logo_stored_name) }}"
alt="{{ branding.app_name }}"
style="width:52px;height:52px;border-radius:12px;object-fit:contain;margin:0 auto 12px;display:block;background:var(--accent);padding:4px;"/>
{% else %}
<div class="logo">{{ branding.logo_initials[:2] }}</div>
{% endif %}
<h1>Forgot Password?</h1>
<p class="subtitle">Enter your email and we'll send you a reset link.</p>
</div>
{% with messages = get_flashed_messages(with_categories=true) %}
{% for cat, msg in messages %}
<div class="alert alert-{{ cat }}"><i class="bi bi-info-circle me-2"></i>{{ msg }}</div>
{% endfor %}
{% endwith %}
<form method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<div class="form-group">
<label>Email Address</label>
<input type="email" name="email" required placeholder="you@company.com" autofocus/>
</div>
<button type="submit" class="btn"><i class="bi bi-envelope me-2"></i>Send Reset Link</button>
</form>
<div class="links"><a href="{{ url_for('auth.login') }}">← Back to Sign In</a></div>
</div>
</body>
</html>
+3 -2
View File
@@ -78,8 +78,9 @@
<button type="submit" class="btn"><i class="bi bi-box-arrow-in-right me-2"></i>Sign In</button>
</form>
<div class="links">
Don't have an account? <a href="{{ url_for('auth.register') }}">Register here</a>
<div class="links" style="display:flex;justify-content:space-between;">
<a href="{{ url_for('auth.forgot_password') }}" style="color:var(--muted);">Forgot password?</a>
<a href="{{ url_for('auth.register') }}">Register here</a>
</div>
</div>
</body>
+99
View File
@@ -0,0 +1,99 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/><meta name="viewport" content="width=device-width,initial-scale=1.0"/>
<title>Reset Password — {{ branding.app_name }}</title>
<style>:root { --accent: {{ branding.primary_color }}; --accent-h: {{ branding.primary_color }}; }</style>
<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;--border:#e2e8f0;--accent:#2563eb;--accent-h:#1d4ed8;--text:#0f172a;--text2:#334155;--muted:#64748b;--danger:#dc2626;--danger-bg:#fef2f2;}
*{box-sizing:border-box;margin:0;padding:0;}
body{font-family:'DM Sans',sans-serif;background:var(--bg);min-height:100vh;display:flex;align-items:center;justify-content:center;}
.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;}
.card{background:#fff;border:1px solid var(--border);border-radius:16px;padding:40px;width:100%;max-width:420px;position:relative;z-index:1;box-shadow:0 4px 24px rgba(0,0,0,.08);}
.brand{text-align:center;margin-bottom:28px;}
.logo{width:52px;height:52px;background:var(--accent);border-radius:12px;display:flex;align-items:center;justify-content:center;font-family:'Space Mono',monospace;font-weight:700;font-size:17px;color:#fff;margin:0 auto 12px;box-shadow:0 2px 10px rgba(37,99,235,.35);}
h1{font-size:20px;font-weight:700;color:var(--text);}
.subtitle{font-size:13px;color:var(--muted);margin-top:4px;}
.form-group{margin-bottom:18px;}
label{display:block;font-size:12px;font-weight:600;letter-spacing:.4px;color:var(--text2);text-transform:uppercase;margin-bottom:7px;}
input{width:100%;background:#fff;border:1px solid var(--border);color:var(--text);border-radius:9px;padding:11px 14px;font-size:14px;font-family:inherit;transition:border-color .15s,box-shadow .15s;box-shadow:0 1px 3px rgba(0,0,0,.06);}
input:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px rgba(37,99,235,.1);}
input::placeholder{color:#94a3b8;}
.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;box-shadow:0 2px 6px rgba(37,99,235,.3);}
.btn:hover{background:var(--accent-h);}
.links{text-align:center;margin-top:18px;font-size:13px;color:var(--muted);}
.links a{color:var(--accent);}
.alert{border-radius:8px;padding:11px 14px;font-size:13px;margin-bottom:18px;}
.alert-danger{background:var(--danger-bg);color:var(--danger);border:1px solid #fecaca;}
/* strength meter */
#pw-strength-bar{height:4px;border-radius:2px;transition:width .3s,background .3s;background:#e2e8f0;width:0;}
#pw-strength-text{font-size:11px;color:var(--muted);margin-top:4px;}
</style>
</head>
<body>
<div class="bg-grid"></div>
<div class="card">
<div class="brand">
{% if branding.logo_stored_name %}
<img src="{{ url_for('auth.serve_logo', filename=branding.logo_stored_name) }}"
alt="{{ branding.app_name }}"
style="width:52px;height:52px;border-radius:12px;object-fit:contain;margin:0 auto 12px;display:block;background:var(--accent);padding:4px;"/>
{% else %}
<div class="logo">{{ branding.logo_initials[:2] }}</div>
{% endif %}
<h1>Set New Password</h1>
<p class="subtitle">Choose a strong password for your account.</p>
</div>
{% with messages = get_flashed_messages(with_categories=true) %}
{% for cat, msg in messages %}
<div class="alert alert-{{ cat }}"><i class="bi bi-exclamation-circle me-2"></i>{{ msg }}</div>
{% endfor %}
{% endwith %}
<form method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<div class="form-group">
<label>New Password</label>
<input type="password" name="password" id="pw-new" required placeholder="Min. 8 characters" autofocus
oninput="checkStrength(this.value)"/>
<div style="margin-top:6px;background:#e2e8f0;border-radius:2px;height:4px;">
<div id="pw-strength-bar"></div>
</div>
<div id="pw-strength-text"></div>
</div>
<div class="form-group">
<label>Confirm Password</label>
<input type="password" name="confirm_password" required placeholder="Repeat password"/>
</div>
<button type="submit" class="btn"><i class="bi bi-lock me-2"></i>Update Password</button>
</form>
<div class="links"><a href="{{ url_for('auth.login') }}">← Back to Sign In</a></div>
</div>
<script>
function checkStrength(pw) {
let score = 0;
if (pw.length >= 8) score++;
if (/[A-Z]/.test(pw)) score++;
if (/[0-9]/.test(pw)) score++;
if (/[^A-Za-z0-9]/.test(pw)) score++;
const bar = document.getElementById('pw-strength-bar');
const text = document.getElementById('pw-strength-text');
const levels = [
{ w: '0%', bg: '#e2e8f0', label: '' },
{ w: '25%', bg: '#dc2626', label: 'Weak' },
{ w: '50%', bg: '#d97706', label: 'Fair' },
{ w: '75%', bg: '#2563eb', label: 'Good' },
{ w: '100%', bg: '#059669', label: 'Strong' },
];
const lvl = levels[score] || levels[0];
bar.style.width = lvl.w;
bar.style.background = lvl.bg;
text.textContent = lvl.label;
text.style.color = lvl.bg;
}
</script>
</body>
</html>
+6 -6
View File
@@ -329,15 +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>
<li><a href="{{ url_for('admin.canned_responses') }}" class="{{ 'active' if 'canned_response' in request.endpoint }}">
<i class="bi bi-chat-square-text"></i> Quick Replies
</a></li>
{% if current_user.is_admin %}
<li><a href="{{ url_for('admin.users') }}" class="{{ 'active' if request.endpoint == 'admin.users' }}">
<i class="bi bi-people"></i> Users
@@ -345,6 +339,12 @@
<li><a href="{{ url_for('admin.activity_logs') }}" class="{{ 'active' if request.endpoint == 'admin.activity_logs' }}">
<i class="bi bi-list-ul"></i> Activity Logs
</a></li>
<li><a href="{{ url_for('admin.ticket_templates') }}" class="{{ 'active' if 'ticket_template' in request.endpoint }}">
<i class="bi bi-layout-text-window-reverse"></i> Ticket Templates
</a></li>
<li><a href="{{ url_for('admin.satisfaction_report') }}" class="{{ 'active' if request.endpoint == 'admin.satisfaction_report' }}">
<i class="bi bi-star-half"></i> Satisfaction
</a></li>
<li><a href="{{ url_for('admin.settings') }}" class="{{ 'active' if request.endpoint == 'admin.settings' }}">
<i class="bi bi-gear"></i> Settings
</a></li>
+56
View File
@@ -5,6 +5,31 @@
{% block content %}
<div class="row justify-content-center">
<div class="col-lg-8">
{% if templates %}
<div class="card mb-3">
<div class="card-header"><i class="bi bi-layout-text-window-reverse me-2"></i>Start from a Template</div>
<div class="card-body">
<p style="font-size:13px;color:var(--muted);margin-bottom:14px;">
Select a common request type to pre-fill the form, or fill it in manually below.
</p>
<div class="row g-2">
{% for t in templates %}
<div class="col-6 col-md-4">
<button type="button" class="btn btn-secondary w-100 text-start template-btn"
style="padding:10px 14px;font-size:13px;"
data-title="{{ t.title_hint }}"
data-category="{{ t.category }}"
data-priority="{{ t.priority }}"
data-description="{{ t.description | e }}">
<i class="bi {{ t.icon }} me-2" style="color:var(--accent);"></i>{{ t.name }}
</button>
</div>
{% endfor %}
</div>
</div>
</div>
{% endif %}
<div class="card">
<div class="card-header">
<i class="bi bi-plus-circle me-2"></i>New Support Request
@@ -108,4 +133,35 @@
</div>
</div>
</div>
{% block scripts %}
<script>
document.querySelectorAll('.template-btn').forEach(btn => {
btn.addEventListener('click', () => {
const title = btn.dataset.title;
const cat = btn.dataset.category;
const prio = btn.dataset.priority;
const desc = btn.dataset.description;
if (title) document.querySelector('input[name=title]').value = title;
if (desc) document.querySelector('textarea[name=description]').value = desc;
const catSel = document.querySelector('select[name=category]');
if (catSel) {
for (const opt of catSel.options) {
if (opt.value === cat) { opt.selected = true; break; }
}
}
const prioSel = document.querySelector('select[name=priority]');
if (prioSel) {
for (const opt of prioSel.options) {
if (opt.value === prio) { opt.selected = true; break; }
}
}
// Scroll to form
document.querySelector('input[name=title]').scrollIntoView({behavior:'smooth', block:'center'});
document.querySelector('input[name=title]').focus();
});
});
</script>
{% endblock %}
{% endblock %}
+26
View File
@@ -752,6 +752,32 @@ function buildCommentEl(c) {
</div>
<!-- History -->
<!-- ── Satisfaction rating (visible to creator + IT staff) ──────── -->
{% if ticket.satisfaction %}
<div class="card mb-3">
<div class="card-header"><i class="bi bi-star-half me-2"></i>Customer Satisfaction</div>
<div class="card-body" style="padding:16px 20px;">
{% if ticket.satisfaction.submitted %}
<div style="font-size:28px;color:#f59e0b;margin-bottom:6px;">
{% for i in range(ticket.satisfaction.rating) %}★{% endfor %}<span style="color:var(--border2);">{% for i in range(5 - ticket.satisfaction.rating) %}★{% endfor %}</span>
</div>
<div style="font-size:12px;color:var(--muted);">
Rated {{ ticket.satisfaction.rating }}/5 on {{ ticket.satisfaction.submitted_at | localtime("%b %d, %Y") }}
</div>
{% if ticket.satisfaction.comment %}
<div style="margin-top:10px;font-size:13px;color:var(--text2);background:var(--surface2);border-radius:6px;padding:10px 12px;border:1px solid var(--border);">
"{{ ticket.satisfaction.comment }}"
</div>
{% endif %}
{% else %}
<div style="font-size:13px;color:var(--muted);">
<i class="bi bi-hourglass-split me-2"></i>Survey sent — awaiting response
</div>
{% endif %}
</div>
</div>
{% endif %}
{% if history %}
<div class="card">
<div class="card-header"><i class="bi bi-clock-history me-2"></i>Change History</div>
+90
View File
@@ -0,0 +1,90 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/><meta name="viewport" content="width=device-width,initial-scale=1.0"/>
<title>How did we do? — TechDesk</title>
<link href="https://fonts.googleapis.com/css2?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>
*{box-sizing:border-box;margin:0;padding:0;}
body{font-family:'DM Sans',sans-serif;background:#f0f4f8;min-height:100vh;display:flex;align-items:center;justify-content:center;padding:20px;}
.bg-grid{position:fixed;inset:0;background-image:linear-gradient(#e2e8f0 1px,transparent 1px),linear-gradient(90deg,#e2e8f0 1px,transparent 1px);background-size:48px 48px;opacity:.6;pointer-events:none;}
.card{background:#fff;border:1px solid #e2e8f0;border-radius:16px;padding:40px;width:100%;max-width:500px;position:relative;z-index:1;box-shadow:0 4px 24px rgba(0,0,0,.08);}
h1{font-size:22px;font-weight:700;color:#0f172a;margin-bottom:6px;}
.sub{font-size:13px;color:#64748b;margin-bottom:24px;}
.ticket-ref{background:#f8fafc;border:1px solid #e2e8f0;border-radius:8px;padding:12px 16px;margin-bottom:24px;font-size:13px;color:#334155;}
.ticket-ref strong{color:#2563eb;font-family:'Courier New',monospace;}
/* Star rating */
.stars{display:flex;flex-direction:row-reverse;justify-content:center;gap:6px;margin-bottom:28px;}
.stars input{display:none;}
.stars label{font-size:44px;color:#e2e8f0;cursor:pointer;transition:color .15s;line-height:1;}
.stars input:checked ~ label,
.stars label:hover,
.stars label:hover ~ label{color:#f59e0b;}
.stars input:checked + label{color:#f59e0b;}
textarea{width:100%;border:1px solid #e2e8f0;border-radius:9px;padding:11px 14px;font-size:14px;font-family:inherit;resize:vertical;min-height:90px;color:#0f172a;transition:border-color .15s,box-shadow .15s;}
textarea:focus{outline:none;border-color:#2563eb;box-shadow:0 0 0 3px rgba(37,99,235,.1);}
textarea::placeholder{color:#94a3b8;}
label.field-label{display:block;font-size:12px;font-weight:600;letter-spacing:.4px;color:#334155;text-transform:uppercase;margin-bottom:7px;}
.btn{width:100%;background:#2563eb;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:16px;box-shadow:0 2px 6px rgba(37,99,235,.3);}
.btn:hover{background:#1d4ed8;}
.alert{border-radius:8px;padding:10px 14px;font-size:13px;margin-bottom:16px;background:#fef2f2;color:#dc2626;border:1px solid #fecaca;}
.rating-hint{text-align:center;font-size:13px;color:#64748b;margin-bottom:4px;min-height:20px;}
</style>
</head>
<body>
<div class="bg-grid"></div>
<div class="card">
<h1>⭐ How did we do?</h1>
<p class="sub">Your feedback helps us improve our IT support.</p>
<div class="ticket-ref">
<strong>{{ survey.ticket.ticket_number }}</strong> — {{ survey.ticket.title }}
</div>
{% if error %}
<div class="alert"><i class="bi bi-exclamation-circle me-2"></i>{{ error }}</div>
{% endif %}
<form method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<div style="margin-bottom:8px;">
<label class="field-label" style="text-align:center;display:block;">Your Rating *</label>
<p class="rating-hint" id="rating-hint">Click a star to rate</p>
<div class="stars">
{% for i in [5,4,3,2,1] %}
<input type="radio" name="rating" id="star{{ i }}" value="{{ i }}"
{% if quick_rating == i %}checked{% endif %}
onchange="updateHint({{ i }})"/>
<label for="star{{ i }}" title="{{ i }} star{{ 's' if i > 1 }}" onmouseover="hintOver({{ i }})" onmouseout="hintOut()"></label>
{% endfor %}
</div>
</div>
<div class="mb-3">
<label class="field-label">Comment <span style="font-weight:400;text-transform:none;font-size:11px;color:#94a3b8;">(optional)</span></label>
<textarea name="comment" placeholder="Tell us what went well or how we could improve…"></textarea>
</div>
<button type="submit" class="btn"><i class="bi bi-send me-2"></i>Submit Feedback</button>
</form>
</div>
<script>
const hints = {1:'Very dissatisfied',2:'Dissatisfied',3:'Neutral',4:'Satisfied',5:'Very satisfied'};
function updateHint(n){ document.getElementById('rating-hint').textContent = hints[n] || ''; }
function hintOver(n){ document.getElementById('rating-hint').textContent = hints[n] || ''; }
function hintOut(){
const checked = document.querySelector('.stars input:checked');
document.getElementById('rating-hint').textContent = checked ? hints[checked.value] : 'Click a star to rate';
}
// Auto-submit when star clicked via quick_rating link
{% if quick_rating %}
document.addEventListener('DOMContentLoaded', () => {
const el = document.getElementById('star{{ quick_rating }}');
if(el){ el.checked = true; updateHint({{ quick_rating }}); }
});
{% endif %}
</script>
</body>
</html>
+41
View File
@@ -0,0 +1,41 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/><meta name="viewport" content="width=device-width,initial-scale=1.0"/>
<title>Thank You — TechDesk</title>
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:opsz,wght@9..40,400;9..40,600&display=swap" rel="stylesheet"/>
<style>
*{box-sizing:border-box;margin:0;padding:0;}
body{font-family:'DM Sans',sans-serif;background:#f0f4f8;min-height:100vh;display:flex;align-items:center;justify-content:center;padding:20px;}
.bg-grid{position:fixed;inset:0;background-image:linear-gradient(#e2e8f0 1px,transparent 1px),linear-gradient(90deg,#e2e8f0 1px,transparent 1px);background-size:48px 48px;opacity:.6;pointer-events:none;}
.card{background:#fff;border:1px solid #e2e8f0;border-radius:16px;padding:48px 40px;width:100%;max-width:440px;position:relative;z-index:1;box-shadow:0 4px 24px rgba(0,0,0,.08);text-align:center;}
.icon{font-size:56px;margin-bottom:16px;}
h1{font-size:22px;font-weight:700;color:#0f172a;margin-bottom:8px;}
.sub{font-size:14px;color:#64748b;line-height:1.6;}
.stars{font-size:32px;color:#f59e0b;margin:20px 0 8px;}
.comment{background:#f8fafc;border:1px solid #e2e8f0;border-radius:8px;padding:12px 16px;font-size:13px;color:#334155;margin-top:12px;text-align:left;}
</style>
</head>
<body>
<div class="bg-grid"></div>
<div class="card">
<div class="icon">🙏</div>
<h1>Thank you for your feedback!</h1>
<p class="sub">Your response has been recorded and will help us improve our IT support.</p>
{% if survey.rating %}
<div class="stars">
{% for i in range(survey.rating) %}★{% endfor %}
<span style="color:#e2e8f0;">{% for i in range(5 - survey.rating) %}★{% endfor %}</span>
</div>
{% if survey.comment %}
<div class="comment">"{{ survey.comment }}"</div>
{% endif %}
{% endif %}
<p class="sub" style="margin-top:20px;font-size:12px;color:#94a3b8;">
You can close this page. If the issue recurs, please submit a new ticket.
</p>
</div>
</body>
</html>
+24
View File
@@ -0,0 +1,24 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/><meta name="viewport" content="width=device-width,initial-scale=1.0"/>
<title>Survey Not Found — TechDesk</title>
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:opsz,wght@9..40,400;9..40,600&display=swap" rel="stylesheet"/>
<style>
*{box-sizing:border-box;margin:0;padding:0;}
body{font-family:'DM Sans',sans-serif;background:#f0f4f8;min-height:100vh;display:flex;align-items:center;justify-content:center;padding:20px;}
.bg-grid{position:fixed;inset:0;background-image:linear-gradient(#e2e8f0 1px,transparent 1px),linear-gradient(90deg,#e2e8f0 1px,transparent 1px);background-size:48px 48px;opacity:.6;pointer-events:none;}
.card{background:#fff;border:1px solid #e2e8f0;border-radius:16px;padding:48px 40px;width:100%;max-width:440px;position:relative;z-index:1;box-shadow:0 4px 24px rgba(0,0,0,.08);text-align:center;}
h1{font-size:22px;font-weight:700;color:#0f172a;margin-bottom:8px;}
.sub{font-size:14px;color:#64748b;line-height:1.6;}
</style>
</head>
<body>
<div class="bg-grid"></div>
<div class="card">
<div style="font-size:56px;margin-bottom:16px;">🔗</div>
<h1>This survey link is invalid</h1>
<p class="sub">The link may have already been used, or it does not exist. No further action is needed.</p>
</div>
</body>
</html>