Files
edr-platform/ticket-extractor.html
2026-07-18 11:44:11 +03:00

240 lines
9.4 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 Ticket 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 Ticket Extractor</h1>
<div class="card">
<label>Tickets 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>tickets.json</code> here or click to browse
</div>
<p class="hint">Accepts a JSON array of tickets or an object with a <code>tickets</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 class="table-wrap">
<table>
<thead>
<tr>
<th>#</th>
<th>Ticket No.</th>
<th>Booking Ref</th>
<th>Passenger</th>
<th>Phone</th>
<th>Email</th>
<th>Journey Type</th>
<th>Origin</th>
<th>Destination</th>
<th>Seat Class</th>
<th>Coach</th>
<th>Seat</th>
</tr>
</thead>
<tbody id="tbody"></tbody>
</table>
</div>
</div>
<script>
const FIELDS = [
{ key: 'ticket_number', paths: [['ticketNumber']] },
{ key: 'booking_ref', paths: [['bookingRef']] },
{ key: 'passenger_name', paths: [['passengerName'], ['booking','seats',0,'passengerName']] },
{ key: 'phone_number', paths: [['booking','contactPhone'], ['booking','passenger','phone']] },
{ key: 'email', paths: [['booking','contactEmail'], ['booking','passenger','email']] },
{ key: 'journey_type', paths: [['booking','bookingType']] },
{ key: 'origin', paths: [['schedule','originStation','code']] },
{ key: 'destination', paths: [['schedule','destinationStation','code']] },
{ key: 'seat_class', paths: [['seat','coach','coachType','name']] },
{ key: 'coach_number', paths: [['seat','coach','number']] },
{ key: 'seat_number', paths: [['seat','seatNumber']] },
];
let extracted = [];
function getField(obj, keys) {
let cur = obj;
for (const k of keys) {
if (cur == null) return '';
if (Array.isArray(cur) && typeof k === 'number') cur = cur[k];
else if (typeof cur === 'object' && k in cur) cur = cur[k];
else return '';
}
return cur ?? '';
}
function extractRow(t) {
const row = {};
for (const f of FIELDS) {
for (const path of f.paths) {
const val = getField(t, path);
if (val !== '') { row[f.key] = String(val); break; }
}
if (!row[f.key]) row[f.key] = '';
}
return row;
}
// Extract complete JSON objects from a truncated array string using brace-depth counting
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 tickets = [];
let parseWarning = '';
try {
const data = JSON.parse(text);
tickets = Array.isArray(data) ? data : (data.tickets ?? []);
} catch (_) {
// Truncated file: extract complete JSON objects by brace depth
tickets = extractObjects(text);
parseWarning = `⚠ File was truncated — recovered ${tickets.length} complete record(s).`;
}
if (!Array.isArray(tickets) || !tickets.length) throw new Error('No ticket records found in the JSON.');
extracted = tickets.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> ticket(s) from <strong>${file.name}</strong>`;
setStatus(parseWarning);
} catch (err) {
setStatus(`Error: ${err.message}`);
}
};
reader.readAsText(file);
}
function renderTable() {
const tbody = document.getElementById('tbody');
tbody.innerHTML = '';
extracted.forEach((row, i) => {
const tr = document.createElement('tr');
tr.innerHTML = `
<td>${i + 1}</td>
<td><code>${row.ticket_number || '—'}</code></td>
<td><code>${row.booking_ref || '—'}</code></td>
<td>${row.passenger_name || '—'}</td>
<td>${row.phone_number || '—'}</td>
<td>${row.email || '—'}</td>
<td>${row.journey_type || '—'}</td>
<td>${row.origin || '—'}</td>
<td>${row.destination || '—'}</td>
<td>${row.seat_class || '—'}</td>
<td>${row.coach_number || '—'}</td>
<td>${row.seat_number || '—'}</td>`;
tbody.appendChild(tr);
});
}
window.exportCSV = function() {
const header = FIELDS.map(f => f.key).join(',');
const lines = extracted.map(row =>
FIELDS.map(f => `"${String(row[f.key] ?? '').replace(/"/g, '""')}"`).join(',')
);
const csv = [header, ...lines].join('\n');
const a = document.createElement('a');
a.href = URL.createObjectURL(new Blob([csv], { type: 'text/csv' }));
a.download = 'tickets_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 = '';
setStatus('');
}
function setStatus(msg) { document.getElementById('status').textContent = msg; }
// Drag and drop
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>