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
+7
View File
@@ -0,0 +1,7 @@
__pycache__/
*.pyc
.venv/
venv/
data/
*.db
.git/
+20
View File
@@ -0,0 +1,20 @@
# syntax=docker/dockerfile:1
FROM python:3.12-slim AS runtime
WORKDIR /app
RUN pip install --no-cache-dir --upgrade pip
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
# The SQLite database lives outside the container - see docker-compose.yml volumes.
VOLUME ["/app/data"]
ENV TELCO_DATA_DIR=/app/data
ENV PORT=8000
EXPOSE 8000
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "--workers", "2", "app:app"]
+443
View File
@@ -0,0 +1,443 @@
import os
import secrets
from datetime import date, datetime, timedelta
from functools import wraps
from flask import Flask, jsonify, redirect, render_template, request, session, url_for
from sqlalchemy import text
from werkzeug.security import check_password_hash, generate_password_hash
from models import InternetConnection, Phone, User, db
DATA_DIR = os.environ.get("TELCO_DATA_DIR", os.path.join(os.path.dirname(__file__), "..", "data"))
os.makedirs(DATA_DIR, exist_ok=True)
DB_PATH = os.path.join(DATA_DIR, "telco.db")
SECRET_KEY_PATH = os.path.join(DATA_DIR, "secret_key.txt")
# Seeded once, only if the users table is empty (first run against a fresh database).
# Passwords are hashed before they ever touch the database - see seed_users().
SEED_USERS = [
("MBelan", "KFC@99", "admin"),
("AMaar", "KFC@99", "admin"),
("CGasser", "KFC@99", "admin"),
("RSimko", "KFC@99", "admin"),
("TPanakova", "KFC@99", "admin"),
("JLapinova", "KFC@11", "viewer"),
("JTrnovska", "KFC@11", "viewer"),
]
def get_or_create_secret_key():
"""Persist a random session-signing key in the data dir so it survives container restarts
and is shared by every gunicorn worker (each worker imports this module independently, so
a key generated only in memory would differ per worker and invalidate sessions randomly)."""
try:
with open(SECRET_KEY_PATH, "r") as f:
existing = f.read().strip()
if existing:
return existing
except FileNotFoundError:
pass
key = secrets.token_hex(32)
tmp_path = f"{SECRET_KEY_PATH}.tmp.{os.getpid()}"
try:
with open(tmp_path, "w") as f:
f.write(key)
os.replace(tmp_path, SECRET_KEY_PATH)
except OSError:
pass
# Re-read in case another worker won the race and wrote its own key first.
try:
with open(SECRET_KEY_PATH, "r") as f:
existing = f.read().strip()
if existing:
return existing
except FileNotFoundError:
pass
return key
def create_app():
app = Flask(__name__)
app.config["SQLALCHEMY_DATABASE_URI"] = f"sqlite:///{DB_PATH}"
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
app.config["JSON_SORT_KEYS"] = False
app.config["PERMANENT_SESSION_LIFETIME"] = timedelta(days=30)
app.secret_key = os.environ.get("TELCO_SECRET_KEY") or get_or_create_secret_key()
db.init_app(app)
with app.app_context():
db.create_all()
run_migrations()
seed_users()
register_routes(app)
return app
def run_migrations():
"""Lightweight in-place migrations for columns added after the DB was first created.
db.create_all() only creates missing tables, not missing columns on existing tables."""
existing_columns = {row[1] for row in db.session.execute(text("PRAGMA table_info(phones)")).fetchall()}
if "comment" not in existing_columns:
db.session.execute(text("ALTER TABLE phones ADD COLUMN comment TEXT"))
db.session.commit()
if "verified" not in existing_columns:
db.session.execute(text("ALTER TABLE phones ADD COLUMN verified VARCHAR(8)"))
db.session.commit()
def seed_users():
"""First-run only: create the initial login accounts if no users exist yet at all."""
if User.query.count() > 0:
return
for username, password, role in SEED_USERS:
db.session.add(User(username=username, password_hash=generate_password_hash(password), role=role))
db.session.commit()
def parse_date(value):
"""Parse an ISO date string ('YYYY-MM-DD') from a form/JSON payload. Empty -> None."""
if not value:
return None
if isinstance(value, date):
return value
value = value.strip()
if not value:
return None
return datetime.strptime(value, "%Y-%m-%d").date()
VENUE_OPTIONS = ["QWE Headoffice", "KFC Restaurant"]
VERIFIED_OPTIONS = ["Yes", "No"]
ROLE_OPTIONS = ["admin", "viewer"]
def login_required(view):
@wraps(view)
def wrapped(*args, **kwargs):
if "user_id" not in session:
if request.path.startswith("/api/"):
return jsonify({"error": "Login required."}), 401
return redirect(url_for("login", next=request.path))
return view(*args, **kwargs)
return wrapped
def admin_required(view):
@wraps(view)
def wrapped(*args, **kwargs):
if "user_id" not in session:
if request.path.startswith("/api/"):
return jsonify({"error": "Login required."}), 401
return redirect(url_for("login", next=request.path))
if session.get("role") != "admin":
if request.path.startswith("/api/"):
return jsonify({"error": "Admin access required."}), 403
return render_template("403.html"), 403
return view(*args, **kwargs)
return wrapped
def register_routes(app):
# ---------------------------------------------------------------
# Auth
# ---------------------------------------------------------------
@app.get("/login")
def login():
if "user_id" in session:
return redirect(url_for("index"))
return render_template("login.html", error=None, next=request.args.get("next", ""))
@app.post("/login")
def login_submit():
username = (request.form.get("username") or "").strip()
password = request.form.get("password") or ""
next_url = request.form.get("next") or ""
user = User.query.filter(db.func.lower(User.username) == username.lower()).first()
if not user or not check_password_hash(user.password_hash, password):
return render_template("login.html", error="Invalid username or password.", next=next_url), 401
session.clear()
session.permanent = True
session["user_id"] = user.id
session["username"] = user.username
session["role"] = user.role
if next_url.startswith("/") and not next_url.startswith("//"):
return redirect(next_url)
return redirect(url_for("index"))
@app.get("/logout")
def logout():
session.clear()
return redirect(url_for("login"))
@app.get("/")
@login_required
def index():
return render_template("index.html")
# ---------------------------------------------------------------
# Mobile phones
# ---------------------------------------------------------------
SORTABLE_PHONE_COLUMNS = {
"phone_number": Phone.phone_number,
"plan": Phone.plan,
"contract_expiration": Phone.contract_expiration,
"eligible_for_replacement": Phone.eligible_for_replacement,
"current_owner": Phone.current_owner,
"venue": Phone.venue,
"verified": Phone.verified,
"comment": Phone.comment,
}
@app.get("/phones")
@login_required
def phones_page():
sort = request.args.get("sort", "phone_number")
direction = request.args.get("dir", "asc")
if sort not in SORTABLE_PHONE_COLUMNS:
sort = "phone_number"
if direction not in ("asc", "desc"):
direction = "asc"
column = SORTABLE_PHONE_COLUMNS[sort]
order = column.desc() if direction == "desc" else column.asc()
# Secondary sort by phone number keeps ties in a stable, predictable order.
phones = Phone.query.order_by(order, Phone.phone_number.asc()).all()
today = date.today()
plan_options = [
row[0] for row in
db.session.query(Phone.plan).filter(Phone.plan.isnot(None)).distinct().order_by(Phone.plan).all()
]
headoffice_count = sum(1 for p in phones if p.venue == "QWE Headoffice")
restaurant_count = sum(1 for p in phones if p.venue == "KFC Restaurant")
unassigned_count = len(phones) - headoffice_count - restaurant_count
verified_count = sum(1 for p in phones if p.verified == "Yes")
not_verified_count = len(phones) - verified_count
stats = {
"total": len(phones),
"expired": sum(1 for p in phones if p.contract_expiration and p.contract_expiration < today),
"expiring_soon": sum(
1 for p in phones
if p.contract_expiration and p.contract_expiration >= today
and (p.contract_expiration - today).days <= 60
),
"eligible_now": sum(
1 for p in phones
if not p.eligible_for_replacement or p.eligible_for_replacement <= today
),
"headoffice": headoffice_count,
"restaurant": restaurant_count,
"unassigned": unassigned_count,
"headoffice_pct": round(100 * headoffice_count / len(phones)) if phones else 0,
"restaurant_pct": round(100 * restaurant_count / len(phones)) if phones else 0,
"verified": verified_count,
"not_verified": not_verified_count,
}
return render_template(
"phones.html",
phones=phones,
today=today,
sort=sort,
direction=direction,
plan_options=plan_options,
venue_options=VENUE_OPTIONS,
verified_options=VERIFIED_OPTIONS,
stats=stats,
active="phones",
)
@app.get("/api/phones/<int:phone_id>")
@login_required
def get_phone(phone_id):
phone = Phone.query.get_or_404(phone_id)
return jsonify(phone.to_dict())
@app.post("/api/phones")
@admin_required
def create_phone():
payload = request.get_json(force=True)
phone = Phone(
phone_number=(payload.get("phone_number") or "").strip(),
plan=(payload.get("plan") or "").strip() or None,
contract_expiration=parse_date(payload.get("contract_expiration")),
eligible_for_replacement=parse_date(payload.get("eligible_for_replacement")),
current_owner=(payload.get("current_owner") or "").strip() or None,
venue=(payload.get("venue") or "").strip() or None,
verified=(payload.get("verified") or "").strip() or None,
comment=(payload.get("comment") or "").strip() or None,
)
if not phone.phone_number:
return jsonify({"error": "Phone number is required."}), 400
db.session.add(phone)
db.session.commit()
return jsonify(phone.to_dict()), 201
@app.put("/api/phones/<int:phone_id>")
@admin_required
def update_phone(phone_id):
phone = Phone.query.get_or_404(phone_id)
payload = request.get_json(force=True)
# Phone Number and Plan are set once at creation and are not editable afterward -
# ignore any values sent for them here (enforced server-side, not just hidden in the UI).
phone.contract_expiration = parse_date(payload.get("contract_expiration"))
phone.eligible_for_replacement = parse_date(payload.get("eligible_for_replacement"))
phone.current_owner = (payload.get("current_owner") or "").strip() or None
phone.venue = (payload.get("venue") or "").strip() or None
phone.verified = (payload.get("verified") or "").strip() or None
phone.comment = (payload.get("comment") or "").strip() or None
db.session.commit()
return jsonify(phone.to_dict())
@app.delete("/api/phones/<int:phone_id>")
@admin_required
def delete_phone(phone_id):
phone = Phone.query.get_or_404(phone_id)
db.session.delete(phone)
db.session.commit()
return jsonify({"ok": True})
# ---------------------------------------------------------------
# Internet connectivity
# ---------------------------------------------------------------
@app.get("/internet")
@login_required
def internet_page():
connections = InternetConnection.query.order_by(InternetConnection.venue.asc()).all()
return render_template("internet.html", connections=connections, active="internet")
@app.get("/api/internet/<int:conn_id>")
@login_required
def get_connection(conn_id):
conn = InternetConnection.query.get_or_404(conn_id)
return jsonify(conn.to_dict())
@app.post("/api/internet")
@admin_required
def create_connection():
payload = request.get_json(force=True)
conn = InternetConnection(
venue=(payload.get("venue") or "").strip(),
isp_provider=(payload.get("isp_provider") or "").strip() or None,
plan=(payload.get("plan") or "").strip() or None,
contract_expiration=parse_date(payload.get("contract_expiration")),
notes=(payload.get("notes") or "").strip() or None,
)
if not conn.venue:
return jsonify({"error": "Venue is required."}), 400
db.session.add(conn)
db.session.commit()
return jsonify(conn.to_dict()), 201
@app.put("/api/internet/<int:conn_id>")
@admin_required
def update_connection(conn_id):
conn = InternetConnection.query.get_or_404(conn_id)
payload = request.get_json(force=True)
conn.venue = (payload.get("venue") or conn.venue).strip()
conn.isp_provider = (payload.get("isp_provider") or "").strip() or None
conn.plan = (payload.get("plan") or "").strip() or None
conn.contract_expiration = parse_date(payload.get("contract_expiration"))
conn.notes = (payload.get("notes") or "").strip() or None
db.session.commit()
return jsonify(conn.to_dict())
@app.delete("/api/internet/<int:conn_id>")
@admin_required
def delete_connection(conn_id):
conn = InternetConnection.query.get_or_404(conn_id)
db.session.delete(conn)
db.session.commit()
return jsonify({"ok": True})
# ---------------------------------------------------------------
# User accounts (admin only) - login/password management
# ---------------------------------------------------------------
@app.get("/users")
@admin_required
def users_page():
users = User.query.order_by(User.username.asc()).all()
admin_count = sum(1 for u in users if u.role == "admin")
viewer_count = len(users) - admin_count
return render_template(
"users.html",
users=users,
role_options=ROLE_OPTIONS,
admin_count=admin_count,
viewer_count=viewer_count,
active="users",
)
@app.post("/api/users")
@admin_required
def create_user():
payload = request.get_json(force=True)
username = (payload.get("username") or "").strip()
password = payload.get("password") or ""
role = (payload.get("role") or "").strip()
if not username or not password:
return jsonify({"error": "Username and password are required."}), 400
if role not in ROLE_OPTIONS:
return jsonify({"error": "Role must be admin or viewer."}), 400
if User.query.filter(db.func.lower(User.username) == username.lower()).first():
return jsonify({"error": "That username already exists."}), 400
user = User(username=username, password_hash=generate_password_hash(password), role=role)
db.session.add(user)
db.session.commit()
return jsonify(user.to_dict()), 201
@app.put("/api/users/<int:user_id>")
@admin_required
def update_user(user_id):
user = User.query.get_or_404(user_id)
payload = request.get_json(force=True)
username = (payload.get("username") or "").strip()
password = payload.get("password") or ""
role = (payload.get("role") or "").strip()
if not username:
return jsonify({"error": "Username is required."}), 400
if role not in ROLE_OPTIONS:
return jsonify({"error": "Role must be admin or viewer."}), 400
clash = User.query.filter(
db.func.lower(User.username) == username.lower(), User.id != user_id
).first()
if clash:
return jsonify({"error": "That username already exists."}), 400
if user.role == "admin" and role != "admin":
remaining_admins = User.query.filter(User.role == "admin", User.id != user_id).count()
if remaining_admins == 0:
return jsonify({"error": "At least one admin account must remain."}), 400
user.username = username
user.role = role
if password:
user.password_hash = generate_password_hash(password)
db.session.commit()
# Keep the active session in sync if an admin edits their own account.
if user.id == session.get("user_id"):
session["username"] = user.username
session["role"] = user.role
return jsonify(user.to_dict())
@app.delete("/api/users/<int:user_id>")
@admin_required
def delete_user(user_id):
user = User.query.get_or_404(user_id)
if user.id == session.get("user_id"):
return jsonify({"error": "You can't delete your own account while logged in."}), 400
if user.role == "admin":
remaining_admins = User.query.filter(User.role == "admin", User.id != user_id).count()
if remaining_admins == 0:
return jsonify({"error": "At least one admin account must remain."}), 400
db.session.delete(user)
db.session.commit()
return jsonify({"ok": True})
app = create_app()
if __name__ == "__main__":
app.run(host="0.0.0.0", port=int(os.environ.get("PORT", 8000)), debug=True)
+55
View File
@@ -0,0 +1,55 @@
"""
One-off bulk update: set Current Owner for a list of phone numbers, without touching any other
field (Plan, Venue, Contract Expiration, Eligibility, Comment are left exactly as they are).
Usage:
python apply_owner_mapping.py /path/to/telco.db mapping.csv
mapping.csv has two columns: phone_number,current_owner (phone_number in the same raw format as
the source Excel - digits only, with or without the leading 0; it is normalized the same way the
original import did).
"""
import argparse
import csv
import sqlite3
def normalize_phone(value):
s = str(value).strip()
if s.isdigit() and not s.startswith("0"):
s = "0" + s
return s or None
def main():
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("db_path")
parser.add_argument("mapping_csv")
args = parser.parse_args()
con = sqlite3.connect(args.db_path)
cur = con.cursor()
updated, missing = [], []
with open(args.mapping_csv, newline="", encoding="utf-8-sig") as f:
reader = csv.DictReader(f)
for row in reader:
phone_number = normalize_phone(row["phone_number"])
owner = row["current_owner"]
cur.execute("UPDATE phones SET current_owner = ? WHERE phone_number = ?", (owner, phone_number))
if cur.rowcount == 0:
missing.append(phone_number)
else:
updated.append(phone_number)
con.commit()
con.close()
print(f"Updated {len(updated)} rows.")
if missing:
print(f"No matching phone found for {len(missing)} entries: {missing}")
if __name__ == "__main__":
main()
+157
View File
@@ -0,0 +1,157 @@
"""
One-time migration: load the mobile phone repository from the source Excel file into the
SQLite database. Run this once; after that the app reads/writes only the SQLite database and
the Excel file can be forgotten.
Usage:
python import_excel.py "/path/to/Mobilne telefony 20260901.xlsx" [--mapping mapping.csv] [--reset]
Expected Excel columns (Slovak headers, in this exact order in the source file):
T.C. -> Phone Number
Meno uzivatela -> raw text, NOT auto-split into Owner/Venue (see README)
Uzivatelsky balik -> Plan
Viazanost do: -> Contract Expiration
Moznost vymeny od: -> Eligibility for Replacement (blank / '---' = eligible now)
Optional --mapping CSV (phone_number,current_owner,venue) backfills Current Owner / Venue for
matching phone numbers - this is the recommended way to fix up ownership since the Excel column
doesn't cleanly separate the two for most rows.
"""
import argparse
import csv
import sys
from datetime import date, datetime
import openpyxl
from app import create_app
from models import Phone, db
EXPECTED_HEADERS = [
"T.Č.",
"Meno užívatela",
"Užívateľský balík",
"Viazanosť do:",
"Možnost výmeny od:",
]
def normalize_phone(value):
"""Excel stores the subscriber number without the leading 0 (numeric cell). Re-add it."""
if value is None:
return None
s = str(value).strip()
if s.isdigit() and not s.startswith("0"):
s = "0" + s
return s or None
def normalize_date(value):
if value is None:
return None
if isinstance(value, datetime):
return value.date()
if isinstance(value, date):
return value
s = str(value).strip()
if not s or s == "---":
return None
for fmt in ("%Y-%m-%d", "%d.%m.%Y", "%d/%m/%Y"):
try:
return datetime.strptime(s, fmt).date()
except ValueError:
continue
print(f" warning: could not parse date '{value}', leaving blank", file=sys.stderr)
return None
def load_mapping(path):
mapping = {}
if not path:
return mapping
with open(path, newline="", encoding="utf-8-sig") as f:
reader = csv.DictReader(f)
for row in reader:
phone = normalize_phone(row.get("phone_number"))
if not phone:
continue
mapping[phone] = {
"current_owner": (row.get("current_owner") or "").strip() or None,
"venue": (row.get("venue") or "").strip() or None,
}
return mapping
def main():
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("excel_path", help="Path to the source .xlsx file")
parser.add_argument("--mapping", help="Optional CSV with phone_number,current_owner,venue to backfill Owner/Venue")
parser.add_argument("--reset", action="store_true", help="Delete all existing phone rows before importing")
args = parser.parse_args()
mapping = load_mapping(args.mapping)
wb = openpyxl.load_workbook(args.excel_path, data_only=True)
ws = wb.active
rows = list(ws.iter_rows(values_only=True))
header = [str(h).strip() if h is not None else "" for h in rows[0]]
print(f"Sheet '{ws.title}' header: {header}")
app = create_app()
with app.app_context():
if args.reset:
deleted = Phone.query.delete()
db.session.commit()
print(f"Deleted {deleted} existing phone rows (--reset).")
created, updated, skipped = 0, 0, 0
for row in rows[1:]:
if row is None or all(v is None for v in row):
continue
raw_phone, raw_name, raw_plan, raw_expiration, raw_eligible = (list(row) + [None] * 5)[:5]
phone_number = normalize_phone(raw_phone)
if not phone_number:
skipped += 1
continue
plan = str(raw_plan).strip() if raw_plan else None
contract_expiration = normalize_date(raw_expiration)
eligible_for_replacement = normalize_date(raw_eligible)
backfill = mapping.get(phone_number, {})
current_owner = backfill.get("current_owner")
venue = backfill.get("venue")
# No mapping supplied for this number yet: keep the raw Excel text around in
# Current Owner so nothing is lost, and leave Venue for manual/edit-later cleanup.
if not mapping and raw_name:
current_owner = str(raw_name).strip()
existing = Phone.query.filter_by(phone_number=phone_number).one_or_none()
if existing:
existing.plan = plan
existing.contract_expiration = contract_expiration
existing.eligible_for_replacement = eligible_for_replacement
if backfill:
existing.current_owner = current_owner
existing.venue = venue
updated += 1
else:
db.session.add(Phone(
phone_number=phone_number,
plan=plan,
contract_expiration=contract_expiration,
eligible_for_replacement=eligible_for_replacement,
current_owner=current_owner,
venue=venue,
))
created += 1
db.session.commit()
print(f"Import complete: {created} created, {updated} updated, {skipped} skipped (no phone number).")
if __name__ == "__main__":
main()
+74
View File
@@ -0,0 +1,74 @@
from datetime import datetime
from flask_sqlalchemy import SQLAlchemy
db = SQLAlchemy()
class Phone(db.Model):
__tablename__ = "phones"
id = db.Column(db.Integer, primary_key=True)
phone_number = db.Column(db.String(32), unique=True, nullable=False, index=True)
plan = db.Column(db.String(120), nullable=True)
contract_expiration = db.Column(db.Date, nullable=True)
eligible_for_replacement = db.Column(db.Date, nullable=True)
current_owner = db.Column(db.String(200), nullable=True)
venue = db.Column(db.String(200), nullable=True)
verified = db.Column(db.String(8), nullable=True)
comment = db.Column(db.Text, nullable=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
def to_dict(self):
return {
"id": self.id,
"phone_number": self.phone_number,
"plan": self.plan,
"contract_expiration": self.contract_expiration.isoformat() if self.contract_expiration else None,
"eligible_for_replacement": self.eligible_for_replacement.isoformat() if self.eligible_for_replacement else None,
"current_owner": self.current_owner,
"venue": self.venue,
"verified": self.verified,
"comment": self.comment,
}
class User(db.Model):
__tablename__ = "users"
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(80), unique=True, nullable=False, index=True)
password_hash = db.Column(db.String(255), nullable=False)
role = db.Column(db.String(20), nullable=False, default="viewer") # "admin" or "viewer"
created_at = db.Column(db.DateTime, default=datetime.utcnow)
def to_dict(self):
return {
"id": self.id,
"username": self.username,
"role": self.role,
}
class InternetConnection(db.Model):
__tablename__ = "internet_connections"
id = db.Column(db.Integer, primary_key=True)
venue = db.Column(db.String(200), nullable=False)
isp_provider = db.Column(db.String(200), nullable=True)
plan = db.Column(db.String(200), nullable=True)
contract_expiration = db.Column(db.Date, nullable=True)
notes = db.Column(db.Text, nullable=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
def to_dict(self):
return {
"id": self.id,
"venue": self.venue,
"isp_provider": self.isp_provider,
"plan": self.plan,
"contract_expiration": self.contract_expiration.isoformat() if self.contract_expiration else None,
"notes": self.notes,
}
+4
View File
@@ -0,0 +1,4 @@
Flask==3.0.3
Flask-SQLAlchemy==3.1.1
openpyxl==3.1.5
gunicorn==22.0.0
+341
View File
@@ -0,0 +1,341 @@
body {
background-color: #f4f6f8;
}
.navbar-brand {
font-weight: 600;
}
.table td, .table th {
vertical-align: middle;
}
.card-title {
font-weight: 600;
}
.icon {
vertical-align: -0.125em;
margin-right: 0.35rem;
}
h2 .icon {
width: 1.3em;
height: 1.3em;
}
.carrier-badge {
display: inline-block;
padding: 0.35em 0.75em;
font-size: 0.8rem;
font-weight: 600;
color: #fff;
background-color: #ff7900;
border-radius: 0.375rem;
vertical-align: middle;
}
.user-pill {
font-size: 0.85rem;
color: #fff !important;
opacity: 0.9;
}
/* ------------------------------------------------------------------ */
/* Login page */
/* ------------------------------------------------------------------ */
.login-body {
background-color: #f4f6f8;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.login-wrap {
width: 100%;
max-width: 400px;
padding: 1.5rem;
}
.login-card {
background: #fff;
border-radius: 1rem;
box-shadow: 0 0.75rem 2rem rgba(20, 40, 60, 0.12);
padding: 2.25rem 2rem;
}
.login-logo {
max-height: 56px;
width: auto;
}
/* ------------------------------------------------------------------ */
/* Home page: logo + animated tiles */
/* ------------------------------------------------------------------ */
.hero-logo {
max-height: 70px;
width: auto;
}
@keyframes fadeInDown {
from { opacity: 0; transform: translateY(-14px); }
to { opacity: 1; transform: translateY(0); }
}
.hero-fade-in {
animation: fadeInDown 0.6s ease both;
}
@keyframes fadeInUp {
from { opacity: 0; transform: translateY(24px); }
to { opacity: 1; transform: translateY(0); }
}
.tile-card {
border: none;
border-radius: 1rem;
color: inherit;
animation: fadeInUp 0.55s ease both;
transition: transform 0.25s ease, box-shadow 0.25s ease;
}
/* Stagger the entrance animation for each tile in the grid. */
.row > div:nth-child(1) .tile-card { animation-delay: 0.05s; }
.row > div:nth-child(2) .tile-card { animation-delay: 0.18s; }
.row > div:nth-child(3) .tile-card { animation-delay: 0.31s; }
.tile-card:hover,
.tile-card:focus-visible {
transform: translateY(-8px) scale(1.015);
box-shadow: 0 1.25rem 2.5rem rgba(20, 40, 60, 0.15) !important;
}
.tile-icon-wrap {
width: 84px;
height: 84px;
margin: 0 auto 1.25rem;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
transition: transform 0.3s ease;
}
.tile-icon-wrap svg {
width: 40px;
height: 40px;
}
.tile-card:hover .tile-icon-wrap {
transform: scale(1.12) rotate(-6deg);
}
.tile-phones .tile-icon-wrap {
color: #fff;
background: linear-gradient(135deg, #0d6efd, #6ea8fe);
}
.tile-internet .tile-icon-wrap {
color: #fff;
background: linear-gradient(135deg, #198754, #63e6be);
}
@media (prefers-reduced-motion: reduce) {
.hero-fade-in, .tile-card, .tile-icon-wrap {
animation: none !important;
transition: none !important;
}
}
/* ------------------------------------------------------------------ */
/* Mobile Phones page */
/* ------------------------------------------------------------------ */
.stat-card {
display: flex;
align-items: center;
gap: 0.9rem;
background: #fff;
border-radius: 0.9rem;
padding: 1rem 1.15rem;
box-shadow: 0 0.25rem 0.75rem rgba(20, 40, 60, 0.06);
animation: fadeInUp 0.5s ease both;
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.stat-card:hover {
transform: translateY(-3px);
box-shadow: 0 0.5rem 1.25rem rgba(20, 40, 60, 0.1);
}
.row.g-3 > div:nth-child(1) .stat-card { animation-delay: 0.02s; }
.row.g-3 > div:nth-child(2) .stat-card { animation-delay: 0.09s; }
.row.g-3 > div:nth-child(3) .stat-card { animation-delay: 0.16s; }
.row.g-3 > div:nth-child(4) .stat-card { animation-delay: 0.23s; }
.row.g-3 > div:nth-child(5) .stat-card { animation-delay: 0.30s; }
.row.g-3 > div:nth-child(6) .stat-card { animation-delay: 0.37s; }
.row.g-3 > div:nth-child(7) .stat-card { animation-delay: 0.44s; }
.row.g-3 > div:nth-child(8) .stat-card { animation-delay: 0.51s; }
.stat-icon {
flex: none;
width: 46px;
height: 46px;
border-radius: 0.7rem;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
}
.stat-icon svg { width: 22px; height: 22px; }
.stat-icon-total { background: linear-gradient(135deg, #0d6efd, #6ea8fe); }
.stat-icon-warning { background: linear-gradient(135deg, #fd7e14, #ffc078); }
.stat-icon-danger { background: linear-gradient(135deg, #dc3545, #ff8a94); }
.stat-icon-success { background: linear-gradient(135deg, #198754, #63e6be); }
.stat-value {
font-size: 1.5rem;
font-weight: 700;
line-height: 1.1;
}
.stat-label {
font-size: 0.8rem;
color: #6c757d;
}
.modern-card {
background: #fff;
border-radius: 1rem;
box-shadow: 0 0.25rem 0.75rem rgba(20, 40, 60, 0.06);
overflow: hidden;
animation: fadeInUp 0.5s ease both;
animation-delay: 0.28s;
}
.stat-icon-headoffice { background: linear-gradient(135deg, #6f42c1, #b197fc); }
.stat-icon-restaurant { background: linear-gradient(135deg, #d63384, #ff8fb1); }
.stat-icon-verified { background: linear-gradient(135deg, #0ca678, #63e6be); }
.stat-icon-unverified { background: linear-gradient(135deg, #868e96, #ced4da); }
.table-toolbar {
padding: 1rem 1.25rem;
border-bottom: 1px solid #eef0f2;
}
.search-box {
position: relative;
max-width: 360px;
flex: 1 1 260px;
}
.search-box svg {
position: absolute;
left: 0.75rem;
top: 50%;
transform: translateY(-50%);
width: 16px;
height: 16px;
color: #adb5bd;
}
.search-box .form-control {
padding-left: 2.25rem;
border-radius: 2rem;
background-color: #f4f6f8;
border-color: transparent;
}
.search-box .form-control:focus {
background-color: #fff;
}
.modern-table thead th {
background-color: #f8f9fb;
color: #6c757d;
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.04em;
font-weight: 700;
border-bottom: 2px solid #eef0f2;
padding: 0.85rem 0.9rem;
white-space: nowrap;
}
.modern-table tbody td {
padding: 0.75rem 0.9rem;
border-bottom: 1px solid #f1f3f5;
white-space: nowrap;
}
.modern-table tbody td.text-truncate {
white-space: nowrap; /* already implied by .text-truncate, kept explicit for clarity */
}
.modern-table tbody tr {
transition: background-color 0.15s ease;
}
.sort-link {
color: inherit;
text-decoration: none;
}
.sort-link:hover {
color: #0d6efd;
}
.sort-inactive {
opacity: 0.35;
}
.sort-active {
color: #0d6efd;
}
.btn-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
padding: 0;
border-radius: 0.6rem;
border: 1px solid transparent;
background: transparent;
transition: background-color 0.15s ease, transform 0.15s ease;
}
.btn-icon svg { width: 16px; height: 16px; }
.btn-icon:hover {
transform: translateY(-1px);
}
.btn-icon-primary {
color: #0d6efd;
}
.btn-icon-primary:hover {
background-color: #e7f1ff;
}
.btn-icon-danger {
color: #dc3545;
}
.btn-icon-danger:hover {
background-color: #fdeced;
}
@media (prefers-reduced-motion: reduce) {
.stat-card, .modern-card {
animation: none !important;
transition: none !important;
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

+2
View File
@@ -0,0 +1,2 @@
// Shared site-wide JS (currently empty - page-specific behavior lives in each template's
// {% block scripts %}).
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+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 %}