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 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
|
||||
|
||||
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)
|
||||
|
||||
|
||||
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):
|
||||
__tablename__ = "reboot_log"
|
||||
|
||||
|
||||
+205
-4
@@ -8,12 +8,12 @@ import secrets
|
||||
from collections import defaultdict
|
||||
|
||||
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 sqlalchemy.orm import Session
|
||||
|
||||
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__)
|
||||
|
||||
@@ -25,6 +25,14 @@ router = APIRouter()
|
||||
AT_DEPTS = {"QWE AT", "KFC AT", "QWE"}
|
||||
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:
|
||||
if "csrf_token" not in request.session:
|
||||
@@ -32,6 +40,11 @@ def _csrf_token(request: Request) -> str:
|
||||
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):
|
||||
expected = request.session.get("csrf_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": "[]",
|
||||
"filename": None,
|
||||
"error": None,
|
||||
"import_success": None,
|
||||
"prices": {},
|
||||
"license_keys": LICENSE_KEYS,
|
||||
"prices_import_success": None,
|
||||
**kwargs,
|
||||
})
|
||||
|
||||
@@ -175,12 +192,28 @@ async def m365_index(request: Request):
|
||||
|
||||
|
||||
@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)
|
||||
if not user:
|
||||
return RedirectResponse("/auth/login", status_code=302)
|
||||
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)
|
||||
@@ -209,6 +242,7 @@ async def upload_csv(
|
||||
summary=summary,
|
||||
users_json=_users_for_js(licensed),
|
||||
filename=file.filename,
|
||||
prices=_get_prices(db),
|
||||
)
|
||||
except Exception as 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()
|
||||
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 %}
|
||||
</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 %}
|
||||
<div class="bg-red-50 border border-red-200 rounded-lg p-4 text-sm text-red-700">{{ error }}</div>
|
||||
{% endif %}
|
||||
@@ -58,9 +142,15 @@
|
||||
|
||||
<!-- 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="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>
|
||||
<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 class="overflow-x-auto">
|
||||
<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-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-right text-xs font-medium text-gray-500 uppercase tracking-wider">Monthly Cost</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-100" id="clientTableBody">
|
||||
@@ -178,11 +269,37 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
{% if users %}
|
||||
<script>
|
||||
// User data for live client-summary recalculation
|
||||
const USERS_DATA = {{ users_json | safe }};
|
||||
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 ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -194,6 +311,13 @@
|
||||
.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 ────────────────────────────────────────────────
|
||||
|
||||
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() {
|
||||
const tbody = document.getElementById('clientTableBody');
|
||||
if (!tbody) return;
|
||||
const rows = calcClientBreakdown();
|
||||
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;
|
||||
}
|
||||
tbody.innerHTML = rows.map(r => `
|
||||
<tr class="hover:bg-gray-50">
|
||||
<td class="px-4 py-2 font-medium text-gray-900 whitespace-nowrap">${escHtml(r.name)}</td>
|
||||
<td class="px-4 py-2 text-center text-gray-600">${r.count}</td>
|
||||
<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>
|
||||
</tr>
|
||||
`).join('');
|
||||
|
||||
let grandCost = 0, grandUsers = 0;
|
||||
const rowHtml = rows.map(r => {
|
||||
const cost = rowCost(r);
|
||||
grandCost += cost;
|
||||
grandUsers += r.count;
|
||||
return `
|
||||
<tr class="hover:bg-gray-50">
|
||||
<td class="px-4 py-2 font-medium text-gray-900 whitespace-nowrap">${escHtml(r.name)}</td>
|
||||
<td class="px-4 py-2 text-center text-gray-600">${r.count}</td>
|
||||
<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 ────────────────────────────────────────────────
|
||||
@@ -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 ────────────────────────────────────────────────────────
|
||||
renderClientTable();
|
||||
{% endif %}
|
||||
</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 %}
|
||||
{% endblock %}
|
||||
|
||||
Reference in New Issue
Block a user