Files
Salus/app/templates/m365_license.html
T
cgasserandClaude Sonnet 4.6 adf303ad53 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>
2026-06-09 15:40:19 +02:00

320 lines
16 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{% 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 %}