Jul 10 - Update codes to catch up with the single tenant project (Medium)

This commit is contained in:
2026-07-10 12:58:57 -04:00
parent e41998b561
commit aa749107c7
12 changed files with 815 additions and 68 deletions
@@ -0,0 +1,83 @@
{% extends "base.html" %}
{% block title %}AI Chat Conversations{% endblock %}
{% block content %}
<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>AI Chat Conversations</h4>
<small class="text-muted">{{ sessions.total }} conversation{{ 's' if sessions.total != 1 }}</small>
</div>
<div>
<a href="{{ url_for('support.admin_tickets') }}" class="btn btn-outline-secondary btn-sm me-1">
<i class="bi bi-inbox me-1"></i>Tickets
</a>
<a href="{{ url_for('support.admin_knowledge') }}" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-book me-1"></i>Knowledge Base
</a>
</div>
</div>
{% if sessions.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>#</th>
<th>Customer</th>
<th>Topic</th>
<th>Messages</th>
<th>Last Activity</th>
<th></th>
</tr>
</thead>
<tbody>
{% for s in sessions.items %}
<tr>
<td class="text-muted small">{{ s.id }}</td>
<td>{{ s.customer.display_name if s.customer else '<em class="text-muted">deleted</em>' | safe }}</td>
<td>{{ (s.title or '—')[:80] }}</td>
<td class="text-muted small">{{ s.messages | length }}</td>
<td class="text-muted small text-nowrap">{{ s.last_msg_at.strftime('%b %d, %Y %I:%M %p') }}</td>
<td>
<a href="{{ url_for('support.admin_conversation_detail', session_id=s.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>
{% if sessions.pages > 1 %}
<nav class="mt-3">
<ul class="pagination justify-content-center mb-0">
<li class="page-item {% if not sessions.has_prev %}disabled{% endif %}">
<a class="page-link" href="{{ url_for('support.admin_conversations', page=sessions.prev_num) }}">&laquo; Prev</a>
</li>
{% for p in sessions.iter_pages(left_edge=1, right_edge=1, left_current=2, right_current=2) %}
{% if p %}
<li class="page-item {% if p == sessions.page %}active{% endif %}">
<a class="page-link" href="{{ url_for('support.admin_conversations', page=p) }}">{{ p }}</a>
</li>
{% else %}
<li class="page-item disabled"><span class="page-link"></span></li>
{% endif %}
{% endfor %}
<li class="page-item {% if not sessions.has_next %}disabled{% endif %}">
<a class="page-link" href="{{ url_for('support.admin_conversations', page=sessions.next_num) }}">Next &raquo;</a>
</li>
</ul>
</nav>
{% endif %}
{% else %}
<div class="text-center text-muted py-5">
<i class="bi bi-chat-dots fs-1 d-block mb-2"></i>
No AI chat conversations yet.
</div>
{% endif %}
{% endblock %}
+104
View File
@@ -0,0 +1,104 @@
{% extends "base.html" %}
{% block title %}Support Knowledge Base{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<h4 class="mb-0"><i class="bi bi-book me-2 text-primary"></i>Support Knowledge Base</h4>
<small class="text-muted">Active entries are injected into the AI chatbot system prompt</small>
</div>
<div>
<a href="{{ url_for('support.admin_tickets') }}" class="btn btn-outline-secondary btn-sm me-1">
<i class="bi bi-inbox me-1"></i>Tickets
</a>
<a href="{{ url_for('support.admin_conversations') }}" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-chat-dots me-1"></i>Conversations
</a>
</div>
</div>
{# Add new entry form #}
<div class="card shadow-sm mb-4">
<div class="card-header fw-semibold"><i class="bi bi-plus-circle me-2"></i>Add Knowledge Entry</div>
<div class="card-body">
<form method="post" action="{{ url_for('support.admin_knowledge_add') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-3">
<label class="form-label fw-semibold">Title <span class="text-danger">*</span></label>
<input type="text" name="title" class="form-control" maxlength="200" required
placeholder="e.g. Contract Hours, Emergency Contacts, Service Scope">
</div>
<div class="mb-3">
<label class="form-label fw-semibold">Content <span class="text-danger">*</span></label>
<textarea name="body" class="form-control" rows="4" required
placeholder="Add facts, FAQs, or instructions the AI should know about this customer's account…"></textarea>
<div class="form-text">Keep entries focused and factual. Combined active entries are capped at 6,000 characters.</div>
</div>
<button type="submit" class="btn btn-primary btn-sm">
<i class="bi bi-plus me-1"></i>Add Entry
</button>
</form>
</div>
</div>
{# Entry list #}
{% if entries %}
<div class="card shadow-sm">
<div class="card-header fw-semibold">
{{ entries | length }} entr{{ 'ies' if entries | length != 1 else 'y' }}
({{ entries | selectattr('active') | list | length }} active)
</div>
<div class="list-group list-group-flush">
{% for entry in entries %}
<div class="list-group-item">
<div class="d-flex justify-content-between align-items-start">
<div class="flex-grow-1 me-3">
<div class="d-flex align-items-center gap-2 mb-1">
<span class="fw-semibold">{{ entry.title }}</span>
{% if entry.active %}
<span class="badge bg-success">Active</span>
{% else %}
<span class="badge bg-secondary">Inactive</span>
{% endif %}
</div>
<p class="mb-1 text-muted small" style="white-space:pre-wrap;">{{ entry.body[:300] }}{% if entry.body | length > 300 %}…{% endif %}</p>
<div class="text-muted" style="font-size:.75rem;">
Added {{ entry.created_at.strftime('%b %d, %Y') }}
{% if entry.creator %} by {{ entry.creator.display_name }}{% endif %}
{% if entry.updated_at != entry.created_at %}
· Updated {{ entry.updated_at.strftime('%b %d, %Y') }}
{% endif %}
</div>
</div>
<div class="d-flex gap-1 flex-shrink-0">
<a href="{{ url_for('support.admin_knowledge_edit', entry_id=entry.id) }}"
class="btn btn-sm btn-outline-secondary">
<i class="bi bi-pencil"></i>
</a>
<form method="post" action="{{ url_for('support.admin_knowledge_toggle', entry_id=entry.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-sm {{ 'btn-outline-warning' if entry.active else 'btn-outline-success' }}"
title="{{ 'Deactivate' if entry.active else 'Activate' }}">
<i class="bi bi-{{ 'pause' if entry.active else 'play' }}"></i>
</button>
</form>
<form method="post" action="{{ url_for('support.admin_knowledge_delete', entry_id=entry.id) }}"
onsubmit="return confirm('Delete this knowledge entry? The AI will no longer have this context.');">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-sm btn-outline-danger" title="Delete">
<i class="bi bi-trash"></i>
</button>
</form>
</div>
</div>
</div>
{% endfor %}
</div>
</div>
{% else %}
<div class="text-center text-muted py-5">
<i class="bi bi-book fs-1 d-block mb-2"></i>
No knowledge entries yet. Add context above to improve the AI assistant's answers.
</div>
{% endif %}
{% endblock %}
@@ -0,0 +1,35 @@
{% extends "base.html" %}
{% block title %}Edit Knowledge Entry{% endblock %}
{% block content %}
<div class="mb-3">
<a href="{{ url_for('support.admin_knowledge') }}" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-arrow-left me-1"></i>Knowledge Base
</a>
</div>
<div class="card shadow-sm" style="max-width:700px;">
<div class="card-header fw-semibold"><i class="bi bi-pencil me-2"></i>Edit Knowledge Entry</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 fw-semibold">Title <span class="text-danger">*</span></label>
<input type="text" name="title" class="form-control" maxlength="200" required
value="{{ entry.title }}">
</div>
<div class="mb-3">
<label class="form-label fw-semibold">Content <span class="text-danger">*</span></label>
<textarea name="body" class="form-control" rows="8" required>{{ entry.body }}</textarea>
<div class="form-text">Combined active entries are capped at 6,000 characters in the AI prompt.</div>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">
<i class="bi bi-check-lg me-1"></i>Save Changes
</button>
<a href="{{ url_for('support.admin_knowledge') }}" class="btn btn-outline-secondary">Cancel</a>
</div>
</form>
</div>
</div>
{% endblock %}
+8
View File
@@ -7,6 +7,14 @@
<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 class="d-flex gap-2">
<a href="{{ url_for('support.admin_conversations') }}" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-chat-dots me-1"></i>AI Conversations
</a>
<a href="{{ url_for('support.admin_knowledge') }}" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-book me-1"></i>Knowledge Base
</a>
</div>
</div>
{# Status filter tabs #}
+46 -22
View File
@@ -42,11 +42,23 @@
<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>
<small class="text-muted">
{% if chat_session %}
Conversation from {{ chat_session.created_at.strftime('%b %d, %Y') }}
· <a href="{{ url_for('support.chat') }}">New Chat</a>
{% else %}
Ask a question or browse common topics below
{% endif %}
</small>
</div>
<div class="d-flex gap-2">
<a href="{{ url_for('support.my_conversations') }}" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-clock-history me-1"></i>History
</a>
<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>
<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 ── #}
@@ -54,8 +66,9 @@
{# 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">
{# FAQ quick-reply chips — hidden when viewing a prior session #}
<div id="faq-section" class="px-3 pt-2 pb-1 border-top bg-white"
{% if db_history %}style="display:none;"{% endif %}>
<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 %}
@@ -114,7 +127,7 @@
<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"
<input type="text" name="subject" id="ticketSubject" class="form-control" required maxlength="200"
placeholder="Briefly describe your issue">
</div>
{% if facilities %}
@@ -156,14 +169,23 @@
const faqSection = document.getElementById('faq-section');
const CSRF_TOKEN = '{{ csrf_token() }}';
// In-memory conversation history sent to the server with each message
let history = [];
// Persisted session ID — returned by the server on first message, sent on all subsequent ones
let session_id = {{ (chat_session.id if chat_session else none) | tojson }};
// ── Greeting on page load ──────────────────────────────────────────────
// Last user message — used to pre-fill the support ticket subject
let last_user_msg = '';
// ── Render DB history on page load ────────────────────────────────────
var userName = {{ current_user.display_name | tojson }};
appendMessage('ai', "👋 Hi " + userName + "! I'm your JQC support assistant. " +
"I can help you with inspections, issues, reports, and more. " +
"Click a question below or type your own.");
{% if db_history %}
{% for msg in db_history %}
appendMessage({{ msg.role | tojson }}, {{ msg.content | tojson }});
{% endfor %}
{% else %}
appendMessage('ai', "👋 Hi " + userName + "! I'm your JQC support assistant. " +
"I can help you with inspections, issues, reports, and more. " +
"Click a question below or type your own.");
{% endif %}
// ── Append a message bubble ────────────────────────────────────────────
function appendMessage(role, text) {
@@ -198,17 +220,18 @@
if (el) el.remove();
}
// ── Send message to Groq ───────────────────────────────────────────────
// ── Send message to server ─────────────────────────────────────────────
function sendMessage(text) {
if (!text.trim()) return;
last_user_msg = text;
// Hide FAQ chips after first interaction
if (faqSection) faqSection.style.display = 'none';
appendMessage('user', text);
history.push({ role: 'user', content: text });
chatInput.value = '';
chatInput.value = '';
chatInput.disabled = true;
document.getElementById('send-btn').disabled = true;
showTyping();
@@ -219,14 +242,16 @@
'Content-Type': 'application/json',
'X-CSRFToken': CSRF_TOKEN,
},
body: JSON.stringify({ message: text, messages: history }),
body: JSON.stringify({ message: text, session_id: session_id }),
})
.then(function (r) { return r.json(); })
.then(function (data) {
hideTyping();
// Store session_id returned by the server for subsequent messages
if (data.session_id) session_id = data.session_id;
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();
@@ -268,10 +293,9 @@
// ── 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);
const subjectInput = document.getElementById('ticketSubject');
if (subjectInput && !subjectInput.value && last_user_msg) {
subjectInput.value = last_user_msg.slice(0, 200);
}
});
}());
@@ -0,0 +1,78 @@
{% extends "base.html" %}
{% block title %}Conversation #{{ chat_session.id }}{% endblock %}
{% block extra_css %}
<style>
.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:#f8f9fa; border:1px solid #dee2e6; border-bottom-left-radius:.25rem; align-self:flex-start; }
.msg-row { display:flex; flex-direction:column; gap:.75rem; }
</style>
{% endblock %}
{% block content %}
<div class="mb-3">
{% if is_admin %}
<a href="{{ url_for('support.admin_conversations') }}" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-arrow-left me-1"></i>All Conversations
</a>
{% else %}
<a href="{{ url_for('support.my_conversations') }}" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-arrow-left me-1"></i>My Conversations
</a>
{% endif %}
</div>
<div class="row g-4">
<div class="col-lg-8">
<div class="card shadow-sm">
<div class="card-header d-flex justify-content-between align-items-center">
<strong>{{ chat_session.title or 'Conversation #' ~ chat_session.id }}</strong>
<small class="text-muted">{{ messages | length }} messages</small>
</div>
<div class="card-body">
{% if messages %}
<div class="msg-row">
{% for msg in messages %}
<div class="msg-bubble msg-{{ msg.role }}">
{{ msg.content }}
</div>
{% endfor %}
</div>
{% else %}
<div class="text-muted text-center py-3">No messages in this conversation.</div>
{% endif %}
</div>
</div>
</div>
<div class="col-lg-4">
<div class="card shadow-sm">
<div class="card-header"><i class="bi bi-info-circle me-2"></i>Details</div>
<div class="card-body small">
{% if is_admin and chat_session.customer %}
<p class="mb-1"><strong>Customer:</strong> {{ chat_session.customer.display_name }}</p>
<p class="mb-2 text-muted">{{ chat_session.customer.email }}</p>
<hr class="my-2">
{% endif %}
<p class="mb-1"><strong>Started:</strong> {{ chat_session.created_at.strftime('%b %d, %Y %I:%M %p') }}</p>
<p class="mb-2"><strong>Last message:</strong> {{ chat_session.last_msg_at.strftime('%b %d, %Y %I:%M %p') }}</p>
{% if not is_admin %}
<a href="{{ url_for('support.chat', session_id=chat_session.id) }}"
class="btn btn-primary btn-sm w-100 mt-2">
<i class="bi bi-chat me-1"></i>Continue This Chat
</a>
{% endif %}
</div>
</div>
</div>
</div>
{% endblock %}
@@ -0,0 +1,60 @@
{% extends "base.html" %}
{% block title %}My Chat History{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<h4 class="mb-0"><i class="bi bi-clock-history me-2 text-primary"></i>My Chat History</h4>
<small class="text-muted">{{ sessions|length }} conversation{{ 's' if sessions|length != 1 }}</small>
</div>
<a href="{{ url_for('support.chat') }}" class="btn btn-primary btn-sm">
<i class="bi bi-plus-circle me-1"></i>New Chat
</a>
</div>
{% if sessions %}
<div class="card shadow-sm">
<div class="table-responsive">
<table class="table table-hover mb-0 align-middle">
<thead class="table-light">
<tr>
<th>Topic</th>
<th>Last Activity</th>
<th>Messages</th>
<th></th>
</tr>
</thead>
<tbody>
{% for s in sessions %}
<tr>
<td>
<span class="fw-semibold">{{ s.title or 'Conversation' }}</span>
</td>
<td class="text-muted small text-nowrap">
{{ s.last_msg_at.strftime('%b %d, %Y %I:%M %p') }}
</td>
<td class="text-muted small">{{ s.messages | length }}</td>
<td class="text-nowrap">
<a href="{{ url_for('support.my_conversation_detail', session_id=s.id) }}"
class="btn btn-sm btn-outline-secondary me-1">
<i class="bi bi-eye me-1"></i>View
</a>
<a href="{{ url_for('support.chat', session_id=s.id) }}"
class="btn btn-sm btn-outline-primary">
<i class="bi bi-chat me-1"></i>Continue
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% else %}
<div class="text-center text-muted py-5">
<i class="bi bi-chat-dots fs-1 d-block mb-2"></i>
No conversations yet.
<a href="{{ url_for('support.chat') }}">Start a chat</a> with our AI assistant.
</div>
{% endif %}
{% endblock %}
+8 -3
View File
@@ -7,9 +7,14 @@
<h4 class="mb-0"><i class="bi bi-inbox me-2 text-primary"></i>My Support Requests</h4>
<small class="text-muted">{{ tickets | length }} request{{ 's' if tickets | length != 1 }}</small>
</div>
<a href="{{ url_for('support.chat') }}" class="btn btn-outline-primary btn-sm">
<i class="bi bi-chat-dots me-1"></i>New Chat / Ask a Question
</a>
<div class="d-flex gap-2">
<a href="{{ url_for('support.my_conversations') }}" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-clock-history me-1"></i>Chat History
</a>
<a href="{{ url_for('support.chat') }}" class="btn btn-outline-primary btn-sm">
<i class="bi bi-chat-dots me-1"></i>New Chat
</a>
</div>
</div>
{% if tickets %}