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
+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 %}