- Mapping (Username/QWE Client/IsRestaurant): CSV export and import via new routes - License prices: persistent DB table (M365LicensePrice), editable price card with auto-save, CSV export/import - Summary table: Monthly Cost column per client + grand-total row, calculated live in JS from current prices - PDF export: client-side jsPDF/autotable, landscape A4, includes cost column when prices are set Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
526 lines
25 KiB
HTML
526 lines
25 KiB
HTML
{% 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 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 %}
|
||
<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, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"');
|
||
}
|
||
|
||
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;
|
||
const rowHtml = rows.map(r => {
|
||
const cost = rowCost(r);
|
||
grandCost += cost;
|
||
grandUsers += r.count;
|
||
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 colspan="5"></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 %}
|