Files
Salus/app/templates/m365_license.html
T
cgasserandClaude Sonnet 4.6 8af2fe75a6 Fix M365 summary: per-product totals row and QWE Client-based missing assignments
- Total row now sums each license column individually instead of using colspan
- Missing Assignment warning now flags licensed users with no QWE Client set,
  replacing the previous department-based company check

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 16:15:56 +02:00

537 lines
26 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>
<!-- 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 %}
{% 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 gap-3">
<h2 class="text-sm font-semibold text-gray-900">License Counts per QWE Client</h2>
<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">
<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>
<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">
<!-- 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 QWE Client Assignment ({{ summary.missing | length }}) — licensed users without a QWE Client set
</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 %}
<script>
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 ────────────────────────────────────────────────────────────────
function escHtml(s) {
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
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() {
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 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="8" class="px-4 py-4 text-center text-sm text-gray-400">No licensed users</td></tr>';
return;
}
let grandCost = 0, grandUsers = 0,
grandStd = 0, grandBasic = 0, grandDef = 0, grandPbiPro = 0, grandPbiPrem = 0;
const rowHtml = rows.map(r => {
const cost = rowCost(r);
grandCost += cost;
grandUsers += r.count;
grandStd += r.m365_standard;
grandBasic += r.m365_basic;
grandDef += r.defender_p1;
grandPbiPro += r.pbi_pro;
grandPbiPrem += r.pbi_premium;
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 class="px-4 py-2 text-center text-blue-700">${fmt(grandStd)}</td>
<td class="px-4 py-2 text-center text-sky-700">${fmt(grandBasic)}</td>
<td class="px-4 py-2 text-center text-orange-700">${fmt(grandDef)}</td>
<td class="px-4 py-2 text-center text-purple-700">${fmt(grandPbiPro)}</td>
<td class="px-4 py-2 text-center text-red-700">${fmt(grandPbiPrem)}</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 ────────────────────────────────────────────────
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';
});
});
// ── 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 %}