Add M365 mapping/prices export-import, license pricing, and PDF summary export

- 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>
This commit is contained in:
2026-06-09 16:05:26 +02:00
co-authored by Claude Sonnet 4.6
parent adf303ad53
commit 1ec63844ce
3 changed files with 438 additions and 23 deletions
+224 -18
View File
@@ -33,6 +33,90 @@
{% 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 %}
@@ -58,9 +142,15 @@
<!-- 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">
<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>
<span class="text-xs text-gray-400">Updates live as you assign clients below</span>
<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">
@@ -73,6 +163,7 @@
<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">
@@ -178,11 +269,37 @@
{% 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 }};
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 ────────────────────────────────────────────────────────────────
@@ -194,6 +311,13 @@
.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() {
@@ -221,27 +345,50 @@
}));
}
function fmt(n) { return n > 0 ? String(n) : '<span class="text-gray-300">—</span>'; }
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="7" class="px-4 py-4 text-center text-sm text-gray-400">No licensed users</td></tr>';
tbody.innerHTML = '<tr><td colspan="8" 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('');
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 ────────────────────────────────────────────────
@@ -312,8 +459,67 @@
});
});
// ── 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 %}