06/09 Update a customer AI chatbot and support form submision

This commit is contained in:
2026-06-09 16:13:12 -04:00
parent 58d4b351b4
commit 38dff9ea02
9 changed files with 1029 additions and 1 deletions
+19
View File
@@ -165,6 +165,25 @@
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('customers.') }}" href="{{ url_for('customers.index') }}">Customers</a>
</li>
{% endif %}
{% if current_user.role in ['admin', 'director'] %}
<li class="nav-item">
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('support.') }}"
href="{{ url_for('support.admin_tickets') }}">
Support
{% if open_support_tickets_count > 0 %}
<span class="badge bg-danger ms-1">{{ open_support_tickets_count }}</span>
{% endif %}
</a>
</li>
{% endif %}
{% if current_user.role == 'customer' %}
<li class="nav-item">
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('support.') }}"
href="{{ url_for('support.chat') }}">
<i class="bi bi-chat-dots me-1"></i>Support
</a>
</li>
{% endif %}
{% if current_user.role == 'admin' %}
<li class="nav-item">
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('audit.') }}" href="{{ url_for('audit.index') }}">Audit Trail</a>
@@ -0,0 +1,160 @@
{% extends "base.html" %}
{% block title %}Ticket #{{ ticket.id }}{% endblock %}
{% block extra_css %}
<style>
.reply-bubble {
max-width: 85%;
padding: .65rem 1rem;
border-radius: 1rem;
font-size: .9rem;
white-space: pre-wrap;
word-break: break-word;
line-height: 1.6;
}
.reply-admin { background:#d1ecf1; border-bottom-right-radius:.25rem; align-self:flex-end; }
.reply-system { background:#f8d7da; border-bottom-left-radius:.25rem; align-self:flex-start; }
#reply-thread { display:flex; flex-direction:column; gap:.75rem; }
</style>
{% endblock %}
{% block content %}
<div class="mb-3">
<a href="{{ url_for('support.admin_tickets') }}" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-arrow-left me-1"></i>All Tickets
</a>
</div>
<div class="row g-4">
{# ── Left column: original message + replies ── #}
<div class="col-lg-8">
{# Original ticket card #}
<div class="card shadow-sm mb-3">
<div class="card-header d-flex justify-content-between align-items-center">
<strong>Ticket #{{ ticket.id }}: {{ ticket.subject }}</strong>
{% set status_class = {'open': 'danger', 'answered': 'success', 'closed': 'secondary'} %}
<span class="badge bg-{{ status_class.get(ticket.status, 'secondary') }}">
{{ ticket.status | capitalize }}
</span>
</div>
<div class="card-body">
<div class="text-muted small mb-2">
<i class="bi bi-person me-1"></i>
{{ ticket.customer.display_name if ticket.customer else 'Unknown' }}
{% if ticket.facility %}
&nbsp;·&nbsp;<i class="bi bi-building me-1"></i>{{ ticket.facility.name }}
{% endif %}
&nbsp;·&nbsp;<i class="bi bi-clock me-1"></i>
{{ ticket.created_at.strftime('%b %d, %Y %I:%M %p') }}
</div>
<p class="mb-0" style="white-space:pre-wrap;">{{ ticket.body }}</p>
</div>
</div>
{# Reply thread #}
{% if replies %}
<div class="card shadow-sm mb-3">
<div class="card-header"><i class="bi bi-chat-left-text me-2"></i>Replies</div>
<div class="card-body">
<div id="reply-thread">
{% for reply in replies %}
<div class="d-flex flex-column">
<div class="reply-bubble reply-admin ms-auto">
<div class="text-muted small mb-1">
<strong>{{ reply.author.display_name if reply.author else 'Support Team' }}</strong>
&nbsp;·&nbsp;{{ reply.created_at.strftime('%b %d, %Y %I:%M %p') }}
</div>
{{ reply.body }}
</div>
</div>
{% endfor %}
</div>
</div>
</div>
{% endif %}
{# Add reply form #}
{% if ticket.status != 'closed' %}
<div class="card shadow-sm">
<div class="card-header"><i class="bi bi-reply me-2"></i>Add Reply</div>
<div class="card-body">
<form method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="action" value="reply">
<div class="mb-3">
<textarea name="body" class="form-control" rows="5" required
placeholder="Type your reply to the customer…"></textarea>
</div>
<button type="submit" class="btn btn-primary">
<i class="bi bi-send me-1"></i>Send Reply
</button>
<span class="text-muted small ms-2">
The customer will be notified by email.
</span>
</form>
</div>
</div>
{% else %}
<div class="alert alert-secondary">
<i class="bi bi-lock me-2"></i>This ticket is closed. Change the status to re-open it.
</div>
{% endif %}
</div>
{# ── Right column: status management ── #}
<div class="col-lg-4">
<div class="card shadow-sm">
<div class="card-header"><i class="bi bi-gear me-2"></i>Ticket Status</div>
<div class="card-body">
<p class="mb-2 text-muted small">Current status:</p>
<p class="fw-semibold mb-3">
<span class="badge bg-{{ status_class.get(ticket.status, 'secondary') }} fs-6">
{{ ticket.status | capitalize }}
</span>
</p>
<form method="post" class="d-grid gap-2">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="action" value="status">
{% if ticket.status != 'open' %}
<button type="submit" name="status" value="open" class="btn btn-outline-danger btn-sm">
<i class="bi bi-envelope-open me-1"></i>Reopen
</button>
{% endif %}
{% if ticket.status != 'answered' %}
<button type="submit" name="status" value="answered" class="btn btn-outline-success btn-sm">
<i class="bi bi-check-circle me-1"></i>Mark Answered
</button>
{% endif %}
{% if ticket.status != 'closed' %}
<button type="submit" name="status" value="closed" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-x-circle me-1"></i>Close Ticket
</button>
{% endif %}
</form>
</div>
</div>
{# Customer info card #}
{% if ticket.customer %}
<div class="card shadow-sm mt-3">
<div class="card-header"><i class="bi bi-person-circle me-2"></i>Customer</div>
<div class="card-body small">
<p class="mb-1"><strong>{{ ticket.customer.display_name }}</strong></p>
<p class="mb-1 text-muted">{{ ticket.customer.email }}</p>
{% if ticket.facility %}
<hr class="my-2">
<p class="mb-1"><i class="bi bi-building me-1 text-muted"></i>{{ ticket.facility.name }}</p>
{% if ticket.facility.address %}
<p class="mb-0 text-muted">{{ ticket.facility.address }}</p>
{% endif %}
{% endif %}
</div>
</div>
{% endif %}
</div>
</div>
{% endblock %}
+114
View File
@@ -0,0 +1,114 @@
{% extends "base.html" %}
{% block title %}Support Tickets{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<h4 class="mb-0"><i class="bi bi-inbox me-2 text-primary"></i>Customer Support Tickets</h4>
<small class="text-muted">{{ tickets.total }} ticket{{ 's' if tickets.total != 1 }}</small>
</div>
</div>
{# Status filter tabs #}
<ul class="nav nav-tabs mb-3">
<li class="nav-item">
<a class="nav-link {% if not status_filter %}active{% endif %}"
href="{{ url_for('support.admin_tickets') }}">All</a>
</li>
<li class="nav-item">
<a class="nav-link {% if status_filter == 'open' %}active{% endif %}"
href="{{ url_for('support.admin_tickets', status='open') }}">
<span class="badge bg-danger me-1">!</span>Open
</a>
</li>
<li class="nav-item">
<a class="nav-link {% if status_filter == 'answered' %}active{% endif %}"
href="{{ url_for('support.admin_tickets', status='answered') }}">Answered</a>
</li>
<li class="nav-item">
<a class="nav-link {% if status_filter == 'closed' %}active{% endif %}"
href="{{ url_for('support.admin_tickets', status='closed') }}">Closed</a>
</li>
</ul>
{% if tickets.items %}
<div class="card shadow-sm">
<div class="table-responsive">
<table class="table table-hover mb-0 align-middle">
<thead class="table-light">
<tr>
<th style="width:60px">#</th>
<th>Customer</th>
<th>Facility</th>
<th>Subject</th>
<th>Status</th>
<th>Submitted</th>
<th></th>
</tr>
</thead>
<tbody>
{% for ticket in tickets.items %}
{% set status_class = {'open': 'danger', 'answered': 'success', 'closed': 'secondary'} %}
<tr>
<td class="text-muted small">{{ ticket.id }}</td>
<td>{{ ticket.customer.display_name if ticket.customer else '—' }}</td>
<td>{{ ticket.facility.name if ticket.facility else '—' }}</td>
<td>{{ ticket.subject }}</td>
<td>
<span class="badge bg-{{ status_class.get(ticket.status, 'secondary') }}">
{{ ticket.status | capitalize }}
</span>
</td>
<td class="text-muted small text-nowrap">
{{ ticket.created_at.strftime('%b %d, %Y %I:%M %p') }}
</td>
<td>
<a href="{{ url_for('support.admin_ticket_detail', ticket_id=ticket.id) }}"
class="btn btn-sm btn-outline-primary">
<i class="bi bi-eye me-1"></i>View
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{# Pagination #}
{% if tickets.pages > 1 %}
<nav class="mt-3">
<ul class="pagination justify-content-center mb-0">
<li class="page-item {% if not tickets.has_prev %}disabled{% endif %}">
<a class="page-link"
href="{{ url_for('support.admin_tickets', page=tickets.prev_num, status=status_filter) }}">
&laquo; Prev
</a>
</li>
{% for p in tickets.iter_pages(left_edge=1, right_edge=1, left_current=2, right_current=2) %}
{% if p %}
<li class="page-item {% if p == tickets.page %}active{% endif %}">
<a class="page-link"
href="{{ url_for('support.admin_tickets', page=p, status=status_filter) }}">{{ p }}</a>
</li>
{% else %}
<li class="page-item disabled"><span class="page-link"></span></li>
{% endif %}
{% endfor %}
<li class="page-item {% if not tickets.has_next %}disabled{% endif %}">
<a class="page-link"
href="{{ url_for('support.admin_tickets', page=tickets.next_num, status=status_filter) }}">
Next &raquo;
</a>
</li>
</ul>
</nav>
{% endif %}
{% else %}
<div class="text-center text-muted py-5">
<i class="bi bi-inbox fs-1 d-block mb-2"></i>
No tickets{% if status_filter %} with status "{{ status_filter }}"{% endif %}.
</div>
{% endif %}
{% endblock %}
+277
View File
@@ -0,0 +1,277 @@
{% extends "base.html" %}
{% block title %}Support Chat{% endblock %}
{% block extra_css %}
<style>
#chat-window {
height: 420px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: .75rem;
padding: 1rem;
background: #f8f9fa;
}
.msg-bubble {
max-width: 80%;
padding: .6rem .9rem;
border-radius: 1rem;
font-size: .9rem;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-word;
}
.msg-user { background:#0d6efd; color:#fff; border-bottom-right-radius:.25rem; align-self:flex-end; }
.msg-ai { background:#fff; border:1px solid #dee2e6; border-bottom-left-radius:.25rem; align-self:flex-start; }
.msg-system { background:#fff3cd; border:1px solid #ffc107; border-radius:.5rem; align-self:center;
font-size:.8rem; text-align:center; padding:.4rem .8rem; color:#664d03; }
.typing-dot { display:inline-block; width:8px; height:8px; border-radius:50%;
background:#adb5bd; margin:0 2px; animation:blink 1.2s infinite; }
.typing-dot:nth-child(2) { animation-delay:.2s; }
.typing-dot:nth-child(3) { animation-delay:.4s; }
@keyframes blink { 0%,80%,100%{opacity:.2} 40%{opacity:1} }
.faq-btn { font-size:.82rem; }
</style>
{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-lg-8">
{# ── Header ── #}
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<h4 class="mb-0"><i class="bi bi-chat-dots me-2 text-primary"></i>JQC Support Chat</h4>
<small class="text-muted">Ask a question or browse common topics below</small>
</div>
<button class="btn btn-outline-danger btn-sm" data-bs-toggle="modal" data-bs-target="#submitModal">
<i class="bi bi-envelope me-1"></i>Submit to Support
</button>
</div>
{# ── Chat card ── #}
<div class="card shadow-sm">
{# Message window #}
<div id="chat-window"></div>
{# FAQ quick-reply chips #}
<div id="faq-section" class="px-3 pt-2 pb-1 border-top bg-white">
<p class="small text-muted mb-2"><i class="bi bi-lightning-charge me-1"></i>Common questions:</p>
<div class="d-flex flex-wrap gap-2 mb-2">
{% for faq in faqs %}
<button class="btn btn-outline-secondary btn-sm faq-btn"
onclick="sendFaq(this, {{ faq.text | tojson }})">
<i class="{{ faq.icon }} me-1"></i>{{ faq.text }}
</button>
{% endfor %}
</div>
</div>
{# Input row #}
<div class="p-3 border-top bg-white">
<div class="input-group">
<input id="chat-input" type="text" class="form-control"
placeholder="Type your question…" maxlength="500"
{% if not groq_ready %}disabled title="AI assistant not configured"{% endif %}>
<button id="send-btn" class="btn btn-primary" onclick="sendUserMessage()"
{% if not groq_ready %}disabled{% endif %}>
<i class="bi bi-send"></i>
</button>
</div>
{% if not groq_ready %}
<div class="text-muted small mt-1">
<i class="bi bi-info-circle me-1"></i>
AI assistant is not configured. Please
<a href="#" data-bs-toggle="modal" data-bs-target="#submitModal">submit a request</a>
to reach our team.
</div>
{% endif %}
</div>
</div>
<p class="text-muted small mt-2 text-center">
Can't find what you need?
<a href="#" data-bs-toggle="modal" data-bs-target="#submitModal">Submit a support request</a>
and our team will respond by email.
</p>
</div>
</div>
{# ── Submit to Support modal ── #}
<div class="modal fade" id="submitModal" tabindex="-1" aria-labelledby="submitModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="submitModalLabel">
<i class="bi bi-envelope me-2"></i>Submit Support Request
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<form method="post" action="{{ url_for('support.submit_ticket') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="modal-body">
<div class="mb-3">
<label class="form-label fw-semibold">Subject <span class="text-danger">*</span></label>
<input type="text" name="subject" class="form-control" required maxlength="200"
placeholder="Briefly describe your issue">
</div>
{% if facilities %}
<div class="mb-3">
<label class="form-label fw-semibold">Related Facility</label>
<select name="facility_id" class="form-select">
<option value="">— Not facility-specific —</option>
{% for f in facilities %}
<option value="{{ f.id }}">{{ f.name }}</option>
{% endfor %}
</select>
</div>
{% endif %}
<div class="mb-3">
<label class="form-label fw-semibold">Description <span class="text-danger">*</span></label>
<textarea name="body" class="form-control" rows="5" required
placeholder="Please describe your question or concern in detail…"></textarea>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary">
<i class="bi bi-send me-1"></i>Submit Request
</button>
</div>
</form>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
(function () {
'use strict';
const chatWindow = document.getElementById('chat-window');
const chatInput = document.getElementById('chat-input');
const faqSection = document.getElementById('faq-section');
const CSRF_TOKEN = '{{ csrf_token() }}';
// In-memory conversation history sent to the server with each message
let history = [];
// ── Greeting on page load ──────────────────────────────────────────────
appendMessage('ai', "👋 Hi {{ current_user.display_name }}! I'm your JQC support assistant. " +
"I can help you with inspections, issues, reports, and more. " +
"Click a question below or type your own.");
// ── Append a message bubble ────────────────────────────────────────────
function appendMessage(role, text) {
const div = document.createElement('div');
div.className = 'msg-bubble msg-' + role;
div.textContent = text;
chatWindow.appendChild(div);
chatWindow.scrollTop = chatWindow.scrollHeight;
return div;
}
function appendSystem(text) {
const div = document.createElement('div');
div.className = 'msg-bubble msg-system';
div.textContent = text;
chatWindow.appendChild(div);
chatWindow.scrollTop = chatWindow.scrollHeight;
}
// ── Typing indicator ───────────────────────────────────────────────────
function showTyping() {
const div = document.createElement('div');
div.id = 'typing-indicator';
div.className = 'msg-bubble msg-ai';
div.innerHTML = '<span class="typing-dot"></span><span class="typing-dot"></span><span class="typing-dot"></span>';
chatWindow.appendChild(div);
chatWindow.scrollTop = chatWindow.scrollHeight;
}
function hideTyping() {
const el = document.getElementById('typing-indicator');
if (el) el.remove();
}
// ── Send message to Groq ───────────────────────────────────────────────
function sendMessage(text) {
if (!text.trim()) return;
// Hide FAQ chips after first interaction
if (faqSection) faqSection.style.display = 'none';
appendMessage('user', text);
history.push({ role: 'user', content: text });
chatInput.value = '';
chatInput.disabled = true;
document.getElementById('send-btn').disabled = true;
showTyping();
fetch('{{ url_for("support.chat_message") }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': CSRF_TOKEN,
},
body: JSON.stringify({ message: text, messages: history }),
})
.then(function (r) { return r.json(); })
.then(function (data) {
hideTyping();
const reply = data.reply || 'Sorry, I could not process your request.';
appendMessage('ai', reply);
history.push({ role: 'assistant', content: reply });
// Suggest escalation if the AI hints it can't help
const lower = reply.toLowerCase();
if (lower.includes('submit to support') || lower.includes('admin team') ||
lower.includes("can't resolve") || lower.includes('contact us')) {
appendSystem('💡 Tip: Click "Submit to Support" above to send a message directly to our team.');
}
})
.catch(function () {
hideTyping();
appendMessage('ai', 'Network error — please check your connection and try again.');
})
.finally(function () {
chatInput.disabled = false;
document.getElementById('send-btn').disabled = false;
chatInput.focus();
});
}
// ── Public helpers called from inline onclick ──────────────────────────
window.sendUserMessage = function () {
sendMessage(chatInput.value);
};
window.sendFaq = function (btn, text) {
btn.disabled = true;
sendMessage(text);
};
// ── Enter key support ──────────────────────────────────────────────────
if (chatInput) {
chatInput.addEventListener('keydown', function (e) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendMessage(chatInput.value);
}
});
}
// ── Pre-fill modal subject from last user message ──────────────────────
document.getElementById('submitModal').addEventListener('show.bs.modal', function () {
const subjectInput = this.querySelector('[name="subject"]');
if (subjectInput && !subjectInput.value && history.length) {
const lastUser = [...history].reverse().find(function (m) { return m.role === 'user'; });
if (lastUser) subjectInput.value = lastUser.content.slice(0, 200);
}
});
}());
</script>
{% endblock %}