mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 05:30:55 +00:00
Merge pull request #894 from Tria-plc/alpha
Passenger and seats reports updates
This commit is contained in:
@@ -272,13 +272,17 @@ export class ReportsService {
|
|||||||
|
|
||||||
if (!schedule) return null;
|
if (!schedule) return null;
|
||||||
|
|
||||||
// Fetch booking seats directly by scheduleId — avoids deserializing legacy
|
// Fetch booking seats for this schedule — covers:
|
||||||
// BookingSeat rows with null scheduleId that crash Prisma when loaded via relation.
|
// • outbound seats (leg=1, scheduleId=scheduleId)
|
||||||
|
// • return seats (leg=2, booking.returnScheduleId=scheduleId)
|
||||||
|
// • legacy rows where scheduleId is null but booking.scheduleId matches
|
||||||
const allBookingSeats = await this.prisma.bookingSeat.findMany({
|
const allBookingSeats = await this.prisma.bookingSeat.findMany({
|
||||||
where: {
|
where: {
|
||||||
scheduleId,
|
OR: [
|
||||||
leg: 1,
|
{ scheduleId, booking: { status: { in: ['CONFIRMED', 'BOARDED'] } } },
|
||||||
booking: { status: { in: ['CONFIRMED', 'BOARDED'] } },
|
{ leg: 2, booking: { returnScheduleId: scheduleId, status: { in: ['CONFIRMED', 'BOARDED'] } } },
|
||||||
|
{ scheduleId: null, leg: 1, booking: { scheduleId, status: { in: ['CONFIRMED', 'BOARDED'] } } },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
bookingId: true,
|
bookingId: true,
|
||||||
@@ -379,9 +383,11 @@ export class ReportsService {
|
|||||||
async getPassengerList(scheduleId: string) {
|
async getPassengerList(scheduleId: string) {
|
||||||
const seats = await this.prisma.bookingSeat.findMany({
|
const seats = await this.prisma.bookingSeat.findMany({
|
||||||
where: {
|
where: {
|
||||||
scheduleId,
|
OR: [
|
||||||
leg: 1,
|
{ scheduleId, booking: { status: { in: ['CONFIRMED', 'BOARDED'] } } },
|
||||||
booking: { status: { in: ["CONFIRMED", "BOARDED"] } },
|
{ leg: 2, booking: { returnScheduleId: scheduleId, status: { in: ['CONFIRMED', 'BOARDED'] } } },
|
||||||
|
{ scheduleId: null, leg: 1, booking: { scheduleId, status: { in: ['CONFIRMED', 'BOARDED'] } } },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
booking: {
|
booking: {
|
||||||
@@ -448,9 +454,11 @@ export class ReportsService {
|
|||||||
// Confirmed/boarded seats — exclude dining coaches
|
// Confirmed/boarded seats — exclude dining coaches
|
||||||
const bookingSeats = await this.prisma.bookingSeat.findMany({
|
const bookingSeats = await this.prisma.bookingSeat.findMany({
|
||||||
where: {
|
where: {
|
||||||
scheduleId,
|
OR: [
|
||||||
leg: 1,
|
{ scheduleId, booking: { status: { in: ['CONFIRMED', 'BOARDED', 'PENDING_PAYMENT'] } } },
|
||||||
booking: { status: { in: ['CONFIRMED', 'BOARDED', 'PENDING_PAYMENT'] } },
|
{ leg: 2, booking: { returnScheduleId: scheduleId, status: { in: ['CONFIRMED', 'BOARDED', 'PENDING_PAYMENT'] } } },
|
||||||
|
{ scheduleId: null, leg: 1, booking: { scheduleId, status: { in: ['CONFIRMED', 'BOARDED', 'PENDING_PAYMENT'] } } },
|
||||||
|
],
|
||||||
seat: { coach: { coachType: { type: { not: 'dining' } } } },
|
seat: { coach: { coachType: { type: { not: 'dining' } } } },
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
|
|||||||
@@ -1,530 +0,0 @@
|
|||||||
<!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 GHIJKL MNOPQR 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>
|
|
||||||
@@ -1,256 +0,0 @@
|
|||||||
<!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>
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
import http from 'http';
|
|
||||||
import https from 'https';
|
|
||||||
import fs from 'fs';
|
|
||||||
import path from 'path';
|
|
||||||
import { fileURLToPath } from 'url';
|
|
||||||
|
|
||||||
const PORT = 8080;
|
|
||||||
const __dir = path.dirname(fileURLToPath(import.meta.url));
|
|
||||||
|
|
||||||
const server = http.createServer((req, res) => {
|
|
||||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
||||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
|
|
||||||
|
|
||||||
if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; }
|
|
||||||
|
|
||||||
// Serve any .html file in the same directory
|
|
||||||
if (req.url === '/' || req.url.endsWith('.html')) {
|
|
||||||
const filename = req.url === '/' ? 'booking-checker.html' : req.url.slice(1);
|
|
||||||
const filepath = path.join(__dir, filename);
|
|
||||||
if (fs.existsSync(filepath)) {
|
|
||||||
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
||||||
fs.createReadStream(filepath).pipe(res);
|
|
||||||
} else {
|
|
||||||
res.writeHead(404); res.end('Not found');
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Proxy /proxy?url=<encoded-api-url>
|
|
||||||
if (req.url.startsWith('/proxy?url=')) {
|
|
||||||
const target = decodeURIComponent(req.url.slice('/proxy?url='.length));
|
|
||||||
const parsed = new URL(target);
|
|
||||||
const mod = parsed.protocol === 'https:' ? https : http;
|
|
||||||
const options = {
|
|
||||||
hostname: parsed.hostname,
|
|
||||||
port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
|
|
||||||
path: parsed.pathname + parsed.search,
|
|
||||||
method: req.method,
|
|
||||||
headers: { ...req.headers, host: parsed.hostname },
|
|
||||||
};
|
|
||||||
const proxy = mod.request(options, (apiRes) => {
|
|
||||||
res.writeHead(apiRes.statusCode, apiRes.headers);
|
|
||||||
apiRes.pipe(res);
|
|
||||||
});
|
|
||||||
proxy.on('error', (e) => { res.writeHead(502); res.end(e.message); });
|
|
||||||
req.pipe(proxy);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
res.writeHead(404); res.end();
|
|
||||||
});
|
|
||||||
|
|
||||||
server.listen(PORT, () => console.log(`Booking checker: http://localhost:${PORT}/booking-checker.html`));
|
|
||||||
@@ -1,239 +0,0 @@
|
|||||||
<!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>
|
|
||||||
Reference in New Issue
Block a user