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:
@@ -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 %}
|
||||
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
// ── 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 %}
|
||||
Reference in New Issue
Block a user