mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
257 lines
11 KiB
HTML
257 lines
11 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
<title>EDR Booking Extractor</title>
|
|
<style>
|
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
body { font-family: system-ui, sans-serif; background: #f1f5f9; color: #1e293b; min-height: 100vh; padding: 2rem; }
|
|
h1 { font-size: 1.25rem; font-weight: 700; margin-bottom: 1.5rem; color: #0f172a; }
|
|
.card { background: #fff; border-radius: 0.75rem; border: 1px solid #e2e8f0; padding: 1.5rem; margin-bottom: 1.5rem; }
|
|
label { display: block; font-size: 0.8rem; font-weight: 600; color: #475569; margin-bottom: 0.4rem; text-transform: uppercase; letter-spacing: 0.05em; }
|
|
.hint { font-size: 0.75rem; color: #94a3b8; margin-top: 0.35rem; }
|
|
.drop-zone {
|
|
border: 2px dashed #cbd5e1; border-radius: 0.5rem; padding: 2rem;
|
|
text-align: center; cursor: pointer; color: #94a3b8; font-size: 0.9rem;
|
|
transition: border-color 0.15s, background 0.15s;
|
|
}
|
|
.drop-zone.over { border-color: #10b981; background: #f0fdf4; color: #065f46; }
|
|
.drop-zone input[type="file"] { display: none; }
|
|
button {
|
|
background: #10b981; color: #fff; border: none; border-radius: 0.5rem;
|
|
padding: 0.65rem 1.5rem; font-size: 0.9rem; font-weight: 600; cursor: pointer; transition: background 0.15s;
|
|
}
|
|
button:hover:not(:disabled) { background: #059669; }
|
|
button:disabled { background: #a7f3d0; cursor: not-allowed; }
|
|
.btn-secondary { background: #e2e8f0; color: #475569; }
|
|
.btn-secondary:hover:not(:disabled) { background: #cbd5e1; }
|
|
.export-btn { background: #6366f1; color: #fff; }
|
|
.export-btn:hover:not(:disabled) { background: #4f46e5; }
|
|
.btn-row { display: flex; gap: 0.75rem; align-items: center; margin-top: 1rem; flex-wrap: wrap; }
|
|
#status { font-size: 0.85rem; color: #64748b; }
|
|
.summary { display: flex; gap: 1.5rem; flex-wrap: wrap; margin-bottom: 1rem; font-size: 0.85rem; color: #475569; }
|
|
.summary strong { color: #0f172a; }
|
|
.table-wrap { overflow-x: auto; }
|
|
table { width: 100%; border-collapse: collapse; font-size: 0.82rem; }
|
|
thead tr { background: #f8fafc; border-bottom: 2px solid #e2e8f0; }
|
|
th { text-align: left; padding: 0.5rem 0.75rem; font-weight: 600; color: #475569; font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.04em; white-space: nowrap; }
|
|
td { padding: 0.5rem 0.75rem; border-bottom: 1px solid #f1f5f9; vertical-align: middle; }
|
|
tr:last-child td { border-bottom: none; }
|
|
tr:hover td { background: #f8fafc; }
|
|
#results-section { display: none; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
|
|
<h1>EDR Booking Extractor</h1>
|
|
|
|
<div class="card">
|
|
<label>Bookings JSON File</label>
|
|
<div class="drop-zone" id="drop-zone" onclick="document.getElementById('file-input').click()">
|
|
<input type="file" id="file-input" accept=".json" onchange="handleFile(this.files[0])" />
|
|
Drop <code>bookings.json</code> here or click to browse
|
|
</div>
|
|
<p class="hint">Accepts a JSON array of bookings or an object with a <code>bookings</code> key.</p>
|
|
<div class="btn-row">
|
|
<button class="btn-secondary" onclick="clearAll()">Clear</button>
|
|
<button class="export-btn" id="export-btn" onclick="exportCSV()" style="display:none">Export CSV</button>
|
|
<span id="status"></span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card" id="results-section">
|
|
<div class="summary" id="summary"></div>
|
|
<div style="margin-bottom:1rem;display:flex;align-items:center;gap:0.5rem;flex-wrap:wrap">
|
|
<label for="date-filter" style="margin:0;text-transform:none;font-size:0.82rem;letter-spacing:0">Departure Date:</label>
|
|
<input type="text" id="date-filter" placeholder="e.g. 18 Jul 2026" style="width:160px;border:1px solid #cbd5e1;border-radius:0.5rem;padding:0.4rem 0.6rem;font-size:0.85rem;outline:none" oninput="applyFilter()" />
|
|
<span id="row-count" style="font-size:0.82rem;color:#64748b"></span>
|
|
</div>
|
|
<div class="table-wrap">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>#</th>
|
|
<th>Booking Ref</th>
|
|
<th>Status</th>
|
|
<th>Booking Type</th>
|
|
<th>Phone</th>
|
|
<th>Email</th>
|
|
<th>Departure</th>
|
|
<th>Origin</th>
|
|
<th>Destination</th>
|
|
<th>Passenger(s)</th>
|
|
<th>Coach - Seat</th>
|
|
<th>Payment Method</th>
|
|
<th>Payment Status</th>
|
|
<th>Total (DJF)</th>
|
|
<th>Created At</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="tbody"></tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
let extracted = [];
|
|
|
|
function fmt(v) { return v ?? ''; }
|
|
|
|
function extractRow(b) {
|
|
const seats = (b.seats ?? b.passengers ?? []);
|
|
const passengerNames = seats.map(s => s.passengerName ?? s.name ?? '').filter(Boolean).join(', ');
|
|
const coachSeats = seats.map(s => {
|
|
const num = s.seat?.seatNumber ?? '';
|
|
const coach = typeof s.seat?.coach === 'string' ? s.seat.coach : (s.seat?.coach?.number ?? '');
|
|
return coach && num ? `${coach}-${num}` : (num || coach || '');
|
|
}).filter(Boolean).join(', ');
|
|
|
|
const dep = b.schedule?.departureAt
|
|
? new Date(b.schedule.departureAt).toLocaleString('en-GB', { day: '2-digit', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit' })
|
|
: '';
|
|
const createdAt = b.createdAt
|
|
? new Date(b.createdAt).toLocaleString('en-GB', { day: '2-digit', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit' })
|
|
: '';
|
|
|
|
return {
|
|
booking_ref: fmt(b.bookingRef),
|
|
status: fmt(b.status),
|
|
booking_type: fmt(b.bookingType),
|
|
phone: fmt(b.contactPhone),
|
|
email: fmt(b.contactEmail),
|
|
departure: dep,
|
|
origin: fmt(b.schedule?.originStation?.code),
|
|
destination: fmt(b.schedule?.destinationStation?.code),
|
|
passenger_names: passengerNames,
|
|
coach_seat: coachSeats,
|
|
payment_method: fmt(b.paymentIntent?.method),
|
|
payment_status: fmt(b.paymentIntent?.status),
|
|
total: b.displayTotalMinor != null ? (b.displayTotalMinor / 100).toFixed(2) : '',
|
|
created_at: createdAt,
|
|
};
|
|
}
|
|
|
|
function extractObjects(text) {
|
|
const results = [];
|
|
let depth = 0, start = -1, inString = false, escape = false;
|
|
for (let i = 0; i < text.length; i++) {
|
|
const ch = text[i];
|
|
if (escape) { escape = false; continue; }
|
|
if (ch === '\\' && inString) { escape = true; continue; }
|
|
if (ch === '"') { inString = !inString; continue; }
|
|
if (inString) continue;
|
|
if (ch === '{') { if (depth === 0) start = i; depth++; }
|
|
else if (ch === '}') {
|
|
depth--;
|
|
if (depth === 0 && start !== -1) {
|
|
try { results.push(JSON.parse(text.slice(start, i + 1))); } catch (_) {}
|
|
start = -1;
|
|
}
|
|
}
|
|
}
|
|
return results;
|
|
}
|
|
|
|
window.handleFile = function(file) {
|
|
if (!file) return;
|
|
setStatus(`Reading ${file.name}…`);
|
|
const reader = new FileReader();
|
|
reader.onload = e => {
|
|
try {
|
|
const text = e.target.result.replace(/^\uFEFF/, '');
|
|
let bookings = [], parseWarning = '';
|
|
try {
|
|
const data = JSON.parse(text);
|
|
bookings = Array.isArray(data) ? data : (data.bookings ?? []);
|
|
} catch (_) {
|
|
bookings = extractObjects(text);
|
|
parseWarning = `⚠ File was truncated — recovered ${bookings.length} complete record(s).`;
|
|
}
|
|
if (!bookings.length) throw new Error('No booking records found in the JSON.');
|
|
extracted = bookings.map(extractRow);
|
|
renderTable();
|
|
document.getElementById('results-section').style.display = 'block';
|
|
document.getElementById('export-btn').style.display = 'inline-block';
|
|
document.getElementById('summary').innerHTML = `Loaded <strong>${extracted.length}</strong> booking(s) from <strong>${file.name}</strong>`;
|
|
setStatus(parseWarning);
|
|
} catch (err) {
|
|
setStatus(`Error: ${err.message}`);
|
|
}
|
|
};
|
|
reader.readAsText(file);
|
|
};
|
|
|
|
const HEADERS = ['booking_ref','status','booking_type','phone','email','departure','origin','destination','passenger_names','coach_seat','payment_method','payment_status','total','created_at'];
|
|
|
|
function renderTable() {
|
|
const tbody = document.getElementById('tbody');
|
|
tbody.innerHTML = '';
|
|
extracted.forEach((row, i) => {
|
|
const tr = document.createElement('tr');
|
|
tr.dataset.departure = row.departure.toLowerCase();
|
|
tr.innerHTML = `
|
|
<td>${i + 1}</td>
|
|
<td><code>${row.booking_ref || '—'}</code></td>
|
|
<td>${row.status || '—'}</td>
|
|
<td>${row.booking_type || '—'}</td>
|
|
<td>${row.phone || '—'}</td>
|
|
<td>${row.email || '—'}</td>
|
|
<td>${row.departure || '—'}</td>
|
|
<td>${row.origin || '—'}</td>
|
|
<td>${row.destination || '—'}</td>
|
|
<td>${row.passenger_names || '—'}</td>
|
|
<td>${row.coach_seat || '—'}</td>
|
|
<td>${row.payment_method || '—'}</td>
|
|
<td>${row.payment_status || '—'}</td>
|
|
<td>${row.total || '—'}</td>
|
|
<td>${row.created_at || '—'}</td>`;
|
|
tbody.appendChild(tr);
|
|
});
|
|
applyFilter();
|
|
}
|
|
|
|
function applyFilter() {
|
|
const q = (document.getElementById('date-filter')?.value ?? '').trim().toLowerCase();
|
|
let visible = 0;
|
|
document.querySelectorAll('#tbody tr').forEach(tr => {
|
|
const show = !q || (tr.dataset.departure ?? '').includes(q);
|
|
tr.style.display = show ? '' : 'none';
|
|
if (show) visible++;
|
|
});
|
|
document.getElementById('row-count').textContent = `Showing ${visible} row${visible !== 1 ? 's' : ''}`;
|
|
}
|
|
|
|
window.exportCSV = function() {
|
|
const header = HEADERS.join(',');
|
|
const lines = extracted.map(row =>
|
|
HEADERS.map(k => `"${String(row[k] ?? '').replace(/"/g, '""')}"`).join(',')
|
|
);
|
|
const a = document.createElement('a');
|
|
a.href = URL.createObjectURL(new Blob([[header, ...lines].join('\n')], { type: 'text/csv' }));
|
|
a.download = 'bookings_extracted.csv';
|
|
a.click();
|
|
};
|
|
|
|
window.clearAll = function() {
|
|
extracted = [];
|
|
document.getElementById('tbody').innerHTML = '';
|
|
document.getElementById('results-section').style.display = 'none';
|
|
document.getElementById('export-btn').style.display = 'none';
|
|
document.getElementById('file-input').value = '';
|
|
document.getElementById('date-filter').value = '';
|
|
document.getElementById('row-count').textContent = '';
|
|
setStatus('');
|
|
};
|
|
|
|
function setStatus(msg) { document.getElementById('status').textContent = msg; }
|
|
|
|
const dz = document.getElementById('drop-zone');
|
|
dz.addEventListener('dragover', e => { e.preventDefault(); dz.classList.add('over'); });
|
|
dz.addEventListener('dragleave', () => dz.classList.remove('over'));
|
|
dz.addEventListener('drop', e => { e.preventDefault(); dz.classList.remove('over'); handleFile(e.dataTransfer.files[0]); });
|
|
</script>
|
|
|
|
</body>
|
|
</html>
|