Add M365 License Calculator feature

New top-nav section with a CSV-based license calculator. Upload an Azure AD
user export to see license counts (M365 Standard/Basic, Defender P1, PBI Pro,
PBI Premium) grouped by QWE Client. QWE Client and IsRestaurant are editable
per user, saved to SQLite, and restored on the next upload. Department-based
defaults pre-populate QWE Client (AT→QWE AT, SK→QWE SK) when no saved value
exists.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-09 15:40:19 +02:00
co-authored by Claude Sonnet 4.6
parent e37ee99054
commit adf303ad53
5 changed files with 581 additions and 1 deletions
+10 -1
View File
@@ -1,6 +1,6 @@
import os
import datetime
from sqlalchemy import create_engine, Column, Integer, String, DateTime
from sqlalchemy import create_engine, Column, Integer, String, DateTime, Boolean
from sqlalchemy.orm import declarative_base, sessionmaker
DATABASE_URL = f"sqlite:///{os.getenv('DB_PATH', '/data/audit.db')}"
@@ -10,6 +10,15 @@ SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()
class M365UserOverride(Base):
__tablename__ = "m365_user_overrides"
username = Column(String, primary_key=True) # User principal name
qwe_client = Column(String, default="", nullable=False)
is_restaurant = Column(Boolean, default=False, nullable=False)
updated_at = Column(DateTime, default=datetime.datetime.utcnow)
class RebootLog(Base):
__tablename__ = "reboot_log"
+244
View File
@@ -0,0 +1,244 @@
import csv
import datetime
import io
import json
import logging
import os
import secrets
from collections import defaultdict
from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from sqlalchemy.orm import Session
from app.auth import get_current_user
from app.database import M365UserOverride, get_db
logger = logging.getLogger(__name__)
BASE_DIR = os.path.dirname(__file__)
templates = Jinja2Templates(directory=os.path.join(BASE_DIR, "templates"))
router = APIRouter()
AT_DEPTS = {"QWE AT", "KFC AT", "QWE"}
SK_DEPTS = {"QWE SK", "KFC SK"}
def _csrf_token(request: Request) -> str:
if "csrf_token" not in request.session:
request.session["csrf_token"] = secrets.token_hex(32)
return request.session["csrf_token"]
def _verify_csrf(request: Request, token: str):
expected = request.session.get("csrf_token")
if not expected or not secrets.compare_digest(expected, token):
raise HTTPException(status_code=403, detail="CSRF token invalid")
def _parse_csv(content: bytes) -> list[dict]:
try:
text = content.decode("utf-8-sig")
except UnicodeDecodeError:
text = content.decode("latin-1")
reader = csv.DictReader(io.StringIO(text))
users = []
for row in reader:
upn = row.get("User principal name", "").strip()
licenses = row.get("Licenses", "").strip()
department = row.get("Department", "").strip()
is_licensed = bool(licenses) and licenses != "Unlicensed"
company = ""
if department in AT_DEPTS:
company = "AT"
elif department in SK_DEPTS:
company = "SK"
users.append({
"display_name": row.get("Display name", "").strip(),
"username": upn,
"first_name": row.get("First name", "").strip(),
"last_name": row.get("Last name", "").strip(),
"department": department,
"job_title": row.get("Title", "").strip(),
"usage_location": row.get("Usage location", "").strip(),
"licenses": licenses,
"is_licensed": is_licensed,
"company": company,
"has_m365_standard": "Microsoft 365 Business Standard" in licenses,
"has_m365_basic": "Microsoft 365 Business Basic" in licenses,
"has_defender_p1": "Microsoft Defender for Office 365 (Plan 1)" in licenses,
"has_pbi_pro": "Power BI Pro" in licenses,
"has_pbi_premium": "Power BI Premium Per User" in licenses,
"qwe_client": "QWE AT" if "AT" in department else ("QWE SK" if "SK" in department else ""),
"is_restaurant": False,
})
return users
def _apply_overrides(users: list[dict], db: Session) -> list[dict]:
upns = [u["username"] for u in users if u["username"]]
overrides = {
o.username: o
for o in db.query(M365UserOverride).filter(M365UserOverride.username.in_(upns)).all()
}
for user in users:
ov = overrides.get(user["username"])
if ov:
if ov.qwe_client: # keep department default when saved value is empty
user["qwe_client"] = ov.qwe_client
user["is_restaurant"] = bool(ov.is_restaurant)
return users
def _calculate_summary(users: list[dict]) -> dict:
licensed = [u for u in users if u["is_licensed"]]
missing = [u for u in licensed if not u["company"]]
# Per-QWE-Client breakdown (primary goal)
client_groups: dict[str, list] = defaultdict(list)
for u in licensed:
key = u["qwe_client"].strip()
client_groups[key].append(u)
def _counts(group: list) -> dict:
return {
"m365_standard": sum(1 for u in group if u["has_m365_standard"]),
"m365_basic": sum(1 for u in group if u["has_m365_basic"]),
"defender_p1": sum(1 for u in group if u["has_defender_p1"]),
"pbi_pro": sum(1 for u in group if u["has_pbi_pro"]),
"pbi_premium": sum(1 for u in group if u["has_pbi_premium"]),
}
# Named clients first (sorted), unnamed last
client_breakdown = []
for name in sorted(client_groups.keys(), key=lambda x: (x == "", x.lower())):
group = client_groups[name]
row = {"name": name or "(no client)", "count": len(group)}
row.update(_counts(group))
client_breakdown.append(row)
return {
"total": len(users),
"licensed": len(licensed),
"client_breakdown": client_breakdown,
"missing": missing,
}
def _users_for_js(users: list[dict]) -> str:
"""Minimal user data serialised as JSON for client-side summary recalculation."""
return json.dumps([
{
"username": u["username"],
"qwe_client": u["qwe_client"],
"is_licensed": u["is_licensed"],
"company": u["company"],
"has_m365_standard": u["has_m365_standard"],
"has_m365_basic": u["has_m365_basic"],
"has_defender_p1": u["has_defender_p1"],
"has_pbi_pro": u["has_pbi_pro"],
"has_pbi_premium": u["has_pbi_premium"],
}
for u in users
])
def _render(request: Request, user, csrf: str, **kwargs):
return templates.TemplateResponse("m365_license.html", {
"request": request,
"user": user,
"csrf_token": csrf,
"users": None,
"summary": None,
"users_json": "[]",
"filename": None,
"error": None,
**kwargs,
})
# ---------------------------------------------------------------------------
# Routes
# ---------------------------------------------------------------------------
@router.get("/m365", response_class=HTMLResponse)
async def m365_index(request: Request):
user = get_current_user(request)
if not user:
return RedirectResponse("/auth/login", status_code=302)
return RedirectResponse("/m365/license-calculator", status_code=302)
@router.get("/m365/license-calculator", response_class=HTMLResponse)
async def license_calculator_page(request: Request):
user = get_current_user(request)
if not user:
return RedirectResponse("/auth/login", status_code=302)
csrf = _csrf_token(request)
return _render(request, user, csrf)
@router.post("/m365/license-calculator/upload", response_class=HTMLResponse)
async def upload_csv(
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)
csrf = _csrf_token(request)
try:
content = await file.read()
users = _parse_csv(content)
users = _apply_overrides(users, db)
summary = _calculate_summary(users)
licensed = [u for u in users if u["is_licensed"]]
return _render(
request, user, csrf,
users=licensed,
summary=summary,
users_json=_users_for_js(licensed),
filename=file.filename,
)
except Exception as exc:
logger.error("M365 CSV parse error: %s", exc)
return _render(request, user, csrf, error=f"Failed to process CSV: {exc}")
@router.post("/m365/override")
async def save_override(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", ""))
username = body.get("username", "").strip()
if not username:
raise HTTPException(status_code=400, detail="username required")
override = db.query(M365UserOverride).filter(M365UserOverride.username == username).first()
if override:
override.qwe_client = body.get("qwe_client") or ""
override.is_restaurant = bool(body.get("is_restaurant", False))
override.updated_at = datetime.datetime.utcnow()
else:
db.add(M365UserOverride(
username=username,
qwe_client=body.get("qwe_client") or "",
is_restaurant=bool(body.get("is_restaurant", False)),
))
db.commit()
return {"status": "ok"}
+3
View File
@@ -25,12 +25,15 @@ from app.auth import (
get_current_user,
)
from app.omada import omada_client
from app.m365 import router as m365_router
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
logger = logging.getLogger(__name__)
app = FastAPI(title="Salus by Stranto", docs_url=None, redoc_url=None)
app.include_router(m365_router)
app.add_middleware(
SessionMiddleware,
secret_key=SESSION_SECRET_KEY,
+5
View File
@@ -50,6 +50,11 @@
{% if request.url.path == '/audit' %}bg-gray-100 text-gray-900{% else %}text-gray-600 hover:text-gray-900 hover:bg-gray-100{% endif %}">
Audit Log
</a>
<a href="/m365/license-calculator"
class="px-3 py-2 rounded-md text-sm font-medium transition-colors
{% if request.url.path.startswith('/m365') %}bg-gray-100 text-gray-900{% else %}text-gray-600 hover:text-gray-900 hover:bg-gray-100{% endif %}">
M365
</a>
</div>
</div>
{% if user %}
+319
View File
@@ -0,0 +1,319 @@
{% extends "base.html" %}
{% block title %}M365 License Calculator Salus by Stranto{% endblock %}
{% block content %}
<div class="space-y-6">
<!-- Page header -->
<div>
<h1 class="text-xl font-semibold text-gray-900">M365 License Calculator</h1>
<p class="mt-1 text-sm text-gray-500">Upload an Azure AD user export CSV. Assign users to QWE Clients, then see the live license breakdown per client.</p>
</div>
<!-- Upload card -->
<div class="bg-white border border-gray-200 rounded-lg p-5 shadow-sm">
<form method="post" action="/m365/license-calculator/upload" enctype="multipart/form-data"
class="flex items-end gap-4 flex-wrap">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<div class="flex-1 min-w-64">
<label class="block text-sm font-medium text-gray-700 mb-1">M365 User Export CSV</label>
<input type="file" name="file" accept=".csv" required id="csvFileInput"
class="block w-full text-sm text-gray-500
file:mr-3 file:py-2 file:px-3 file:rounded-md file:border-0
file:text-sm file:font-medium file:bg-blue-50 file:text-blue-700
hover:file:bg-blue-100 cursor-pointer border border-gray-300 rounded-md">
</div>
<button type="submit"
class="px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-md hover:bg-blue-700 transition-colors whitespace-nowrap">
Calculate
</button>
</form>
{% if filename %}
<p class="mt-2 text-xs text-gray-400">Last file: <span class="font-medium text-gray-600">{{ filename }}</span></p>
{% endif %}
</div>
{% if error %}
<div class="bg-red-50 border border-red-200 rounded-lg p-4 text-sm text-red-700">{{ error }}</div>
{% endif %}
{% if summary %}
<!-- Stat cards -->
<div class="grid grid-cols-2 sm:grid-cols-3 gap-4">
<div class="bg-white border border-gray-200 rounded-lg p-4 shadow-sm">
<p class="text-xs text-gray-500 uppercase tracking-wide font-medium">Total Users</p>
<p class="mt-1 text-3xl font-bold text-gray-900">{{ summary.total }}</p>
</div>
<div class="bg-white border border-gray-200 rounded-lg p-4 shadow-sm">
<p class="text-xs text-gray-500 uppercase tracking-wide font-medium">Licensed</p>
<p class="mt-1 text-3xl font-bold text-blue-600">{{ summary.licensed }}</p>
</div>
{% if summary.missing %}
<div class="bg-amber-50 border border-amber-200 rounded-lg p-4 shadow-sm">
<p class="text-xs text-amber-700 uppercase tracking-wide font-medium">Missing Assignment</p>
<p class="mt-1 text-3xl font-bold text-amber-600">{{ summary.missing | length }}</p>
</div>
{% endif %}
</div>
<!-- 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">
<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>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200 text-sm">
<thead class="bg-gray-50">
<tr>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">QWE Client</th>
<th class="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">Licensed Users</th>
<th class="px-4 py-3 text-center text-xs font-medium text-blue-600 uppercase tracking-wider">M365 Standard</th>
<th class="px-4 py-3 text-center text-xs font-medium text-sky-600 uppercase tracking-wider">M365 Basic</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-red-600 uppercase tracking-wider">PBI Premium</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-100" id="clientTableBody">
<!-- rendered by JS from USERS_DATA -->
</tbody>
</table>
</div>
</div>
{% if summary.missing %}
<!-- Missing assignments -->
<details class="bg-amber-50 border border-amber-200 rounded-lg">
<summary class="px-5 py-3 text-sm font-medium text-amber-800 cursor-pointer select-none">
Missing Company Assignments ({{ summary.missing | length }}) — licensed users not in QWE AT / QWE SK / KFC AT / KFC SK / QWE
</summary>
<ul class="px-5 pb-4 pt-2 text-xs text-amber-700 space-y-1 max-h-48 overflow-y-auto">
{% for u in summary.missing %}
<li>{{ u.display_name }} — {{ u.department or '(no department)' }}</li>
{% endfor %}
</ul>
</details>
{% endif %}
<!-- User table -->
<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 gap-4 flex-wrap">
<h2 class="text-sm font-semibold text-gray-900">All Users ({{ users | length }})</h2>
<input type="text" id="userFilter" placeholder="Filter by name, UPN, or department…"
class="text-sm border border-gray-300 rounded-md px-3 py-1.5 w-72 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none">
</div>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200 text-sm" id="userTable">
<thead class="bg-gray-50">
<tr>
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider min-w-48">Display Name</th>
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Department</th>
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Co</th>
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Licenses</th>
<th class="px-3 py-3 text-left text-xs font-medium text-gray-900 uppercase tracking-wider min-w-40">QWE Client</th>
<th class="px-3 py-3 text-center text-xs font-medium text-gray-900 uppercase tracking-wider">Restaurant</th>
<th class="w-6 px-2"></th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-100">
{% for u in users %}
<tr class="hover:bg-gray-50 user-row"
data-name="{{ u.display_name | lower }}"
data-upn="{{ u.username | lower }}"
data-dept="{{ u.department | lower }}">
<td class="px-3 py-2">
<div class="font-medium text-gray-900 truncate max-w-48" title="{{ u.display_name }}">{{ u.display_name }}</div>
<div class="text-xs text-gray-400 truncate max-w-48" title="{{ u.username }}">{{ u.username }}</div>
</td>
<td class="px-3 py-2 text-gray-600 whitespace-nowrap">{{ u.department or '—' }}</td>
<td class="px-3 py-2">
{% if u.company == 'AT' %}
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-700">AT</span>
{% elif u.company == 'SK' %}
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-700">SK</span>
{% elif u.is_licensed %}
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-amber-100 text-amber-700" title="No company assignment">?</span>
{% else %}
<span class="text-gray-300"></span>
{% endif %}
</td>
<td class="px-3 py-2">
<div class="flex flex-wrap gap-1">
{% if u.has_m365_standard %}<span class="px-1.5 py-0.5 rounded text-xs font-medium bg-blue-50 text-blue-700">Std</span>{% endif %}
{% if u.has_m365_basic %}<span class="px-1.5 py-0.5 rounded text-xs font-medium bg-sky-50 text-sky-700">Basic</span>{% endif %}
{% if u.has_defender_p1 %}<span class="px-1.5 py-0.5 rounded text-xs font-medium bg-orange-50 text-orange-700">Def</span>{% endif %}
{% if u.has_pbi_pro %}<span class="px-1.5 py-0.5 rounded text-xs font-medium bg-purple-50 text-purple-700">PBI Pro</span>{% endif %}
{% if u.has_pbi_premium %}<span class="px-1.5 py-0.5 rounded text-xs font-medium bg-red-50 text-red-700">PBI Prem</span>{% endif %}
{% if not u.is_licensed %}<span class="text-xs text-gray-400">Unlicensed</span>{% endif %}
</div>
</td>
<td class="px-3 py-2">
<select class="qwe-client-input w-full min-w-32 px-2 py-1 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 cursor-pointer"
data-username="{{ u.username }}">
<option value="" {% if not u.qwe_client %}selected{% endif %}></option>
<option value="QWE AT" {% if u.qwe_client == 'QWE AT' %}selected{% endif %}>QWE AT</option>
<option value="QWE SK" {% if u.qwe_client == 'QWE SK' %}selected{% endif %}>QWE SK</option>
</select>
</td>
<td class="px-3 py-2 text-center">
<input type="checkbox"
class="is-restaurant-checkbox h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500 cursor-pointer"
{% if u.is_restaurant %}checked{% endif %}
data-username="{{ u.username }}">
</td>
<td class="px-2 py-2 text-center">
<span class="save-indicator text-xs" data-username="{{ u.username }}"></span>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}
</div>
{% 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 }};
// ── Helpers ────────────────────────────────────────────────────────────────
function escHtml(s) {
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
// ── Live QWE-Client summary ────────────────────────────────────────────────
function calcClientBreakdown() {
const groups = {};
USERS_DATA.filter(u => u.is_licensed).forEach(u => {
const key = (u.qwe_client || '').trim();
if (!groups[key]) groups[key] = [];
groups[key].push(u);
});
return Object.entries(groups)
.sort(([a], [b]) => {
if (a === '' && b !== '') return 1;
if (b === '' && a !== '') return -1;
return a.toLowerCase().localeCompare(b.toLowerCase());
})
.map(([name, grp]) => ({
name: name || '(no client)',
count: grp.length,
m365_standard: grp.filter(u => u.has_m365_standard).length,
m365_basic: grp.filter(u => u.has_m365_basic).length,
defender_p1: grp.filter(u => u.has_defender_p1).length,
pbi_pro: grp.filter(u => u.has_pbi_pro).length,
pbi_premium: grp.filter(u => u.has_pbi_premium).length,
}));
}
function fmt(n) { return n > 0 ? String(n) : '<span class="text-gray-300">—</span>'; }
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>';
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('');
}
// ── Save override to server ────────────────────────────────────────────────
async function saveOverride(username, qweClient, isRestaurant) {
try {
const res = await fetch('/m365/override', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
csrf_token: CSRF_TOKEN,
username,
qwe_client: qweClient,
is_restaurant: isRestaurant,
}),
});
if (!res.ok) throw new Error(await res.text());
const ind = document.querySelector(`.save-indicator[data-username="${CSS.escape(username)}"]`);
if (ind) {
ind.textContent = '✓';
ind.className = 'save-indicator text-xs text-green-500';
setTimeout(() => { ind.textContent = ''; ind.className = 'save-indicator text-xs'; }, 2000);
}
} catch (e) {
showToast('Save failed: ' + e.message, 'error');
}
}
function getRowData(username) {
const sel = CSS.escape(username);
const ci = document.querySelector(`.qwe-client-input[data-username="${sel}"]`);
const cb = document.querySelector(`.is-restaurant-checkbox[data-username="${sel}"]`);
return { qweClient: ci ? ci.value : '', isRestaurant: cb ? cb.checked : false };
}
// ── Wire up QWE Client inputs ──────────────────────────────────────────────
document.querySelectorAll('.qwe-client-input').forEach(select => {
select.addEventListener('change', () => {
const username = select.dataset.username;
const obj = USERS_DATA.find(u => u.username === username);
if (obj) obj.qwe_client = select.value;
renderClientTable();
const { qweClient, isRestaurant } = getRowData(username);
saveOverride(username, qweClient, isRestaurant);
});
});
// ── Wire up IsRestaurant checkboxes ───────────────────────────────────────
document.querySelectorAll('.is-restaurant-checkbox').forEach(cb => {
cb.addEventListener('change', () => {
const { qweClient, isRestaurant } = getRowData(cb.dataset.username);
saveOverride(cb.dataset.username, qweClient, isRestaurant);
});
});
// ── User table filter ─────────────────────────────────────────────────────
document.getElementById('userFilter').addEventListener('input', function () {
const q = this.value.toLowerCase();
document.querySelectorAll('.user-row').forEach(row => {
const match = !q
|| row.dataset.name.includes(q)
|| row.dataset.upn.includes(q)
|| row.dataset.dept.includes(q);
row.style.display = match ? '' : 'none';
});
});
// ── Initial render ────────────────────────────────────────────────────────
renderClientTable();
</script>
{% endif %}
{% endblock %}