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

531 lines
21 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 Checker</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;
}
input[type="text"], textarea {
width: 100%;
border: 1px solid #cbd5e1;
border-radius: 0.5rem;
padding: 0.6rem 0.75rem;
font-size: 0.9rem;
outline: none;
transition: border-color 0.15s;
}
input[type="text"]:focus, textarea:focus {
border-color: #10b981;
box-shadow: 0 0 0 3px rgba(16,185,129,0.15);
}
textarea {
resize: vertical;
min-height: 140px;
font-family: monospace;
font-size: 0.85rem;
}
.hint { font-size: 0.75rem; color: #94a3b8; margin-top: 0.35rem; }
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; }
.btn-row { display: flex; gap: 0.75rem; align-items: center; margin-top: 1rem; flex-wrap: wrap; }
#status { font-size: 0.85rem; color: #64748b; }
.progress-bar-wrap {
width: 100%;
background: #e2e8f0;
border-radius: 9999px;
height: 6px;
margin-top: 0.75rem;
display: none;
}
.progress-bar {
height: 6px;
background: #10b981;
border-radius: 9999px;
transition: width 0.2s;
width: 0%;
}
.summary {
display: flex;
gap: 1.5rem;
flex-wrap: wrap;
margin-bottom: 1rem;
font-size: 0.85rem;
color: #475569;
}
.summary strong { color: #0f172a; }
.filter-row {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
flex-wrap: wrap;
}
.filter-btn {
background: #f1f5f9;
color: #475569;
border: 1px solid #e2e8f0;
border-radius: 9999px;
padding: 0.3rem 0.9rem;
font-size: 0.78rem;
font-weight: 600;
cursor: pointer;
}
.filter-btn.active { background: #10b981; color: #fff; border-color: #10b981; }
.table-wrap { overflow-x: auto; }
table { width: 100%; border-collapse: collapse; font-size: 0.85rem; }
thead tr { background: #f8fafc; border-bottom: 2px solid #e2e8f0; }
th {
text-align: left;
padding: 0.6rem 0.75rem;
font-weight: 600;
color: #475569;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.04em;
white-space: nowrap;
}
td { padding: 0.6rem 0.75rem; border-bottom: 1px solid #f1f5f9; vertical-align: middle; }
tr:last-child td { border-bottom: none; }
tr:hover td { background: #f8fafc; }
tr.hidden-row { display: none; }
.badge {
display: inline-block;
padding: 0.2rem 0.55rem;
border-radius: 9999px;
font-size: 0.72rem;
font-weight: 600;
}
.badge-yes { background: #fef3c7; color: #92400e; }
.badge-no { background: #f1f5f9; color: #64748b; }
.badge-confirmed { background: #d1fae5; color: #065f46; }
.badge-cancelled { background: #fee2e2; color: #991b1b; }
.badge-pending { background: #fef9c3; color: #854d0e; }
.badge-other { background: #f1f5f9; color: #475569; }
.badge-dup { background: #fee2e2; color: #991b1b; }
.error-row td { color: #ef4444; font-style: italic; }
.dup-row td { background: #fff5f5; }
.dup-cell { background:#fee2e2;color:#991b1b;border-radius:6px;padding:0.4rem 0.6rem;display:inline-block;font-weight:600;font-size:0.82rem; }
#results-section { display: none; }
.export-btn { background: #6366f1; }
.export-btn:hover:not(:disabled) { background: #4f46e5; }
</style>
</head>
<body>
<h1>EDR Booking Checker</h1>
<div class="card">
<label for="base-url">API Base URL</label>
<input type="text" id="base-url" value="http://localhost:4000" />
<p class="hint">No trailing slash. e.g. https://api.edrsc.com</p>
</div>
<div class="card">
<label for="refs">Booking References</label>
<textarea id="refs" placeholder="ABCDEF&#10;GHIJKL&#10;MNOPQR&#10;One per line, comma-separated, or wrapped in {curly,braces}"></textarea>
<p class="hint">Supports any format: one per line, comma-separated, or <code>{REF1,REF2}</code> groups.</p>
<div class="btn-row">
<button id="run-btn" onclick="run()">Check Bookings</button>
<button class="btn-secondary" onclick="clearAll()">Clear</button>
<button class="btn-secondary export-btn" onclick="exportCSV()" id="export-btn" style="display:none;background:#6366f1;color:#fff;">Export CSV</button>
<span id="status"></span>
</div>
<div class="progress-bar-wrap" id="progress-wrap">
<div class="progress-bar" id="progress-bar"></div>
</div>
</div>
<div class="card" id="results-section">
<div class="summary" id="summary"></div>
<div class="filter-row">
<button class="filter-btn active" onclick="setFilter('all', this)">All</button>
<button class="filter-btn" onclick="setFilter('package', this)">Package Only</button>
<button class="filter-btn" onclick="setFilter('regular', this)">Regular Only</button>
<button class="filter-btn" onclick="setFilter('error', this)">Errors Only</button>
<button class="filter-btn" onclick="setFilter('dup', this)">Duplicate Seats</button>
</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">Date:</label>
<input type="text" id="date-filter" placeholder="e.g. 18 Jul 2026" style="width:160px" oninput="applyFilter(currentFilter)" />
<label for="route-filter" style="margin:0;text-transform:none;font-size:0.82rem;letter-spacing:0">Route:</label>
<input type="text" id="route-filter" placeholder="e.g. DIR or BISH" style="width:160px" oninput="applyFilter(currentFilter)" />
<label for="class-filter" style="margin:0;text-transform:none;font-size:0.82rem;letter-spacing:0">Seat Class:</label>
<input type="text" id="class-filter" placeholder="e.g. VIP" style="width:140px" oninput="applyFilter(currentFilter)" />
<span id="row-count" style="font-size:0.82rem;color:#64748b;margin-left:0.5rem"></span>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Journey</th>
<th>Duplicate Bookings</th>
</tr>
</thead>
<tbody id="tbody"></tbody>
</table>
</div>
</div>
<script>
let allRows = [];
let currentFilter = 'all';
function parseRefs(raw) {
// Remove curly braces, split on newlines/commas, deduplicate
const cleaned = raw.replace(/[{}]/g, ' ');
const refs = cleaned.split(/[\n,\s]+/).map(r => r.trim()).filter(Boolean);
return [...new Set(refs)];
}
function statusBadge(status) {
if (!status) return '<span class="badge badge-other">—</span>';
const s = status.toUpperCase();
const cls = s === 'CONFIRMED' ? 'confirmed'
: s === 'CANCELLED' ? 'cancelled'
: s.includes('PENDING') ? 'pending'
: 'other';
return `<span class="badge badge-${cls}">${status}</span>`;
}
async function run() {
const baseUrl = document.getElementById('base-url').value.trim().replace(/\/$/, '');
const refs = parseRefs(document.getElementById('refs').value);
if (!refs.length) { setStatus('Enter at least one booking reference.'); return; }
const btn = document.getElementById('run-btn');
btn.disabled = true;
document.getElementById('export-btn').style.display = 'none';
document.getElementById('results-section').style.display = 'none';
document.getElementById('progress-wrap').style.display = 'block';
setProgress(0);
setStatus(`Checking ${refs.length} booking(s)…`);
allRows = [];
let done = 0;
// Run in batches of 20 to avoid overwhelming the server
const BATCH = 20;
for (let i = 0; i < refs.length; i += BATCH) {
const batch = refs.slice(i, i + BATCH);
const batchResults = await Promise.all(
batch.map(async (ref) => {
try {
const proxyUrl = `http://localhost:8080/proxy?url=${encodeURIComponent(`${baseUrl}/bookings/${ref}`)}`;
const res = await fetch(proxyUrl);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const json = await res.json();
const d = json.data ?? json;
return {
ref,
status: d.status ?? 'UNKNOWN',
bookingType: d.bookingType ?? '—',
isPackage: d.isPackageBooking ?? !!d.packageId,
packageName: d.packageName ?? '—',
packageId: d.packageId ?? '—',
passengerNames: (d.passengers ?? []).map(p => p.fullName ?? '—').join(', '),
phone: d.contactPhone ?? '—',
coaches: (d.passengers ?? []).map(p => p.seat?.coach ?? '—').join(', '),
seatNumbers: (d.passengers ?? []).map(p => p.seat?.number ?? '—').join(', '),
seatClasses: (d.passengers ?? []).map(p => p.seat?.seatClass ?? '—').join(', '),
seatIds: (d.passengers ?? []).map(p => p.seat?.id).filter(Boolean),
seats: (d.passengers ?? []).map(p => {
const legScheduleMap = {
1: d.schedule?.id,
2: d.bookingType === 'ROUND_TRIP' ? d.returnSchedule?.id : d.leg2Schedule?.id,
3: d.returnSchedule?.id,
4: d.returnLeg2Schedule?.id,
};
const legScheduleObjMap = {
1: d.schedule,
2: d.bookingType === 'ROUND_TRIP' ? d.returnSchedule : d.leg2Schedule,
3: d.returnSchedule,
4: d.returnLeg2Schedule,
};
const sched = legScheduleObjMap[p.leg] ?? d.schedule;
const scheduleId = sched?.id ?? null;
const originSeq = sched?.origin?.sequence ?? sched?.originStation?.sequence ?? 0;
const destSeq = sched?.destination?.sequence ?? sched?.destinationStation?.sequence ?? 999;
const depMap = {
1: d.schedule?.departureAt,
2: d.bookingType === 'ROUND_TRIP' ? d.returnSchedule?.departureAt : d.leg2Schedule?.departureAt,
3: d.returnSchedule?.departureAt,
4: d.returnLeg2Schedule?.departureAt,
};
const departureAt = depMap[p.leg] ?? d.schedule?.departureAt;
const depDate = departureAt ? new Date(departureAt).toLocaleDateString('en-GB', { day:'2-digit', month:'short', year:'numeric' }) : '—';
const legLabel = d.bookingType === 'ONE_WAY' ? 'Outbound'
: d.bookingType === 'ROUND_TRIP'
? (p.leg === 1 ? 'Outbound' : 'Return')
: d.bookingType === 'TRANSIT'
? (p.leg === 1 ? 'Outbound Leg 1' : 'Outbound Leg 2')
: d.bookingType === 'ROUND_TRIP_TRANSIT'
? (p.leg === 1 ? 'Outbound Leg 1' : p.leg === 2 ? 'Outbound Leg 2' : p.leg === 3 ? 'Return Leg 1' : 'Return Leg 2')
: `Leg ${p.leg}`;
return { scheduleId, originSeq, destSeq, depDate, legLabel, coach: p.seat?.coach ?? '—', number: p.seat?.number ?? '—', seatClass: p.seat?.seatClass ?? '—' };
}),
origin: d.schedule?.origin?.code ?? d.schedule?.origin?.name ?? '—',
destination: d.schedule?.destination?.code ?? d.schedule?.destination?.name ?? '—',
hasDupSeat: false,
dupSeatRefs: [],
error: null,
};
} catch (e) {
return { ref, status: 'ERROR', bookingType: '—', isPackage: false, packageName: '—', packageId: '—', passengerNames: '—', phone: '—', seatClasses: '—', seatIds: [], seats: [], origin: '—', destination: '—', hasDupSeat: false, dupSeatRefs: [], dupSeatIds: new Set(), error: e.message };
}
})
);
allRows.push(...batchResults);
done += batch.length;
setProgress(Math.round((done / refs.length) * 100));
setStatus(`Checked ${done} / ${refs.length}`);
}
detectDuplicateSeats();
renderTable();
renderSummary();
document.getElementById('results-section').style.display = 'block';
document.getElementById('export-btn').style.display = 'inline-block';
document.getElementById('progress-wrap').style.display = 'none';
setStatus('');
btn.disabled = false;
}
function detectDuplicateSeats() {
// Group seats by scheduleId|coach|seatNumber
// Two bookings conflict if they share the same seat on the same schedule
// AND their origin→destination sequence ranges overlap
const seatMap = new Map(); // key: scheduleId|coach|seat -> [{ref, originSeq, destSeq}]
for (const row of allRows) {
if (row.error) continue;
for (const seat of row.seats) {
if (!seat.scheduleId || seat.coach === '—' || seat.number === '—') continue;
const key = `${seat.scheduleId}|${seat.coach}|${seat.number}`;
if (!seatMap.has(key)) seatMap.set(key, []);
seatMap.get(key).push({ ref: row.ref, originSeq: seat.originSeq, destSeq: seat.destSeq });
}
}
// Two segments [a1,a2] and [b1,b2] overlap if a1 < b2 && b1 < a2
function overlaps(a1, a2, b1, b2) { return a1 < b2 && b1 < a2; }
for (const row of allRows) {
if (row.error) continue;
const dupRefs = new Set();
const dupSeatKeys = new Set();
for (const seat of row.seats) {
if (!seat.scheduleId) continue;
const key = `${seat.scheduleId}|${seat.coach}|${seat.number}`;
const entries = seatMap.get(key) ?? [];
for (const other of entries) {
if (other.ref === row.ref) continue;
if (overlaps(seat.originSeq, seat.destSeq, other.originSeq, other.destSeq)) {
dupRefs.add(other.ref);
dupSeatKeys.add(`${seat.coach}|${seat.number}`);
}
}
}
row.hasDupSeat = dupRefs.size > 0;
row.dupSeatRefs = [...dupRefs];
row.dupSeatIds = dupSeatKeys;
}
}
function buildDupGroups() {
const groups = new Map();
for (const row of allRows) {
if (row.error || !row.hasDupSeat) continue;
const names = row.passengerNames.split(', ');
for (const seat of row.seats) {
if (!row.dupSeatIds.has(`${seat.coach}|${seat.number}`)) continue;
const key = `${seat.depDate}|${seat.legLabel}|${row.origin}${row.destination}|${seat.seatClass}|${seat.coach}|${seat.number}`;
if (!groups.has(key)) groups.set(key, { entries: [], isPackage: false });
const name = (names[row.seats.indexOf(seat)] ?? '').trim() || '—';
groups.get(key).entries.push({ ref: row.ref, name, phone: row.phone, isPackage: row.isPackage });
if (row.isPackage) groups.get(key).isPackage = true;
}
}
return groups;
}
function renderTable() {
const tbody = document.getElementById('tbody');
tbody.innerHTML = '';
const groups = buildDupGroups();
if (!groups.size) return;
for (const [key, { entries, isPackage }] of groups) {
if (new Set(entries.map(e => e.ref)).size < 2) continue;
const [depDate, legLabel, route, seatClass, coach, seatNum] = key.split('|');
const tr = document.createElement('tr');
tr.dataset.dup = '1';
tr.dataset.type = isPackage ? 'package' : 'regular';
tr.dataset.date = depDate;
tr.dataset.route = route;
tr.dataset.seatclass = seatClass.toLowerCase();
tr.classList.add('dup-row');
const journeyCell = `<td style="white-space:nowrap">${depDate} · ${legLabel} · ${route} · ${seatClass} · ${coach}-${seatNum}</td>`;
const bookingsCell = `<td>${entries.map(e =>
`<div style="margin-bottom:0.5rem">
<span class="dup-cell">${e.ref}</span>
<span style="margin-left:0.4rem">${e.name}</span>
<span style="font-size:0.78rem;color:#64748b;margin-left:0.4rem">${e.phone}</span>
</div>`
).join('')}</td>`;
tr.innerHTML = journeyCell + bookingsCell;
tbody.appendChild(tr);
}
applyFilter(currentFilter);
}
function renderSummary() {
const total = allRows.length;
const pkgCount = allRows.filter(r => r.isPackage).length;
const errCount = allRows.filter(r => r.error).length;
const dupCount = allRows.filter(r => r.hasDupSeat).length;
const regCount = total - pkgCount - errCount;
document.getElementById('summary').innerHTML = `
<span>Total: <strong>${total}</strong></span>
<span>Package: <strong>${pkgCount}</strong></span>
<span>Regular: <strong>${regCount}</strong></span>
${errCount ? `<span style="color:#ef4444">Errors: <strong>${errCount}</strong></span>` : ''}
${dupCount ? `<span style="color:#dc2626">⚠ Duplicate Seats: <strong>${dupCount}</strong></span>` : ''}
`;
}
function setFilter(type, btn) {
currentFilter = type;
document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
applyFilter(type);
}
function applyFilter(type) {
const dateQ = (document.getElementById('date-filter')?.value ?? '').trim().toLowerCase();
const routeQ = (document.getElementById('route-filter')?.value ?? '').trim().toLowerCase();
const classQ = (document.getElementById('class-filter')?.value ?? '').trim().toLowerCase();
let visible = 0;
document.querySelectorAll('#tbody tr').forEach(tr => {
const t = tr.dataset.type;
const typeMatch = type === 'all'
|| (type === 'package' && t === 'package')
|| (type === 'regular' && t === 'regular')
|| (type === 'error' && t === 'error')
|| (type === 'dup' && tr.dataset.dup === '1');
const dateMatch = !dateQ || (tr.dataset.date ?? '').toLowerCase().includes(dateQ);
const routeMatch = !routeQ || (tr.dataset.route ?? '').toLowerCase().includes(routeQ);
const classMatch = !classQ || (tr.dataset.seatclass ?? '').includes(classQ);
const show = typeMatch && dateMatch && routeMatch && classMatch;
tr.classList.toggle('hidden-row', !show);
if (show) visible++;
});
document.getElementById('row-count').textContent = `Showing ${visible} row${visible !== 1 ? 's' : ''}`;
}
function exportCSV() {
const header = 'Booking Ref,Status,Booking Type,Is Package,Package Name,Package ID,Passenger(s),Phone,Coach-Seat,Seat Class,Duplicate Seat In';
const lines = allRows.map(r =>
[r.ref, r.status, r.bookingType, r.isPackage, r.packageName, r.packageId, r.seats.map((s, idx) => `${(r.passengerNames.split(', ')[idx] ?? '').trim()} · ${s.legLabel} · ${s.seatClass} · ${r.origin}${r.destination} · ${s.coach}-${s.number}`).join('; '), r.phone, r.seatClasses, r.dupSeatRefs.join('; ') || '']
.map(v => `"${String(v).replace(/"/g, '""')}"`)
.join(',')
);
const csv = [header, ...lines].join('\n');
const blob = new Blob([csv], { type: 'text/csv' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'booking-check-' + new Date().toISOString().slice(0,10) + '.csv';
a.click();
}
function clearAll() {
document.getElementById('date-filter').value = '';
document.getElementById('route-filter').value = '';
document.getElementById('class-filter').value = '';
document.getElementById('refs').value = '';
document.getElementById('tbody').innerHTML = '';
document.getElementById('results-section').style.display = 'none';
document.getElementById('export-btn').style.display = 'none';
document.getElementById('progress-wrap').style.display = 'none';
allRows = [];
setStatus('');
}
function setStatus(msg) { document.getElementById('status').textContent = msg; }
function setProgress(pct) { document.getElementById('progress-bar').style.width = pct + '%'; }
</script>
</body>
</html>