Add M365 mapping/prices export-import, license pricing, and PDF summary export
- Mapping (Username/QWE Client/IsRestaurant): CSV export and import via new routes - License prices: persistent DB table (M365LicensePrice), editable price card with auto-save, CSV export/import - Summary table: Monthly Cost column per client + grand-total row, calculated live in JS from current prices - PDF export: client-side jsPDF/autotable, landscape A4, includes cost column when prices are set Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+9
-1
@@ -1,6 +1,6 @@
|
|||||||
import os
|
import os
|
||||||
import datetime
|
import datetime
|
||||||
from sqlalchemy import create_engine, Column, Integer, String, DateTime, Boolean
|
from sqlalchemy import create_engine, Column, Integer, String, DateTime, Boolean, Float
|
||||||
from sqlalchemy.orm import declarative_base, sessionmaker
|
from sqlalchemy.orm import declarative_base, sessionmaker
|
||||||
|
|
||||||
DATABASE_URL = f"sqlite:///{os.getenv('DB_PATH', '/data/audit.db')}"
|
DATABASE_URL = f"sqlite:///{os.getenv('DB_PATH', '/data/audit.db')}"
|
||||||
@@ -19,6 +19,14 @@ class M365UserOverride(Base):
|
|||||||
updated_at = Column(DateTime, default=datetime.datetime.utcnow)
|
updated_at = Column(DateTime, default=datetime.datetime.utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
class M365LicensePrice(Base):
|
||||||
|
__tablename__ = "m365_license_prices"
|
||||||
|
|
||||||
|
license_key = Column(String, primary_key=True)
|
||||||
|
price = Column(Float, default=0.0, nullable=False)
|
||||||
|
updated_at = Column(DateTime, default=datetime.datetime.utcnow)
|
||||||
|
|
||||||
|
|
||||||
class RebootLog(Base):
|
class RebootLog(Base):
|
||||||
__tablename__ = "reboot_log"
|
__tablename__ = "reboot_log"
|
||||||
|
|
||||||
|
|||||||
+205
-4
@@ -8,12 +8,12 @@ import secrets
|
|||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile
|
from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile
|
||||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
from fastapi.responses import HTMLResponse, RedirectResponse, StreamingResponse
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.auth import get_current_user
|
from app.auth import get_current_user
|
||||||
from app.database import M365UserOverride, get_db
|
from app.database import M365LicensePrice, M365UserOverride, get_db
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -25,6 +25,14 @@ router = APIRouter()
|
|||||||
AT_DEPTS = {"QWE AT", "KFC AT", "QWE"}
|
AT_DEPTS = {"QWE AT", "KFC AT", "QWE"}
|
||||||
SK_DEPTS = {"QWE SK", "KFC SK"}
|
SK_DEPTS = {"QWE SK", "KFC SK"}
|
||||||
|
|
||||||
|
LICENSE_KEYS = [
|
||||||
|
("m365_standard", "M365 Business Standard"),
|
||||||
|
("m365_basic", "M365 Business Basic"),
|
||||||
|
("defender_p1", "Defender P1"),
|
||||||
|
("pbi_pro", "Power BI Pro"),
|
||||||
|
("pbi_premium", "Power BI Premium"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def _csrf_token(request: Request) -> str:
|
def _csrf_token(request: Request) -> str:
|
||||||
if "csrf_token" not in request.session:
|
if "csrf_token" not in request.session:
|
||||||
@@ -32,6 +40,11 @@ def _csrf_token(request: Request) -> str:
|
|||||||
return request.session["csrf_token"]
|
return request.session["csrf_token"]
|
||||||
|
|
||||||
|
|
||||||
|
def _get_prices(db: Session) -> dict[str, float]:
|
||||||
|
rows = {r.license_key: r.price for r in db.query(M365LicensePrice).all()}
|
||||||
|
return {key: rows.get(key, 0.0) for key, _ in LICENSE_KEYS}
|
||||||
|
|
||||||
|
|
||||||
def _verify_csrf(request: Request, token: str):
|
def _verify_csrf(request: Request, token: str):
|
||||||
expected = request.session.get("csrf_token")
|
expected = request.session.get("csrf_token")
|
||||||
if not expected or not secrets.compare_digest(expected, token):
|
if not expected or not secrets.compare_digest(expected, token):
|
||||||
@@ -158,6 +171,10 @@ def _render(request: Request, user, csrf: str, **kwargs):
|
|||||||
"users_json": "[]",
|
"users_json": "[]",
|
||||||
"filename": None,
|
"filename": None,
|
||||||
"error": None,
|
"error": None,
|
||||||
|
"import_success": None,
|
||||||
|
"prices": {},
|
||||||
|
"license_keys": LICENSE_KEYS,
|
||||||
|
"prices_import_success": None,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -175,12 +192,28 @@ async def m365_index(request: Request):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/m365/license-calculator", response_class=HTMLResponse)
|
@router.get("/m365/license-calculator", response_class=HTMLResponse)
|
||||||
async def license_calculator_page(request: Request):
|
async def license_calculator_page(
|
||||||
|
request: Request,
|
||||||
|
imported: int | None = None,
|
||||||
|
import_error: bool = False,
|
||||||
|
prices_imported: int | None = None,
|
||||||
|
prices_error: bool = False,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
user = get_current_user(request)
|
user = get_current_user(request)
|
||||||
if not user:
|
if not user:
|
||||||
return RedirectResponse("/auth/login", status_code=302)
|
return RedirectResponse("/auth/login", status_code=302)
|
||||||
csrf = _csrf_token(request)
|
csrf = _csrf_token(request)
|
||||||
return _render(request, user, csrf)
|
kwargs: dict = {"prices": _get_prices(db)}
|
||||||
|
if imported is not None:
|
||||||
|
kwargs["import_success"] = imported
|
||||||
|
if import_error:
|
||||||
|
kwargs["error"] = "Failed to import mapping CSV. Check that the file has Username, QWE Client, and IsRestaurant columns."
|
||||||
|
if prices_imported is not None:
|
||||||
|
kwargs["prices_import_success"] = prices_imported
|
||||||
|
if prices_error:
|
||||||
|
kwargs["error"] = "Failed to import prices CSV. Check that the file has License Key and Price columns."
|
||||||
|
return _render(request, user, csrf, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/m365/license-calculator/upload", response_class=HTMLResponse)
|
@router.post("/m365/license-calculator/upload", response_class=HTMLResponse)
|
||||||
@@ -209,6 +242,7 @@ async def upload_csv(
|
|||||||
summary=summary,
|
summary=summary,
|
||||||
users_json=_users_for_js(licensed),
|
users_json=_users_for_js(licensed),
|
||||||
filename=file.filename,
|
filename=file.filename,
|
||||||
|
prices=_get_prices(db),
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.error("M365 CSV parse error: %s", exc)
|
logger.error("M365 CSV parse error: %s", exc)
|
||||||
@@ -242,3 +276,170 @@ async def save_override(request: Request, db: Session = Depends(get_db)):
|
|||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/m365/mapping/export")
|
||||||
|
async def export_mapping(request: Request, db: Session = Depends(get_db)):
|
||||||
|
user = get_current_user(request)
|
||||||
|
if not user:
|
||||||
|
return RedirectResponse("/auth/login", status_code=302)
|
||||||
|
|
||||||
|
overrides = db.query(M365UserOverride).order_by(M365UserOverride.username).all()
|
||||||
|
|
||||||
|
output = io.StringIO()
|
||||||
|
writer = csv.writer(output)
|
||||||
|
writer.writerow(["Username", "QWE Client", "IsRestaurant"])
|
||||||
|
for o in overrides:
|
||||||
|
writer.writerow([o.username, o.qwe_client, "1" if o.is_restaurant else "0"])
|
||||||
|
|
||||||
|
return StreamingResponse(
|
||||||
|
iter([output.getvalue()]),
|
||||||
|
media_type="text/csv",
|
||||||
|
headers={"Content-Disposition": "attachment; filename=m365_mapping.csv"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/m365/mapping/import", response_class=HTMLResponse)
|
||||||
|
async def import_mapping(
|
||||||
|
request: Request,
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
csrf_token: str = Form(...),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
user = get_current_user(request)
|
||||||
|
if not user:
|
||||||
|
return RedirectResponse("/auth/login", status_code=302)
|
||||||
|
|
||||||
|
_verify_csrf(request, csrf_token)
|
||||||
|
|
||||||
|
try:
|
||||||
|
content = await file.read()
|
||||||
|
try:
|
||||||
|
text = content.decode("utf-8-sig")
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
text = content.decode("latin-1")
|
||||||
|
|
||||||
|
reader = csv.DictReader(io.StringIO(text))
|
||||||
|
count = 0
|
||||||
|
for row in reader:
|
||||||
|
username = row.get("Username", "").strip()
|
||||||
|
if not username:
|
||||||
|
continue
|
||||||
|
qwe_client = row.get("QWE Client", "").strip()
|
||||||
|
is_restaurant = row.get("IsRestaurant", "0").strip().lower() in ("1", "true", "yes")
|
||||||
|
|
||||||
|
override = db.query(M365UserOverride).filter(M365UserOverride.username == username).first()
|
||||||
|
if override:
|
||||||
|
override.qwe_client = qwe_client
|
||||||
|
override.is_restaurant = is_restaurant
|
||||||
|
override.updated_at = datetime.datetime.utcnow()
|
||||||
|
else:
|
||||||
|
db.add(M365UserOverride(
|
||||||
|
username=username,
|
||||||
|
qwe_client=qwe_client,
|
||||||
|
is_restaurant=is_restaurant,
|
||||||
|
))
|
||||||
|
count += 1
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
return RedirectResponse(f"/m365/license-calculator?imported={count}", status_code=302)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("M365 mapping import error: %s", exc)
|
||||||
|
return RedirectResponse("/m365/license-calculator?import_error=1", status_code=302)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/m365/prices")
|
||||||
|
async def save_prices(request: Request, db: Session = Depends(get_db)):
|
||||||
|
user = get_current_user(request)
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||||
|
|
||||||
|
body = await request.json()
|
||||||
|
_verify_csrf(request, body.get("csrf_token", ""))
|
||||||
|
|
||||||
|
for key, _ in LICENSE_KEYS:
|
||||||
|
raw = body.get(key)
|
||||||
|
if raw is None:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
price = max(0.0, float(raw))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
|
||||||
|
row = db.query(M365LicensePrice).filter(M365LicensePrice.license_key == key).first()
|
||||||
|
if row:
|
||||||
|
row.price = price
|
||||||
|
row.updated_at = datetime.datetime.utcnow()
|
||||||
|
else:
|
||||||
|
db.add(M365LicensePrice(license_key=key, price=price))
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/m365/prices/export")
|
||||||
|
async def export_prices(request: Request, db: Session = Depends(get_db)):
|
||||||
|
user = get_current_user(request)
|
||||||
|
if not user:
|
||||||
|
return RedirectResponse("/auth/login", status_code=302)
|
||||||
|
|
||||||
|
prices = _get_prices(db)
|
||||||
|
output = io.StringIO()
|
||||||
|
writer = csv.writer(output)
|
||||||
|
writer.writerow(["License Key", "Label", "Price"])
|
||||||
|
for key, label in LICENSE_KEYS:
|
||||||
|
writer.writerow([key, label, prices[key]])
|
||||||
|
|
||||||
|
return StreamingResponse(
|
||||||
|
iter([output.getvalue()]),
|
||||||
|
media_type="text/csv",
|
||||||
|
headers={"Content-Disposition": "attachment; filename=m365_prices.csv"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/m365/prices/import", response_class=HTMLResponse)
|
||||||
|
async def import_prices(
|
||||||
|
request: Request,
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
csrf_token: str = Form(...),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
user = get_current_user(request)
|
||||||
|
if not user:
|
||||||
|
return RedirectResponse("/auth/login", status_code=302)
|
||||||
|
|
||||||
|
_verify_csrf(request, csrf_token)
|
||||||
|
|
||||||
|
valid_keys = {key for key, _ in LICENSE_KEYS}
|
||||||
|
|
||||||
|
try:
|
||||||
|
content = await file.read()
|
||||||
|
try:
|
||||||
|
text = content.decode("utf-8-sig")
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
text = content.decode("latin-1")
|
||||||
|
|
||||||
|
reader = csv.DictReader(io.StringIO(text))
|
||||||
|
count = 0
|
||||||
|
for row in reader:
|
||||||
|
key = row.get("License Key", "").strip()
|
||||||
|
if key not in valid_keys:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
price = max(0.0, float(row.get("Price", "0").strip()))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
|
||||||
|
existing = db.query(M365LicensePrice).filter(M365LicensePrice.license_key == key).first()
|
||||||
|
if existing:
|
||||||
|
existing.price = price
|
||||||
|
existing.updated_at = datetime.datetime.utcnow()
|
||||||
|
else:
|
||||||
|
db.add(M365LicensePrice(license_key=key, price=price))
|
||||||
|
count += 1
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
return RedirectResponse(f"/m365/license-calculator?prices_imported={count}", status_code=302)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("M365 prices import error: %s", exc)
|
||||||
|
return RedirectResponse("/m365/license-calculator?prices_error=1", status_code=302)
|
||||||
|
|||||||
+224
-18
@@ -33,6 +33,90 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Mapping export / import card -->
|
||||||
|
<div class="bg-white border border-gray-200 rounded-lg p-5 shadow-sm">
|
||||||
|
<div class="flex items-center justify-between gap-6 flex-wrap">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-gray-700">Mapping</p>
|
||||||
|
<p class="text-xs text-gray-400 mt-0.5">Export or import the saved QWE Client / Restaurant assignments.</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3 flex-wrap">
|
||||||
|
<a href="/m365/mapping/export"
|
||||||
|
class="px-4 py-2 bg-gray-100 text-gray-700 text-sm font-medium rounded-md hover:bg-gray-200 transition-colors whitespace-nowrap">
|
||||||
|
Export CSV
|
||||||
|
</a>
|
||||||
|
<form method="post" action="/m365/mapping/import" enctype="multipart/form-data"
|
||||||
|
class="flex items-center gap-2">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
<input type="file" name="file" accept=".csv" required
|
||||||
|
class="text-sm text-gray-500
|
||||||
|
file:mr-2 file:py-1.5 file:px-3 file:rounded-md file:border-0
|
||||||
|
file:text-sm file:font-medium file:bg-gray-100 file:text-gray-700
|
||||||
|
hover:file:bg-gray-200 cursor-pointer border border-gray-300 rounded-md">
|
||||||
|
<button type="submit"
|
||||||
|
class="px-4 py-2 bg-gray-700 text-white text-sm font-medium rounded-md hover:bg-gray-800 transition-colors whitespace-nowrap">
|
||||||
|
Import
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- License prices card -->
|
||||||
|
<div class="bg-white border border-gray-200 rounded-lg p-5 shadow-sm">
|
||||||
|
<div class="flex items-start justify-between gap-4 flex-wrap mb-3">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-gray-700">License Prices <span class="text-xs font-normal text-gray-400">(€ / user / month)</span></p>
|
||||||
|
<p class="text-xs text-gray-400 mt-0.5">Used to calculate monthly totals in the summary. Saved automatically.</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2 flex-shrink-0">
|
||||||
|
<a href="/m365/prices/export"
|
||||||
|
class="px-3 py-1.5 bg-gray-100 text-gray-700 text-xs font-medium rounded-md hover:bg-gray-200 transition-colors whitespace-nowrap">
|
||||||
|
Export CSV
|
||||||
|
</a>
|
||||||
|
<form method="post" action="/m365/prices/import" enctype="multipart/form-data" class="flex items-center gap-2">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
<input type="file" name="file" accept=".csv" required
|
||||||
|
class="text-xs text-gray-500
|
||||||
|
file:mr-2 file:py-1 file:px-2.5 file:rounded file:border-0
|
||||||
|
file:text-xs file:font-medium file:bg-gray-100 file:text-gray-700
|
||||||
|
hover:file:bg-gray-200 cursor-pointer border border-gray-300 rounded-md">
|
||||||
|
<button type="submit"
|
||||||
|
class="px-3 py-1.5 bg-gray-700 text-white text-xs font-medium rounded-md hover:bg-gray-800 transition-colors whitespace-nowrap">
|
||||||
|
Import
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3">
|
||||||
|
{% for key, label in license_keys %}
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs text-gray-500 mb-1">{{ label }}</label>
|
||||||
|
<div class="relative">
|
||||||
|
<span class="absolute left-2.5 top-1/2 -translate-y-1/2 text-xs text-gray-400 pointer-events-none">€</span>
|
||||||
|
<input type="number" min="0" step="0.01"
|
||||||
|
class="price-input w-full pl-6 pr-2 py-1.5 text-sm border border-gray-200 rounded focus:ring-1 focus:ring-blue-500 focus:border-blue-500 outline-none bg-gray-50 hover:bg-white focus:bg-white transition-colors"
|
||||||
|
data-key="{{ key }}"
|
||||||
|
value="{{ prices[key] if prices[key] else '' }}"
|
||||||
|
placeholder="0.00">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if import_success is not none %}
|
||||||
|
<div class="bg-green-50 border border-green-200 rounded-lg p-4 text-sm text-green-700">
|
||||||
|
Imported {{ import_success }} mapping {{ 'entry' if import_success == 1 else 'entries' }} successfully.
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if prices_import_success is not none %}
|
||||||
|
<div class="bg-green-50 border border-green-200 rounded-lg p-4 text-sm text-green-700">
|
||||||
|
Imported {{ prices_import_success }} price {{ 'entry' if prices_import_success == 1 else 'entries' }} successfully.
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% if error %}
|
{% if error %}
|
||||||
<div class="bg-red-50 border border-red-200 rounded-lg p-4 text-sm text-red-700">{{ error }}</div>
|
<div class="bg-red-50 border border-red-200 rounded-lg p-4 text-sm text-red-700">{{ error }}</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -58,9 +142,15 @@
|
|||||||
|
|
||||||
<!-- Per-QWE-Client license breakdown (primary goal, updates live) -->
|
<!-- Per-QWE-Client license breakdown (primary goal, updates live) -->
|
||||||
<div class="bg-white border border-gray-200 rounded-lg shadow-sm overflow-hidden">
|
<div class="bg-white border border-gray-200 rounded-lg shadow-sm overflow-hidden">
|
||||||
<div class="px-5 py-3 border-b border-gray-200 flex items-center justify-between">
|
<div class="px-5 py-3 border-b border-gray-200 flex items-center justify-between gap-3">
|
||||||
<h2 class="text-sm font-semibold text-gray-900">License Counts per QWE Client</h2>
|
<h2 class="text-sm font-semibold text-gray-900">License Counts per QWE Client</h2>
|
||||||
<span class="text-xs text-gray-400">Updates live as you assign clients below</span>
|
<div class="flex items-center gap-3">
|
||||||
|
<span class="text-xs text-gray-400">Updates live as you assign clients below</span>
|
||||||
|
<button onclick="exportPDF()"
|
||||||
|
class="px-3 py-1.5 bg-gray-100 text-gray-700 text-xs font-medium rounded-md hover:bg-gray-200 transition-colors whitespace-nowrap">
|
||||||
|
Export PDF
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="overflow-x-auto">
|
<div class="overflow-x-auto">
|
||||||
<table class="min-w-full divide-y divide-gray-200 text-sm">
|
<table class="min-w-full divide-y divide-gray-200 text-sm">
|
||||||
@@ -73,6 +163,7 @@
|
|||||||
<th class="px-4 py-3 text-center text-xs font-medium text-orange-600 uppercase tracking-wider">Defender P1</th>
|
<th class="px-4 py-3 text-center text-xs font-medium text-orange-600 uppercase tracking-wider">Defender P1</th>
|
||||||
<th class="px-4 py-3 text-center text-xs font-medium text-purple-600 uppercase tracking-wider">PBI Pro</th>
|
<th class="px-4 py-3 text-center text-xs font-medium text-purple-600 uppercase tracking-wider">PBI Pro</th>
|
||||||
<th class="px-4 py-3 text-center text-xs font-medium text-red-600 uppercase tracking-wider">PBI Premium</th>
|
<th class="px-4 py-3 text-center text-xs font-medium text-red-600 uppercase tracking-wider">PBI Premium</th>
|
||||||
|
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Monthly Cost</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="bg-white divide-y divide-gray-100" id="clientTableBody">
|
<tbody class="bg-white divide-y divide-gray-100" id="clientTableBody">
|
||||||
@@ -178,11 +269,37 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
{% if users %}
|
|
||||||
<script>
|
<script>
|
||||||
// User data for live client-summary recalculation
|
|
||||||
const USERS_DATA = {{ users_json | safe }};
|
|
||||||
const CSRF_TOKEN = {{ csrf_token | tojson }};
|
const CSRF_TOKEN = {{ csrf_token | tojson }};
|
||||||
|
let PRICES = {{ prices | tojson }};
|
||||||
|
|
||||||
|
// ── Price inputs (always active) ───────────────────────────────────────────
|
||||||
|
|
||||||
|
async function savePrices(payload) {
|
||||||
|
try {
|
||||||
|
await fetch('/m365/prices', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
let priceTimer = null;
|
||||||
|
document.querySelectorAll('.price-input').forEach(inp => {
|
||||||
|
inp.addEventListener('input', () => {
|
||||||
|
PRICES[inp.dataset.key] = parseFloat(inp.value) || 0;
|
||||||
|
if (typeof renderClientTable === 'function') renderClientTable();
|
||||||
|
clearTimeout(priceTimer);
|
||||||
|
const snap = Object.assign({ csrf_token: CSRF_TOKEN }, PRICES);
|
||||||
|
priceTimer = setTimeout(() => savePrices(snap), 800);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
{% if users %}
|
||||||
|
// ── User data ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const USERS_DATA = {{ users_json | safe }};
|
||||||
|
|
||||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -194,6 +311,13 @@
|
|||||||
.replace(/"/g, '"');
|
.replace(/"/g, '"');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function fmt(n) { return n > 0 ? String(n) : '<span class="text-gray-300">—</span>'; }
|
||||||
|
|
||||||
|
function fmtEur(n) {
|
||||||
|
if (n === 0) return '<span class="text-gray-300">—</span>';
|
||||||
|
return '€ ' + n.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||||
|
}
|
||||||
|
|
||||||
// ── Live QWE-Client summary ────────────────────────────────────────────────
|
// ── Live QWE-Client summary ────────────────────────────────────────────────
|
||||||
|
|
||||||
function calcClientBreakdown() {
|
function calcClientBreakdown() {
|
||||||
@@ -221,27 +345,50 @@
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
function fmt(n) { return n > 0 ? String(n) : '<span class="text-gray-300">—</span>'; }
|
function rowCost(r) {
|
||||||
|
return r.m365_standard * PRICES.m365_standard
|
||||||
|
+ r.m365_basic * PRICES.m365_basic
|
||||||
|
+ r.defender_p1 * PRICES.defender_p1
|
||||||
|
+ r.pbi_pro * PRICES.pbi_pro
|
||||||
|
+ r.pbi_premium * PRICES.pbi_premium;
|
||||||
|
}
|
||||||
|
|
||||||
function renderClientTable() {
|
function renderClientTable() {
|
||||||
const tbody = document.getElementById('clientTableBody');
|
const tbody = document.getElementById('clientTableBody');
|
||||||
if (!tbody) return;
|
if (!tbody) return;
|
||||||
const rows = calcClientBreakdown();
|
const rows = calcClientBreakdown();
|
||||||
if (rows.length === 0) {
|
if (rows.length === 0) {
|
||||||
tbody.innerHTML = '<tr><td colspan="7" class="px-4 py-4 text-center text-sm text-gray-400">No licensed users</td></tr>';
|
tbody.innerHTML = '<tr><td colspan="8" class="px-4 py-4 text-center text-sm text-gray-400">No licensed users</td></tr>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
tbody.innerHTML = rows.map(r => `
|
|
||||||
<tr class="hover:bg-gray-50">
|
let grandCost = 0, grandUsers = 0;
|
||||||
<td class="px-4 py-2 font-medium text-gray-900 whitespace-nowrap">${escHtml(r.name)}</td>
|
const rowHtml = rows.map(r => {
|
||||||
<td class="px-4 py-2 text-center text-gray-600">${r.count}</td>
|
const cost = rowCost(r);
|
||||||
<td class="px-4 py-2 text-center font-medium text-blue-700">${fmt(r.m365_standard)}</td>
|
grandCost += cost;
|
||||||
<td class="px-4 py-2 text-center font-medium text-sky-700">${fmt(r.m365_basic)}</td>
|
grandUsers += r.count;
|
||||||
<td class="px-4 py-2 text-center font-medium text-orange-700">${fmt(r.defender_p1)}</td>
|
return `
|
||||||
<td class="px-4 py-2 text-center font-medium text-purple-700">${fmt(r.pbi_pro)}</td>
|
<tr class="hover:bg-gray-50">
|
||||||
<td class="px-4 py-2 text-center font-medium text-red-700">${fmt(r.pbi_premium)}</td>
|
<td class="px-4 py-2 font-medium text-gray-900 whitespace-nowrap">${escHtml(r.name)}</td>
|
||||||
</tr>
|
<td class="px-4 py-2 text-center text-gray-600">${r.count}</td>
|
||||||
`).join('');
|
<td class="px-4 py-2 text-center font-medium text-blue-700">${fmt(r.m365_standard)}</td>
|
||||||
|
<td class="px-4 py-2 text-center font-medium text-sky-700">${fmt(r.m365_basic)}</td>
|
||||||
|
<td class="px-4 py-2 text-center font-medium text-orange-700">${fmt(r.defender_p1)}</td>
|
||||||
|
<td class="px-4 py-2 text-center font-medium text-purple-700">${fmt(r.pbi_pro)}</td>
|
||||||
|
<td class="px-4 py-2 text-center font-medium text-red-700">${fmt(r.pbi_premium)}</td>
|
||||||
|
<td class="px-4 py-2 text-right font-medium text-gray-700 whitespace-nowrap">${fmtEur(cost)}</td>
|
||||||
|
</tr>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
const totalRow = `
|
||||||
|
<tr class="bg-gray-50 border-t-2 border-gray-200 font-semibold text-sm">
|
||||||
|
<td class="px-4 py-2 text-gray-900">Total</td>
|
||||||
|
<td class="px-4 py-2 text-center text-gray-700">${grandUsers}</td>
|
||||||
|
<td colspan="5"></td>
|
||||||
|
<td class="px-4 py-2 text-right text-gray-900 whitespace-nowrap">${fmtEur(grandCost)}</td>
|
||||||
|
</tr>`;
|
||||||
|
|
||||||
|
tbody.innerHTML = rowHtml + totalRow;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Save override to server ────────────────────────────────────────────────
|
// ── Save override to server ────────────────────────────────────────────────
|
||||||
@@ -312,8 +459,67 @@
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── PDF export ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function exportPDF() {
|
||||||
|
const { jsPDF } = window.jspdf;
|
||||||
|
const doc = new jsPDF({ orientation: 'landscape', unit: 'mm', format: 'a4' });
|
||||||
|
|
||||||
|
const rows = calcClientBreakdown();
|
||||||
|
const hasCost = Object.values(PRICES).some(p => p > 0);
|
||||||
|
|
||||||
|
const head = [['QWE Client', 'Licensed Users', 'M365 Standard', 'M365 Basic', 'Defender P1', 'PBI Pro', 'PBI Premium']];
|
||||||
|
if (hasCost) head[0].push('Monthly Cost (€)');
|
||||||
|
|
||||||
|
let grandUsers = 0, grandCost = 0;
|
||||||
|
const body = rows.map(r => {
|
||||||
|
const cost = rowCost(r);
|
||||||
|
grandUsers += r.count;
|
||||||
|
grandCost += cost;
|
||||||
|
const row = [r.name, r.count, r.m365_standard || '—', r.m365_basic || '—', r.defender_p1 || '—', r.pbi_pro || '—', r.pbi_premium || '—'];
|
||||||
|
if (hasCost) row.push(cost > 0 ? cost.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) : '—');
|
||||||
|
return row;
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalRow = ['Total', grandUsers, '', '', '', '', ''];
|
||||||
|
if (hasCost) totalRow.push(grandCost > 0 ? grandCost.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) : '—');
|
||||||
|
body.push(totalRow);
|
||||||
|
|
||||||
|
const filename = {{ filename | tojson }};
|
||||||
|
const dateStr = new Date().toLocaleDateString('en-GB');
|
||||||
|
doc.setFontSize(13);
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
doc.text('License Counts per QWE Client', 14, 16);
|
||||||
|
doc.setFontSize(9);
|
||||||
|
doc.setFont('helvetica', 'normal');
|
||||||
|
doc.setTextColor(120);
|
||||||
|
doc.text(`Source: ${filename || 'manual'} | Generated: ${dateStr}`, 14, 22);
|
||||||
|
doc.setTextColor(0);
|
||||||
|
|
||||||
|
doc.autoTable({
|
||||||
|
head,
|
||||||
|
body,
|
||||||
|
startY: 27,
|
||||||
|
styles: { fontSize: 9, cellPadding: 3 },
|
||||||
|
headStyles: { fillColor: [55, 65, 81], textColor: 255, fontStyle: 'bold' },
|
||||||
|
columnStyles: { 0: { halign: 'left' } },
|
||||||
|
didParseCell(data) {
|
||||||
|
if (data.row.index === body.length - 1) {
|
||||||
|
data.cell.styles.fontStyle = 'bold';
|
||||||
|
data.cell.styles.fillColor = [243, 244, 246];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
doc.save('m365_license_summary.pdf');
|
||||||
|
}
|
||||||
|
|
||||||
// ── Initial render ────────────────────────────────────────────────────────
|
// ── Initial render ────────────────────────────────────────────────────────
|
||||||
renderClientTable();
|
renderClientTable();
|
||||||
|
{% endif %}
|
||||||
</script>
|
</script>
|
||||||
|
{% if users %}
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js"></script>
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf-autotable/3.8.2/jspdf.plugin.autotable.min.js"></script>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
Reference in New Issue
Block a user