Initial commit

This commit is contained in:
2026-03-25 11:37:46 -04:00
commit dfdbb54875
41 changed files with 5079 additions and 0 deletions
+67
View File
@@ -0,0 +1,67 @@
{% extends "base.html" %}
{% block title %}Activity Logs{% endblock %}
{% block page_title %}Activity Logs{% endblock %}
{% block content %}
<div class="card">
<div class="card-header"><i class="bi bi-list-ul me-2"></i>System Activity Log</div>
<div class="card-body p-0">
<table class="table mb-0">
<thead>
<tr>
<th>Timestamp</th>
<th>Action</th>
<th>User</th>
<th>Entity</th>
<th>Details</th>
<th>IP Address</th>
</tr>
</thead>
<tbody>
{% for log in logs.items %}
<tr>
<td style="font-size:11px;color:var(--muted);font-family:'Space Mono',monospace;white-space:nowrap;">
{{ log.created_at.strftime('%Y-%m-%d %H:%M:%S') }}
</td>
<td>
<span class="mono" style="font-size:11px;
color:{% if 'create' in log.action %}var(--success){% elif 'delete' in log.action or 'deactivate' in log.action %}var(--danger){% elif 'update' in log.action or 'edit' in log.action or 'change' in log.action %}var(--warning){% elif 'login' in log.action %}var(--accent3){% else %}var(--muted){% endif %};">
{{ log.action }}
</span>
</td>
<td style="font-size:13px;">
{% if log.user %}{{ log.user.full_name }}<br><span style="font-size:11px;color:var(--muted);">{{ log.user.email }}</span>{% else %}<span style="color:var(--muted);">System</span>{% endif %}
</td>
<td style="font-size:12px;color:var(--muted);">
{{ log.entity_type or '—' }}{% if log.entity_id %} <span class="mono">#{{ log.entity_id }}</span>{% endif %}
</td>
<td style="font-size:12px;color:var(--muted);max-width:250px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">
{{ log.details or '—' }}
</td>
<td style="font-size:11px;color:var(--muted);font-family:'Space Mono',monospace;">
{{ log.ip_address or '—' }}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% if logs.pages > 1 %}
<div class="d-flex justify-content-center py-3">
<nav><ul class="pagination mb-0">
{% if logs.has_prev %}
<li class="page-item"><a class="page-link" href="{{ url_for('admin.activity_logs', page=logs.prev_num) }}"> Prev</a></li>
{% endif %}
{% for p in logs.iter_pages(left_edge=1, right_edge=1, left_current=2, right_current=2) %}
{% if p %}<li class="page-item {% if p==logs.page %}active{% endif %}"><a class="page-link" href="{{ url_for('admin.activity_logs', page=p) }}">{{ p }}</a></li>
{% else %}<li class="page-item disabled"><span class="page-link"></span></li>{% endif %}
{% endfor %}
{% if logs.has_next %}
<li class="page-item"><a class="page-link" href="{{ url_for('admin.activity_logs', page=logs.next_num) }}">Next </a></li>
{% endif %}
</ul></nav>
</div>
{% endif %}
</div>
</div>
{% endblock %}
+215
View File
@@ -0,0 +1,215 @@
{% extends "base.html" %}
{% block title %}Create User{% endblock %}
{% block page_title %}User Management{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-lg-7">
<div class="mb-3">
<a href="{{ url_for('admin.users') }}" style="font-size:13px;color:var(--accent);">
<i class="bi bi-arrow-left me-1"></i>Back to Users
</a>
</div>
<div class="card">
<div class="card-header">
<i class="bi bi-person-plus me-2"></i>Create New User
</div>
<div class="card-body">
<form method="POST" novalidate>
<!-- ── Identity ─────────────────────────────────────────────────── -->
<div style="font-size:11px;font-weight:700;letter-spacing:1.2px;text-transform:uppercase;color:var(--muted);margin-bottom:14px;">
Identity
</div>
<div class="row g-3 mb-4">
<div class="col-md-6">
<label class="form-label">Full Name *</label>
<input type="text" class="form-control" name="full_name"
value="{{ form.get('full_name', '') }}" required
placeholder="Jane Smith"/>
</div>
<div class="col-md-6">
<label class="form-label">Username *</label>
<input type="text" class="form-control" name="username"
value="{{ form.get('username', '') }}" required
placeholder="jsmith"
pattern="[a-zA-Z0-9_\-]+"
title="Letters, numbers, underscores and hyphens only"/>
</div>
<div class="col-12">
<label class="form-label">Work Email *</label>
<input type="email" class="form-control" name="email"
value="{{ form.get('email', '') }}" required
placeholder="jane.smith@company.com"/>
</div>
<div class="col-md-6">
<label class="form-label">Department</label>
<input type="text" class="form-control" name="department"
value="{{ form.get('department', '') }}"
placeholder="Finance"/>
</div>
<div class="col-md-6">
<label class="form-label">Phone</label>
<input type="text" class="form-control" name="phone"
value="{{ form.get('phone', '') }}"
placeholder="+1 555 000 0000"/>
</div>
</div>
<!-- ── Role & Status ────────────────────────────────────────────── -->
<div style="font-size:11px;font-weight:700;letter-spacing:1.2px;text-transform:uppercase;color:var(--muted);margin-bottom:14px;padding-top:4px;border-top:1px solid var(--border);">
Role &amp; Status
</div>
<div class="row g-3 mb-4">
<div class="col-md-6">
<label class="form-label">Role *</label>
<select class="form-select" name="role">
{% for r in roles %}
<option value="{{ r }}"
{% if form.get('role', 'employee') == r %}selected{% endif %}>
{{ r.replace('_', ' ').title() }}
</option>
{% endfor %}
</select>
<div style="font-size:11px;color:var(--muted);margin-top:5px;">
<strong>Employee</strong> — can submit &amp; track own tickets.<br>
<strong>IT Staff</strong> — can manage all tickets and KB.<br>
<strong>Admin</strong> — full access including user management.
</div>
</div>
<div class="col-md-6 d-flex align-items-start pt-4">
<label style="display:flex;align-items:center;gap:10px;cursor:pointer;margin-top:10px;">
<input type="checkbox" name="is_active" value="1"
style="accent-color:var(--accent);width:17px;height:17px;"
{% if form.get('is_active', '1') != '0' %}checked{% endif %}/>
<span style="font-size:14px;font-weight:500;">Account Active</span>
</label>
</div>
</div>
<!-- ── Password ─────────────────────────────────────────────────── -->
<div style="font-size:11px;font-weight:700;letter-spacing:1.2px;text-transform:uppercase;color:var(--muted);margin-bottom:14px;padding-top:4px;border-top:1px solid var(--border);">
Password
</div>
<div class="row g-3 mb-4">
<div class="col-md-6">
<label class="form-label">Password *</label>
<div style="position:relative;">
<input type="password" class="form-control" name="password"
id="pw-field" required minlength="8"
placeholder="Min. 8 characters"
style="padding-right:44px;"/>
<button type="button" onclick="togglePw('pw-field','eye1')"
style="position:absolute;right:10px;top:50%;transform:translateY(-50%);background:none;border:none;color:var(--muted);cursor:pointer;padding:4px;">
<i class="bi bi-eye" id="eye1"></i>
</button>
</div>
</div>
<div class="col-md-6">
<label class="form-label">Confirm Password *</label>
<div style="position:relative;">
<input type="password" class="form-control" name="confirm_password"
id="pw-field2" required minlength="8"
placeholder="Repeat password"
style="padding-right:44px;"/>
<button type="button" onclick="togglePw('pw-field2','eye2')"
style="position:absolute;right:10px;top:50%;transform:translateY(-50%);background:none;border:none;color:var(--muted);cursor:pointer;padding:4px;">
<i class="bi bi-eye" id="eye2"></i>
</button>
</div>
</div>
<div class="col-12">
<!-- Password strength bar -->
<div style="height:4px;background:var(--border);border-radius:2px;margin-top:4px;">
<div id="pw-strength-bar" style="height:100%;border-radius:2px;width:0%;transition:width .3s,background .3s;"></div>
</div>
<div id="pw-strength-label" style="font-size:11px;color:var(--muted);margin-top:4px;"></div>
</div>
</div>
<!-- ── Actions ──────────────────────────────────────────────────── -->
<div class="d-flex gap-2 pt-2" style="border-top:1px solid var(--border);">
<button type="submit" class="btn btn-primary">
<i class="bi bi-person-check me-2"></i>Create User
</button>
<a href="{{ url_for('admin.users') }}" class="btn btn-secondary">Cancel</a>
</div>
</form>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
// ── Show/hide password ────────────────────────────────────────────────────────
function togglePw(fieldId, iconId) {
const field = document.getElementById(fieldId);
const icon = document.getElementById(iconId);
if (field.type === 'password') {
field.type = 'text';
icon.className = 'bi bi-eye-slash';
} else {
field.type = 'password';
icon.className = 'bi bi-eye';
}
}
// ── Password strength indicator ───────────────────────────────────────────────
document.getElementById('pw-field').addEventListener('input', function () {
const val = this.value;
let score = 0;
if (val.length >= 8) score++;
if (val.length >= 12) score++;
if (/[A-Z]/.test(val) && /[a-z]/.test(val))score++;
if (/[0-9]/.test(val)) score++;
if (/[^A-Za-z0-9]/.test(val)) score++;
const bar = document.getElementById('pw-strength-bar');
const label = document.getElementById('pw-strength-label');
const levels = [
{ pct: '20%', bg: '#dc2626', text: 'Very weak' },
{ pct: '40%', bg: '#d97706', text: 'Weak' },
{ pct: '60%', bg: '#ca8a04', text: 'Fair' },
{ pct: '80%', bg: '#16a34a', text: 'Strong' },
{ pct: '100%', bg: '#059669', text: 'Very strong' },
];
if (val.length === 0) {
bar.style.width = '0%';
label.textContent = '';
return;
}
const lvl = levels[Math.min(score - 1, 4)] || levels[0];
bar.style.width = lvl.pct;
bar.style.background = lvl.bg;
label.textContent = lvl.text;
label.style.color = lvl.bg;
});
// ── Client-side password match check before submit ────────────────────────────
document.querySelector('form').addEventListener('submit', function (e) {
const pw = document.getElementById('pw-field').value;
const pw2 = document.getElementById('pw-field2').value;
if (pw !== pw2) {
e.preventDefault();
document.getElementById('pw-field2').style.borderColor = 'var(--danger)';
const msg = document.createElement('div');
msg.style.cssText = 'font-size:12px;color:var(--danger);margin-top:4px;';
msg.textContent = 'Passwords do not match.';
const existing = document.getElementById('pw-match-msg');
if (existing) existing.remove();
msg.id = 'pw-match-msg';
document.getElementById('pw-field2').insertAdjacentElement('afterend', msg);
}
});
document.getElementById('pw-field2').addEventListener('input', function () {
this.style.borderColor = '';
const msg = document.getElementById('pw-match-msg');
if (msg) msg.remove();
});
</script>
{% endblock %}
+58
View File
@@ -0,0 +1,58 @@
{% extends "base.html" %}
{% block title %}Edit User{% endblock %}
{% block page_title %}Edit User{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-lg-6">
<div class="mb-3"><a href="{{ url_for('admin.users') }}" style="font-size:13px;color:var(--accent3);">← Back to Users</a></div>
<div class="card">
<div class="card-header"><i class="bi bi-person-gear me-2"></i>Edit: {{ user.full_name }}</div>
<div class="card-body">
<form method="POST">
<div class="row g-3">
<div class="col-md-6">
<label class="form-label">Full Name</label>
<input type="text" class="form-control" name="full_name" value="{{ user.full_name }}" required/>
</div>
<div class="col-md-6">
<label class="form-label">Department</label>
<input type="text" class="form-control" name="department" value="{{ user.department or '' }}"/>
</div>
<div class="col-md-6">
<label class="form-label">Phone</label>
<input type="text" class="form-control" name="phone" value="{{ user.phone or '' }}"/>
</div>
<div class="col-md-6">
<label class="form-label">Role</label>
<select class="form-select" name="role">
{% for r in roles %}
<option value="{{ r }}" {% if r == user.role %}selected{% endif %}>{{ r.replace('_',' ').title() }}</option>
{% endfor %}
</select>
</div>
<div class="col-12">
<div class="d-flex align-items-center gap-3">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer;margin:0;">
<input type="checkbox" name="is_active" {% if user.is_active %}checked{% endif %}
style="accent-color:var(--success);width:16px;height:16px;"/>
<span style="font-size:14px;">Account Active</span>
</label>
</div>
</div>
<div class="col-12"><hr style="border-color:var(--border);"/></div>
<div class="col-md-6">
<label class="form-label">New Password <span style="color:var(--muted);font-weight:400;">(optional)</span></label>
<input type="password" class="form-control" name="new_password" placeholder="Leave blank to keep current"/>
</div>
</div>
<div class="d-flex gap-2 mt-4">
<button type="submit" class="btn btn-primary"><i class="bi bi-check2 me-2"></i>Save</button>
<a href="{{ url_for('admin.users') }}" class="btn btn-secondary">Cancel</a>
</div>
</form>
</div>
</div>
</div>
</div>
{% endblock %}
+68
View File
@@ -0,0 +1,68 @@
{% extends "base.html" %}
{% block title %}IT Overview{% endblock %}
{% block page_title %}IT Operations Overview{% endblock %}
{% block content %}
<!-- Stats grid -->
<div class="row g-3 mb-4">
{% for label, value, icon, color, bg in [
('Total Tickets', stats.total_tickets, 'bi-collection', 'var(--text)', 'rgba(255,255,255,.05)'),
('Open', stats.open, 'bi-circle', 'var(--info)', 'rgba(96,165,250,.1)'),
('In Progress', stats.in_progress, 'bi-arrow-repeat', 'var(--accent3)', 'rgba(0,180,216,.1)'),
('Resolved', stats.resolved, 'bi-check-circle', 'var(--success)', 'rgba(45,212,191,.1)'),
('Closed', stats.closed, 'bi-archive', 'var(--muted)', 'rgba(112,112,160,.1)'),
('Active Users', stats.total_users, 'bi-people', 'var(--accent2)', 'rgba(123,94,167,.1)'),
] %}
<div class="col-6 col-xl-2">
<div class="stat-card">
<div class="stat-icon" style="background:{{ bg }};color:{{ color }};"><i class="bi {{ icon }}"></i></div>
<div>
<div class="stat-value" style="color:{{ color }};">{{ value }}</div>
<div class="stat-label">{{ label }}</div>
</div>
</div>
</div>
{% endfor %}
</div>
<!-- Activity log -->
<div class="card">
<div class="card-header d-flex align-items-center justify-content-between">
<span><i class="bi bi-activity me-2"></i>Recent Activity</span>
{% if current_user.is_admin %}
<a href="{{ url_for('admin.activity_logs') }}" style="font-size:12px;color:var(--accent3);">Full log →</a>
{% endif %}
</div>
<div class="card-body p-0">
<table class="table mb-0">
<thead>
<tr>
<th>Action</th>
<th>User</th>
<th>Entity</th>
<th>Details</th>
<th>Time</th>
</tr>
</thead>
<tbody>
{% for log in recent_logs %}
<tr>
<td>
<span class="mono" style="font-size:11px;
color:{% if 'create' in log.action %}var(--success){% elif 'delete' in log.action %}var(--danger){% elif 'update' in log.action or 'edit' in log.action %}var(--warning){% else %}var(--muted){% endif %};">
{{ log.action }}
</span>
</td>
<td style="font-size:13px;">
{% if log.user %}{{ log.user.full_name }}{% else %}<span style="color:var(--muted);">System</span>{% endif %}
</td>
<td style="font-size:12px;color:var(--muted);">{{ log.entity_type or '—' }} {% if log.entity_id %}#{{ log.entity_id }}{% endif %}</td>
<td style="font-size:12px;color:var(--muted);max-width:220px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">{{ log.details or '—' }}</td>
<td style="font-size:11px;color:var(--muted);font-family:'Space Mono',monospace;">{{ log.created_at.strftime('%b %d %H:%M') }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}
+64
View File
@@ -0,0 +1,64 @@
{% extends "base.html" %}
{% block title %}{% if article %}Edit Article{% else %}New Article{% endif %}{% endblock %}
{% block page_title %}{% if article %}Edit Article{% else %}New KB Article{% endif %}{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-lg-8">
<div class="mb-3"><a href="{{ url_for('admin.kb_list') }}" style="font-size:13px;color:var(--accent3);">← Back to Knowledge Base</a></div>
<div class="card">
<div class="card-header">
<i class="bi bi-{% if article %}pencil{% else %}plus-circle{% endif %} me-2"></i>
{% if article %}Edit: {{ article.title[:40] }}{% else %}Create New Article{% endif %}
</div>
<div class="card-body">
<form method="POST">
<div class="row g-3">
<div class="col-12">
<label class="form-label">Title *</label>
<input type="text" class="form-control" name="title" required
value="{{ article.title if article else '' }}"
placeholder="e.g. How to connect to the VPN"/>
</div>
<div class="col-md-6">
<label class="form-label">Category</label>
<select class="form-select" name="category">
<option value="">— Select Category —</option>
{% for cat in ['hardware','software','network','access','email','printer','phone','security','other'] %}
<option value="{{ cat }}" {% if article and article.category == cat %}selected{% endif %}>
{{ cat.replace('_',' ').title() }}
</option>
{% endfor %}
</select>
</div>
<div class="col-md-6">
<label class="form-label">Tags <span style="color:var(--muted);font-weight:400;">(comma-separated)</span></label>
<input type="text" class="form-control" name="tags"
value="{{ article.tags if article else '' }}"
placeholder="vpn, remote, access"/>
</div>
<div class="col-12">
<label class="form-label">Content *</label>
<textarea class="form-control" name="body" rows="16" required
placeholder="Write the article content here. You can use plain text with line breaks.">{{ article.body if article else '' }}</textarea>
</div>
<div class="col-12">
<label style="display:flex;align-items:center;gap:10px;cursor:pointer;">
<input type="checkbox" name="is_published" style="accent-color:var(--accent3);width:16px;height:16px;"
{% if not article or article.is_published %}checked{% endif %}/>
<span style="font-size:14px;">Publish immediately (visible to all employees)</span>
</label>
</div>
</div>
<div class="d-flex gap-2 mt-4">
<button type="submit" class="btn btn-primary">
<i class="bi bi-check2 me-2"></i>{% if article %}Save Changes{% else %}Publish Article{% endif %}
</button>
<a href="{{ url_for('admin.kb_list') }}" class="btn btn-secondary">Cancel</a>
</div>
</form>
</div>
</div>
</div>
</div>
{% endblock %}
+55
View File
@@ -0,0 +1,55 @@
{% extends "base.html" %}
{% block title %}Manage Knowledge Base{% endblock %}
{% block page_title %}Knowledge Base Management{% endblock %}
{% block content %}
<div class="card">
<div class="card-header d-flex align-items-center justify-content-between">
<span><i class="bi bi-journal-text me-2"></i>Articles <span style="font-size:12px;color:var(--muted);">({{ articles|length }})</span></span>
<a href="{{ url_for('admin.kb_new') }}" class="btn btn-primary btn-sm">
<i class="bi bi-plus-lg me-1"></i>New Article
</a>
</div>
<div class="card-body p-0">
{% if articles %}
<table class="table mb-0">
<thead>
<tr><th>Title</th><th>Category</th><th>Author</th><th>Published</th><th>Views</th><th>Updated</th><th>Actions</th></tr>
</thead>
<tbody>
{% for art in articles %}
<tr>
<td style="font-size:14px;">{{ art.title[:60] }}</td>
<td style="font-size:12px;color:var(--muted);">{{ art.category or '—' }}</td>
<td style="font-size:13px;">{{ art.author.full_name if art.author else '—' }}</td>
<td>
{% if art.is_published %}
<span style="color:var(--success);font-size:12px;"><i class="bi bi-check-circle-fill"></i> Yes</span>
{% else %}
<span style="color:var(--muted);font-size:12px;"><i class="bi bi-dash-circle"></i> Draft</span>
{% endif %}
</td>
<td style="font-size:12px;color:var(--muted);">{{ art.view_count }}</td>
<td style="font-size:12px;color:var(--muted);">{{ art.updated_at.strftime('%b %d, %Y') }}</td>
<td>
<div class="d-flex gap-1">
<a href="{{ url_for('tickets.kb_article', article_id=art.id) }}" class="btn btn-secondary btn-sm" title="Preview"><i class="bi bi-eye"></i></a>
<a href="{{ url_for('admin.kb_edit', article_id=art.id) }}" class="btn btn-secondary btn-sm" title="Edit"><i class="bi bi-pencil"></i></a>
<form method="POST" action="{{ url_for('admin.kb_delete', article_id=art.id) }}" onsubmit="return confirm('Delete this article?');">
<button type="submit" class="btn btn-sm" style="background:rgba(248,113,113,.1);border:1px solid rgba(248,113,113,.2);color:var(--danger);" title="Delete"><i class="bi bi-trash"></i></button>
</form>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="p-5 text-center" style="color:var(--muted);">
<i class="bi bi-journal-x" style="font-size:40px;display:block;margin-bottom:12px;"></i>
No articles yet. <a href="{{ url_for('admin.kb_new') }}" style="color:var(--accent3);">Create your first article →</a>
</div>
{% endif %}
</div>
</div>
{% endblock %}
+110
View File
@@ -0,0 +1,110 @@
{% extends "base.html" %}
{% block title %}All Tickets{% endblock %}
{% block page_title %}All Tickets{% endblock %}
{% block content %}
<!-- Filters -->
<div class="card mb-4">
<div class="card-body">
<form method="GET" class="row g-2 align-items-end">
<div class="col-md-3">
<label class="form-label">Status</label>
<select class="form-select" name="status">
<option value="">All Statuses</option>
{% for s in ['open','in_progress','pending','resolved','closed'] %}
<option value="{{ s }}" {% if s == status %}selected{% endif %}>{{ s.replace('_',' ').title() }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-3">
<label class="form-label">Priority</label>
<select class="form-select" name="priority">
<option value="">All Priorities</option>
{% for p in ['low','medium','high','critical'] %}
<option value="{{ p }}" {% if p == priority %}selected{% endif %}>{{ p.title() }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-3">
<label class="form-label">Assignment</label>
<select class="form-select" name="assigned">
<option value="">All</option>
<option value="me" {% if assigned == 'me' %}selected{% endif %}>Assigned to Me</option>
<option value="unassigned" {% if assigned == 'unassigned' %}selected{% endif %}>Unassigned</option>
</select>
</div>
<div class="col-md-3 d-flex gap-2">
<button type="submit" class="btn btn-primary flex-fill"><i class="bi bi-search me-1"></i>Filter</button>
<a href="{{ url_for('admin.all_tickets') }}" class="btn btn-secondary"><i class="bi bi-x-lg"></i></a>
</div>
</form>
</div>
</div>
<div class="card">
<div class="card-header">
<i class="bi bi-collection me-2"></i>Tickets
<span style="font-size:12px;color:var(--muted);font-family:'Space Mono',monospace;">({{ tickets.total }})</span>
</div>
<div class="card-body p-0">
{% if tickets.items %}
<table class="table mb-0">
<thead>
<tr>
<th>Ticket #</th>
<th>Title</th>
<th>Category</th>
<th>Status</th>
<th>Priority</th>
<th>Submitted By</th>
<th>Assigned To</th>
<th>Date</th>
<th></th>
</tr>
</thead>
<tbody>
{% for t in tickets.items %}
<tr>
<td><a href="{{ url_for('tickets.ticket_detail', ticket_id=t.id) }}" class="mono" style="font-size:11px;color:var(--accent3);">{{ t.ticket_number }}</a></td>
<td style="font-size:13px;max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">{{ t.title }}</td>
<td style="font-size:12px;color:var(--muted);">{{ t.category.replace('_',' ').title() }}</td>
<td><span class="badge badge-{{ t.status }}">{{ t.status.replace('_',' ').upper() }}</span></td>
<td><span class="badge badge-{{ t.priority }}">{{ t.priority.upper() }}</span></td>
<td style="font-size:13px;">{{ t.creator.full_name }}</td>
<td style="font-size:13px;color:var(--muted);">{{ t.assignee.full_name if t.assignee else '—' }}</td>
<td style="font-size:11px;color:var(--muted);">{{ t.created_at.strftime('%b %d') }}</td>
<td><a href="{{ url_for('tickets.ticket_detail', ticket_id=t.id) }}" class="btn btn-secondary btn-sm"><i class="bi bi-eye"></i></a></td>
</tr>
{% endfor %}
</tbody>
</table>
{% if tickets.pages > 1 %}
<div class="d-flex justify-content-center py-3">
<nav><ul class="pagination mb-0">
{% if tickets.has_prev %}
<li class="page-item"><a class="page-link" href="{{ url_for('admin.all_tickets', page=tickets.prev_num, status=status, priority=priority, assigned=assigned) }}"></a></li>
{% endif %}
{% 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('admin.all_tickets', page=p, status=status, priority=priority, assigned=assigned) }}">{{ p }}</a>
</li>
{% else %}
<li class="page-item disabled"><span class="page-link"></span></li>
{% endif %}
{% endfor %}
{% if tickets.has_next %}
<li class="page-item"><a class="page-link" href="{{ url_for('admin.all_tickets', page=tickets.next_num, status=status, priority=priority, assigned=assigned) }}"></a></li>
{% endif %}
</ul></nav>
</div>
{% endif %}
{% else %}
<div class="p-5 text-center" style="color:var(--muted);">
<i class="bi bi-inbox" style="font-size:40px;display:block;margin-bottom:12px;"></i>No tickets match the current filters.
</div>
{% endif %}
</div>
</div>
{% endblock %}
+77
View File
@@ -0,0 +1,77 @@
{% extends "base.html" %}
{% block title %}User Management{% endblock %}
{% block page_title %}User Management{% endblock %}
{% block content %}
<div class="card">
<div class="card-header d-flex align-items-center justify-content-between">
<span><i class="bi bi-people me-2"></i>All Users <span style="font-size:12px;color:var(--muted);">({{ users|length }})</span></span>
<a href="{{ url_for('admin.create_user') }}" class="btn btn-primary btn-sm">
<i class="bi bi-person-plus me-1"></i>Add User
</a>
</div>
<div class="card-body p-0">
<table class="table mb-0">
<thead>
<tr>
<th>Name</th>
<th>Email</th>
<th>Department</th>
<th>Role</th>
<th>Status</th>
<th>Last Login</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for u in users %}
<tr>
<td>
<div style="display:flex;align-items:center;gap:10px;">
<div style="width:30px;height:30px;border-radius:50%;background:var(--accent2);display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:700;flex-shrink:0;">
{{ u.full_name[0].upper() }}
</div>
<span style="font-size:14px;">{{ u.full_name }}</span>
</div>
</td>
<td style="font-size:13px;color:var(--muted);">{{ u.email }}</td>
<td style="font-size:13px;color:var(--muted);">{{ u.department or '—' }}</td>
<td>
<span style="font-size:11px;padding:2px 8px;border-radius:4px;
background:{% if u.role == 'admin' %}rgba(233,69,96,.15){% elif u.role == 'it_staff' %}rgba(0,180,216,.15){% else %}rgba(112,112,160,.15){% endif %};
color:{% if u.role == 'admin' %}var(--accent){% elif u.role == 'it_staff' %}var(--accent3){% else %}var(--muted){% endif %};">
{{ u.role.replace('_',' ').upper() }}
</span>
</td>
<td>
{% if u.is_active %}
<span style="color:var(--success);font-size:12px;"><i class="bi bi-circle-fill" style="font-size:8px;"></i> Active</span>
{% else %}
<span style="color:var(--muted);font-size:12px;"><i class="bi bi-circle" style="font-size:8px;"></i> Inactive</span>
{% endif %}
</td>
<td style="font-size:12px;color:var(--muted);">
{{ u.last_login.strftime('%b %d, %Y') if u.last_login else 'Never' }}
</td>
<td>
<div class="d-flex gap-1">
<a href="{{ url_for('admin.edit_user', user_id=u.id) }}" class="btn btn-secondary btn-sm">
<i class="bi bi-pencil"></i>
</a>
{% if u.id != current_user.id and u.is_active %}
<form method="POST" action="{{ url_for('admin.delete_user', user_id=u.id) }}"
onsubmit="return confirm('Deactivate {{ u.full_name }}?');">
<button type="submit" class="btn btn-sm" style="background:rgba(248,113,113,.1);border:1px solid rgba(248,113,113,.2);color:var(--danger);">
<i class="bi bi-person-dash"></i>
</button>
</form>
{% endif %}
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}
+76
View File
@@ -0,0 +1,76 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width,initial-scale=1.0"/>
<title>Login — TechDesk</title>
<link rel="preconnect" href="https://fonts.googleapis.com"/>
<link href="https://fonts.googleapis.com/css2?family=Space+Mono:wght@400;700&family=DM+Sans:opsz,wght@9..40,300;9..40,400;9..40,500;9..40,600&display=swap" rel="stylesheet"/>
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css" rel="stylesheet"/>
<style>
:root{--bg:#f0f4f8;--surface:#ffffff;--border:#e2e8f0;--border2:#cbd5e1;--accent:#2563eb;--accent-h:#1d4ed8;--text:#0f172a;--text2:#334155;--muted:#64748b;}
*{box-sizing:border-box;margin:0;padding:0;}
body{font-family:'DM Sans',sans-serif;background:var(--bg);color:var(--text);min-height:100vh;display:flex;align-items:center;justify-content:center;overflow:hidden;}
.bg-grid{position:fixed;inset:0;background-image:linear-gradient(var(--border) 1px,transparent 1px),linear-gradient(90deg,var(--border) 1px,transparent 1px);background-size:48px 48px;opacity:.6;pointer-events:none;}
.bg-glow{position:fixed;width:700px;height:700px;border-radius:50%;background:radial-gradient(circle,rgba(37,99,235,.06) 0%,transparent 70%);top:-300px;right:-200px;pointer-events:none;}
.card{background:#fff;border:1px solid var(--border);border-radius:16px;padding:40px;width:100%;max-width:420px;position:relative;z-index:1;box-shadow:0 4px 24px rgba(0,0,0,.08);}
.brand{text-align:center;margin-bottom:32px;}
.logo{width:52px;height:52px;background:var(--accent);border-radius:12px;display:flex;align-items:center;justify-content:center;font-family:'Space Mono',monospace;font-weight:700;font-size:17px;color:#fff;margin:0 auto 12px;box-shadow:0 2px 10px rgba(37,99,235,.35);}
h1{font-size:21px;font-weight:700;color:var(--text);}
.subtitle{font-size:13px;color:var(--muted);margin-top:4px;}
.form-group{margin-bottom:18px;}
label{display:block;font-size:12px;font-weight:600;letter-spacing:.4px;color:var(--text2);text-transform:uppercase;margin-bottom:7px;}
input{width:100%;background:#fff;border:1px solid var(--border);color:var(--text);border-radius:9px;padding:11px 14px;font-size:14px;font-family:inherit;transition:border-color .15s,box-shadow .15s;box-shadow:0 1px 3px rgba(0,0,0,.06);}
input:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px rgba(37,99,235,.1);}
input::placeholder{color:#94a3b8;}
.btn{width:100%;background:var(--accent);border:none;color:#fff;border-radius:9px;padding:12px;font-size:14px;font-weight:600;cursor:pointer;transition:background .15s;font-family:inherit;margin-top:4px;box-shadow:0 2px 6px rgba(37,99,235,.3);}
.btn:hover{background:var(--accent-h);}
.links{text-align:center;margin-top:20px;font-size:13px;color:var(--muted);}
.links a{color:var(--accent);}
.checkbox-row{display:flex;align-items:center;gap:8px;margin-bottom:20px;}
.checkbox-row input[type=checkbox]{width:auto;accent-color:var(--accent);}
.checkbox-row label{margin:0;font-size:13px;text-transform:none;letter-spacing:0;color:var(--text2);}
.alert{border-radius:8px;padding:11px 14px;font-size:13px;margin-bottom:18px;}
.alert-danger{background:#fef2f2;color:#dc2626;border:1px solid #fecaca;}
.alert-success{background:#ecfdf5;color:#059669;border:1px solid #a7f3d0;}
.alert-info{background:#f0f9ff;color:#0284c7;border:1px solid #bae6fd;}
</style>
</head>
<body>
<div class="bg-grid"></div>
<div class="bg-glow"></div>
<div class="card">
<div class="brand">
<div class="logo">TD</div>
<h1>TechDesk</h1>
<p class="subtitle">IT Helpdesk Portal — Sign in to continue</p>
</div>
{% with messages = get_flashed_messages(with_categories=true) %}
{% for cat, msg in messages %}
<div class="alert alert-{{ cat }}"><i class="bi bi-exclamation-circle me-2"></i>{{ msg }}</div>
{% endfor %}
{% endwith %}
<form method="POST">
<div class="form-group">
<label>Email Address</label>
<input type="email" name="email" required placeholder="you@company.com" autofocus/>
</div>
<div class="form-group">
<label>Password</label>
<input type="password" name="password" required placeholder="••••••••"/>
</div>
<div class="checkbox-row">
<input type="checkbox" name="remember" id="remember"/>
<label for="remember">Keep me signed in</label>
</div>
<button type="submit" class="btn"><i class="bi bi-box-arrow-in-right me-2"></i>Sign In</button>
</form>
<div class="links">
Don't have an account? <a href="{{ url_for('auth.register') }}">Register here</a>
</div>
</div>
</body>
</html>
+79
View File
@@ -0,0 +1,79 @@
{% extends "base.html" %}
{% block title %}My Profile{% endblock %}
{% block page_title %}My Profile{% 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-person-circle me-2"></i>Account Settings</div>
<div class="card-body">
<form method="POST">
<!-- Avatar placeholder -->
<div class="text-center mb-4">
<div style="width:80px;height:80px;border-radius:50%;background:var(--accent2);display:flex;align-items:center;justify-content:center;font-size:32px;font-weight:700;color:#fff;margin:0 auto 10px;">
{{ current_user.full_name[0].upper() }}
</div>
<div style="font-size:12px;color:var(--muted);">{{ current_user.email }}</div>
<div style="font-size:11px;background:rgba(0,180,216,.1);color:var(--accent3);display:inline-block;padding:2px 10px;border-radius:12px;margin-top:4px;">
{{ current_user.role.replace('_',' ').upper() }}
</div>
</div>
<div class="row g-3">
<div class="col-md-6">
<label class="form-label">Full Name</label>
<input type="text" class="form-control" name="full_name" value="{{ current_user.full_name }}" required/>
</div>
<div class="col-md-6">
<label class="form-label">Department</label>
<input type="text" class="form-control" name="department" value="{{ current_user.department or '' }}"/>
</div>
<div class="col-md-6">
<label class="form-label">Phone</label>
<input type="text" class="form-control" name="phone" value="{{ current_user.phone or '' }}"/>
</div>
<div class="col-md-6">
<label class="form-label">Username</label>
<input type="text" class="form-control" value="{{ current_user.username }}" disabled/>
</div>
</div>
<hr style="border-color:var(--border);margin:24px 0;"/>
<h6 style="font-size:14px;font-weight:700;margin-bottom:16px;">Notification Preferences</h6>
<div class="d-flex flex-column gap-2 mb-4">
<label style="display:flex;align-items:center;gap:10px;cursor:pointer;">
<input type="checkbox" name="email_notif" {% if current_user.email_notif %}checked{% endif %}
style="accent-color:var(--accent3);width:16px;height:16px;"/>
<span style="font-size:14px;"><i class="bi bi-envelope me-2" style="color:var(--accent3);"></i>Email Notifications</span>
</label>
<label style="display:flex;align-items:center;gap:10px;cursor:pointer;">
<input type="checkbox" name="web_notif" {% if current_user.web_notif %}checked{% endif %}
style="accent-color:var(--accent3);width:16px;height:16px;"/>
<span style="font-size:14px;"><i class="bi bi-bell me-2" style="color:var(--accent3);"></i>In-App Notifications</span>
</label>
</div>
<hr style="border-color:var(--border);margin:24px 0;"/>
<h6 style="font-size:14px;font-weight:700;margin-bottom:16px;">Change Password <span style="font-size:12px;color:var(--muted);font-weight:400;">(leave blank to keep current)</span></h6>
<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"/>
</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>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary"><i class="bi bi-check2 me-2"></i>Save Changes</button>
<a href="{{ url_for('tickets.dashboard') }}" class="btn btn-secondary">Cancel</a>
</div>
</form>
</div>
</div>
</div>
</div>
{% endblock %}
+87
View File
@@ -0,0 +1,87 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/><meta name="viewport" content="width=device-width,initial-scale=1.0"/>
<title>Register — TechDesk</title>
<link href="https://fonts.googleapis.com/css2?family=Space+Mono:wght@400;700&family=DM+Sans:opsz,wght@9..40,400;9..40,500;9..40,600&display=swap" rel="stylesheet"/>
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css" rel="stylesheet"/>
<style>
:root{--bg:#f0f4f8;--surface:#ffffff;--border:#e2e8f0;--border2:#cbd5e1;--accent:#2563eb;--accent-h:#1d4ed8;--text:#0f172a;--text2:#334155;--muted:#64748b;}
*{box-sizing:border-box;margin:0;padding:0;}
body{font-family:'DM Sans',sans-serif;background:var(--bg);color:var(--text);min-height:100vh;display:flex;align-items:center;justify-content:center;padding:24px;}
.bg-grid{position:fixed;inset:0;background-image:linear-gradient(var(--border) 1px,transparent 1px),linear-gradient(90deg,var(--border) 1px,transparent 1px);background-size:48px 48px;opacity:.6;pointer-events:none;}
.card{background:#fff;border:1px solid var(--border);border-radius:16px;padding:40px;width:100%;max-width:500px;position:relative;z-index:1;box-shadow:0 4px 24px rgba(0,0,0,.08);}
.brand{text-align:center;margin-bottom:28px;}
.logo{width:46px;height:46px;background:var(--accent);border-radius:11px;display:flex;align-items:center;justify-content:center;font-family:'Space Mono',monospace;font-weight:700;font-size:15px;color:#fff;margin:0 auto 10px;box-shadow:0 2px 10px rgba(37,99,235,.3);}
h1{font-size:20px;font-weight:700;color:var(--text);}
.subtitle{font-size:13px;color:var(--muted);margin-top:4px;}
.row{display:grid;grid-template-columns:1fr 1fr;gap:14px;}
.form-group{margin-bottom:16px;}
label{display:block;font-size:12px;font-weight:600;letter-spacing:.4px;color:var(--text2);text-transform:uppercase;margin-bottom:6px;}
input,select{width:100%;background:#fff;border:1px solid var(--border);color:var(--text);border-radius:8px;padding:10px 13px;font-size:14px;font-family:inherit;transition:border-color .15s,box-shadow .15s;box-shadow:0 1px 3px rgba(0,0,0,.05);}
input:focus,select:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px rgba(37,99,235,.1);}
input::placeholder{color:#94a3b8;}
.btn{width:100%;background:var(--accent);border:none;color:#fff;border-radius:9px;padding:12px;font-size:14px;font-weight:600;cursor:pointer;transition:background .15s;font-family:inherit;margin-top:4px;box-shadow:0 2px 6px rgba(37,99,235,.3);}
.btn:hover{background:var(--accent-h);}
.links{text-align:center;margin-top:18px;font-size:13px;color:var(--muted);}
.links a{color:var(--accent);}
.alert{border-radius:8px;padding:10px 13px;font-size:13px;margin-bottom:16px;}
.alert-danger{background:#fef2f2;color:#dc2626;border:1px solid #fecaca;}
</style>
</head>
<body>
<div class="bg-grid"></div>
<div class="card">
<div class="brand">
<div class="logo">TD</div>
<h1>Create Account</h1>
<p class="subtitle">Join TechDesk to submit IT support requests</p>
</div>
{% with messages = get_flashed_messages(with_categories=true) %}
{% for cat, msg in messages %}
<div class="alert alert-{{ cat }}">{{ msg }}</div>
{% endfor %}
{% endwith %}
<form method="POST">
<div class="row">
<div class="form-group">
<label>Full Name *</label>
<input type="text" name="full_name" required placeholder="Jane Smith"/>
</div>
<div class="form-group">
<label>Username *</label>
<input type="text" name="username" required placeholder="jsmith"/>
</div>
</div>
<div class="form-group">
<label>Work Email *</label>
<input type="email" name="email" required placeholder="jane.smith@company.com"/>
</div>
<div class="row">
<div class="form-group">
<label>Department</label>
<input type="text" name="department" placeholder="Finance"/>
</div>
<div class="form-group">
<label>Phone</label>
<input type="text" name="phone" placeholder="+1 555 000 0000"/>
</div>
</div>
<div class="row">
<div class="form-group">
<label>Password *</label>
<input type="password" name="password" required placeholder="Min. 8 characters"/>
</div>
<div class="form-group">
<label>Confirm Password *</label>
<input type="password" name="confirm_password" required placeholder="Repeat password"/>
</div>
</div>
<button type="submit" class="btn"><i class="bi bi-person-plus me-2"></i>Create Account</button>
</form>
<div class="links">Already have an account? <a href="{{ url_for('auth.login') }}">Sign in</a></div>
</div>
</body>
</html>
+588
View File
@@ -0,0 +1,588 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width,initial-scale=1.0"/>
<title>{% block title %}IT Helpdesk{% endblock %} — TechDesk</title>
<link rel="preconnect" href="https://fonts.googleapis.com"/>
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin/>
<link href="https://fonts.googleapis.com/css2?family=Space+Mono:wght@400;700&family=DM+Sans:ital,opsz,wght@0,9..40,300;0,9..40,400;0,9..40,500;0,9..40,600;1,9..40,300&display=swap" rel="stylesheet"/>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet"/>
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css" rel="stylesheet"/>
<style>
:root {
--bg: #f0f4f8;
--surface: #ffffff;
--surface2: #f8fafc;
--border: #e2e8f0;
--border2: #cbd5e1;
--accent: #2563eb;
--accent-h: #1d4ed8;
--accent2: #7c3aed;
--accent3: #0891b2;
--text: #0f172a;
--text2: #334155;
--muted: #64748b;
--muted2: #94a3b8;
--success: #059669;
--success-bg:#ecfdf5;
--warning: #d97706;
--warning-bg:#fffbeb;
--danger: #dc2626;
--danger-bg: #fef2f2;
--info: #0284c7;
--info-bg: #f0f9ff;
--sidebar-w: 260px;
--sidebar-bg:#1e293b;
--sidebar-text:#94a3b8;
--sidebar-active:#ffffff;
--radius: 10px;
--shadow-sm: 0 1px 3px rgba(0,0,0,.08),0 1px 2px rgba(0,0,0,.05);
--shadow: 0 4px 6px rgba(0,0,0,.07),0 2px 4px rgba(0,0,0,.05);
--shadow-lg: 0 10px 25px rgba(0,0,0,.1),0 4px 10px rgba(0,0,0,.06);
}
*{box-sizing:border-box;margin:0;padding:0;}
body{font-family:'DM Sans',sans-serif;background:var(--bg);color:var(--text);min-height:100vh;overflow-x:hidden;}
a{color:var(--accent);text-decoration:none;}
a:hover{color:var(--accent-h);}
code,pre,.mono{font-family:'Space Mono',monospace;}
/* ── Scrollbar ── */
::-webkit-scrollbar{width:5px;}
::-webkit-scrollbar-track{background:var(--bg);}
::-webkit-scrollbar-thumb{background:var(--border2);border-radius:3px;}
/* ── Sidebar — dark panel on light page ── */
#sidebar{
position:fixed;top:0;left:0;width:var(--sidebar-w);height:100vh;
background:var(--sidebar-bg);
display:flex;flex-direction:column;z-index:100;
transition:transform .25s ease;
box-shadow:2px 0 8px rgba(0,0,0,.12);
}
#sidebar .brand{
padding:20px 18px 16px;border-bottom:1px solid rgba(255,255,255,.07);
display:flex;align-items:center;gap:12px;
}
#sidebar .brand .logo{
width:36px;height:36px;background:var(--accent);border-radius:8px;
display:flex;align-items:center;justify-content:center;
font-family:'Space Mono',monospace;font-weight:700;font-size:13px;color:#fff;
flex-shrink:0;box-shadow:0 2px 8px rgba(37,99,235,.4);
}
#sidebar .brand h1{font-size:15px;font-weight:700;color:#fff;letter-spacing:.2px;}
#sidebar .brand small{font-size:10px;color:rgba(255,255,255,.35);display:block;margin-top:1px;}
.nav-section{padding:20px 16px 6px;font-size:9px;font-weight:700;letter-spacing:1.8px;color:rgba(255,255,255,.3);text-transform:uppercase;}
.sidebar-nav{list-style:none;padding:0 10px;}
.sidebar-nav li a{
display:flex;align-items:center;gap:10px;padding:9px 12px;
border-radius:8px;color:var(--sidebar-text);font-size:13.5px;font-weight:500;
transition:all .15s;
}
.sidebar-nav li a:hover{background:rgba(255,255,255,.07);color:#fff;}
.sidebar-nav li a.active{background:var(--accent);color:#fff;box-shadow:0 2px 8px rgba(37,99,235,.35);}
.sidebar-nav li a .bi{font-size:15px;width:18px;text-align:center;}
.sidebar-footer{margin-top:auto;padding:14px;border-top:1px solid rgba(255,255,255,.07);}
.sidebar-footer .user-card{display:flex;align-items:center;gap:10px;}
.sidebar-footer .avatar{width:32px;height:32px;border-radius:50%;background:var(--accent2);display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:700;color:#fff;flex-shrink:0;}
.sidebar-footer .user-name{font-size:13px;font-weight:600;color:#fff;}
.sidebar-footer .user-role{font-size:10px;color:rgba(255,255,255,.4);text-transform:capitalize;}
/* ── Main ── */
#main{margin-left:var(--sidebar-w);min-height:100vh;display:flex;flex-direction:column;}
/* ── Topbar ── */
#topbar{
position:sticky;top:0;z-index:50;
background:rgba(255,255,255,.92);backdrop-filter:blur(12px);
border-bottom:1px solid var(--border);
padding:11px 28px;display:flex;align-items:center;gap:14px;
box-shadow:var(--shadow-sm);
}
#topbar .page-title{font-size:17px;font-weight:700;flex:1;color:var(--text);}
.notif-btn{position:relative;background:none;border:none;color:var(--muted);font-size:20px;cursor:pointer;padding:4px 8px;border-radius:6px;transition:color .15s;}
.notif-btn:hover{color:var(--accent);background:var(--info-bg);}
.notif-badge{
position:absolute;top:-2px;right:-2px;min-width:17px;height:17px;
background:var(--danger);color:#fff;font-size:9px;font-weight:700;
border-radius:9px;display:flex;align-items:center;justify-content:center;
padding:0 4px;display:none;
}
.notif-badge.show{display:flex;}
/* ── Page content ── */
.page-body{padding:28px;flex:1;}
/* ── Cards ── */
.card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);box-shadow:var(--shadow-sm);}
.card-header{background:var(--surface2);border-bottom:1px solid var(--border);padding:13px 20px;font-weight:600;font-size:14px;color:var(--text2);border-radius:var(--radius) var(--radius) 0 0;}
.card-body{padding:20px;}
/* ── Stat cards ── */
.stat-card{
background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);
padding:20px;display:flex;align-items:center;gap:16px;
transition:all .2s;box-shadow:var(--shadow-sm);
}
.stat-card:hover{box-shadow:var(--shadow);border-color:var(--border2);transform:translateY(-1px);}
.stat-icon{width:46px;height:46px;border-radius:10px;display:flex;align-items:center;justify-content:center;font-size:21px;flex-shrink:0;}
.stat-value{font-size:26px;font-weight:700;font-family:'Space Mono',monospace;line-height:1;color:var(--text);}
.stat-label{font-size:12px;color:var(--muted);margin-top:4px;}
/* ── Badges ── */
.badge{font-size:11px;font-weight:600;padding:3px 9px;border-radius:20px;font-family:'Space Mono',monospace;letter-spacing:.2px;}
.badge-open{background:var(--info-bg);color:var(--info);border:1px solid #bae6fd;}
.badge-in_progress{background:#eff6ff;color:#2563eb;border:1px solid #bfdbfe;}
.badge-pending{background:var(--warning-bg);color:var(--warning);border:1px solid #fde68a;}
.badge-resolved{background:var(--success-bg);color:var(--success);border:1px solid #a7f3d0;}
.badge-closed{background:#f1f5f9;color:var(--muted);border:1px solid var(--border);}
.badge-low{background:var(--success-bg);color:var(--success);border:1px solid #a7f3d0;}
.badge-medium{background:var(--warning-bg);color:var(--warning);border:1px solid #fde68a;}
.badge-high{background:var(--danger-bg);color:var(--danger);border:1px solid #fecaca;}
.badge-critical{background:#fff1f2;color:#be123c;border:1px solid #fecdd3;animation:pulse-badge 2s infinite;}
@keyframes pulse-badge{0%,100%{opacity:1;}50%{opacity:.65;}}
/* ── Buttons ── */
.btn{border-radius:8px;font-size:13px;font-weight:500;padding:8px 16px;transition:all .15s;}
.btn-primary{background:var(--accent);border-color:var(--accent);color:#fff;box-shadow:0 1px 3px rgba(37,99,235,.3);}
.btn-primary:hover{background:var(--accent-h);border-color:var(--accent-h);box-shadow:0 2px 6px rgba(37,99,235,.4);}
.btn-outline-primary{border-color:var(--accent);color:var(--accent);background:transparent;}
.btn-outline-primary:hover{background:var(--accent);color:#fff;}
.btn-secondary{background:#fff;border-color:var(--border);color:var(--text2);box-shadow:var(--shadow-sm);}
.btn-secondary:hover{background:var(--surface2);border-color:var(--border2);color:var(--text);}
.btn-sm{padding:5px 12px;font-size:12px;}
/* ── Form controls ── */
.form-control,.form-select{
background:#fff;border:1px solid var(--border);color:var(--text);
border-radius:8px;padding:9px 14px;font-size:14px;
transition:border-color .15s,box-shadow .15s;
box-shadow:var(--shadow-sm);
}
.form-control:focus,.form-select:focus{
background:#fff;border-color:var(--accent);
color:var(--text);box-shadow:0 0 0 3px rgba(37,99,235,.1);
}
.form-control::placeholder{color:var(--muted2);}
.form-label{font-size:13px;font-weight:600;color:var(--text2);margin-bottom:6px;}
textarea.form-control{resize:vertical;}
/* ── Tables ── */
.table{color:var(--text);--bs-table-bg:transparent;}
.table thead th{background:var(--surface2);color:var(--muted);font-size:11px;font-weight:700;letter-spacing:.8px;text-transform:uppercase;border-bottom:1px solid var(--border);padding:11px 16px;}
.table tbody td{border-bottom:1px solid var(--border);padding:11px 16px;vertical-align:middle;font-size:14px;}
.table tbody tr:hover td{background:#f8fafc;}
.table-hover>tbody>tr:hover>*{background:#f8fafc;}
/* ── Alerts / flashes ── */
.alert{border-radius:8px;font-size:14px;padding:12px 16px;}
.alert-success{background:var(--success-bg);color:var(--success);border:1px solid #a7f3d0;}
.alert-danger{background:var(--danger-bg);color:var(--danger);border:1px solid #fecaca;}
.alert-info{background:var(--info-bg);color:var(--info);border:1px solid #bae6fd;}
.alert-warning{background:var(--warning-bg);color:var(--warning);border:1px solid #fde68a;}
/* ── Dropdown ── */
.dropdown-menu{background:#fff;border:1px solid var(--border);border-radius:var(--radius);box-shadow:var(--shadow-lg);}
.dropdown-item{color:var(--text2);font-size:13.5px;padding:8px 16px;}
.dropdown-item:hover{background:var(--surface2);color:var(--text);}
.dropdown-divider{border-color:var(--border);}
/* ── Notification panel ── */
#notif-panel{
position:fixed;top:58px;right:16px;width:360px;max-height:480px;
background:#fff;border:1px solid var(--border);border-radius:var(--radius);
box-shadow:var(--shadow-lg);z-index:1000;
display:none;overflow:hidden;flex-direction:column;
}
#notif-panel.show{display:flex;}
#notif-panel .notif-head{padding:13px 16px;border-bottom:1px solid var(--border);display:flex;align-items:center;justify-content:space-between;background:var(--surface2);}
#notif-panel .notif-head h6{font-size:13px;font-weight:700;margin:0;color:var(--text);}
#notif-panel .notif-list{overflow-y:auto;flex:1;}
.notif-item{padding:12px 16px;border-bottom:1px solid var(--border);display:flex;gap:12px;cursor:pointer;transition:background .12s;}
.notif-item:hover{background:var(--surface2);}
.notif-item.unread{border-left:3px solid var(--accent);background:#eff6ff;}
.notif-dot{width:8px;height:8px;border-radius:50%;background:var(--accent);margin-top:5px;flex-shrink:0;}
.notif-title{font-size:13px;font-weight:600;margin-bottom:2px;color:var(--text);}
.notif-msg{font-size:12px;color:var(--muted);}
.notif-time{font-size:11px;color:var(--muted2);margin-top:2px;font-family:'Space Mono',monospace;}
/* ── Chat widget ── */
#chat-fab{
position:fixed;bottom:28px;right:28px;width:52px;height:52px;
background:var(--accent);border-radius:50%;display:flex;align-items:center;justify-content:center;
cursor:pointer;box-shadow:0 4px 16px rgba(37,99,235,.4);z-index:200;
transition:transform .2s,box-shadow .2s;
}
#chat-fab:hover{transform:scale(1.08);box-shadow:0 6px 20px rgba(37,99,235,.5);}
#chat-fab .bi{font-size:22px;color:#fff;}
#chat-window{
position:fixed;bottom:90px;right:28px;width:380px;height:520px;
background:#fff;border:1px solid var(--border);border-radius:16px;
box-shadow:var(--shadow-lg);z-index:200;
display:none;flex-direction:column;overflow:hidden;
}
#chat-window.show{display:flex;}
.chat-head{background:var(--accent);padding:14px 18px;display:flex;align-items:center;gap:12px;}
.chat-head .bot-avatar{width:36px;height:36px;background:rgba(255,255,255,.2);border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:18px;}
.chat-head .bot-name{font-size:14px;font-weight:600;color:#fff;}
.chat-head .bot-status{font-size:11px;color:rgba(255,255,255,.75);}
.chat-head .close-chat{margin-left:auto;background:none;border:none;color:rgba(255,255,255,.8);font-size:18px;cursor:pointer;}
.chat-messages{flex:1;overflow-y:auto;padding:16px;display:flex;flex-direction:column;gap:10px;background:var(--bg);}
.chat-bubble{max-width:85%;padding:10px 14px;border-radius:12px;font-size:13px;line-height:1.55;}
.chat-bubble.bot{background:#fff;border:1px solid var(--border);border-radius:4px 12px 12px 12px;align-self:flex-start;color:var(--text);box-shadow:var(--shadow-sm);}
.chat-bubble.user{background:var(--accent);color:#fff;border-radius:12px 4px 12px 12px;align-self:flex-end;}
.chat-bubble a{color:var(--accent3);}
.chat-input-area{padding:12px;border-top:1px solid var(--border);display:flex;gap:8px;background:#fff;}
.chat-input-area input{flex:1;background:var(--surface2);border:1px solid var(--border);color:var(--text);border-radius:8px;padding:9px 14px;font-size:13px;}
.chat-input-area input:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px rgba(37,99,235,.1);}
.chat-send{background:var(--accent);border:none;color:#fff;padding:9px 14px;border-radius:8px;cursor:pointer;transition:background .15s;}
.chat-send:hover{background:var(--accent-h);}
.typing-dot{display:inline-block;width:6px;height:6px;border-radius:50%;background:var(--muted2);animation:typing 1.2s infinite;}
.typing-dot:nth-child(2){animation-delay:.2s;}
.typing-dot:nth-child(3){animation-delay:.4s;}
@keyframes typing{0%,60%,100%{transform:translateY(0);}30%{transform:translateY(-5px);}}
/* ── Pagination ── */
.pagination .page-link{background:#fff;border-color:var(--border);color:var(--text2);}
.pagination .page-link:hover{background:var(--surface2);border-color:var(--border2);color:var(--text);}
.pagination .active .page-link{background:var(--accent);border-color:var(--accent);color:#fff;}
/* ── Ticket detail ── */
.ticket-header{background:#fff;border:1px solid var(--border);border-radius:var(--radius);padding:24px;margin-bottom:20px;box-shadow:var(--shadow-sm);}
.comment-card{background:#fff;border:1px solid var(--border);border-radius:var(--radius);padding:16px;margin-bottom:12px;box-shadow:var(--shadow-sm);}
.comment-card.internal{border-left:3px solid var(--warning);background:var(--warning-bg);}
.comment-author{font-size:13px;font-weight:600;color:var(--text);}
.comment-time{font-size:11px;color:var(--muted);font-family:'Space Mono',monospace;}
.comment-body{font-size:14px;margin-top:8px;white-space:pre-wrap;color:var(--text2);}
.history-item{font-size:12px;color:var(--muted);padding:6px 0;border-bottom:1px solid var(--border);}
/* ── Responsive ── */
@media(max-width:768px){
#sidebar{transform:translateX(-100%);}
#sidebar.open{transform:translateX(0);}
#main{margin-left:0;}
#chat-window{width:calc(100vw - 16px);right:8px;}
}
</style>
{% block head %}{% endblock %}
</head>
<body>
{% if current_user.is_authenticated %}
<!-- ── Sidebar ── -->
<nav id="sidebar">
<div class="brand">
<div class="logo">TD</div>
<div>
<h1>TechDesk</h1>
<small>IT Helpdesk System</small>
</div>
</div>
<p class="nav-section">Main</p>
<ul class="sidebar-nav">
<li><a href="{{ url_for('tickets.dashboard') }}" class="{{ 'active' if request.endpoint == 'tickets.dashboard' }}">
<i class="bi bi-speedometer2"></i> Dashboard
</a></li>
<li><a href="{{ url_for('tickets.ticket_list') }}" class="{{ 'active' if request.endpoint == 'tickets.ticket_list' }}">
<i class="bi bi-ticket-detailed"></i> My Tickets
</a></li>
<li><a href="{{ url_for('tickets.create_ticket') }}" class="{{ 'active' if request.endpoint == 'tickets.create_ticket' }}">
<i class="bi bi-plus-circle"></i> New Ticket
</a></li>
</ul>
<p class="nav-section">Resources</p>
<ul class="sidebar-nav">
<li><a href="{{ url_for('tickets.knowledge_base') }}" class="{{ 'active' if 'knowledge_base' in request.endpoint }}">
<i class="bi bi-book"></i> Knowledge Base
</a></li>
<li><a href="{{ url_for('tickets.notifications') }}" class="{{ 'active' if request.endpoint == 'tickets.notifications' }}">
<i class="bi bi-bell"></i> Notifications
{% if unread_notifications > 0 %}<span class="badge badge-open ms-auto">{{ unread_notifications }}</span>{% endif %}
</a></li>
</ul>
{% if current_user.is_it_staff %}
<p class="nav-section">IT Management</p>
<ul class="sidebar-nav">
<li><a href="{{ url_for('admin.index') }}" class="{{ 'active' if request.endpoint == 'admin.index' }}">
<i class="bi bi-bar-chart-line"></i> IT Overview
</a></li>
<li><a href="{{ url_for('admin.all_tickets') }}" class="{{ 'active' if request.endpoint == 'admin.all_tickets' }}">
<i class="bi bi-collection"></i> All Tickets
</a></li>
<li><a href="{{ url_for('admin.kb_list') }}" class="{{ 'active' if 'admin.kb' in request.endpoint }}">
<i class="bi bi-journal-text"></i> Manage KB
</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
</a></li>
<li><a href="{{ url_for('admin.activity_logs') }}" class="{{ 'active' if request.endpoint == 'admin.activity_logs' }}">
<i class="bi bi-list-ul"></i> Activity Logs
</a></li>
{% endif %}
</ul>
{% endif %}
<div class="sidebar-footer">
<div class="user-card">
<div class="avatar">{{ current_user.full_name[0].upper() }}</div>
<div>
<div class="user-name">{{ current_user.full_name }}</div>
<div class="user-role">{{ current_user.role.replace('_',' ') }}</div>
</div>
<div class="ms-auto dropdown">
<button class="btn btn-sm btn-secondary dropdown-toggle" data-bs-toggle="dropdown" style="padding:5px 8px;">
<i class="bi bi-three-dots-vertical"></i>
</button>
<ul class="dropdown-menu dropdown-menu-end">
<li><a class="dropdown-item" href="{{ url_for('auth.profile') }}"><i class="bi bi-person me-2"></i>Profile</a></li>
<li><hr class="dropdown-divider"/></li>
<li><a class="dropdown-item" href="{{ url_for('auth.logout') }}"><i class="bi bi-box-arrow-right me-2"></i>Logout</a></li>
</ul>
</div>
</div>
</div>
</nav>
<!-- ── Main ── -->
<div id="main">
<header id="topbar">
<button class="btn btn-sm btn-secondary d-md-none" onclick="document.getElementById('sidebar').classList.toggle('open')">
<i class="bi bi-list"></i>
</button>
<div class="page-title">{% block page_title %}{% endblock %}</div>
<!-- Notification bell -->
<button class="notif-btn" onclick="toggleNotifPanel()">
<i class="bi bi-bell"></i>
<span class="notif-badge {% if unread_notifications > 0 %}show{% endif %}" id="notif-badge-count">
{{ unread_notifications if unread_notifications > 0 }}
</span>
</button>
<a href="{{ url_for('auth.profile') }}" class="btn btn-sm btn-secondary">
<i class="bi bi-person-circle me-1"></i>{{ current_user.full_name.split()[0] }}
</a>
</header>
<div class="page-body">
{% with messages = get_flashed_messages(with_categories=true) %}
{% for cat, msg in messages %}
<div class="alert alert-{{ cat }} alert-dismissible mb-3" role="alert">
{{ msg }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endfor %}
{% endwith %}
{% block content %}{% endblock %}
</div>
</div>
<!-- ── Notification Panel ── -->
<div id="notif-panel">
<div class="notif-head">
<h6><i class="bi bi-bell me-2"></i>Notifications</h6>
<button class="btn btn-sm btn-secondary" onclick="markAllRead()">Mark all read</button>
</div>
<div class="notif-list" id="notif-list">
<div class="p-3 text-center" style="color:var(--muted);font-size:13px;">Loading...</div>
</div>
<div style="padding:10px 16px;border-top:1px solid var(--border);">
<a href="{{ url_for('tickets.notifications') }}" style="font-size:12px;color:var(--accent3);">View all notifications →</a>
</div>
</div>
<!-- ── Chat FAB ── -->
<div id="chat-fab" onclick="toggleChat()" title="IT Assistant">
<i class="bi bi-robot"></i>
</div>
<div id="chat-window">
<div class="chat-head">
<div class="bot-avatar">🤖</div>
<div>
<div class="bot-name">IT Assistant</div>
<div class="bot-status">● Online</div>
</div>
<button class="close-chat" onclick="toggleChat()"><i class="bi bi-x-lg"></i></button>
</div>
<div class="chat-messages" id="chat-messages">
<div class="chat-bubble bot">
Hi! I'm your IT Assistant 👋 I can help you report issues, create tickets, or answer IT questions.<br><br>
What can I help you with today?
</div>
</div>
<div class="chat-input-area">
<input type="text" id="chat-input" placeholder="Describe your issue..." onkeydown="if(event.key==='Enter')sendChat()"/>
<button class="chat-send" onclick="sendChat()"><i class="bi bi-send-fill"></i></button>
</div>
</div>
{% else %}
<!-- Not authenticated — minimal layout -->
<div id="main" style="margin-left:0;">
<div class="page-body">
{% with messages = get_flashed_messages(with_categories=true) %}
{% for cat, msg in messages %}
<div class="alert alert-{{ cat }} alert-dismissible mb-3" role="alert">
{{ msg }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endfor %}
{% endwith %}
{{ self.content() }}
</div>
</div>
{% endif %}
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script src="https://cdn.socket.io/4.7.5/socket.io.min.js"></script>
<script>
// ── WebSocket ────────────────────────────────────────────────────────────────
{% if current_user.is_authenticated %}
const socket = io({
path: '/socket.io/',
transports: ['polling', 'websocket'], // polling first → upgrade to WS after handshake
upgrade: true,
reconnection: true,
reconnectionAttempts: 5,
reconnectionDelay: 2000,
});
socket.on('connect',()=>{console.log('[Socket] connected');});
socket.on('new_notification',(n)=>{
updateBadge(1, true);
prependNotif(n);
showToast(n.title, n.message);
});
function updateBadge(delta, increment=false){
const el = document.getElementById('notif-badge-count');
if(!el) return;
let cur = parseInt(el.textContent)||0;
const next = increment ? cur+delta : delta;
el.textContent = next>0?next:'';
el.classList.toggle('show', next>0);
}
// ── Notification panel ────────────────────────────────────────────────────────
let panelOpen = false;
function toggleNotifPanel(){
const panel = document.getElementById('notif-panel');
panelOpen = !panelOpen;
panel.classList.toggle('show', panelOpen);
if(panelOpen) loadNotifications();
}
document.addEventListener('click',(e)=>{
if(panelOpen && !e.target.closest('#notif-panel') && !e.target.closest('.notif-btn')){
panelOpen=false;
document.getElementById('notif-panel').classList.remove('show');
}
});
async function loadNotifications(){
try{
const r = await fetch('/api/notifications/unread-count');
const d = await r.json();
updateBadge(d.count);
}catch(e){}
const list = document.getElementById('notif-list');
try{
const r = await fetch('/notifications?json=1');
// Fallback: show link
list.innerHTML='<div class="p-3 text-center" style="color:var(--muted);font-size:13px;">'+
'<a href="/notifications" style="color:var(--accent3)">View all notifications</a></div>';
}catch(e){
list.innerHTML='<div class="p-3 text-center" style="color:var(--muted);font-size:13px;">Unable to load</div>';
}
}
function prependNotif(n){
const list = document.getElementById('notif-list');
const item = document.createElement('div');
item.className='notif-item unread';
item.innerHTML=`<div class="notif-dot"></div><div><div class="notif-title">${n.title}</div><div class="notif-msg">${n.message||''}</div><div class="notif-time">Just now</div></div>`;
if(n.link) item.onclick=()=>{ window.location=n.link; };
list.prepend(item);
}
async function markAllRead(){
await fetch('/api/notifications/mark-all-read',{method:'POST'});
updateBadge(0);
document.querySelectorAll('.notif-item.unread').forEach(el=>{
el.classList.remove('unread');
const dot=el.querySelector('.notif-dot');
if(dot) dot.remove();
});
}
function showToast(title, msg){
const t = document.createElement('div');
t.style.cssText='position:fixed;bottom:90px;right:28px;background:var(--surface);border:1px solid var(--border);border-left:3px solid var(--accent3);border-radius:8px;padding:12px 16px;z-index:300;max-width:300px;box-shadow:0 4px 16px rgba(0,0,0,.4);animation:slideIn .25s ease;';
t.innerHTML=`<div style="font-size:13px;font-weight:600;margin-bottom:4px;">${title}</div><div style="font-size:12px;color:var(--muted);">${msg||''}</div>`;
document.body.appendChild(t);
setTimeout(()=>t.remove(),5000);
}
// ── Chatbot ───────────────────────────────────────────────────────────────────
let chatHistory = [];
let chatOpen = false;
function toggleChat(){
chatOpen=!chatOpen;
document.getElementById('chat-window').classList.toggle('show',chatOpen);
if(chatOpen) document.getElementById('chat-input').focus();
}
async function sendChat(){
const input = document.getElementById('chat-input');
const msg = input.value.trim();
if(!msg) return;
input.value='';
appendBubble(msg,'user');
chatHistory.push({role:'user',content:msg});
const typing = appendTyping();
try{
const r = await fetch('/chatbot/message',{
method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({message:msg,history:chatHistory})
});
const d = await r.json();
typing.remove();
const reply = d.reply || 'Sorry, I encountered an error.';
appendBubble(reply,'bot');
chatHistory.push({role:'assistant',content:reply});
if(d.ticket){
const notif=document.createElement('div');
notif.className='chat-bubble bot';
notif.style.cssText='background:rgba(45,212,191,.1);border:1px solid rgba(45,212,191,.3);';
notif.innerHTML=`🎫 <strong>${d.ticket.ticket_number}</strong> — <a href="${d.ticket.url}">View Ticket</a>`;
document.getElementById('chat-messages').appendChild(notif);
}
}catch(e){typing.remove();appendBubble('Sorry, something went wrong.','bot');}
scrollChat();
}
function appendBubble(text, role){
const el=document.createElement('div');
el.className=`chat-bubble ${role}`;
el.innerHTML=text.replace(/\*\*(.*?)\*\*/g,'<strong>$1</strong>').replace(/\n/g,'<br>');
document.getElementById('chat-messages').appendChild(el);
scrollChat();
return el;
}
function appendTyping(){
const el=document.createElement('div');
el.className='chat-bubble bot';
el.innerHTML='<span class="typing-dot"></span><span class="typing-dot"></span><span class="typing-dot"></span>';
document.getElementById('chat-messages').appendChild(el);
scrollChat();
return el;
}
function scrollChat(){
const m=document.getElementById('chat-messages');
m.scrollTop=m.scrollHeight;
}
{% endif %}
</script>
<style>@keyframes slideIn{from{transform:translateX(100%);opacity:0;}to{transform:translateX(0);opacity:1;}}</style>
{% block scripts %}{% endblock %}
</body>
</html>
+110
View File
@@ -0,0 +1,110 @@
{% extends "base.html" %}
{% block title %}New Ticket{% endblock %}
{% block page_title %}Submit a New Ticket{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-lg-8">
<div class="card">
<div class="card-header">
<i class="bi bi-plus-circle me-2"></i>New Support Request
</div>
<div class="card-body">
<form method="POST" enctype="multipart/form-data">
<div class="row g-3">
<div class="col-12">
<label class="form-label">Issue Title *</label>
<input type="text" class="form-control" name="title" required
placeholder="Brief description of the issue (e.g. 'Cannot connect to VPN')"/>
</div>
<div class="col-md-6">
<label class="form-label">Category *</label>
<select class="form-select" name="category" required>
{% for cat in categories %}
<option value="{{ cat }}">{{ cat.replace('_',' ').title() }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-6">
<label class="form-label">Priority *</label>
<select class="form-select" name="priority" required>
{% for p in priorities %}
<option value="{{ p }}" {% if p == 'medium' %}selected{% endif %}>{{ p.upper() }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-6">
<label class="form-label">Location / Floor</label>
<input type="text" class="form-control" name="location" placeholder="e.g. 3rd Floor, Room 302"/>
</div>
<div class="col-md-6">
<label class="form-label">Asset Tag / Device Serial</label>
<input type="text" class="form-control" name="asset_tag" placeholder="e.g. ASSET-1234"/>
</div>
<div class="col-12">
<label class="form-label">Detailed Description *</label>
<textarea class="form-control" name="description" rows="6" required
placeholder="Please describe the issue in detail:&#10;- What were you trying to do?&#10;- What happened?&#10;- Any error messages?&#10;- When did it start?"></textarea>
</div>
<div class="col-12">
<label class="form-label">Attachments (screenshots, logs, etc.)</label>
<input type="file" class="form-control" name="attachments" multiple
accept=".png,.jpg,.jpeg,.gif,.pdf,.doc,.docx,.txt,.zip,.log"/>
<div style="font-size:11px;color:var(--muted);margin-top:5px;">
Accepted: PNG, JPG, PDF, DOC, DOCX, TXT, ZIP, LOG. Max 16 MB per file.
</div>
</div>
<!-- Priority guide -->
<div class="col-12">
<div style="background:var(--surface2);border:1px solid var(--border);border-radius:8px;padding:14px 16px;">
<div style="font-size:12px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.5px;margin-bottom:10px;">Priority Guide</div>
<div class="row g-2" style="font-size:12px;">
<div class="col-sm-3"><span class="badge badge-low me-1">LOW</span> Minor inconvenience, no work stoppage</div>
<div class="col-sm-3"><span class="badge badge-medium me-1">MEDIUM</span> Impacting productivity, workaround available</div>
<div class="col-sm-3"><span class="badge badge-high me-1">HIGH</span> Significant impact, no workaround</div>
<div class="col-sm-3"><span class="badge badge-critical me-1">CRITICAL</span> Complete outage or security incident</div>
</div>
</div>
</div>
</div>
<div class="d-flex gap-2 mt-4">
<button type="submit" class="btn btn-primary">
<i class="bi bi-send me-2"></i>Submit Ticket
</button>
<a href="{{ url_for('tickets.dashboard') }}" class="btn btn-secondary">Cancel</a>
</div>
</form>
</div>
</div>
</div>
<div class="col-lg-4 d-none d-lg-block">
<div class="card mb-3">
<div class="card-header"><i class="bi bi-robot me-2"></i>Prefer Chatting?</div>
<div class="card-body">
<p style="font-size:13px;color:var(--muted);">Let our AI assistant guide you through reporting your issue conversationally.</p>
<button class="btn btn-outline-primary w-100" onclick="toggleChat()">
<i class="bi bi-chat-dots me-2"></i>Open IT Assistant
</button>
</div>
</div>
<div class="card">
<div class="card-header"><i class="bi bi-lightbulb me-2"></i>Tips</div>
<div class="card-body" style="font-size:13px;color:var(--muted);">
<p>✅ Include error messages exactly as shown</p>
<p>✅ Mention what changed recently</p>
<p>✅ List affected users or devices</p>
<p>✅ Attach relevant screenshots or logs</p>
</div>
</div>
</div>
</div>
{% endblock %}
@@ -0,0 +1,125 @@
{% extends "base.html" %}
{% block title %}Dashboard{% endblock %}
{% block page_title %}Dashboard{% endblock %}
{% block content %}
<!-- Stats Row -->
<div class="row g-3 mb-4">
<div class="col-sm-4">
<div class="stat-card">
<div class="stat-icon" style="background:rgba(96,165,250,.1);color:var(--info);">
<i class="bi bi-ticket-detailed"></i>
</div>
<div>
<div class="stat-value">{{ open_count }}</div>
<div class="stat-label">Open Tickets</div>
</div>
</div>
</div>
<div class="col-sm-4">
<div class="stat-card">
<div class="stat-icon" style="background:rgba(0,180,216,.1);color:var(--accent3);">
<i class="bi bi-arrow-repeat"></i>
</div>
<div>
<div class="stat-value">{{ active_count }}</div>
<div class="stat-label">In Progress</div>
</div>
</div>
</div>
<div class="col-sm-4">
<div class="stat-card">
<div class="stat-icon" style="background:rgba(45,212,191,.1);color:var(--success);">
<i class="bi bi-check-circle"></i>
</div>
<div>
<div class="stat-value">{{ resolved_count }}</div>
<div class="stat-label">Resolved</div>
</div>
</div>
</div>
</div>
<div class="row g-4">
<!-- My Tickets -->
<div class="col-lg-7">
<div class="card">
<div class="card-header d-flex align-items-center justify-content-between">
<span><i class="bi bi-ticket-detailed me-2"></i>My Recent Tickets</span>
<a href="{{ url_for('tickets.create_ticket') }}" class="btn btn-primary btn-sm">
<i class="bi bi-plus-lg me-1"></i>New Ticket
</a>
</div>
<div class="card-body p-0">
{% if my_tickets %}
<table class="table mb-0">
<thead>
<tr>
<th>Ticket #</th>
<th>Title</th>
<th>Status</th>
<th>Priority</th>
<th>Date</th>
</tr>
</thead>
<tbody>
{% for t in my_tickets %}
<tr style="cursor:pointer;" onclick="window.location='/tickets/{{ t.id }}'">
<td><span class="mono" style="font-size:12px;color:var(--accent3);">{{ t.ticket_number }}</span></td>
<td>{{ t.title[:45] }}{% if t.title|length > 45 %}…{% endif %}</td>
<td><span class="badge badge-{{ t.status }}">{{ t.status.replace('_',' ').upper() }}</span></td>
<td><span class="badge badge-{{ t.priority }}">{{ t.priority.upper() }}</span></td>
<td style="font-size:12px;color:var(--muted);">{{ t.created_at.strftime('%b %d') }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="p-5 text-center" style="color:var(--muted);">
<i class="bi bi-inbox" style="font-size:36px;display:block;margin-bottom:12px;"></i>
No tickets yet. <a href="{{ url_for('tickets.create_ticket') }}">Submit your first ticket →</a>
</div>
{% endif %}
</div>
</div>
</div>
<!-- Quick Actions + KB -->
<div class="col-lg-5">
<div class="card mb-3">
<div class="card-header"><i class="bi bi-lightning-charge me-2"></i>Quick Actions</div>
<div class="card-body d-grid gap-2">
<a href="{{ url_for('tickets.create_ticket') }}" class="btn btn-primary">
<i class="bi bi-plus-circle me-2"></i>Report New Issue
</a>
<a href="{{ url_for('tickets.ticket_list') }}" class="btn btn-secondary">
<i class="bi bi-list-ul me-2"></i>View All My Tickets
</a>
<button class="btn btn-secondary" onclick="toggleChat()">
<i class="bi bi-robot me-2"></i>Ask IT Assistant
</button>
</div>
</div>
<div class="card">
<div class="card-header d-flex align-items-center justify-content-between">
<span><i class="bi bi-book me-2"></i>Knowledge Base</span>
<a href="{{ url_for('tickets.knowledge_base') }}" style="font-size:12px;color:var(--accent3);">View all →</a>
</div>
<div class="card-body p-0">
{% if articles %}
{% for art in articles %}
<a href="{{ url_for('tickets.kb_article', article_id=art.id) }}" style="display:flex;align-items:center;gap:10px;padding:11px 16px;border-bottom:1px solid var(--border);color:var(--text);">
<i class="bi bi-file-earmark-text" style="color:var(--accent3);font-size:16px;flex-shrink:0;"></i>
<span style="font-size:13px;">{{ art.title[:55] }}</span>
<span style="margin-left:auto;font-size:11px;color:var(--muted);">{{ art.view_count }} views</span>
</a>
{% endfor %}
{% else %}
<div class="p-3 text-center" style="font-size:13px;color:var(--muted);">No articles yet.</div>
{% endif %}
</div>
</div>
</div>
</div>
{% endblock %}
+104
View File
@@ -0,0 +1,104 @@
{% extends "base.html" %}
{% block title %}IT Dashboard{% endblock %}
{% block page_title %}IT Operations Dashboard{% endblock %}
{% block content %}
<!-- Stats -->
<div class="row g-3 mb-4">
{% set stat_items = [
('open', open_count, 'bi-circle', 'var(--info)', 'rgba(96,165,250,.1)', 'Open'),
('in_progress', in_progress_count, 'bi-arrow-repeat', 'var(--accent3)', 'rgba(0,180,216,.1)', 'In Progress'),
('pending', pending_count, 'bi-hourglass-split', 'var(--warning)', 'rgba(251,191,36,.1)', 'Pending'),
('resolved', resolved_count, 'bi-check2-circle', 'var(--success)', 'rgba(45,212,191,.1)', 'Resolved'),
] %}
{% for status, count, icon, color, bg, label in stat_items %}
<div class="col-6 col-xl-3">
<a href="{{ url_for('admin.all_tickets', status=status) }}" style="text-decoration:none;">
<div class="stat-card">
<div class="stat-icon" style="background:{{ bg }};color:{{ color }};">
<i class="bi {{ icon }}"></i>
</div>
<div>
<div class="stat-value" style="color:{{ color }};">{{ count }}</div>
<div class="stat-label">{{ label }}</div>
</div>
</div>
</a>
</div>
{% endfor %}
</div>
<div class="row g-4">
<!-- Assigned to me -->
<div class="col-lg-6">
<div class="card">
<div class="card-header d-flex align-items-center justify-content-between">
<span><i class="bi bi-person-check me-2"></i>Assigned to Me</span>
<a href="{{ url_for('admin.all_tickets', assigned='me') }}" style="font-size:12px;color:var(--accent3);">View all →</a>
</div>
<div class="card-body p-0">
{% if my_tickets %}
<table class="table mb-0">
<thead><tr><th>Ticket #</th><th>Title</th><th>Priority</th><th>Status</th></tr></thead>
<tbody>
{% for t in my_tickets %}
<tr style="cursor:pointer;" onclick="window.location='/tickets/{{ t.id }}'">
<td><span class="mono" style="font-size:11px;color:var(--accent3);">{{ t.ticket_number }}</span></td>
<td style="font-size:13px;">{{ t.title[:40] }}{% if t.title|length>40 %}…{% endif %}</td>
<td><span class="badge badge-{{ t.priority }}">{{ t.priority.upper() }}</span></td>
<td><span class="badge badge-{{ t.status }}">{{ t.status.replace('_',' ').upper() }}</span></td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="p-4 text-center" style="color:var(--muted);font-size:13px;"><i class="bi bi-inbox" style="font-size:28px;display:block;margin-bottom:8px;"></i>No tickets assigned to you.</div>
{% endif %}
</div>
</div>
</div>
<!-- Recent tickets -->
<div class="col-lg-6">
<div class="card">
<div class="card-header d-flex align-items-center justify-content-between">
<span><i class="bi bi-clock-history me-2"></i>Recent Tickets</span>
<a href="{{ url_for('admin.all_tickets') }}" style="font-size:12px;color:var(--accent3);">All tickets →</a>
</div>
<div class="card-body p-0">
<table class="table mb-0">
<thead><tr><th>Ticket #</th><th>User</th><th>Category</th><th>Priority</th></tr></thead>
<tbody>
{% for t in recent_tickets %}
<tr style="cursor:pointer;" onclick="window.location='/tickets/{{ t.id }}'">
<td><span class="mono" style="font-size:11px;color:var(--accent3);">{{ t.ticket_number }}</span></td>
<td style="font-size:13px;">{{ t.creator.full_name.split()[0] }}</td>
<td style="font-size:12px;color:var(--muted);">{{ t.category.replace('_',' ').title() }}</td>
<td><span class="badge badge-{{ t.priority }}">{{ t.priority.upper() }}</span></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- Quick links -->
<div class="row g-3 mt-1">
<div class="col-12">
<div class="card">
<div class="card-header"><i class="bi bi-lightning-charge me-2"></i>Quick Actions</div>
<div class="card-body d-flex flex-wrap gap-2">
<a href="{{ url_for('admin.all_tickets', status='open') }}" class="btn btn-outline-primary btn-sm"><i class="bi bi-circle me-1"></i>Open Queue</a>
<a href="{{ url_for('admin.all_tickets', assigned='unassigned') }}" class="btn btn-secondary btn-sm"><i class="bi bi-person-x me-1"></i>Unassigned</a>
<a href="{{ url_for('admin.kb_new') }}" class="btn btn-secondary btn-sm"><i class="bi bi-plus-circle me-1"></i>New KB Article</a>
{% if current_user.is_admin %}
<a href="{{ url_for('admin.users') }}" class="btn btn-secondary btn-sm"><i class="bi bi-people me-1"></i>Manage Users</a>
<a href="{{ url_for('admin.activity_logs') }}" class="btn btn-secondary btn-sm"><i class="bi bi-list-ul me-1"></i>Activity Logs</a>
{% endif %}
</div>
</div>
</div>
</div>
{% endblock %}
+250
View File
@@ -0,0 +1,250 @@
{% extends "base.html" %}
{% block title %}{{ ticket.ticket_number }}{% endblock %}
{% block page_title %}{{ ticket.ticket_number }}{% endblock %}
{% block content %}
<div class="row g-4">
<!-- Main column -->
<div class="col-lg-8">
<!-- Ticket header -->
<div class="ticket-header mb-4">
<div class="d-flex align-items-start justify-content-between gap-3 mb-3">
<h2 style="font-size:20px;font-weight:700;line-height:1.3;margin:0;">{{ ticket.title }}</h2>
<div class="d-flex gap-2 flex-shrink-0">
<span class="badge badge-{{ ticket.priority }}">{{ ticket.priority.upper() }}</span>
<span class="badge badge-{{ ticket.status }}">{{ ticket.status.replace('_',' ').upper() }}</span>
</div>
</div>
<div class="d-flex flex-wrap gap-3" style="font-size:12px;color:var(--muted);">
<span><i class="bi bi-hash me-1"></i><span class="mono">{{ ticket.ticket_number }}</span></span>
<span><i class="bi bi-person me-1"></i>{{ ticket.creator.full_name }}</span>
<span><i class="bi bi-tag me-1"></i>{{ ticket.category.replace('_',' ').title() }}</span>
<span><i class="bi bi-calendar3 me-1"></i>{{ ticket.created_at.strftime('%b %d, %Y %H:%M') }}</span>
{% if ticket.location %}<span><i class="bi bi-geo-alt me-1"></i>{{ ticket.location }}</span>{% endif %}
{% if ticket.asset_tag %}<span><i class="bi bi-cpu me-1"></i>{{ ticket.asset_tag }}</span>{% endif %}
{% if ticket.ai_generated %}<span style="color:var(--accent3);"><i class="bi bi-robot me-1"></i>AI-generated</span>{% endif %}
</div>
</div>
<!-- Description -->
<div class="card mb-4">
<div class="card-header"><i class="bi bi-file-text me-2"></i>Description</div>
<div class="card-body" style="white-space:pre-wrap;font-size:14px;line-height:1.7;">{{ ticket.description }}</div>
</div>
<!-- Attachments on ticket -->
{% set ticket_atts = ticket.attachments.filter_by(comment_id=None).all() %}
{% if ticket_atts %}
<div class="card mb-4">
<div class="card-header"><i class="bi bi-paperclip me-2"></i>Attachments</div>
<div class="card-body d-flex flex-wrap gap-2">
{% for att in ticket_atts %}
<a href="{{ url_for('tickets.download_attachment', att_id=att.id) }}"
class="btn btn-secondary btn-sm">
<i class="bi bi-download me-1"></i>{{ att.filename }}
<span style="font-size:10px;color:var(--muted);">({{ (att.file_size/1024)|int }}KB)</span>
</a>
{% endfor %}
</div>
</div>
{% endif %}
<!-- Resolution notes (IT only, or if resolved) -->
{% if ticket.resolution_notes %}
<div class="card mb-4" style="border-color:var(--success);">
<div class="card-header" style="background:rgba(45,212,191,.08);color:var(--success);">
<i class="bi bi-check-circle me-2"></i>Resolution Notes
</div>
<div class="card-body" style="white-space:pre-wrap;font-size:14px;">{{ ticket.resolution_notes }}</div>
</div>
{% endif %}
<!-- Comments -->
<div class="mb-3 d-flex align-items-center justify-content-between">
<h5 style="font-size:16px;font-weight:700;margin:0;">
<i class="bi bi-chat-dots me-2"></i>Comments
<span style="font-size:13px;color:var(--muted);">({{ comments|length }})</span>
</h5>
</div>
{% for comment in comments %}
<div class="comment-card {% if comment.is_internal %}internal{% endif %}" id="comment-{{ comment.id }}">
<div class="d-flex align-items-center justify-content-between mb-2">
<div class="d-flex align-items-center gap-2">
<div style="width:28px;height:28px;border-radius:50%;background:var(--accent2);display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:700;">
{{ comment.author.full_name[0].upper() }}
</div>
<span class="comment-author">{{ comment.author.full_name }}</span>
{% if comment.author.is_it_staff %}
<span style="font-size:10px;background:rgba(0,180,216,.15);color:var(--accent3);padding:1px 7px;border-radius:4px;font-weight:600;">IT STAFF</span>
{% endif %}
{% if comment.is_internal %}
<span style="font-size:10px;background:rgba(251,191,36,.15);color:var(--warning);padding:1px 7px;border-radius:4px;font-weight:600;">INTERNAL NOTE</span>
{% endif %}
</div>
<div class="d-flex align-items-center gap-2">
<span class="comment-time">{{ comment.created_at.strftime('%b %d, %Y %H:%M') }}</span>
{% if current_user.is_it_staff or comment.author_id == current_user.id %}
<form method="POST" action="{{ url_for('tickets.delete_comment', comment_id=comment.id) }}"
onsubmit="return confirm('Delete this comment?');">
<button type="submit" class="btn btn-sm" style="background:none;border:none;color:var(--muted);padding:2px 6px;"
title="Delete comment">
<i class="bi bi-trash"></i>
</button>
</form>
{% endif %}
</div>
</div>
<div class="comment-body">{{ comment.body }}</div>
<!-- Comment attachments -->
{% set c_atts = comment.attachments.all() %}
{% if c_atts %}
<div class="mt-2 d-flex flex-wrap gap-2">
{% for att in c_atts %}
<a href="{{ url_for('tickets.download_attachment', att_id=att.id) }}" class="btn btn-secondary btn-sm">
<i class="bi bi-download me-1"></i>{{ att.filename }}
</a>
{% endfor %}
</div>
{% endif %}
</div>
{% else %}
<div class="p-4 text-center" style="color:var(--muted);font-size:13px;">
<i class="bi bi-chat" style="font-size:28px;display:block;margin-bottom:8px;"></i>
No comments yet. Be the first to add an update.
</div>
{% endfor %}
<!-- Add comment -->
{% if ticket.status not in ('closed',) %}
<div class="card mt-4">
<div class="card-header"><i class="bi bi-chat-plus me-2"></i>Add Comment</div>
<div class="card-body">
<form method="POST" enctype="multipart/form-data">
<div class="mb-3">
<textarea class="form-control" name="body" rows="4" required
placeholder="Add your update, follow-up, or response here…"></textarea>
</div>
<div class="mb-3">
<label class="form-label">Attachments</label>
<input type="file" class="form-control" name="attachments" multiple
accept=".png,.jpg,.jpeg,.gif,.pdf,.doc,.docx,.txt,.zip,.log"/>
</div>
{% if current_user.is_it_staff %}
<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;">
<i class="bi bi-lock me-1"></i>Internal note (IT staff only)
</label>
</div>
{% endif %}
<button type="submit" class="btn btn-primary">
<i class="bi bi-send me-2"></i>Post Comment
</button>
</form>
</div>
</div>
{% endif %}
</div>
<!-- Sidebar column -->
<div class="col-lg-4">
<!-- IT Update Panel -->
{% if current_user.is_it_staff %}
<div class="card mb-3">
<div class="card-header"><i class="bi bi-pencil-square me-2"></i>Update Ticket</div>
<div class="card-body">
<form method="POST" action="{{ url_for('tickets.update_ticket', ticket_id=ticket.id) }}">
<div class="mb-3">
<label class="form-label">Status</label>
<select class="form-select" name="status">
{% for s in statuses %}
<option value="{{ s }}" {% if s == ticket.status %}selected{% endif %}>{{ s.replace('_',' ').title() }}</option>
{% endfor %}
</select>
</div>
<div class="mb-3">
<label class="form-label">Priority</label>
<select class="form-select" name="priority">
{% for p in priorities %}
<option value="{{ p }}" {% if p == ticket.priority %}selected{% endif %}>{{ p.title() }}</option>
{% endfor %}
</select>
</div>
<div class="mb-3">
<label class="form-label">Assign To</label>
<select class="form-select" name="assigned_to_id">
<option value="">— Unassigned —</option>
{% for staff in it_staff %}
<option value="{{ staff.id }}" {% if ticket.assigned_to_id == staff.id %}selected{% endif %}>
{{ staff.full_name }}
</option>
{% endfor %}
</select>
</div>
<div class="mb-3">
<label class="form-label">Due Date</label>
<input type="date" class="form-control" name="due_date"
value="{{ ticket.due_date.strftime('%Y-%m-%d') if ticket.due_date else '' }}"/>
</div>
<div class="mb-3">
<label class="form-label">Resolution Notes</label>
<textarea class="form-control" name="resolution_notes" rows="3"
placeholder="Describe how the issue was resolved…">{{ ticket.resolution_notes or '' }}</textarea>
</div>
<div class="mb-3">
<label class="form-label">Internal Notes <span style="color:var(--warning);">(IT only)</span></label>
<textarea class="form-control" name="internal_notes" rows="2"
placeholder="Private notes for IT team…">{{ ticket.internal_notes or '' }}</textarea>
</div>
<button type="submit" class="btn btn-primary w-100">
<i class="bi bi-check2 me-2"></i>Save Changes
</button>
</form>
</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>
<div class="card-body" style="font-size:13px;">
{% macro info_row(icon, label, value) %}
<div style="display:flex;gap:10px;padding:7px 0;border-bottom:1px solid var(--border);">
<i class="bi {{ icon }}" style="color:var(--muted);width:16px;text-align:center;margin-top:1px;flex-shrink:0;"></i>
<div style="color:var(--muted);">{{ label }}</div>
<div style="margin-left:auto;text-align:right;max-width:55%;">{{ value }}</div>
</div>
{% endmacro %}
{{ info_row('bi-hash', 'Number', '<span class="mono" style="font-size:11px;color:var(--accent3);">'~ticket.ticket_number~'</span>') }}
{{ info_row('bi-tag', 'Category', ticket.category.replace('_',' ').title()) }}
{{ info_row('bi-person', 'Submitted by', ticket.creator.full_name) }}
{{ info_row('bi-person-check', 'Assigned to', ticket.assignee.full_name if ticket.assignee else '—') }}
{{ info_row('bi-calendar3', 'Created', ticket.created_at.strftime('%b %d, %Y')) }}
{{ info_row('bi-calendar-check', 'Updated', ticket.updated_at.strftime('%b %d, %Y')) }}
{% if ticket.due_date %}{{ info_row('bi-alarm', 'Due Date', ticket.due_date.strftime('%b %d, %Y')) }}{% endif %}
{% if ticket.resolved_at %}{{ info_row('bi-check-circle', 'Resolved', ticket.resolved_at.strftime('%b %d, %Y')) }}{% endif %}
</div>
</div>
<!-- History -->
{% if history %}
<div class="card">
<div class="card-header"><i class="bi bi-clock-history me-2"></i>Change History</div>
<div class="card-body p-2">
{% for h in history %}
<div class="history-item">
<strong>{{ h.field_name.replace('_',' ').title() }}</strong>
changed from <em>{{ h.old_value or '—' }}</em> to <em>{{ h.new_value or '—' }}</em>
<br>
<span style="font-size:10px;">by {{ h.changer.full_name }} · {{ h.changed_at.strftime('%b %d %H:%M') }}</span>
</div>
{% endfor %}
</div>
</div>
{% endif %}
</div>
</div>
{% endblock %}
+55
View File
@@ -0,0 +1,55 @@
{% extends "base.html" %}
{% block title %}{{ article.title }}{% endblock %}
{% block page_title %}Knowledge Base{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-lg-8">
<div class="mb-3">
<a href="{{ url_for('tickets.knowledge_base') }}" style="font-size:13px;color:var(--accent3);">
← Back to Knowledge Base
</a>
</div>
<div class="card">
<div class="card-header d-flex align-items-center justify-content-between">
<span><i class="bi bi-file-earmark-text me-2"></i>Article</span>
{% if current_user.is_it_staff %}
<a href="{{ url_for('admin.kb_edit', article_id=article.id) }}" class="btn btn-secondary btn-sm">
<i class="bi bi-pencil me-1"></i>Edit
</a>
{% endif %}
</div>
<div class="card-body">
<h1 style="font-size:22px;font-weight:700;margin-bottom:12px;">{{ article.title }}</h1>
<div style="font-size:12px;color:var(--muted);margin-bottom:24px;display:flex;gap:16px;">
{% if article.category %}<span><i class="bi bi-tag me-1"></i>{{ article.category.replace('_',' ').title() }}</span>{% endif %}
<span><i class="bi bi-person me-1"></i>{{ article.author.full_name if article.author else 'IT Team' }}</span>
<span><i class="bi bi-calendar3 me-1"></i>{{ article.updated_at.strftime('%B %d, %Y') }}</span>
<span><i class="bi bi-eye me-1"></i>{{ article.view_count }} views</span>
</div>
{% if article.tags %}
<div class="mb-3">
{% for tag in article.tags.split(',') %}
<span style="background:var(--surface2);border:1px solid var(--border);border-radius:5px;padding:2px 10px;font-size:11px;color:var(--muted);margin-right:4px;">{{ tag.strip() }}</span>
{% endfor %}
</div>
{% endif %}
<div style="white-space:pre-wrap;font-size:14px;line-height:1.8;color:var(--text);">{{ article.body }}</div>
</div>
</div>
<div class="card mt-3">
<div class="card-body d-flex align-items-center justify-content-between">
<span style="font-size:13px;color:var(--muted);">Did this article solve your issue?</span>
<div class="d-flex gap-2">
<a href="{{ url_for('tickets.knowledge_base') }}" class="btn btn-secondary btn-sm">
<i class="bi bi-arrow-left me-1"></i>Back
</a>
<a href="{{ url_for('tickets.create_ticket') }}" class="btn btn-primary btn-sm">
<i class="bi bi-ticket me-1"></i>Still need help
</a>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
+60
View File
@@ -0,0 +1,60 @@
{% extends "base.html" %}
{% block title %}Knowledge Base{% endblock %}
{% block page_title %}Knowledge Base{% endblock %}
{% block content %}
<div class="row g-4">
<div class="col-lg-8">
<div class="card">
<div class="card-header d-flex align-items-center justify-content-between">
<span><i class="bi bi-book me-2"></i>Self-Service Articles</span>
{% if current_user.is_it_staff %}
<a href="{{ url_for('admin.kb_new') }}" class="btn btn-primary btn-sm">
<i class="bi bi-plus-lg me-1"></i>New Article
</a>
{% endif %}
</div>
<div class="card-body p-0">
{% if articles %}
{% for art in articles %}
<a href="{{ url_for('tickets.kb_article', article_id=art.id) }}"
style="display:flex;align-items:center;gap:14px;padding:14px 20px;border-bottom:1px solid var(--border);color:var(--text);">
<div style="width:40px;height:40px;background:rgba(0,180,216,.1);border-radius:8px;display:flex;align-items:center;justify-content:center;flex-shrink:0;">
<i class="bi bi-file-earmark-text" style="color:var(--accent3);font-size:18px;"></i>
</div>
<div style="flex:1;">
<div style="font-size:14px;font-weight:600;">{{ art.title }}</div>
<div style="font-size:12px;color:var(--muted);margin-top:2px;">
{% if art.category %}{{ art.category.replace('_',' ').title() }} · {% endif %}
{{ art.updated_at.strftime('%b %d, %Y') }}
</div>
</div>
<div style="font-size:12px;color:var(--muted);flex-shrink:0;">
<i class="bi bi-eye me-1"></i>{{ art.view_count }}
</div>
</a>
{% endfor %}
{% else %}
<div class="p-5 text-center" style="color:var(--muted);">
<i class="bi bi-journal-x" style="font-size:40px;display:block;margin-bottom:12px;"></i>
No articles published yet.
</div>
{% endif %}
</div>
</div>
</div>
<div class="col-lg-4">
<div class="card">
<div class="card-header"><i class="bi bi-lightbulb me-2"></i>Can't find an answer?</div>
<div class="card-body d-grid gap-2">
<a href="{{ url_for('tickets.create_ticket') }}" class="btn btn-primary">
<i class="bi bi-ticket me-2"></i>Submit a Ticket
</a>
<button class="btn btn-secondary" onclick="toggleChat()">
<i class="bi bi-robot me-2"></i>Ask IT Assistant
</button>
</div>
</div>
</div>
</div>
{% endblock %}
+149
View File
@@ -0,0 +1,149 @@
{% extends "base.html" %}
{% block title %}My Tickets{% endblock %}
{% block page_title %}Tickets{% endblock %}
{% block content %}
<!-- Filters -->
<div class="card mb-4">
<div class="card-body">
<form method="GET" class="row g-2 align-items-end">
<div class="col-md-4">
<label class="form-label">Search</label>
<input type="text" class="form-control" name="q" value="{{ search }}" placeholder="Search by title, ticket #, description…"/>
</div>
<div class="col-md-2">
<label class="form-label">Status</label>
<select class="form-select" name="status">
<option value="">All Statuses</option>
{% for s in statuses %}
<option value="{{ s }}" {% if s == status %}selected{% endif %}>{{ s.replace('_',' ').title() }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-2">
<label class="form-label">Priority</label>
<select class="form-select" name="priority">
<option value="">All Priorities</option>
{% for p in priorities %}
<option value="{{ p }}" {% if p == priority %}selected{% endif %}>{{ p.title() }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-2">
<label class="form-label">Category</label>
<select class="form-select" name="category">
<option value="">All Categories</option>
{% for c in categories %}
<option value="{{ c }}" {% if c == category %}selected{% endif %}>{{ c.replace('_',' ').title() }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-2 d-flex gap-2">
<button type="submit" class="btn btn-primary flex-fill"><i class="bi bi-search me-1"></i>Filter</button>
<a href="{{ url_for('tickets.ticket_list') }}" class="btn btn-secondary"><i class="bi bi-x-lg"></i></a>
</div>
</form>
</div>
</div>
<!-- Ticket table -->
<div class="card">
<div class="card-header d-flex align-items-center justify-content-between">
<span><i class="bi bi-ticket-detailed me-2"></i>
Tickets
<span style="font-size:12px;color:var(--muted);font-family:'Space Mono',monospace;">({{ tickets.total }})</span>
</span>
<a href="{{ url_for('tickets.create_ticket') }}" class="btn btn-primary btn-sm">
<i class="bi bi-plus-lg me-1"></i>New Ticket
</a>
</div>
<div class="card-body p-0">
{% if tickets.items %}
<table class="table mb-0">
<thead>
<tr>
<th>Ticket #</th>
<th>Title</th>
<th>Category</th>
<th>Status</th>
<th>Priority</th>
{% if current_user.is_it_staff %}<th>Submitted By</th>{% endif %}
<th>Date</th>
<th></th>
</tr>
</thead>
<tbody>
{% for t in tickets.items %}
<tr>
<td>
<a href="{{ url_for('tickets.ticket_detail', ticket_id=t.id) }}"
class="mono" style="font-size:12px;color:var(--accent3);">{{ t.ticket_number }}</a>
{% if t.ai_generated %}
<i class="bi bi-robot" style="font-size:11px;color:var(--muted);" title="Created via AI Assistant"></i>
{% endif %}
</td>
<td>
<a href="{{ url_for('tickets.ticket_detail', ticket_id=t.id) }}" style="color:var(--text);font-size:14px;">
{{ t.title[:55] }}{% if t.title|length > 55 %}…{% endif %}
</a>
</td>
<td style="font-size:12px;color:var(--muted);">{{ t.category.replace('_',' ').title() }}</td>
<td><span class="badge badge-{{ t.status }}">{{ t.status.replace('_',' ').upper() }}</span></td>
<td><span class="badge badge-{{ t.priority }}">{{ t.priority.upper() }}</span></td>
{% if current_user.is_it_staff %}
<td style="font-size:13px;">{{ t.creator.full_name }}</td>
{% endif %}
<td style="font-size:12px;color:var(--muted);">{{ t.created_at.strftime('%b %d, %Y') }}</td>
<td>
<a href="{{ url_for('tickets.ticket_detail', ticket_id=t.id) }}" class="btn btn-secondary btn-sm">
<i class="bi bi-eye"></i>
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
<!-- Pagination -->
{% if tickets.pages > 1 %}
<div class="d-flex justify-content-center py-3">
<nav>
<ul class="pagination mb-0">
{% if tickets.has_prev %}
<li class="page-item">
<a class="page-link" href="{{ url_for('tickets.ticket_list', page=tickets.prev_num, status=status, priority=priority, category=category, q=search) }}"> Prev</a>
</li>
{% endif %}
{% 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('tickets.ticket_list', page=p, status=status, priority=priority, category=category, q=search) }}">{{ p }}</a>
</li>
{% else %}
<li class="page-item disabled"><span class="page-link"></span></li>
{% endif %}
{% endfor %}
{% if tickets.has_next %}
<li class="page-item">
<a class="page-link" href="{{ url_for('tickets.ticket_list', page=tickets.next_num, status=status, priority=priority, category=category, q=search) }}">Next </a>
</li>
{% endif %}
</ul>
</nav>
</div>
{% endif %}
{% else %}
<div class="p-5 text-center" style="color:var(--muted);">
<i class="bi bi-inbox" style="font-size:40px;display:block;margin-bottom:12px;"></i>
No tickets found.
{% if search or status or priority or category %}
<a href="{{ url_for('tickets.ticket_list') }}" style="color:var(--accent3);">Clear filters</a>
{% else %}
<a href="{{ url_for('tickets.create_ticket') }}" style="color:var(--accent3);">Submit your first ticket →</a>
{% endif %}
</div>
{% endif %}
</div>
</div>
{% endblock %}
+80
View File
@@ -0,0 +1,80 @@
{% extends "base.html" %}
{% block title %}Notifications{% endblock %}
{% block page_title %}Notifications{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-lg-8">
<div class="card">
<div class="card-header d-flex align-items-center justify-content-between">
<span><i class="bi bi-bell me-2"></i>All Notifications</span>
<form method="POST" action="{{ url_for('tickets.mark_notifications_read') }}">
<button type="submit" class="btn btn-secondary btn-sm">
<i class="bi bi-check2-all me-1"></i>Mark All Read
</button>
</form>
</div>
<div class="card-body p-0">
{% if notifs.items %}
{% for n in notifs.items %}
<a href="{{ n.link or '#' }}"
style="display:flex;gap:14px;padding:16px 20px;border-bottom:1px solid var(--border);color:var(--text);
{% if not n.is_read %}border-left:3px solid var(--accent3);background:rgba(0,180,216,.03);{% endif %}">
<div style="margin-top:2px;flex-shrink:0;">
{% if not n.is_read %}
<div style="width:8px;height:8px;border-radius:50%;background:var(--accent3);margin-top:3px;"></div>
{% else %}
<i class="bi bi-check2" style="color:var(--muted);font-size:14px;"></i>
{% endif %}
</div>
<div style="flex:1;">
<div style="font-size:14px;font-weight:{% if not n.is_read %}600{% else %}400{% endif %};">
{{ n.title }}
</div>
{% if n.message %}
<div style="font-size:12px;color:var(--muted);margin-top:3px;">{{ n.message[:120] }}</div>
{% endif %}
<div style="font-size:11px;color:var(--muted);margin-top:5px;font-family:'Space Mono',monospace;">
{{ n.created_at.strftime('%b %d, %Y at %H:%M') }}
</div>
</div>
<div style="flex-shrink:0;align-self:center;">
<i class="bi bi-chevron-right" style="color:var(--muted);font-size:12px;"></i>
</div>
</a>
{% endfor %}
<!-- Pagination -->
{% if notifs.pages > 1 %}
<div class="d-flex justify-content-center py-3">
<nav><ul class="pagination mb-0">
{% if notifs.has_prev %}
<li class="page-item"><a class="page-link" href="{{ url_for('tickets.notifications', page=notifs.prev_num) }}"></a></li>
{% endif %}
{% for p in notifs.iter_pages() %}
{% if p %}
<li class="page-item {% if p==notifs.page %}active{% endif %}">
<a class="page-link" href="{{ url_for('tickets.notifications', page=p) }}">{{ p }}</a>
</li>
{% else %}
<li class="page-item disabled"><span class="page-link"></span></li>
{% endif %}
{% endfor %}
{% if notifs.has_next %}
<li class="page-item"><a class="page-link" href="{{ url_for('tickets.notifications', page=notifs.next_num) }}"></a></li>
{% endif %}
</ul></nav>
</div>
{% endif %}
{% else %}
<div class="p-5 text-center" style="color:var(--muted);">
<i class="bi bi-bell-slash" style="font-size:40px;display:block;margin-bottom:12px;"></i>
No notifications yet.
</div>
{% endif %}
</div>
</div>
</div>
</div>
{% endblock %}