06/09 Update a customer AI chatbot and support form submision
This commit is contained in:
+9
-1
@@ -116,13 +116,19 @@ def create_app(config_name='default'):
|
|||||||
pv_count = Issue.query.filter_by(
|
pv_count = Issue.query.filter_by(
|
||||||
status='pending_verification'
|
status='pending_verification'
|
||||||
).count()
|
).count()
|
||||||
|
# Open support tickets — admin/director only
|
||||||
|
open_support = 0
|
||||||
|
if current_user.role in ('admin', 'director'):
|
||||||
|
from app.models.support import SupportTicket
|
||||||
|
open_support = SupportTicket.query.filter_by(status='open').count()
|
||||||
return {
|
return {
|
||||||
'unread_notification_count': unread,
|
'unread_notification_count': unread,
|
||||||
'pending_verification_count': pv_count,
|
'pending_verification_count': pv_count,
|
||||||
|
'open_support_tickets_count': open_support,
|
||||||
}
|
}
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
return {'unread_notification_count': 0, 'pending_verification_count': 0}
|
return {'unread_notification_count': 0, 'pending_verification_count': 0, 'open_support_tickets_count': 0}
|
||||||
|
|
||||||
os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True)
|
os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True)
|
||||||
|
|
||||||
@@ -133,6 +139,7 @@ def create_app(config_name='default'):
|
|||||||
from app.routes import projects # Phase 1/2 — Project management
|
from app.routes import projects # Phase 1/2 — Project management
|
||||||
from app.routes import customers # Phase 5 — Customer management
|
from app.routes import customers # Phase 5 — Customer management
|
||||||
from app.routes import scheduled_reports # Phase 6 — Scheduled reports
|
from app.routes import scheduled_reports # Phase 6 — Scheduled reports
|
||||||
|
from app.routes import support # Support chat + admin tickets
|
||||||
|
|
||||||
app.register_blueprint(auth.bp)
|
app.register_blueprint(auth.bp)
|
||||||
app.register_blueprint(dashboard.bp)
|
app.register_blueprint(dashboard.bp)
|
||||||
@@ -146,6 +153,7 @@ def create_app(config_name='default'):
|
|||||||
app.register_blueprint(projects.bp)
|
app.register_blueprint(projects.bp)
|
||||||
app.register_blueprint(customers.bp)
|
app.register_blueprint(customers.bp)
|
||||||
app.register_blueprint(scheduled_reports.bp)
|
app.register_blueprint(scheduled_reports.bp)
|
||||||
|
app.register_blueprint(support.bp)
|
||||||
|
|
||||||
# ── Mobile API (Phase 7 / Phase A / Phase B / Phase C) ───────────────────
|
# ── Mobile API (Phase 7 / Phase A / Phase B / Phase C) ───────────────────
|
||||||
# The /api/v1 blueprint group uses JWT Bearer tokens — no CSRF cookies needed.
|
# The /api/v1 blueprint group uses JWT Bearer tokens — no CSRF cookies needed.
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
from app import db
|
||||||
|
from app.utils.time_utils import now_eastern
|
||||||
|
|
||||||
|
|
||||||
|
class SupportTicket(db.Model):
|
||||||
|
__tablename__ = 'support_tickets'
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
customer_id = db.Column(db.Integer, db.ForeignKey('users.id', ondelete='SET NULL'), nullable=True)
|
||||||
|
facility_id = db.Column(db.Integer, db.ForeignKey('facilities.id', ondelete='SET NULL'), nullable=True)
|
||||||
|
subject = db.Column(db.String(200), nullable=False)
|
||||||
|
body = db.Column(db.Text, nullable=False)
|
||||||
|
status = db.Column(db.String(20), nullable=False, default='open') # open / answered / closed
|
||||||
|
created_at = db.Column(db.DateTime, default=now_eastern, nullable=False)
|
||||||
|
|
||||||
|
customer = db.relationship('User', foreign_keys=[customer_id], backref='support_tickets')
|
||||||
|
facility = db.relationship('Facility', foreign_keys=[facility_id], backref='support_tickets')
|
||||||
|
replies = db.relationship(
|
||||||
|
'SupportTicketReply', backref='ticket',
|
||||||
|
cascade='all, delete-orphan',
|
||||||
|
order_by='SupportTicketReply.created_at',
|
||||||
|
lazy='dynamic',
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f'<SupportTicket {self.id} [{self.status}]>'
|
||||||
|
|
||||||
|
|
||||||
|
class SupportTicketReply(db.Model):
|
||||||
|
__tablename__ = 'support_ticket_replies'
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
ticket_id = db.Column(db.Integer, db.ForeignKey('support_tickets.id', ondelete='CASCADE'), nullable=False)
|
||||||
|
user_id = db.Column(db.Integer, db.ForeignKey('users.id', ondelete='SET NULL'), nullable=True)
|
||||||
|
body = db.Column(db.Text, nullable=False)
|
||||||
|
created_at = db.Column(db.DateTime, default=now_eastern, nullable=False)
|
||||||
|
|
||||||
|
author = db.relationship('User', foreign_keys=[user_id])
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f'<SupportTicketReply {self.id} ticket={self.ticket_id}>'
|
||||||
@@ -0,0 +1,338 @@
|
|||||||
|
import os
|
||||||
|
import logging
|
||||||
|
import threading
|
||||||
|
|
||||||
|
from flask import (Blueprint, render_template, redirect, url_for,
|
||||||
|
flash, request, current_app, jsonify, abort)
|
||||||
|
from flask_login import login_required, current_user
|
||||||
|
from flask_mail import Message
|
||||||
|
|
||||||
|
from app import db, mail
|
||||||
|
from app.models.support import SupportTicket, SupportTicketReply
|
||||||
|
from app.models.user import User
|
||||||
|
from app.models.facility import Facility
|
||||||
|
from app.utils.decorators import supervisor_required
|
||||||
|
from app.utils.scope import get_customer_scope
|
||||||
|
from app.utils.audit import log_action, ACTION_CREATE, ACTION_UPDATE
|
||||||
|
from app.utils.time_utils import now_eastern
|
||||||
|
|
||||||
|
bp = Blueprint('support', __name__, url_prefix='/support')
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# ── Groq system prompt ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_SYSTEM_PROMPT = """\
|
||||||
|
You are JQC Support, a friendly assistant for customers of JQC (Janitorial Quality Control), \
|
||||||
|
a commercial cleaning quality management platform.
|
||||||
|
|
||||||
|
Help customers with:
|
||||||
|
- Navigating the portal: Dashboard, Inspections, Issues, Reports pages
|
||||||
|
- Inspection scores: 90%+ = Excellent, 70-89% = Satisfactory, below 70% = Needs Improvement
|
||||||
|
- SLA timelines: Critical issues = 4 h, High = 24 h, Medium = 72 h, Low = 168 h
|
||||||
|
- Issue statuses: Open → In Progress → Pending Verification → Resolved
|
||||||
|
- Following issues to receive email/in-app update notifications
|
||||||
|
- Reporting new cleaning concerns via the Issues > Log Issue page
|
||||||
|
- Understanding facility scorecards and trend charts in Reports
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- Keep answers concise (3-5 sentences max) and friendly.
|
||||||
|
- Never invent specific staff names, contract prices, schedules, or contact numbers.
|
||||||
|
- If the customer has an access problem, billing question, or a concern you genuinely \
|
||||||
|
cannot resolve through guidance, say so clearly and suggest they click \
|
||||||
|
"Submit to Support" to reach the admin team directly.\
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Preset FAQ questions shown as quick-reply chips on first load
|
||||||
|
FAQS = [
|
||||||
|
{'icon': 'bi-clipboard-check', 'text': 'How do I view my inspection reports?'},
|
||||||
|
{'icon': 'bi-graph-up', 'text': 'What do inspection scores mean?'},
|
||||||
|
{'icon': 'bi-exclamation-circle','text': 'How do I track an open issue?'},
|
||||||
|
{'icon': 'bi-megaphone', 'text': 'How do I report a cleaning concern?'},
|
||||||
|
{'icon': 'bi-alarm', 'text': 'What is SLA and how does it work?'},
|
||||||
|
{'icon': 'bi-bell', 'text': 'How do I get notified on issue updates?'},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ── Customer chat page ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@bp.route('/chat')
|
||||||
|
@login_required
|
||||||
|
def chat():
|
||||||
|
if current_user.role != 'customer':
|
||||||
|
return redirect(url_for('support.admin_tickets'))
|
||||||
|
|
||||||
|
cids = get_customer_scope(current_user) or []
|
||||||
|
facilities = (Facility.query
|
||||||
|
.filter(Facility.id.in_(cids), Facility.active == True)
|
||||||
|
.order_by(Facility.name).all()) if cids else []
|
||||||
|
|
||||||
|
groq_ready = bool(os.environ.get('GROQ_API_KEY'))
|
||||||
|
return render_template('support/chat.html',
|
||||||
|
faqs=FAQS,
|
||||||
|
facilities=facilities,
|
||||||
|
groq_ready=groq_ready)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Groq chat AJAX endpoint ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@bp.route('/chat/message', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def chat_message():
|
||||||
|
if current_user.role != 'customer':
|
||||||
|
return jsonify({'error': 'Forbidden'}), 403
|
||||||
|
|
||||||
|
api_key = os.environ.get('GROQ_API_KEY')
|
||||||
|
if not api_key:
|
||||||
|
return jsonify({'reply': (
|
||||||
|
"I'm sorry, the AI assistant isn't configured right now. "
|
||||||
|
"Please use the **Submit to Support** form to reach our team directly."
|
||||||
|
)})
|
||||||
|
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
history = data.get('messages', []) # list of {role, content} dicts
|
||||||
|
user_message = data.get('message', '').strip()
|
||||||
|
|
||||||
|
if not user_message:
|
||||||
|
return jsonify({'error': 'Empty message'}), 400
|
||||||
|
|
||||||
|
try:
|
||||||
|
from groq import Groq
|
||||||
|
client = Groq(api_key=api_key)
|
||||||
|
|
||||||
|
messages = [{'role': 'system', 'content': _SYSTEM_PROMPT}]
|
||||||
|
# Append prior conversation (cap at last 20 turns to control token usage)
|
||||||
|
for m in history[-20:]:
|
||||||
|
if m.get('role') in ('user', 'assistant') and m.get('content'):
|
||||||
|
messages.append({'role': m['role'], 'content': m['content']})
|
||||||
|
messages.append({'role': 'user', 'content': user_message})
|
||||||
|
|
||||||
|
model = os.environ.get('GROQ_MODEL', 'llama-3.3-70b-versatile')
|
||||||
|
completion = client.chat.completions.create(
|
||||||
|
model=model,
|
||||||
|
messages=messages,
|
||||||
|
max_tokens=512,
|
||||||
|
temperature=0.5,
|
||||||
|
)
|
||||||
|
reply = completion.choices[0].message.content.strip()
|
||||||
|
return jsonify({'reply': reply})
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error('SUPPORT | Groq error: %s', exc)
|
||||||
|
return jsonify({'reply': (
|
||||||
|
"I ran into a problem reaching the AI assistant. "
|
||||||
|
"Please try again, or use **Submit to Support** to contact our team."
|
||||||
|
)})
|
||||||
|
|
||||||
|
|
||||||
|
# ── Submit support ticket ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@bp.route('/tickets', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def submit_ticket():
|
||||||
|
if current_user.role != 'customer':
|
||||||
|
abort(403)
|
||||||
|
|
||||||
|
subject = request.form.get('subject', '').strip()
|
||||||
|
body = request.form.get('body', '').strip()
|
||||||
|
facility_id = request.form.get('facility_id', type=int)
|
||||||
|
|
||||||
|
if not subject or not body:
|
||||||
|
flash('Please fill in both subject and description.', 'warning')
|
||||||
|
return redirect(url_for('support.chat'))
|
||||||
|
|
||||||
|
# Validate facility belongs to this customer
|
||||||
|
cids = get_customer_scope(current_user) or []
|
||||||
|
if facility_id and facility_id not in cids:
|
||||||
|
facility_id = None
|
||||||
|
|
||||||
|
ticket = SupportTicket(
|
||||||
|
customer_id = current_user.id,
|
||||||
|
facility_id = facility_id,
|
||||||
|
subject = subject,
|
||||||
|
body = body,
|
||||||
|
status = 'open',
|
||||||
|
created_at = now_eastern(),
|
||||||
|
)
|
||||||
|
db.session.add(ticket)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
log_action(ACTION_CREATE, 'SupportTicket', ticket.id,
|
||||||
|
f'#{ticket.id}: {subject[:60]}',
|
||||||
|
f'customer={current_user.username}')
|
||||||
|
|
||||||
|
_notify_admins_new_ticket(ticket)
|
||||||
|
|
||||||
|
flash('Your message has been submitted. Our team will get back to you soon.', 'success')
|
||||||
|
return redirect(url_for('support.chat'))
|
||||||
|
|
||||||
|
|
||||||
|
def _notify_admins_new_ticket(ticket):
|
||||||
|
"""Send email notification to all active admin users in a background thread."""
|
||||||
|
admins = User.query.filter_by(role='admin', active=True).all()
|
||||||
|
if not admins:
|
||||||
|
return
|
||||||
|
|
||||||
|
base_url = current_app.config.get('APP_BASE_URL', '').rstrip('/')
|
||||||
|
ticket_url = f'{base_url}{url_for("support.admin_ticket_detail", ticket_id=ticket.id)}'
|
||||||
|
facility_label = ticket.facility.name if ticket.facility else 'N/A'
|
||||||
|
customer_label = ticket.customer.display_name if ticket.customer else 'Unknown'
|
||||||
|
|
||||||
|
subject_line = f'[JQC Support] New ticket #{ticket.id}: {ticket.subject}'
|
||||||
|
html_body = f"""\
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<body style="font-family:Arial,sans-serif;color:#333;max-width:600px;margin:auto;">
|
||||||
|
<h2 style="color:#0d6efd;">New Support Ticket #{ticket.id}</h2>
|
||||||
|
<p><strong>From:</strong> {customer_label}</p>
|
||||||
|
<p><strong>Facility:</strong> {facility_label}</p>
|
||||||
|
<p><strong>Subject:</strong> {ticket.subject}</p>
|
||||||
|
<hr style="border:none;border-top:1px solid #eee;">
|
||||||
|
<p style="white-space:pre-wrap;">{ticket.body}</p>
|
||||||
|
<hr style="border:none;border-top:1px solid #eee;">
|
||||||
|
<p>
|
||||||
|
<a href="{ticket_url}"
|
||||||
|
style="background:#0d6efd;color:#fff;padding:10px 20px;
|
||||||
|
text-decoration:none;border-radius:4px;display:inline-block;">
|
||||||
|
View & Reply
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
<p style="font-size:12px;color:#888;">JQC Support System — automated notification.</p>
|
||||||
|
</body>
|
||||||
|
</html>"""
|
||||||
|
|
||||||
|
def _send():
|
||||||
|
try:
|
||||||
|
with current_app.app_context():
|
||||||
|
for admin in admins:
|
||||||
|
msg = Message(
|
||||||
|
subject = subject_line,
|
||||||
|
recipients = [admin.email],
|
||||||
|
html = html_body,
|
||||||
|
sender = current_app.config.get('MAIL_DEFAULT_SENDER'),
|
||||||
|
)
|
||||||
|
mail.send(msg)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error('SUPPORT | email notification failed: %s', exc)
|
||||||
|
|
||||||
|
threading.Thread(target=_send, daemon=True).start()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Admin: ticket list ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@bp.route('/admin/tickets')
|
||||||
|
@login_required
|
||||||
|
@supervisor_required
|
||||||
|
def admin_tickets():
|
||||||
|
status_filter = request.args.get('status', '')
|
||||||
|
page = request.args.get('page', 1, type=int)
|
||||||
|
|
||||||
|
q = SupportTicket.query.order_by(SupportTicket.created_at.desc())
|
||||||
|
if status_filter:
|
||||||
|
q = q.filter(SupportTicket.status == status_filter)
|
||||||
|
|
||||||
|
tickets = q.paginate(page=page, per_page=25, error_out=False)
|
||||||
|
return render_template('support/admin_tickets.html',
|
||||||
|
tickets=tickets,
|
||||||
|
status_filter=status_filter)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Admin: ticket detail + reply ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
@bp.route('/admin/tickets/<int:ticket_id>', methods=['GET', 'POST'])
|
||||||
|
@login_required
|
||||||
|
@supervisor_required
|
||||||
|
def admin_ticket_detail(ticket_id):
|
||||||
|
ticket = db.session.get(SupportTicket, ticket_id)
|
||||||
|
if ticket is None:
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
if request.method == 'POST':
|
||||||
|
action = request.form.get('action')
|
||||||
|
|
||||||
|
if action == 'reply':
|
||||||
|
body = request.form.get('body', '').strip()
|
||||||
|
if not body:
|
||||||
|
flash('Reply cannot be empty.', 'warning')
|
||||||
|
return redirect(url_for('support.admin_ticket_detail', ticket_id=ticket_id))
|
||||||
|
|
||||||
|
reply = SupportTicketReply(
|
||||||
|
ticket_id = ticket.id,
|
||||||
|
user_id = current_user.id,
|
||||||
|
body = body,
|
||||||
|
created_at = now_eastern(),
|
||||||
|
)
|
||||||
|
db.session.add(reply)
|
||||||
|
# Auto-advance status to answered if still open
|
||||||
|
if ticket.status == 'open':
|
||||||
|
ticket.status = 'answered'
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
log_action(ACTION_UPDATE, 'SupportTicket', ticket.id,
|
||||||
|
f'#{ticket.id}: {ticket.subject[:60]}',
|
||||||
|
f'reply added by {current_user.username}')
|
||||||
|
|
||||||
|
_notify_customer_reply(ticket, reply)
|
||||||
|
flash('Reply sent.', 'success')
|
||||||
|
|
||||||
|
elif action == 'status':
|
||||||
|
new_status = request.form.get('status', '')
|
||||||
|
if new_status in ('open', 'answered', 'closed'):
|
||||||
|
ticket.status = new_status
|
||||||
|
db.session.commit()
|
||||||
|
log_action(ACTION_UPDATE, 'SupportTicket', ticket.id,
|
||||||
|
f'#{ticket.id}: {ticket.subject[:60]}',
|
||||||
|
f'status={new_status}')
|
||||||
|
flash(f'Ticket marked as {new_status}.', 'success')
|
||||||
|
|
||||||
|
return redirect(url_for('support.admin_ticket_detail', ticket_id=ticket_id))
|
||||||
|
|
||||||
|
replies = ticket.replies.order_by(SupportTicketReply.created_at.asc()).all()
|
||||||
|
return render_template('support/admin_ticket_detail.html',
|
||||||
|
ticket=ticket,
|
||||||
|
replies=replies)
|
||||||
|
|
||||||
|
|
||||||
|
def _notify_customer_reply(ticket, reply):
|
||||||
|
"""Email the customer when an admin replies to their ticket."""
|
||||||
|
if not ticket.customer or not ticket.customer.email:
|
||||||
|
return
|
||||||
|
|
||||||
|
base_url = current_app.config.get('APP_BASE_URL', '').rstrip('/')
|
||||||
|
chat_url = f'{base_url}{url_for("support.chat")}'
|
||||||
|
admin_name = reply.author.display_name if reply.author else 'Support Team'
|
||||||
|
|
||||||
|
html_body = f"""\
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<body style="font-family:Arial,sans-serif;color:#333;max-width:600px;margin:auto;">
|
||||||
|
<h2 style="color:#0d6efd;">Reply to Your Support Request</h2>
|
||||||
|
<p>Hi {ticket.customer.display_name},</p>
|
||||||
|
<p><strong>{admin_name}</strong> replied to your support ticket
|
||||||
|
<strong>#{ticket.id}: {ticket.subject}</strong>:</p>
|
||||||
|
<blockquote style="border-left:4px solid #0d6efd;padding-left:12px;color:#555;
|
||||||
|
white-space:pre-wrap;">{reply.body}</blockquote>
|
||||||
|
<p>
|
||||||
|
<a href="{chat_url}"
|
||||||
|
style="background:#0d6efd;color:#fff;padding:10px 20px;
|
||||||
|
text-decoration:none;border-radius:4px;display:inline-block;">
|
||||||
|
View Support Chat
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
<p style="font-size:12px;color:#888;">JQC Support System — automated notification.</p>
|
||||||
|
</body>
|
||||||
|
</html>"""
|
||||||
|
|
||||||
|
def _send():
|
||||||
|
try:
|
||||||
|
with current_app.app_context():
|
||||||
|
msg = Message(
|
||||||
|
subject = f'[JQC Support] Reply to #{ticket.id}: {ticket.subject}',
|
||||||
|
recipients = [ticket.customer.email],
|
||||||
|
html = html_body,
|
||||||
|
sender = current_app.config.get('MAIL_DEFAULT_SENDER'),
|
||||||
|
)
|
||||||
|
mail.send(msg)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error('SUPPORT | customer reply email failed: %s', exc)
|
||||||
|
|
||||||
|
threading.Thread(target=_send, daemon=True).start()
|
||||||
@@ -165,6 +165,25 @@
|
|||||||
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('customers.') }}" href="{{ url_for('customers.index') }}">Customers</a>
|
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('customers.') }}" href="{{ url_for('customers.index') }}">Customers</a>
|
||||||
</li>
|
</li>
|
||||||
{% endif %}
|
{% 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' %}
|
{% if current_user.role == 'admin' %}
|
||||||
<li class="nav-item">
|
<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>
|
<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 %}
|
||||||
|
· <i class="bi bi-building me-1"></i>{{ ticket.facility.name }}
|
||||||
|
{% endif %}
|
||||||
|
· <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>
|
||||||
|
· {{ 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 %}
|
||||||
@@ -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) }}">
|
||||||
|
« 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 »
|
||||||
|
</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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
"""phase23 — support tickets
|
||||||
|
|
||||||
|
Creates two tables:
|
||||||
|
support_tickets — customer-submitted help requests
|
||||||
|
support_ticket_replies — admin/staff replies to those tickets
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = 'phase23_support_tickets'
|
||||||
|
down_revision = 'phase22_comment_visibility'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def _table_exists(bind, table: str) -> bool:
|
||||||
|
result = bind.execute(sa.text(
|
||||||
|
"SELECT COUNT(*) FROM information_schema.tables "
|
||||||
|
"WHERE table_schema = DATABASE() AND table_name = :t"
|
||||||
|
), {'t': table})
|
||||||
|
return result.scalar() > 0
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
bind = op.get_bind()
|
||||||
|
|
||||||
|
if not _table_exists(bind, 'support_tickets'):
|
||||||
|
op.execute(sa.text("""
|
||||||
|
CREATE TABLE support_tickets (
|
||||||
|
id INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
customer_id INT NULL,
|
||||||
|
facility_id INT NULL,
|
||||||
|
subject VARCHAR(200) NOT NULL,
|
||||||
|
body TEXT NOT NULL,
|
||||||
|
status VARCHAR(20) NOT NULL DEFAULT 'open',
|
||||||
|
created_at DATETIME NOT NULL,
|
||||||
|
CONSTRAINT fk_st_customer FOREIGN KEY (customer_id)
|
||||||
|
REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
CONSTRAINT fk_st_facility FOREIGN KEY (facility_id)
|
||||||
|
REFERENCES facilities(id) ON DELETE SET NULL,
|
||||||
|
INDEX ix_support_tickets_customer (customer_id),
|
||||||
|
INDEX ix_support_tickets_status (status),
|
||||||
|
INDEX ix_support_tickets_created (created_at)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||||
|
"""))
|
||||||
|
|
||||||
|
if not _table_exists(bind, 'support_ticket_replies'):
|
||||||
|
op.execute(sa.text("""
|
||||||
|
CREATE TABLE support_ticket_replies (
|
||||||
|
id INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
ticket_id INT NOT NULL,
|
||||||
|
user_id INT NULL,
|
||||||
|
body TEXT NOT NULL,
|
||||||
|
created_at DATETIME NOT NULL,
|
||||||
|
CONSTRAINT fk_str_ticket FOREIGN KEY (ticket_id)
|
||||||
|
REFERENCES support_tickets(id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT fk_str_user FOREIGN KEY (user_id)
|
||||||
|
REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
INDEX ix_support_replies_ticket (ticket_id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||||
|
"""))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
bind = op.get_bind()
|
||||||
|
if _table_exists(bind, 'support_ticket_replies'):
|
||||||
|
op.execute(sa.text('DROP TABLE support_ticket_replies'))
|
||||||
|
if _table_exists(bind, 'support_tickets'):
|
||||||
|
op.execute(sa.text('DROP TABLE support_tickets'))
|
||||||
@@ -21,3 +21,4 @@ reportlab
|
|||||||
pytz
|
pytz
|
||||||
pyJWT
|
pyJWT
|
||||||
openpyxl
|
openpyxl
|
||||||
|
groq
|
||||||
|
|||||||
Reference in New Issue
Block a user