Initial commit: Queensway Europe Telco Portal (Flask + SQLite, Docker deploy)
@@ -0,0 +1,17 @@
|
|||||||
|
# Live application data - real phone numbers, employee names, login credentials, session key.
|
||||||
|
# Never commit these. The Docker volume recreates this folder at deploy time.
|
||||||
|
/TelcoPortal-data/
|
||||||
|
|
||||||
|
# Leftovers not meant to be tracked (see chat history for context).
|
||||||
|
/_to_delete/
|
||||||
|
|
||||||
|
# One-off bulk-import source data containing real employee names and phone numbers.
|
||||||
|
TelcoPortal-source/owner_mapping.csv
|
||||||
|
|
||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
# Queensway Europe Telco Portal - Docker (runs locally on this PC)
|
||||||
|
|
||||||
|
A Docker container you run directly on your own PC, same setup as TostaSklad. Requires
|
||||||
|
**Docker Desktop for Windows** (with WSL2 backend, the default) - download from
|
||||||
|
https://www.docker.com/products/docker-desktop/ if you don't have it yet.
|
||||||
|
|
||||||
|
## First run
|
||||||
|
|
||||||
|
Open a terminal (PowerShell) in this `QWE Telco` folder and run:
|
||||||
|
|
||||||
|
```
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
This builds the image from `TelcoPortal-source/` (Python/Flask app) and starts the container in
|
||||||
|
the background.
|
||||||
|
|
||||||
|
Open **http://localhost:8092/** in your browser.
|
||||||
|
|
||||||
|
The Mobile Phones table is pre-loaded from the one-time Excel import (see
|
||||||
|
`TelcoPortal-source/import_excel.py` and the main README). There is no login - anyone who can
|
||||||
|
reach the URL can view and edit.
|
||||||
|
|
||||||
|
## Data persistence
|
||||||
|
|
||||||
|
The `TelcoPortal-data/` folder next to this file is bind-mounted into the container so the
|
||||||
|
database survives container restarts, rebuilds, and updates:
|
||||||
|
|
||||||
|
- `TelcoPortal-data/telco.db` - the SQLite database (both Mobile Phones and Internet
|
||||||
|
Connectivity tables live in this one file)
|
||||||
|
|
||||||
|
**Back up by simply copying `TelcoPortal-data/telco.db`.** Nothing important lives inside the
|
||||||
|
container itself.
|
||||||
|
|
||||||
|
## Common commands
|
||||||
|
|
||||||
|
```
|
||||||
|
docker compose logs -f # follow app logs
|
||||||
|
docker compose ps # check container status
|
||||||
|
docker compose down # stop and remove the container (data folder is untouched)
|
||||||
|
docker compose up -d # start it again
|
||||||
|
```
|
||||||
|
|
||||||
|
## Updating after you change the source code
|
||||||
|
|
||||||
|
```
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
This rebuilds the image with your latest changes from `TelcoPortal-source/` and replaces the
|
||||||
|
running container. Your data in `TelcoPortal-data/` is not affected.
|
||||||
|
|
||||||
|
## Changing the port
|
||||||
|
|
||||||
|
Edit the `ports:` line in `docker-compose.yml`, e.g. `"80:8000"` to use the standard HTTP port
|
||||||
|
instead of 8092, then run `docker compose up -d` again.
|
||||||
|
|
||||||
|
## Note on this setup
|
||||||
|
|
||||||
|
This was written and validated by running the Flask app directly (not inside Docker - Docker
|
||||||
|
itself was not available where these files were generated), so the app logic, database, and
|
||||||
|
import script are confirmed working end-to-end. The Docker build/run itself could not be
|
||||||
|
executed from where these files were generated. Please run the "First run" command above and
|
||||||
|
let me know if anything doesn't come up cleanly (e.g. build errors, port conflicts) so it can be
|
||||||
|
fixed.
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
# Queensway Europe Telco Portal
|
||||||
|
|
||||||
|
Management web portal for:
|
||||||
|
|
||||||
|
- The mobile phone repository (phone numbers, plan, contract expiration, replacement
|
||||||
|
eligibility, current owner, venue)
|
||||||
|
- Internet connectivity at each restaurant venue
|
||||||
|
|
||||||
|
Same deployment approach as TostaSklad/TostaPortal: a Docker container you run locally on this
|
||||||
|
PC with Docker Desktop, with all data kept in a bind-mounted folder outside the container. See
|
||||||
|
`README-Docker.md` for how to start/stop it.
|
||||||
|
|
||||||
|
## Folder layout
|
||||||
|
|
||||||
|
```
|
||||||
|
QWE Telco/
|
||||||
|
docker-compose.yml
|
||||||
|
README-Docker.md <- Docker instructions (start here)
|
||||||
|
TelcoPortal-README.md <- this file
|
||||||
|
TelcoPortal-source/ <- Flask app source (rebuilt into the Docker image)
|
||||||
|
TelcoPortal-data/
|
||||||
|
telco.db <- the SQLite database (both tables live in this one file)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Data model
|
||||||
|
|
||||||
|
**Mobile Phones** (`phones` table): Phone Number, Plan, Contract Expiration, Eligibility for
|
||||||
|
Replacement, Current Owner, Venue.
|
||||||
|
|
||||||
|
**Internet Connectivity** (`internet_connections` table): Venue, ISP Provider, Plan, Contract
|
||||||
|
Expiration, Notes. This table starts empty - the source Excel file only had mobile phone data -
|
||||||
|
add rows with the "+ Add Connection" button on the Internet Connectivity page as you go.
|
||||||
|
|
||||||
|
There is no login. Anyone who can reach the URL (`http://localhost:8092/` by default) can view
|
||||||
|
and edit both tables.
|
||||||
|
|
||||||
|
## How the mobile phone data was loaded
|
||||||
|
|
||||||
|
The one-off migration script `TelcoPortal-source/import_excel.py` reads the source Excel file
|
||||||
|
(`Mobilne telefony 20260901.xlsx`) and loads it into `TelcoPortal-data/telco.db`. This has
|
||||||
|
**already been run once** to populate the database - the Excel file is not needed by the running
|
||||||
|
app and can be archived/deleted.
|
||||||
|
|
||||||
|
Column mapping from the Excel file:
|
||||||
|
|
||||||
|
| Excel column (Slovak) | Portal field |
|
||||||
|
|-----------------------------|------------------------------|
|
||||||
|
| T.Č. | Phone Number (re-prefixed with the leading 0 Excel drops from numeric cells) |
|
||||||
|
| Užívateľský balík | Plan |
|
||||||
|
| Viazanosť do: | Contract Expiration |
|
||||||
|
| Možnost výmeny od: | Eligibility for Replacement (blank/`---` in the source means "eligible now", not "unknown") |
|
||||||
|
| Meno užívatela | **not auto-split** - see note below |
|
||||||
|
|
||||||
|
### Current Owner / Venue
|
||||||
|
|
||||||
|
The source `Meno užívatela` column mixes two things: a handful of rows have a
|
||||||
|
`"<number> - <person or venue>"` pattern, but most rows just repeat the company name
|
||||||
|
("Queensway Restaurants Slovakia, s.r.o.") with nothing usable as a venue. There was no reliable
|
||||||
|
way to auto-split this into separate Owner and Venue fields, so on import:
|
||||||
|
|
||||||
|
- **Current Owner** was set to the raw `Meno užívatela` text for each row (nothing was thrown
|
||||||
|
away)
|
||||||
|
- **Venue** was left blank
|
||||||
|
|
||||||
|
To fix this up in bulk instead of editing 70+ rows by hand one at a time, re-run the import with
|
||||||
|
a mapping file:
|
||||||
|
|
||||||
|
```
|
||||||
|
python import_excel.py "Mobilne telefony 20260901.xlsx" --mapping owner_venue_mapping.csv
|
||||||
|
```
|
||||||
|
|
||||||
|
where `owner_venue_mapping.csv` has three columns: `phone_number,current_owner,venue` (phone
|
||||||
|
number in the same format shown in the portal, e.g. `0905988240`). Rows are matched by phone
|
||||||
|
number, and only the rows present in the mapping file are updated - existing Plan/Contract
|
||||||
|
Expiration/Eligibility data is left as-is either way. You can also just fix individual rows with
|
||||||
|
the Edit button in the portal itself.
|
||||||
|
|
||||||
|
## Backups
|
||||||
|
|
||||||
|
Copy or zip `TelcoPortal-data/telco.db` any time - that single file is the entire database.
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
data/
|
||||||
|
*.db
|
||||||
|
.git/
|
||||||
@@ -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"]
|
||||||
@@ -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)
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
Flask==3.0.3
|
||||||
|
Flask-SQLAlchemy==3.1.1
|
||||||
|
openpyxl==3.1.5
|
||||||
|
gunicorn==22.0.0
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 103 KiB |
@@ -0,0 +1,2 @@
|
|||||||
|
// Shared site-wide JS (currently empty - page-specific behavior lives in each template's
|
||||||
|
// {% block scripts %}).
|
||||||
@@ -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 %}
|
||||||
@@ -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>
|
||||||
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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>
|
||||||
@@ -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 · {{ 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 · {{ 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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
services:
|
||||||
|
telcoportal:
|
||||||
|
build:
|
||||||
|
context: ./TelcoPortal-source
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: telcoportal
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "8092:8000"
|
||||||
|
volumes:
|
||||||
|
# Host-side folder - keeps the SQLite database across rebuilds/updates, and makes it
|
||||||
|
# easy to back up (just copy/zip this folder).
|
||||||
|
- ./TelcoPortal-data:/app/data
|
||||||
|
environment:
|
||||||
|
- TELCO_DATA_DIR=/app/data
|
||||||