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:
+224
-18
@@ -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, '"');
|
||||
}
|
||||
|
||||
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 %}
|
||||
|
||||
Reference in New Issue
Block a user