04/06 adding some additional functions

This commit is contained in:
2026-04-06 18:02:16 -04:00
parent bc236a3d19
commit 299bf2ca05
12 changed files with 1142 additions and 9 deletions
@@ -0,0 +1,71 @@
{% extends "base.html" %}
{% block title %}{{ 'Edit' if item else 'New' }} Quick Reply{% endblock %}
{% block page_title %}{{ 'Edit' if item else 'New' }} Quick Reply{% 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-chat-square-text me-2"></i>
{{ 'Edit Quick Reply' if item else 'Create Quick Reply' }}
</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">Title *
<span class="text-muted fw-normal" style="font-size:12px;">
— shown in the dropdown menu on ticket comments
</span>
</label>
<input type="text" class="form-control" name="title" required maxlength="120"
value="{{ item.title if item else '' }}"
placeholder="e.g. Please restart and confirm"/>
</div>
<div class="mb-3">
<label class="form-label">Category
<span class="text-muted fw-normal" style="font-size:12px;">— optional grouping label</span>
</label>
<input type="text" class="form-control" name="category" maxlength="50"
value="{{ item.category if item and item.category else '' }}"
placeholder="e.g. Hardware, Software, Escalation"/>
</div>
<div class="mb-3">
<label class="form-label">Response Body *
<span class="text-muted fw-normal" style="font-size:12px;">— Markdown supported</span>
</label>
<textarea class="form-control" name="body" rows="8" required
placeholder="Please restart your device and let us know if the issue persists.">{{ item.body if item else '' }}</textarea>
<div class="form-text">
Use **bold**, _italic_, `code`, and - lists. This text will be inserted
directly into the comment box when selected.
</div>
</div>
{% if item %}
<div class="mb-4 d-flex align-items-center gap-2">
<input type="checkbox" id="is_active" name="is_active"
{% if item.is_active %}checked{% endif %}
style="accent-color:var(--accent);width:16px;height:16px;"/>
<label for="is_active" style="font-size:14px;margin:0;cursor:pointer;">
Active (visible to IT staff in the quick replies dropdown)
</label>
</div>
{% endif %}
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">
<i class="bi bi-check2 me-2"></i>{{ 'Save Changes' if item else 'Create Quick Reply' }}
</button>
<a href="{{ url_for('admin.canned_responses') }}" class="btn btn-secondary">Cancel</a>
</div>
</form>
</div>
</div>
</div>
</div>
{% endblock %}
+63
View File
@@ -0,0 +1,63 @@
{% extends "base.html" %}
{% block title %}Quick Replies{% endblock %}
{% block page_title %}Quick Replies{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<p style="color:var(--muted);font-size:13px;margin:0;">
Pre-written responses that IT staff can insert into ticket comments with one click.
</p>
<a href="{{ url_for('admin.canned_response_new') }}" class="btn btn-primary">
<i class="bi bi-plus-circle me-1"></i>New Quick Reply
</a>
</div>
{% if items %}
{% set ns = namespace(last_cat='') %}
{% for item in items %}
{% if item.category and item.category != ns.last_cat %}
<h6 style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.8px;
color:var(--muted);margin:20px 0 8px;">{{ item.category }}</h6>
{% set ns.last_cat = item.category %}
{% elif not item.category and ns.last_cat %}
<h6 style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.8px;
color:var(--muted);margin:20px 0 8px;">Uncategorised</h6>
{% set ns.last_cat = '' %}
{% endif %}
<div class="card mb-2 {% if not item.is_active %}opacity-50{% endif %}">
<div class="card-body py-3 px-4 d-flex align-items-start gap-3">
<div style="flex:1;min-width:0;">
<div class="d-flex align-items-center gap-2 mb-1">
<span style="font-size:14px;font-weight:600;">{{ item.title }}</span>
{% if not item.is_active %}
<span class="badge bg-secondary" style="font-size:10px;">Inactive</span>
{% endif %}
</div>
<div style="font-size:13px;color:var(--muted);white-space:pre-wrap;max-height:60px;overflow:hidden;text-overflow:ellipsis;">{{ item.body }}</div>
<div style="font-size:11px;color:var(--muted2);margin-top:4px;">
Added by {{ item.creator.full_name }} · {{ item.created_at.strftime('%b %d, %Y') }}
</div>
</div>
<div class="d-flex gap-2 flex-shrink-0">
<a href="{{ url_for('admin.canned_response_edit', item_id=item.id) }}"
class="btn btn-secondary btn-sm"><i class="bi bi-pencil"></i></a>
<form method="POST"
action="{{ url_for('admin.canned_response_delete', item_id=item.id) }}"
onsubmit="return confirm('Delete this quick reply?')">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<button type="submit" class="btn btn-sm btn-outline-danger"><i class="bi bi-trash"></i></button>
</form>
</div>
</div>
</div>
{% endfor %}
{% else %}
<div class="card">
<div class="p-5 text-center" style="color:var(--muted);">
<i class="bi bi-chat-square-text" style="font-size:40px;display:block;margin-bottom:12px;"></i>
No quick replies yet. Create one to speed up IT responses.
</div>
</div>
{% endif %}
{% endblock %}
+122 -2
View File
@@ -73,11 +73,51 @@
<div class="row g-3 mb-4">
<div class="col-md-6">
<label class="form-label">New Password</label>
<input type="password" class="form-control" name="new_password" placeholder="Min. 8 characters"/>
<!-- Show/hide toggle wrapper -->
<div style="position:relative;">
<input type="password" class="form-control" name="new_password"
id="prof-pw" placeholder="Min. 8 characters"
autocomplete="new-password"
oninput="profPwInput()" style="padding-right:40px;"/>
<button type="button" id="prof-pw-toggle"
onclick="profToggle('prof-pw','prof-pw-toggle')"
tabindex="-1"
style="position:absolute;right:10px;top:50%;transform:translateY(-50%);
background:none;border:none;color:var(--muted);cursor:pointer;font-size:15px;">
<i class="bi bi-eye"></i>
</button>
</div>
<!-- Strength bar (hidden until typing starts) -->
<div id="prof-strength-wrap" style="display:none;margin-top:6px;">
<div style="height:4px;background:var(--border);border-radius:2px;overflow:hidden;margin-bottom:5px;">
<div id="prof-strength-bar" style="height:100%;border-radius:2px;width:0%;transition:width .3s,background .3s;"></div>
</div>
<div id="prof-strength-label" style="font-size:11px;font-weight:600;font-family:'Space Mono',monospace;display:flex;align-items:center;gap:5px;"></div>
</div>
<!-- Rule checklist -->
<div id="prof-rules" style="display:none;margin-top:7px;background:var(--surface2);border:1px solid var(--border);border-radius:8px;padding:9px 12px;display:grid;grid-template-columns:1fr 1fr;gap:3px 10px;">
<div class="prof-rule" id="prof-rule-len" style="display:flex;align-items:center;gap:5px;font-size:11.5px;color:var(--muted);"><span class="prof-ri"><i class="bi bi-circle"></i></span> 8+ characters</div>
<div class="prof-rule" id="prof-rule-upper" style="display:flex;align-items:center;gap:5px;font-size:11.5px;color:var(--muted);"><span class="prof-ri"><i class="bi bi-circle"></i></span> Uppercase letter</div>
<div class="prof-rule" id="prof-rule-digit" style="display:flex;align-items:center;gap:5px;font-size:11.5px;color:var(--muted);"><span class="prof-ri"><i class="bi bi-circle"></i></span> Number</div>
<div class="prof-rule" id="prof-rule-special"style="display:flex;align-items:center;gap:5px;font-size:11.5px;color:var(--muted);"><span class="prof-ri"><i class="bi bi-circle"></i></span> Special character</div>
</div>
</div>
<div class="col-md-6">
<label class="form-label">Confirm Password</label>
<input type="password" class="form-control" name="confirm_password" placeholder="Repeat password"/>
<div style="position:relative;">
<input type="password" class="form-control" name="confirm_password"
id="prof-pw2" placeholder="Repeat password"
autocomplete="new-password"
oninput="profConfirmInput()" style="padding-right:40px;"/>
<button type="button" id="prof-pw2-toggle"
onclick="profToggle('prof-pw2','prof-pw2-toggle')"
tabindex="-1"
style="position:absolute;right:10px;top:50%;transform:translateY(-50%);
background:none;border:none;color:var(--muted);cursor:pointer;font-size:15px;">
<i class="bi bi-eye"></i>
</button>
</div>
<div id="prof-match-msg" style="font-size:11.5px;margin-top:6px;font-family:'Space Mono',monospace;font-weight:600;min-height:18px;"></div>
</div>
</div>
@@ -90,4 +130,84 @@
</div>
</div>
</div>
{% block scripts %}
<script>
// ── Profile page password strength — mirrors register.html rules exactly ──────
const PROF_RULES = {
len: pw => pw.length >= 8,
upper: pw => /[A-Z]/.test(pw),
digit: pw => /\d/.test(pw),
special: pw => /[!@#$%^&*()\-_=+\[\]{};:'",.<>?/\|`~]/.test(pw),
};
const PROF_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 profScore(pw) {
return [PROF_RULES.len,PROF_RULES.upper,PROF_RULES.digit,PROF_RULES.special]
.filter(r => r(pw)).length + (pw.length >= 16 ? 1 : 0);
}
function profSetRule(id, met) {
const el = document.getElementById('prof-rule-' + id);
if (!el) return;
el.style.color = met ? 'var(--success)' : 'var(--muted)';
el.querySelector('.prof-ri').innerHTML = met
? '<i class="bi bi-check-circle-fill" style="color:var(--success);"></i>'
: '<i class="bi bi-circle"></i>';
}
function profPwInput() {
const pw = document.getElementById('prof-pw').value;
const wrap = document.getElementById('prof-strength-wrap');
const rules = document.getElementById('prof-rules');
const bar = document.getElementById('prof-strength-bar');
const lbl = document.getElementById('prof-strength-label');
if (!pw) {
wrap.style.display = 'none'; rules.style.display = 'none';
document.getElementById('prof-pw').classList.remove('is-valid','is-invalid');
profConfirmInput(); return;
}
wrap.style.display = 'block'; rules.style.display = 'grid';
profSetRule('len', PROF_RULES.len(pw));
profSetRule('upper', PROF_RULES.upper(pw));
profSetRule('digit', PROF_RULES.digit(pw));
profSetRule('special', PROF_RULES.special(pw));
const score = profScore(pw);
const lvl = PROF_LEVELS[Math.max(0, score - 1)] || PROF_LEVELS[0];
bar.style.width = lvl.pct + '%';
bar.style.background = lvl.color;
lbl.style.color = lvl.color;
const icon = score >= 4 ? 'shield-fill' : score >= 2 ? 'shield-half' : 'shield';
lbl.innerHTML = `<i class="bi bi-${icon}"></i> ${lvl.label}`;
const allMet = Object.values(PROF_RULES).every(r => r(pw));
document.getElementById('prof-pw').classList.toggle('is-valid', allMet);
document.getElementById('prof-pw').classList.toggle('is-invalid', !allMet);
profConfirmInput();
}
function profConfirmInput() {
const pw = document.getElementById('prof-pw').value;
const pw2 = document.getElementById('prof-pw2').value;
const msg = document.getElementById('prof-match-msg');
const el2 = document.getElementById('prof-pw2');
if (!pw2) { msg.innerHTML=''; el2.classList.remove('is-valid','is-invalid'); return; }
const ok = pw === pw2;
msg.innerHTML = ok
? '<span style="color:var(--success);"><i class="bi bi-check-circle-fill me-1"></i>Passwords match</span>'
: '<span style="color:var(--danger);"><i class="bi bi-x-circle-fill me-1"></i>Passwords do not match</span>';
el2.classList.toggle('is-valid', ok);
el2.classList.toggle('is-invalid', !ok);
}
function profToggle(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';
}
</script>
{% endblock %}
{% endblock %}
+3
View File
@@ -335,6 +335,9 @@
<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
+153
View File
@@ -179,6 +179,33 @@
accept=".png,.jpg,.jpeg,.gif,.pdf,.doc,.docx,.txt,.zip,.log"/>
</div>
{% if current_user.is_it_staff %}
<!-- Canned responses picker -->
{% if canned_responses %}
<div class="mb-3">
<div class="dropdown">
<button type="button" class="btn btn-secondary btn-sm dropdown-toggle"
id="canned-responses-btn" data-bs-toggle="dropdown" aria-expanded="false">
<i class="bi bi-chat-square-text me-1"></i>Quick Replies
</button>
<ul class="dropdown-menu" style="max-height:260px;overflow-y:auto;min-width:300px;">
{% set ns = namespace(last_cat='') %}
{% for r in canned_responses %}
{% if r.category and r.category != ns.last_cat %}
{% if ns.last_cat %}<li><hr class="dropdown-divider"/></li>{% endif %}
<li><span class="dropdown-header" style="font-size:10px;text-transform:uppercase;letter-spacing:.5px;">{{ r.category }}</span></li>
{% set ns.last_cat = r.category %}
{% endif %}
<li>
<button type="button" class="dropdown-item" style="font-size:13px;white-space:normal;"
onclick="insertCannedResponse({{ r.id }})">
{{ r.title }}
</button>
</li>
{% endfor %}
</ul>
</div>
</div>
{% endif %}
<div class="mb-3 d-flex align-items-center gap-2">
<input type="checkbox" id="is_internal" name="is_internal" style="accent-color:var(--warning);"/>
<label for="is_internal" style="font-size:13px;color:var(--warning);margin:0;cursor:pointer;">
@@ -196,6 +223,20 @@
{% endif %}
<script>
const CANNED_RESPONSES = {
{% for r in canned_responses %}
{{ r.id }}: {{ r.body | tojson }},
{% endfor %}
};
function insertCannedResponse(id) {
const body = CANNED_RESPONSES[id];
if (!body) return;
const ta = document.getElementById('comment-body');
const cur = ta.value;
ta.value = cur ? cur + '\n\n' + body : body;
ta.focus();
ta.setSelectionRange(ta.value.length, ta.value.length);
}
const TICKET_ID = {{ ticket.id }};
const CURRENT_UID = {{ current_user.id }};
const IS_IT_STAFF = {{ 'true' if current_user.is_it_staff else 'false' }};
@@ -587,6 +628,69 @@ function buildCommentEl(c) {
</div>
{% endif %}
<!-- Linked Tickets -->
{% if current_user.is_it_staff %}
<div class="card mb-3">
<div class="card-header d-flex align-items-center justify-content-between">
<span><i class="bi bi-link-45deg me-2"></i>Linked Tickets</span>
<button class="btn btn-secondary btn-sm" type="button"
data-bs-toggle="collapse" data-bs-target="#link-form-collapse"
style="font-size:11px;">
<i class="bi bi-plus me-1"></i>Link
</button>
</div>
<div class="card-body" style="font-size:13px;">
{% if linked_tickets %}
{% for lnk, other in linked_tickets %}
<div class="d-flex align-items-center justify-content-between mb-2"
style="padding:6px 8px;background:var(--surface2);border-radius:6px;border:1px solid var(--border);">
<div style="min-width:0;">
<a href="{{ url_for('tickets.ticket_detail', ticket_id=other.id) }}"
class="mono" style="font-size:11px;color:var(--accent3);">{{ other.ticket_number }}</a>
<span style="font-size:10px;background:var(--border);color:var(--muted);padding:1px 6px;border-radius:4px;margin-left:4px;">{{ lnk.link_type.replace('_',' ') }}</span>
<div style="font-size:12px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">{{ other.title }}</div>
</div>
<form method="POST"
action="{{ url_for('tickets.unlink_ticket', ticket_id=ticket.id, link_id=lnk.id) }}"
onsubmit="return confirm('Remove this link?')" style="margin:0;flex-shrink:0;">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<button type="submit" class="btn btn-sm" title="Remove link"
style="background:none;border:none;color:var(--muted);padding:2px 6px;">
<i class="bi bi-x-lg"></i>
</button>
</form>
</div>
{% endfor %}
{% else %}
<p style="color:var(--muted);font-size:12px;margin:0;">No linked tickets yet.</p>
{% endif %}
<!-- Link form (collapsed by default) -->
<div class="collapse mt-3" id="link-form-collapse">
<form method="POST" action="{{ url_for('tickets.link_ticket', ticket_id=ticket.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<div class="mb-2">
<label class="form-label" style="font-size:12px;">Ticket ID or Number</label>
<input type="number" class="form-control form-control-sm"
name="linked_ticket_id" placeholder="e.g. 42" required min="1"/>
</div>
<div class="mb-2">
<label class="form-label" style="font-size:12px;">Relationship</label>
<select class="form-select form-select-sm" name="link_type">
<option value="related">Related</option>
<option value="duplicate">Duplicate</option>
<option value="follow_up">Follow-up</option>
</select>
</div>
<button type="submit" class="btn btn-primary btn-sm w-100">
<i class="bi bi-link-45deg me-1"></i>Create Link
</button>
</form>
</div>
</div>
</div>
{% endif %}
<!-- Ticket Info -->
<div class="card mb-3">
<div class="card-header"><i class="bi bi-info-circle me-2"></i>Ticket Details</div>
@@ -647,4 +751,53 @@ function buildCommentEl(c) {
{% endif %}
</div>
</div>
<!-- Re-open modal trigger — shown to ticket creator when resolved or closed -->
{% if ticket.status in ('resolved', 'closed') and
(current_user.is_it_staff or ticket.created_by_id == current_user.id) %}
<div class="card mt-4" style="border-color:var(--warning);max-width:760px;">
<div class="card-body d-flex align-items-center gap-3" style="padding:16px 20px;">
<i class="bi bi-arrow-repeat" style="font-size:22px;color:var(--warning);flex-shrink:0;"></i>
<div style="flex:1;">
<div style="font-weight:600;font-size:14px;">Has this issue returned?</div>
<div style="font-size:12px;color:var(--muted);margin-top:2px;">
Re-open this ticket to notify IT staff and resume tracking.
</div>
</div>
<button type="button" class="btn btn-outline-warning btn-sm"
data-bs-toggle="modal" data-bs-target="#reopen-modal">
<i class="bi bi-arrow-repeat me-1"></i>Re-open Ticket
</button>
</div>
</div>
<!-- Re-open modal -->
<div class="modal fade" id="reopen-modal" tabindex="-1">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="bi bi-arrow-repeat me-2"></i>Re-open Ticket</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<form method="POST" action="{{ url_for('tickets.reopen_ticket', ticket_id=ticket.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<div class="modal-body">
<p style="font-size:13px;color:var(--muted);">
Please describe why the issue has returned. This will be posted as a comment
and IT staff will be notified immediately.
</p>
<textarea class="form-control" name="reopen_reason" rows="4" required
placeholder="e.g. The problem came back after the weekend restart. The error message is now different: …"></textarea>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-warning">
<i class="bi bi-arrow-repeat me-1"></i>Re-open &amp; Notify IT
</button>
</div>
</form>
</div>
</div>
</div>
{% endif %}
{% endblock %}