Initial commit: Queensway Europe Telco Portal (Flask + SQLite, Docker deploy)

This commit is contained in:
Martin Belan
2026-09-11 07:23:40 +00:00
commit 919df2220e
42 changed files with 2206 additions and 0 deletions
+12
View File
@@ -0,0 +1,12 @@
{% extends "base.html" %}
{% block title %}Access Denied - Queensway Europe Telco Portal{% endblock %}
{% block content %}
<div class="modern-card p-5 text-center mx-auto" style="max-width: 480px;">
<div class="stat-icon stat-icon-danger mx-auto mb-3" style="width: 56px; height: 56px;">
{% include "icons/shield-lock-fill.svg" %}
</div>
<h4 class="fw-bold mb-2">Access Denied</h4>
<p class="text-muted mb-4">Your account has view-only access. Only admins can manage this page.</p>
<a href="/" class="btn btn-primary">Back to Home</a>
</div>
{% endblock %}
+60
View File
@@ -0,0 +1,60 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}Queensway Europe Telco Portal{% endblock %}</title>
<link rel="stylesheet" href="/static/vendor/bootstrap/bootstrap.min.css">
<link rel="stylesheet" href="/static/css/site.css">
</head>
<body>
<nav class="navbar navbar-expand-lg navbar-dark bg-dark mb-4">
<div class="container-fluid">
<a class="navbar-brand" href="/">Queensway Europe Telco Portal</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navMain">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navMain">
<ul class="navbar-nav">
<li class="nav-item">
<a class="nav-link {% if active == 'phones' %}active{% endif %}" href="/phones">
{% include "icons/phone.svg" %} Mobile Phones
</a>
</li>
<li class="nav-item">
<a class="nav-link {% if active == 'internet' %}active{% endif %}" href="/internet">
{% include "icons/wifi.svg" %} Internet Connectivity
</a>
</li>
{% if session.role == 'admin' %}
<li class="nav-item">
<a class="nav-link {% if active == 'users' %}active{% endif %}" href="/users">
{% include "icons/people.svg" %} Users
</a>
</li>
{% endif %}
</ul>
{% if session.user_id %}
<ul class="navbar-nav ms-auto align-items-lg-center">
<li class="nav-item">
<span class="nav-link disabled user-pill">
{{ session.username }}
<span class="badge rounded-pill bg-light text-dark ms-1">{{ session.role }}</span>
</span>
</li>
<li class="nav-item">
<a class="nav-link" href="/logout">{% include "icons/box-arrow-right.svg" %} Logout</a>
</li>
</ul>
{% endif %}
</div>
</div>
</nav>
<div class="container-fluid px-4 pb-5">
{% block content %}{% endblock %}
</div>
<script src="/static/vendor/bootstrap/bootstrap.bundle.min.js"></script>
<script src="/static/js/site.js"></script>
{% block scripts %}{% endblock %}
</body>
</html>
+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-box-arrow-right" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M10 12.5a.5.5 0 0 1-.5.5h-8a.5.5 0 0 1-.5-.5v-9a.5.5 0 0 1 .5-.5h8a.5.5 0 0 1 .5.5v2a.5.5 0 0 0 1 0v-2A1.5 1.5 0 0 0 9.5 2h-8A1.5 1.5 0 0 0 0 3.5v9A1.5 1.5 0 0 0 1.5 14h8a1.5 1.5 0 0 0 1.5-1.5v-2a.5.5 0 0 0-1 0z"/>
<path fill-rule="evenodd" d="M15.854 8.354a.5.5 0 0 0 0-.708l-3-3a.5.5 0 0 0-.708.708L14.293 7.5H5.5a.5.5 0 0 0 0 1h8.793l-2.147 2.146a.5.5 0 0 0 .708.708z"/>
</svg>

After

Width:  |  Height:  |  Size: 548 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-building" viewBox="0 0 16 16">
<path d="M4 2.5a.5.5 0 0 1 .5-.5h1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-1a.5.5 0 0 1-.5-.5zm3 0a.5.5 0 0 1 .5-.5h1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-1a.5.5 0 0 1-.5-.5zm3.5-.5a.5.5 0 0 0-.5.5v1a.5.5 0 0 0 .5.5h1a.5.5 0 0 0 .5-.5v-1a.5.5 0 0 0-.5-.5zM4 5.5a.5.5 0 0 1 .5-.5h1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-1a.5.5 0 0 1-.5-.5zM7.5 5a.5.5 0 0 0-.5.5v1a.5.5 0 0 0 .5.5h1a.5.5 0 0 0 .5-.5v-1a.5.5 0 0 0-.5-.5zm2.5.5a.5.5 0 0 1 .5-.5h1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-1a.5.5 0 0 1-.5-.5zM4.5 8a.5.5 0 0 0-.5.5v1a.5.5 0 0 0 .5.5h1a.5.5 0 0 0 .5-.5v-1a.5.5 0 0 0-.5-.5zm2.5.5a.5.5 0 0 1 .5-.5h1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-1a.5.5 0 0 1-.5-.5zm3.5-.5a.5.5 0 0 0-.5.5v1a.5.5 0 0 0 .5.5h1a.5.5 0 0 0 .5-.5v-1a.5.5 0 0 0-.5-.5z"/>
<path d="M2 1a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v14a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1zm11 0H3v14h3v-2.5a.5.5 0 0 1 .5-.5h3a.5.5 0 0 1 .5.5V15h3z"/>
</svg>

After

Width:  |  Height:  |  Size: 1011 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-calendar-check" viewBox="0 0 16 16">
<path d="M10.854 7.146a.5.5 0 0 1 0 .708l-3 3a.5.5 0 0 1-.708 0l-1.5-1.5a.5.5 0 1 1 .708-.708L7.5 9.793l2.646-2.647a.5.5 0 0 1 .708 0"/>
<path d="M3.5 0a.5.5 0 0 1 .5.5V1h8V.5a.5.5 0 0 1 1 0V1h1a2 2 0 0 1 2 2v11a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V3a2 2 0 0 1 2-2h1V.5a.5.5 0 0 1 .5-.5M1 4v10a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V4z"/>
</svg>

After

Width:  |  Height:  |  Size: 468 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-calendar-x" viewBox="0 0 16 16">
<path d="M6.146 7.146a.5.5 0 0 1 .708 0L8 8.293l1.146-1.147a.5.5 0 1 1 .708.708L8.707 9l1.147 1.146a.5.5 0 0 1-.708.708L8 9.707l-1.146 1.147a.5.5 0 0 1-.708-.708L7.293 9 6.146 7.854a.5.5 0 0 1 0-.708"/>
<path d="M3.5 0a.5.5 0 0 1 .5.5V1h8V.5a.5.5 0 0 1 1 0V1h1a2 2 0 0 1 2 2v11a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V3a2 2 0 0 1 2-2h1V.5a.5.5 0 0 1 .5-.5M1 4v10a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V4z"/>
</svg>

After

Width:  |  Height:  |  Size: 530 B

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-collection" viewBox="0 0 16 16">
<path d="M2.5 3.5a.5.5 0 0 1 0-1h11a.5.5 0 0 1 0 1zm2-2a.5.5 0 0 1 0-1h7a.5.5 0 0 1 0 1zM0 13a1.5 1.5 0 0 0 1.5 1.5h13A1.5 1.5 0 0 0 16 13V6a1.5 1.5 0 0 0-1.5-1.5h-13A1.5 1.5 0 0 0 0 6zm1.5.5A.5.5 0 0 1 1 13V6a.5.5 0 0 1 .5-.5h13a.5.5 0 0 1 .5.5v7a.5.5 0 0 1-.5.5z"/>
</svg>

After

Width:  |  Height:  |  Size: 406 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-exclamation-triangle" viewBox="0 0 16 16">
<path d="M7.938 2.016A.13.13 0 0 1 8.002 2a.13.13 0 0 1 .063.016.15.15 0 0 1 .054.057l6.857 11.667c.036.06.035.124.002.183a.2.2 0 0 1-.054.06.1.1 0 0 1-.066.017H1.146a.1.1 0 0 1-.066-.017.2.2 0 0 1-.054-.06.18.18 0 0 1 .002-.183L7.884 2.073a.15.15 0 0 1 .054-.057m1.044-.45a1.13 1.13 0 0 0-1.96 0L.165 13.233c-.457.778.091 1.767.98 1.767h13.713c.889 0 1.438-.99.98-1.767z"/>
<path d="M7.002 12a1 1 0 1 1 2 0 1 1 0 0 1-2 0M7.1 5.995a.905.905 0 1 1 1.8 0l-.35 3.507a.552.552 0 0 1-1.1 0z"/>
</svg>

After

Width:  |  Height:  |  Size: 639 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-patch-check" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M10.354 6.146a.5.5 0 0 1 0 .708l-3 3a.5.5 0 0 1-.708 0l-1.5-1.5a.5.5 0 1 1 .708-.708L7 8.793l2.646-2.647a.5.5 0 0 1 .708 0"/>
<path d="m10.273 2.513-.921-.944.715-.698.622.637.89-.011a2.89 2.89 0 0 1 2.924 2.924l-.01.89.636.622a2.89 2.89 0 0 1 0 4.134l-.637.622.011.89a2.89 2.89 0 0 1-2.924 2.924l-.89-.01-.622.636a2.89 2.89 0 0 1-4.134 0l-.622-.637-.89.011a2.89 2.89 0 0 1-2.924-2.924l.01-.89-.636-.622a2.89 2.89 0 0 1 0-4.134l.637-.622-.011-.89a2.89 2.89 0 0 1 2.924-2.924l.89.01.622-.636a2.89 2.89 0 0 1 4.134 0l-.715.698a1.89 1.89 0 0 0-2.704 0l-.92.944-1.32-.016a1.89 1.89 0 0 0-1.911 1.912l.016 1.318-.944.921a1.89 1.89 0 0 0 0 2.704l.944.92-.016 1.32a1.89 1.89 0 0 0 1.912 1.911l1.318-.016.921.944a1.89 1.89 0 0 0 2.704 0l.92-.944 1.32.016a1.89 1.89 0 0 0 1.911-1.912l-.016-1.318.944-.921a1.89 1.89 0 0 0 0-2.704l-.944-.92.016-1.32a1.89 1.89 0 0 0-1.912-1.911z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-patch-exclamation" viewBox="0 0 16 16">
<path d="M7.001 11a1 1 0 1 1 2 0 1 1 0 0 1-2 0M7.1 4.995a.905.905 0 1 1 1.8 0l-.35 3.507a.553.553 0 0 1-1.1 0z"/>
<path d="m10.273 2.513-.921-.944.715-.698.622.637.89-.011a2.89 2.89 0 0 1 2.924 2.924l-.01.89.636.622a2.89 2.89 0 0 1 0 4.134l-.637.622.011.89a2.89 2.89 0 0 1-2.924 2.924l-.89-.01-.622.636a2.89 2.89 0 0 1-4.134 0l-.622-.637-.89.011a2.89 2.89 0 0 1-2.924-2.924l.01-.89-.636-.622a2.89 2.89 0 0 1 0-4.134l.637-.622-.011-.89a2.89 2.89 0 0 1 2.924-2.924l.89.01.622-.636a2.89 2.89 0 0 1 4.134 0l-.715.698a1.89 1.89 0 0 0-2.704 0l-.92.944-1.32-.016a1.89 1.89 0 0 0-1.911 1.912l.016 1.318-.944.921a1.89 1.89 0 0 0 0 2.704l.944.92-.016 1.32a1.89 1.89 0 0 0 1.912 1.911l1.318-.016.921.944a1.89 1.89 0 0 0 2.704 0l.92-.944 1.32.016a1.89 1.89 0 0 0 1.911-1.912l-.016-1.318.944-.921a1.89 1.89 0 0 0 0-2.704l-.944-.92.016-1.32a1.89 1.89 0 0 0-1.912-1.911z"/>
</svg>

After

Width:  |  Height:  |  Size: 1006 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-pencil-square" viewBox="0 0 16 16">
<path d="M15.502 1.94a.5.5 0 0 1 0 .706L14.459 3.69l-2-2L13.502.646a.5.5 0 0 1 .707 0l1.293 1.293zm-1.75 2.456-2-2L4.939 9.21a.5.5 0 0 0-.121.196l-.805 2.414a.25.25 0 0 0 .316.316l2.414-.805a.5.5 0 0 0 .196-.12l6.813-6.814z"/>
<path fill-rule="evenodd" d="M1 13.5A1.5 1.5 0 0 0 2.5 15h11a1.5 1.5 0 0 0 1.5-1.5v-6a.5.5 0 0 0-1 0v6a.5.5 0 0 1-.5.5h-11a.5.5 0 0 1-.5-.5v-11a.5.5 0 0 1 .5-.5H9a.5.5 0 0 0 0-1H2.5A1.5 1.5 0 0 0 1 2.5z"/>
</svg>

After

Width:  |  Height:  |  Size: 576 B

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-people" viewBox="0 0 16 16">
<path d="M15 14s1 0 1-1-1-4-5-4-5 3-5 4 1 1 1 1zm-7.978-1L7 12.996c.001-.264.167-1.03.76-1.72C8.312 10.629 9.282 10 11 10c1.717 0 2.687.63 3.24 1.276.593.69.758 1.457.76 1.72l-.008.002-.014.002zM11 7a2 2 0 1 0 0-4 2 2 0 0 0 0 4m3-2a3 3 0 1 1-6 0 3 3 0 0 1 6 0M6.936 9.28a6 6 0 0 0-1.23-.247A7 7 0 0 0 5 9c-4 0-5 3-5 4q0 1 1 1h4.216A2.24 2.24 0 0 1 5 13c0-1.01.377-2.042 1.09-2.904.243-.294.526-.569.846-.816M4.92 10A5.5 5.5 0 0 0 4 13H1c0-.26.164-1.03.76-1.724.545-.636 1.492-1.256 3.16-1.275ZM1.5 5.5a3 3 0 1 1 6 0 3 3 0 0 1-6 0m3-2a2 2 0 1 0 0 4 2 2 0 0 0 0-4"/>
</svg>

After

Width:  |  Height:  |  Size: 699 B

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-phone-fill" viewBox="0 0 16 16">
<path d="M3 2a2 2 0 0 1 2-2h6a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2zm6 11a1 1 0 1 0-2 0 1 1 0 0 0 2 0"/>
</svg>

After

Width:  |  Height:  |  Size: 252 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-phone icon" viewBox="0 0 16 16">
<path d="M11 1a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1zM5 0a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2z"/>
<path d="M8 14a1 1 0 1 0 0-2 1 1 0 0 0 0 2"/>
</svg>

After

Width:  |  Height:  |  Size: 336 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-plus-circle" viewBox="0 0 16 16">
<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14m0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16"/>
<path d="M8 4a.5.5 0 0 1 .5.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3A.5.5 0 0 1 8 4"/>
</svg>

After

Width:  |  Height:  |  Size: 334 B

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-router icon" viewBox="0 0 16 16">
<path d="M5.525 3.025a3.5 3.5 0 0 1 4.95 0 .5.5 0 1 0 .707-.707 4.5 4.5 0 0 0-6.364 0 .5.5 0 0 0 .707.707"/>
<path d="M6.94 4.44a1.5 1.5 0 0 1 2.12 0 .5.5 0 0 0 .708-.708 2.5 2.5 0 0 0-3.536 0 .5.5 0 0 0 .707.707ZM2.5 11a.5.5 0 1 1 0-1 .5.5 0 0 1 0 1m4.5-.5a.5.5 0 1 0 1 0 .5.5 0 0 0-1 0m2.5.5a.5.5 0 1 1 0-1 .5.5 0 0 1 0 1m1.5-.5a.5.5 0 1 0 1 0 .5.5 0 0 0-1 0m2 0a.5.5 0 1 0 1 0 .5.5 0 0 0-1 0"/>
<path d="M2.974 2.342a.5.5 0 1 0-.948.316L3.806 8H1.5A1.5 1.5 0 0 0 0 9.5v2A1.5 1.5 0 0 0 1.5 13H2a.5.5 0 0 0 .5.5h2A.5.5 0 0 0 5 13h6a.5.5 0 0 0 .5.5h2a.5.5 0 0 0 .5-.5h.5a1.5 1.5 0 0 0 1.5-1.5v-2A1.5 1.5 0 0 0 14.5 8h-2.306l1.78-5.342a.5.5 0 1 0-.948-.316L11.14 8H4.86zM14.5 9a.5.5 0 0 1 .5.5v2a.5.5 0 0 1-.5.5h-13a.5.5 0 0 1-.5-.5v-2a.5.5 0 0 1 .5-.5z"/>
<path d="M8.5 5.5a.5.5 0 1 1-1 0 .5.5 0 0 1 1 0"/>
</svg>

After

Width:  |  Height:  |  Size: 952 B

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-search" viewBox="0 0 16 16">
<path d="M11.742 10.344a6.5 6.5 0 1 0-1.397 1.398h-.001q.044.06.098.115l3.85 3.85a1 1 0 0 0 1.415-1.414l-3.85-3.85a1 1 0 0 0-.115-.1zM12 6.5a5.5 5.5 0 1 1-11 0 5.5 5.5 0 0 1 11 0"/>
</svg>

After

Width:  |  Height:  |  Size: 316 B

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-shield-lock-fill" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M8 0c-.69 0-1.843.265-2.928.56-1.11.3-2.229.655-2.887.87a1.54 1.54 0 0 0-1.044 1.262c-.596 4.477.787 7.795 2.465 9.99a11.8 11.8 0 0 0 2.517 2.453c.386.273.744.482 1.048.625.28.132.581.24.829.24s.548-.108.829-.24a7 7 0 0 0 1.048-.625 11.8 11.8 0 0 0 2.517-2.453c1.678-2.195 3.061-5.513 2.465-9.99a1.54 1.54 0 0 0-1.044-1.263 63 63 0 0 0-2.887-.87C9.843.266 8.69 0 8 0m0 5a1.5 1.5 0 0 1 .5 2.915l.385 1.99a.5.5 0 0 1-.491.595h-.788a.5.5 0 0 1-.49-.595l.384-1.99A1.5 1.5 0 0 1 8 5"/>
</svg>

After

Width:  |  Height:  |  Size: 654 B

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-shop" viewBox="0 0 16 16">
<path d="M2.97 1.35A1 1 0 0 1 3.73 1h8.54a1 1 0 0 1 .76.35l2.609 3.044A1.5 1.5 0 0 1 16 5.37v.255a2.375 2.375 0 0 1-4.25 1.458A2.37 2.37 0 0 1 9.875 8 2.37 2.37 0 0 1 8 7.083 2.37 2.37 0 0 1 6.125 8a2.37 2.37 0 0 1-1.875-.917A2.375 2.375 0 0 1 0 5.625V5.37a1.5 1.5 0 0 1 .361-.976zm1.78 4.275a1.375 1.375 0 0 0 2.75 0 .5.5 0 0 1 1 0 1.375 1.375 0 0 0 2.75 0 .5.5 0 0 1 1 0 1.375 1.375 0 1 0 2.75 0V5.37a.5.5 0 0 0-.12-.325L12.27 2H3.73L1.12 5.045A.5.5 0 0 0 1 5.37v.255a1.375 1.375 0 0 0 2.75 0 .5.5 0 0 1 1 0M1.5 8.5A.5.5 0 0 1 2 9v6h1v-5a1 1 0 0 1 1-1h3a1 1 0 0 1 1 1v5h6V9a.5.5 0 0 1 1 0v6h.5a.5.5 0 0 1 0 1H.5a.5.5 0 0 1 0-1H1V9a.5.5 0 0 1 .5-.5M4 15h3v-5H4zm5-5a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1v3a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1zm3 0h-2v3h2z"/>
</svg>

After

Width:  |  Height:  |  Size: 878 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-trash" viewBox="0 0 16 16">
<path d="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5m2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5m3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0z"/>
<path d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1H6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1h3.5a1 1 0 0 1 1 1zM4.118 4 4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4zM2.5 3h11V2h-11z"/>
</svg>

After

Width:  |  Height:  |  Size: 540 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-wifi icon" viewBox="0 0 16 16">
<path d="M15.384 6.115a.485.485 0 0 0-.047-.736A12.44 12.44 0 0 0 8 3C5.259 3 2.723 3.882.663 5.379a.485.485 0 0 0-.048.736.52.52 0 0 0 .668.05A11.45 11.45 0 0 1 8 4c2.507 0 4.827.802 6.716 2.164.205.148.49.13.668-.049"/>
<path d="M13.229 8.271a.482.482 0 0 0-.063-.745A9.46 9.46 0 0 0 8 6c-1.905 0-3.68.56-5.166 1.526a.48.48 0 0 0-.063.745.525.525 0 0 0 .652.065A8.46 8.46 0 0 1 8 7a8.46 8.46 0 0 1 4.576 1.336c.206.132.48.108.653-.065m-2.183 2.183c.226-.226.185-.605-.1-.75A6.5 6.5 0 0 0 8 9c-1.06 0-2.062.254-2.946.704-.285.145-.326.524-.1.75l.015.015c.16.16.407.19.611.09A5.5 5.5 0 0 1 8 10c.868 0 1.69.201 2.42.56.203.1.45.07.61-.091zM9.06 12.44c.196-.196.198-.52-.04-.66A2 2 0 0 0 8 11.5a2 2 0 0 0-1.02.28c-.238.14-.236.464-.04.66l.706.706a.5.5 0 0 0 .707 0l.707-.707z"/>
</svg>

After

Width:  |  Height:  |  Size: 917 B

+40
View File
@@ -0,0 +1,40 @@
{% extends "base.html" %}
{% block title %}Queensway Europe Telco Portal{% endblock %}
{% block content %}
<div class="text-center mb-5 hero-fade-in">
<img src="/static/img/queensway-europe-logo.png" alt="Queensway Europe" class="hero-logo mb-3">
<h1 class="display-6 fw-bold mb-1">Telco Portal</h1>
<p class="text-muted mb-0">Mobile phones and restaurant internet connectivity, in one place.</p>
</div>
<div class="row g-4 justify-content-center">
<div class="col-md-5">
<a href="/phones" class="text-decoration-none">
<div class="card tile-card tile-phones shadow-sm h-100">
<div class="card-body text-center py-5">
<div class="tile-icon-wrap">
{% include "icons/phone-fill.svg" %}
</div>
<h5 class="card-title">Mobile Phones</h5>
<p class="card-text text-muted">Phone numbers, plans, contract expiration, replacement eligibility, owner and venue.</p>
<span class="btn btn-primary">Open</span>
</div>
</div>
</a>
</div>
<div class="col-md-5">
<a href="/internet" class="text-decoration-none">
<div class="card tile-card tile-internet shadow-sm h-100">
<div class="card-body text-center py-5">
<div class="tile-icon-wrap">
{% include "icons/router.svg" %}
</div>
<h5 class="card-title">Internet Connectivity</h5>
<p class="card-text text-muted">Internet connections at each restaurant venue.</p>
<span class="btn btn-primary">Open</span>
</div>
</div>
</a>
</div>
</div>
{% endblock %}
+148
View File
@@ -0,0 +1,148 @@
{% extends "base.html" %}
{% block title %}Internet Connectivity - Queensway Europe Telco Portal{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-3">
<h2 class="mb-0 d-flex align-items-center gap-2">{% include "icons/wifi.svg" %} Internet Connectivity</h2>
{% if session.role == 'admin' %}
<button type="button" class="btn btn-success" onclick="openConnModal(null)">+ Add Connection</button>
{% endif %}
</div>
<div class="table-responsive">
<table class="table table-striped table-hover align-middle bg-white">
<thead class="table-dark">
<tr>
<th>Venue</th>
<th>ISP Provider</th>
<th>Plan</th>
<th>Contract Expiration</th>
<th>Notes</th>
{% if session.role == 'admin' %}<th class="text-end">Actions</th>{% endif %}
</tr>
</thead>
<tbody>
{% for c in connections %}
<tr>
<td>{{ c.venue }}</td>
<td>{{ c.isp_provider or "" }}</td>
<td>{{ c.plan or "" }}</td>
<td>{{ c.contract_expiration.strftime("%d-%m-%Y") if c.contract_expiration else "" }}</td>
<td>{{ c.notes or "" }}</td>
{% if session.role == 'admin' %}
<td class="text-end">
<button type="button" class="btn btn-sm btn-outline-primary"
onclick='openConnModal({{ c.to_dict() | tojson }})'>Edit</button>
<button type="button" class="btn btn-sm btn-outline-danger"
onclick="deleteConn({{ c.id }}, {{ c.venue | tojson }})">Delete</button>
</td>
{% endif %}
</tr>
{% else %}
<tr><td colspan="{{ 6 if session.role == 'admin' else 5 }}" class="text-center text-muted py-4">No internet connections recorded yet.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- Edit / Add modal -->
<div class="modal fade" id="connModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<form id="connForm">
<div class="modal-header">
<h5 class="modal-title" id="connModalTitle">Edit Connection</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<input type="hidden" id="connId">
<div class="mb-3">
<label class="form-label">Venue</label>
<input type="text" class="form-control" id="connVenue" required>
</div>
<div class="mb-3">
<label class="form-label">ISP Provider</label>
<input type="text" class="form-control" id="connProvider">
</div>
<div class="mb-3">
<label class="form-label">Plan</label>
<input type="text" class="form-control" id="connPlan">
</div>
<div class="mb-3">
<label class="form-label">Contract Expiration</label>
<input type="date" class="form-control" id="connContractExpiration">
</div>
<div class="mb-3">
<label class="form-label">Notes</label>
<textarea class="form-control" id="connNotes" rows="3"></textarea>
</div>
<div id="connError" class="text-danger small"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary">Save</button>
</div>
</form>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
const connModalEl = document.getElementById('connModal');
const connModal = new bootstrap.Modal(connModalEl);
function openConnModal(conn) {
document.getElementById('connForm').reset();
document.getElementById('connError').textContent = '';
if (conn) {
document.getElementById('connModalTitle').textContent = 'Edit Connection';
document.getElementById('connId').value = conn.id;
document.getElementById('connVenue').value = conn.venue || '';
document.getElementById('connProvider').value = conn.isp_provider || '';
document.getElementById('connPlan').value = conn.plan || '';
document.getElementById('connContractExpiration').value = conn.contract_expiration || '';
document.getElementById('connNotes').value = conn.notes || '';
} else {
document.getElementById('connModalTitle').textContent = 'Add Connection';
document.getElementById('connId').value = '';
}
connModal.show();
}
document.getElementById('connForm').addEventListener('submit', async function (e) {
e.preventDefault();
const id = document.getElementById('connId').value;
const payload = {
venue: document.getElementById('connVenue').value,
isp_provider: document.getElementById('connProvider').value,
plan: document.getElementById('connPlan').value,
contract_expiration: document.getElementById('connContractExpiration').value,
notes: document.getElementById('connNotes').value,
};
const url = id ? `/api/internet/${id}` : '/api/internet';
const method = id ? 'PUT' : 'POST';
const resp = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (resp.ok) {
window.location.reload();
} else {
const data = await resp.json().catch(() => ({}));
document.getElementById('connError').textContent = data.error || 'Failed to save. Please check the fields.';
}
});
async function deleteConn(id, venue) {
if (!confirm(`Delete internet connection for "${venue}"? This cannot be undone.`)) return;
const resp = await fetch(`/api/internet/${id}`, { method: 'DELETE' });
if (resp.ok) {
window.location.reload();
} else {
alert('Failed to delete.');
}
}
</script>
{% endblock %}
+38
View File
@@ -0,0 +1,38 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Sign in - Queensway Europe Telco Portal</title>
<link rel="stylesheet" href="/static/vendor/bootstrap/bootstrap.min.css">
<link rel="stylesheet" href="/static/css/site.css">
</head>
<body class="login-body">
<div class="login-wrap">
<div class="login-card hero-fade-in">
<div class="text-center mb-4">
<img src="/static/img/queensway-europe-logo.png" alt="Queensway Europe" class="login-logo mb-3">
<h1 class="h4 fw-bold mb-1 d-flex align-items-center justify-content-center gap-2">
{% include "icons/shield-lock-fill.svg" %} Telco Portal
</h1>
<p class="text-muted small mb-0">Sign in to continue</p>
</div>
{% if error %}
<div class="alert alert-danger py-2 small" role="alert">{{ error }}</div>
{% endif %}
<form method="post" action="/login">
<input type="hidden" name="next" value="{{ next }}">
<div class="mb-3">
<label class="form-label" for="username">Username</label>
<input type="text" class="form-control" id="username" name="username" autocomplete="username" required autofocus>
</div>
<div class="mb-4">
<label class="form-label" for="password">Password</label>
<input type="password" class="form-control" id="password" name="password" autocomplete="current-password" required>
</div>
<button type="submit" class="btn btn-primary w-100">Sign In</button>
</form>
</div>
</div>
</body>
</html>
+360
View File
@@ -0,0 +1,360 @@
{% extends "base.html" %}
{% block title %}Mobile Phones - Queensway Europe Telco Portal{% endblock %}
{% block content %}
<div class="d-flex flex-wrap justify-content-between align-items-center mb-4 gap-2">
<h2 class="mb-0 d-flex align-items-center gap-2">
{% include "icons/phone.svg" %} Mobile Phones
<span class="carrier-badge" title="Carrier">Orange</span>
</h2>
{% if session.role == 'admin' %}
<button type="button" class="btn btn-success d-inline-flex align-items-center gap-2" onclick="openPhoneModal(null)">
{% include "icons/plus-circle.svg" %} Add Phone
</button>
{% endif %}
</div>
<div class="row g-3 mb-4">
<div class="col-6 col-lg-3">
<div class="stat-card">
<div class="stat-icon stat-icon-total">{% include "icons/collection.svg" %}</div>
<div>
<div class="stat-value">{{ stats.total }}</div>
<div class="stat-label">Total Phones</div>
</div>
</div>
</div>
<div class="col-6 col-lg-3">
<div class="stat-card">
<div class="stat-icon stat-icon-warning">{% include "icons/exclamation-triangle.svg" %}</div>
<div>
<div class="stat-value">{{ stats.expiring_soon }}</div>
<div class="stat-label">Expiring Soon</div>
</div>
</div>
</div>
<div class="col-6 col-lg-3">
<div class="stat-card">
<div class="stat-icon stat-icon-danger">{% include "icons/calendar-x.svg" %}</div>
<div>
<div class="stat-value">{{ stats.expired }}</div>
<div class="stat-label">Expired</div>
</div>
</div>
</div>
<div class="col-6 col-lg-3">
<div class="stat-card">
<div class="stat-icon stat-icon-success">{% include "icons/calendar-check.svg" %}</div>
<div>
<div class="stat-value">{{ stats.eligible_now }}</div>
<div class="stat-label">Eligible Now</div>
</div>
</div>
</div>
<div class="col-6 col-lg-3">
<div class="stat-card">
<div class="stat-icon stat-icon-headoffice">{% include "icons/building.svg" %}</div>
<div>
<div class="stat-value">{{ stats.headoffice }}</div>
<div class="stat-label">QWE Headoffice &middot; {{ stats.headoffice_pct }}%</div>
</div>
</div>
</div>
<div class="col-6 col-lg-3">
<div class="stat-card">
<div class="stat-icon stat-icon-restaurant">{% include "icons/shop.svg" %}</div>
<div>
<div class="stat-value">{{ stats.restaurant }}</div>
<div class="stat-label">KFC Restaurant &middot; {{ stats.restaurant_pct }}%</div>
</div>
</div>
</div>
<div class="col-6 col-lg-3">
<div class="stat-card">
<div class="stat-icon stat-icon-verified">{% include "icons/patch-check.svg" %}</div>
<div>
<div class="stat-value">{{ stats.verified }}</div>
<div class="stat-label">Verified</div>
</div>
</div>
</div>
<div class="col-6 col-lg-3">
<div class="stat-card">
<div class="stat-icon stat-icon-unverified">{% include "icons/patch-exclamation.svg" %}</div>
<div>
<div class="stat-value">{{ stats.not_verified }}</div>
<div class="stat-label">Not Verified</div>
</div>
</div>
</div>
</div>
<div class="modern-card mb-0">
<div class="table-toolbar d-flex flex-wrap align-items-center justify-content-between gap-2">
<div class="search-box">
{% include "icons/search.svg" %}
<input type="text" id="phoneSearch" class="form-control" placeholder="Search phones, owners, venues, comments...">
</div>
<div class="text-muted small" id="phoneRowCount"></div>
</div>
<div class="table-responsive">
<table class="table table-hover align-middle mb-0 modern-table" id="phonesTable">
<thead>
<tr>
{% macro sort_link(field, label) -%}
{% set next_dir = 'desc' if sort == field and direction == 'asc' else 'asc' %}
<th>
<a href="{{ url_for('phones_page', sort=field, dir=next_dir) }}" class="sort-link d-inline-flex align-items-center gap-1">
{{ label }}
{% if sort == field %}
<span class="sort-active">{{ '▲' if direction == 'asc' else '▼' }}</span>
{% else %}
<span class="sort-inactive">↕</span>
{% endif %}
</a>
</th>
{%- endmacro %}
{{ sort_link('phone_number', 'Phone Number') }}
{{ sort_link('current_owner', 'Current Owner') }}
{{ sort_link('plan', 'Plan') }}
{{ sort_link('contract_expiration', 'Contract Expiration') }}
{{ sort_link('eligible_for_replacement', 'Eligibility for Replacement') }}
{{ sort_link('venue', 'Venue') }}
{{ sort_link('verified', 'Verified') }}
{{ sort_link('comment', 'Comment') }}
{% if session.role == 'admin' %}<th class="text-end">Actions</th>{% endif %}
</tr>
</thead>
<tbody>
{% for p in phones %}
<tr>
<td class="fw-semibold">{{ p.phone_number }}</td>
<td>{{ p.current_owner or "" }}</td>
<td>{{ p.plan or "" }}</td>
<td>
{% if p.contract_expiration %}
{% if p.contract_expiration < today %}
<span class="badge rounded-pill bg-danger-subtle text-danger-emphasis">Expired</span>
{% elif (p.contract_expiration - today).days <= 60 %}
<span class="badge rounded-pill bg-warning-subtle text-warning-emphasis">Expiring soon</span>
{% endif %}
<span class="d-block text-muted small mt-1">{{ p.contract_expiration.strftime("%d-%m-%Y") }}</span>
{% endif %}
</td>
<td>
{% if not p.eligible_for_replacement or p.eligible_for_replacement <= today %}
<span class="badge rounded-pill bg-success-subtle text-success-emphasis">Eligible now</span>
{% else %}
<span class="badge rounded-pill bg-secondary-subtle text-secondary-emphasis">From {{ p.eligible_for_replacement.strftime("%d-%m-%Y") }}</span>
{% endif %}
</td>
<td>{% if p.venue %}<span class="badge rounded-pill text-bg-light border">{{ p.venue }}</span>{% endif %}</td>
<td>
{% if p.verified == "Yes" %}
<span class="badge rounded-pill bg-success-subtle text-success-emphasis">Yes</span>
{% elif p.verified == "No" %}
<span class="badge rounded-pill bg-secondary-subtle text-secondary-emphasis">No</span>
{% endif %}
</td>
<td class="text-truncate" style="max-width: 220px;" title="{{ p.comment or '' }}">{{ p.comment or "" }}</td>
{% if session.role == 'admin' %}
<td class="text-end">
<div class="d-inline-flex gap-1">
<button type="button" class="btn btn-icon btn-icon-primary" title="Edit"
onclick='openPhoneModal({{ p.to_dict() | tojson }})'>{% include "icons/pencil-square.svg" %}</button>
<button type="button" class="btn btn-icon btn-icon-danger" title="Delete"
onclick="deletePhone({{ p.id }}, '{{ p.phone_number }}')">{% include "icons/trash.svg" %}</button>
</div>
</td>
{% endif %}
</tr>
{% else %}
<tr><td colspan="{{ 9 if session.role == 'admin' else 8 }}" class="text-center text-muted py-5">No phones yet.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
<div id="phoneNoResults" class="text-center text-muted py-5" hidden>No phones match your search.</div>
</div>
<!-- Edit / Add modal -->
<div class="modal fade" id="phoneModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<form id="phoneForm">
<div class="modal-header">
<h5 class="modal-title" id="phoneModalTitle">Edit Phone</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<input type="hidden" id="phoneId">
<div class="mb-3">
<label class="form-label">Phone Number</label>
<input type="text" class="form-control" id="phoneNumber" required>
<div class="form-text" id="phoneNumberHelp" hidden>Phone number can't be changed after the phone is added.</div>
</div>
<div class="mb-3">
<label class="form-label">Current Owner</label>
<input type="text" class="form-control" id="phoneOwner">
</div>
<div class="mb-3">
<label class="form-label">Plan</label>
<select class="form-select" id="phonePlan">
<option value="">-- Select Plan --</option>
{% for option in plan_options %}
<option value="{{ option }}">{{ option }}</option>
{% endfor %}
</select>
<div class="form-text" id="phonePlanHelp" hidden>Plan can't be changed after the phone is added.</div>
</div>
<div class="mb-3">
<label class="form-label">Contract Expiration</label>
<input type="date" class="form-control" id="phoneContractExpiration">
</div>
<div class="mb-3">
<label class="form-label">Eligibility for Replacement</label>
<input type="date" class="form-control" id="phoneEligible">
<div class="form-text">Leave blank if there is no restriction (eligible now).</div>
</div>
<div class="mb-3">
<label class="form-label">Venue</label>
<select class="form-select" id="phoneVenue">
<option value="">-- Select Venue --</option>
{% for option in venue_options %}
<option value="{{ option }}">{{ option }}</option>
{% endfor %}
</select>
</div>
<div class="mb-3">
<label class="form-label">Verified</label>
<select class="form-select" id="phoneVerified">
<option value="">-- Select --</option>
{% for option in verified_options %}
<option value="{{ option }}">{{ option }}</option>
{% endfor %}
</select>
</div>
<div class="mb-3">
<label class="form-label">Comment</label>
<textarea class="form-control" id="phoneComment" rows="3"></textarea>
</div>
<div id="phoneError" class="text-danger small"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary">Save</button>
</div>
</form>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
const phoneModalEl = document.getElementById('phoneModal');
const phoneModal = new bootstrap.Modal(phoneModalEl);
function openPhoneModal(phone) {
document.getElementById('phoneForm').reset();
document.getElementById('phoneError').textContent = '';
const isEdit = !!phone;
const numberInput = document.getElementById('phoneNumber');
const planSelect = document.getElementById('phonePlan');
numberInput.readOnly = isEdit;
planSelect.disabled = isEdit;
document.getElementById('phoneNumberHelp').hidden = !isEdit;
document.getElementById('phonePlanHelp').hidden = !isEdit;
if (isEdit) {
document.getElementById('phoneModalTitle').textContent = 'Edit Phone';
document.getElementById('phoneId').value = phone.id;
numberInput.value = phone.phone_number || '';
planSelect.value = phone.plan || '';
document.getElementById('phoneContractExpiration').value = phone.contract_expiration || '';
document.getElementById('phoneEligible').value = phone.eligible_for_replacement || '';
document.getElementById('phoneOwner').value = phone.current_owner || '';
document.getElementById('phoneVenue').value = phone.venue || '';
document.getElementById('phoneVerified').value = phone.verified || '';
document.getElementById('phoneComment').value = phone.comment || '';
} else {
document.getElementById('phoneModalTitle').textContent = 'Add Phone';
document.getElementById('phoneId').value = '';
}
phoneModal.show();
}
document.getElementById('phoneForm').addEventListener('submit', async function (e) {
e.preventDefault();
const id = document.getElementById('phoneId').value;
const payload = {
phone_number: document.getElementById('phoneNumber').value,
plan: document.getElementById('phonePlan').value,
contract_expiration: document.getElementById('phoneContractExpiration').value,
eligible_for_replacement: document.getElementById('phoneEligible').value,
current_owner: document.getElementById('phoneOwner').value,
venue: document.getElementById('phoneVenue').value,
verified: document.getElementById('phoneVerified').value,
comment: document.getElementById('phoneComment').value,
};
const url = id ? `/api/phones/${id}` : '/api/phones';
const method = id ? 'PUT' : 'POST';
const resp = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (resp.ok) {
window.location.reload();
} else {
const data = await resp.json().catch(() => ({}));
document.getElementById('phoneError').textContent = data.error || 'Failed to save. Please check the fields.';
}
});
async function deletePhone(id, phoneNumber) {
if (!confirm(`Delete phone ${phoneNumber}? This cannot be undone.`)) return;
const resp = await fetch(`/api/phones/${id}`, { method: 'DELETE' });
if (resp.ok) {
window.location.reload();
} else {
alert('Failed to delete.');
}
}
// Live client-side search across every visible column of the phones table.
(function () {
const searchInput = document.getElementById('phoneSearch');
const table = document.getElementById('phonesTable');
const noResults = document.getElementById('phoneNoResults');
const rowCount = document.getElementById('phoneRowCount');
if (!searchInput || !table) return;
const rows = Array.from(table.querySelectorAll('tbody tr')).filter(tr => tr.children.length > 1);
const totalRows = rows.length;
// Strip accents/diacritics so a search for "n" also matches "ň", "z" matches "ž", etc.
function foldDiacritics(str) {
return str.normalize('NFD').replace(/[\u0300-\u036f]/g, '');
}
const rowText = rows.map(row => foldDiacritics(row.textContent.toLowerCase()));
function applyFilter() {
const q = foldDiacritics(searchInput.value.trim().toLowerCase());
let visible = 0;
rows.forEach((row, i) => {
const match = !q || rowText[i].includes(q);
row.hidden = !match;
if (match) visible++;
});
noResults.hidden = !(q && visible === 0);
table.closest('.table-responsive').hidden = q && visible === 0;
rowCount.textContent = q ? `${visible} of ${totalRows} phones` : `${totalRows} phones`;
}
searchInput.addEventListener('input', applyFilter);
applyFilter();
})();
</script>
{% endblock %}
+183
View File
@@ -0,0 +1,183 @@
{% extends "base.html" %}
{% block title %}Users - Queensway Europe Telco Portal{% endblock %}
{% block content %}
<div class="d-flex flex-wrap justify-content-between align-items-center mb-4 gap-2">
<h2 class="mb-0 d-flex align-items-center gap-2">
{% include "icons/people.svg" %} Users
</h2>
<button type="button" class="btn btn-success d-inline-flex align-items-center gap-2" onclick="openUserModal(null)">
{% include "icons/plus-circle.svg" %} Add User
</button>
</div>
<div class="row g-3 mb-4">
<div class="col-6 col-lg-3">
<div class="stat-card">
<div class="stat-icon stat-icon-total">{% include "icons/collection.svg" %}</div>
<div>
<div class="stat-value">{{ users|length }}</div>
<div class="stat-label">Total Users</div>
</div>
</div>
</div>
<div class="col-6 col-lg-3">
<div class="stat-card">
<div class="stat-icon stat-icon-verified">{% include "icons/shield-lock-fill.svg" %}</div>
<div>
<div class="stat-value">{{ admin_count }}</div>
<div class="stat-label">Admins</div>
</div>
</div>
</div>
<div class="col-6 col-lg-3">
<div class="stat-card">
<div class="stat-icon stat-icon-headoffice">{% include "icons/people.svg" %}</div>
<div>
<div class="stat-value">{{ viewer_count }}</div>
<div class="stat-label">Viewers</div>
</div>
</div>
</div>
</div>
<div class="modern-card mb-0">
<div class="table-toolbar d-flex flex-wrap align-items-center justify-content-between gap-2">
<div class="text-muted small">Admins can add, edit and remove phones, internet connections and other user accounts. Viewers can only view.</div>
</div>
<div class="table-responsive">
<table class="table table-hover align-middle mb-0 modern-table">
<thead>
<tr>
<th>Username</th>
<th>Role</th>
<th class="text-end">Actions</th>
</tr>
</thead>
<tbody>
{% for u in users %}
<tr>
<td class="fw-semibold">{{ u.username }}{% if u.id == session.user_id %}<span class="text-muted fw-normal"> (you)</span>{% endif %}</td>
<td>
{% if u.role == "admin" %}
<span class="badge rounded-pill bg-success-subtle text-success-emphasis">Admin</span>
{% else %}
<span class="badge rounded-pill bg-secondary-subtle text-secondary-emphasis">Viewer</span>
{% endif %}
</td>
<td class="text-end">
<div class="d-inline-flex gap-1">
<button type="button" class="btn btn-icon btn-icon-primary" title="Edit"
onclick='openUserModal({{ u.to_dict() | tojson }})'>{% include "icons/pencil-square.svg" %}</button>
<button type="button" class="btn btn-icon btn-icon-danger" title="Delete"
onclick="deleteUser({{ u.id }}, {{ u.username | tojson }})" {% if u.id == session.user_id %}disabled{% endif %}>{% include "icons/trash.svg" %}</button>
</div>
</td>
</tr>
{% else %}
<tr><td colspan="3" class="text-center text-muted py-5">No users yet.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<!-- Add / Edit modal -->
<div class="modal fade" id="userModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<form id="userForm">
<div class="modal-header">
<h5 class="modal-title" id="userModalTitle">Edit User</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<input type="hidden" id="userId">
<div class="mb-3">
<label class="form-label">Username</label>
<input type="text" class="form-control" id="userUsername" required>
</div>
<div class="mb-3">
<label class="form-label">Password</label>
<input type="password" class="form-control" id="userPassword" autocomplete="new-password">
<div class="form-text" id="userPasswordHelp" hidden>Leave blank to keep the current password.</div>
</div>
<div class="mb-3">
<label class="form-label">Role</label>
<select class="form-select" id="userRole">
{% for option in role_options %}
<option value="{{ option }}">{{ option|capitalize }}</option>
{% endfor %}
</select>
</div>
<div id="userError" class="text-danger small"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary">Save</button>
</div>
</form>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
const userModalEl = document.getElementById('userModal');
const userModal = new bootstrap.Modal(userModalEl);
function openUserModal(user) {
document.getElementById('userForm').reset();
document.getElementById('userError').textContent = '';
const isEdit = !!user;
document.getElementById('userPasswordHelp').hidden = !isEdit;
document.getElementById('userPassword').required = !isEdit;
if (isEdit) {
document.getElementById('userModalTitle').textContent = 'Edit User';
document.getElementById('userId').value = user.id;
document.getElementById('userUsername').value = user.username || '';
document.getElementById('userRole').value = user.role || 'viewer';
} else {
document.getElementById('userModalTitle').textContent = 'Add User';
document.getElementById('userId').value = '';
document.getElementById('userRole').value = 'viewer';
}
userModal.show();
}
document.getElementById('userForm').addEventListener('submit', async function (e) {
e.preventDefault();
const id = document.getElementById('userId').value;
const payload = {
username: document.getElementById('userUsername').value,
password: document.getElementById('userPassword').value,
role: document.getElementById('userRole').value,
};
const url = id ? `/api/users/${id}` : '/api/users';
const method = id ? 'PUT' : 'POST';
const resp = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (resp.ok) {
window.location.reload();
} else {
const data = await resp.json().catch(() => ({}));
document.getElementById('userError').textContent = data.error || 'Failed to save. Please check the fields.';
}
});
async function deleteUser(id, username) {
if (!confirm(`Delete user "${username}"? This cannot be undone.`)) return;
const resp = await fetch(`/api/users/${id}`, { method: 'DELETE' });
if (resp.ok) {
window.location.reload();
} else {
const data = await resp.json().catch(() => ({}));
alert(data.error || 'Failed to delete.');
}
}
</script>
{% endblock %}