|
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8"/>
|
|
<meta name="viewport" content="width=device-width,initial-scale=1"/>
|
|
<title>Car List – Tickets Mockup (with Summary Column)</title>
|
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700;800&display=swap" rel="stylesheet">
|
|
<style>
|
|
:root{
|
|
--blue:#1f7ae0; --bg:#fff; --line:#e8e8ef; --accent:#208dee;
|
|
--sidebar:#0f2642; --sidebar-2:#122b4a; --shadow:0 8px 24px rgba(16,24,40,.08);
|
|
}
|
|
*{box-sizing:border-box} html,body{height:100%} body{margin:0;font-family:Inter,system-ui,Segoe UI,Roboto,Arial;background:#fff;color:#0b1324}
|
|
.app{display:grid;grid-template-columns:280px 1fr;min-height:100vh}
|
|
.sidebar{background:linear-gradient(180deg,var(--sidebar),var(--sidebar-2));color:#e8eef9;display:flex;flex-direction:column}
|
|
.brand{display:flex;gap:12px;padding:16px;align-items:center}
|
|
.avatar{width:48px;height:48px;border-radius:50%;background:#fff;display:grid;place-items:center;color:#0f2642;font-weight:800}
|
|
.menu a{display:flex;align-items:center;gap:12px;padding:10px 12px;margin:4px;border-radius:12px;color:#e8eef9;text-decoration:none}
|
|
.menu a:hover{background:rgba(255,255,255,.08)} .menu a.active{background:rgba(255,255,255,.16)}
|
|
.topbar{background:var(--blue);color:#fff;display:flex;justify-content:center;align-items:center;height:60px;box-shadow:var(--shadow)}
|
|
.topbar .title{font-size:22px;font-weight:800}
|
|
main{display:grid;grid-template-rows:auto 1fr}
|
|
.content{padding:20px;display:grid;grid-template-columns:1fr;grid-template-rows: auto 16px 1fr;gap:12px}
|
|
.section{display:flex;flex-direction:column;min-height:0}
|
|
.section .hd{display:flex;align-items:center;justify-content:space-between;padding:8px 0;border-bottom:1px solid var(--line)}
|
|
.section .h2{font-weight:800}
|
|
.section .body{padding:8px 0;overflow:auto}
|
|
.table{width:100%;border-collapse:collapse;font-size:13px}
|
|
.table th,.table td{padding:10px;border-bottom:1px solid #eef0f5;text-align:left}
|
|
.table th{font-size:12px;text-transform:uppercase;letter-spacing:.02em;color:#6b7280;background:#fafafa}
|
|
.row-hover:hover{background:#f3f6ff}
|
|
|
|
.details-grid{display:grid;grid-template-columns:220px 12px 1fr;gap:18px;align-items:start}
|
|
.tabs{border:1px solid var(--line);border-radius:12px;overflow:hidden;background:#fff}
|
|
.tab-btn{width:100%;display:flex;align-items:center;gap:10px;padding:10px 12px;border:none;background:#fff;border-bottom:1px solid #f1f3f7;cursor:pointer;text-align:left}
|
|
.tab-btn:last-child{border-bottom:none}
|
|
.tab-btn[aria-selected="true"]{background:#e8f1ff;font-weight:700}
|
|
.mini-splitter{width:12px;border:1px solid #dbe4ee;background:#e9eef5;border-radius:12px;height:100%}
|
|
.panel{display:none} .panel.active{display:block}
|
|
.input, .select{padding:9px 10px;border:1px solid var(--line);border-radius:8px;background:#fff}
|
|
.btn{padding:8px 12px;border:1px solid var(--line);background:#fff;border-radius:8px;cursor:pointer}
|
|
.btn.primary{background:var(--accent);color:#fff;border-color:transparent}
|
|
.small{font-size:12px;color:#6b7280}
|
|
.badge{font-size:11px;background:#1f7ae0;color:#fff;padding:2px 6px;border-radius:999px;font-weight:800}
|
|
|
|
.table-mini{width:100%;border-collapse:collapse;font-size:13px}
|
|
.table-mini th,.table-mini td{padding:8px;border-bottom:1px solid #eef0f5;text-align:left;vertical-align:top}
|
|
.table-mini th{background:#fafafa;color:#6b7280;text-transform:uppercase;font-size:12px;cursor:pointer;user-select:none}
|
|
.tickets-toolbar{display:flex;gap:8px;align-items:center;margin:0 0 8px;flex-wrap:wrap}
|
|
.tickets-toolbar .right{margin-left:auto;display:flex;gap:8px;align-items:center}
|
|
.pager{display:flex;gap:8px;align-items:center}
|
|
.pill{display:inline-block;padding:2px 8px;border-radius:999px;font-size:11px;font-weight:700;text-transform:capitalize}
|
|
.pill.unpaid{background:#fee2e2;color:#991b1b}
|
|
.pill.paid{background:#dcfce7;color:#065f46}
|
|
.pill.pending{background:#fef9c3;color:#854d0e}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="app">
|
|
<aside class="sidebar">
|
|
<div class="brand">
|
|
<div class="avatar">A</div>
|
|
<div class="who"><strong>Andre</strong><small>hardwood4us@gmail.com</small></div>
|
|
</div>
|
|
<nav class="menu">
|
|
<a class="active" href="#">Cars List</a>
|
|
</nav>
|
|
</aside>
|
|
|
|
<main>
|
|
<div class="topbar"><div class="title">Car List – Tickets Mockup</div></div>
|
|
|
|
<section class="content">
|
|
<!-- Cars table -->
|
|
<div class="section">
|
|
<div class="hd">
|
|
<div class="h2">Cars</div>
|
|
<div class="small">Click a row to load details & tickets</div>
|
|
</div>
|
|
<div class="body">
|
|
<table class="table" id="carsTable">
|
|
<thead>
|
|
<tr>
|
|
<th>ID</th><th>Model</th><th>Plate</th><th>Status</th><th>Weekly</th><th>Last upd.</th>
|
|
<th>Tickets (Unpaid) / Total $</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody></tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
<div style="height:16px"></div>
|
|
|
|
<!-- Details + Tickets -->
|
|
<div class="section">
|
|
<div class="hd">
|
|
<div class="h2">Car Details</div>
|
|
<div class="small" id="detailHint">Select a car from the list</div>
|
|
</div>
|
|
<div class="body">
|
|
<div class="details-grid">
|
|
<div class="tabs" id="detailTabs" role="tablist">
|
|
<button class="tab-btn" id="tab-cdetails" role="tab" aria-controls="panel-cdetails" aria-selected="true" data-tab="cdetails">
|
|
<span class="tab-title">Car details</span>
|
|
</button>
|
|
<button class="tab-btn" id="tab-ctickets" role="tab" aria-controls="panel-ctickets" aria-selected="false" data-tab="ctickets">
|
|
<span class="tab-title">Tickets</span>
|
|
<span class="badge" id="ctixBadge">0</span>
|
|
</button>
|
|
</div>
|
|
|
|
<div class="mini-splitter" aria-hidden="true"></div>
|
|
|
|
<div>
|
|
<div class="panel active" id="panel-cdetails" role="tabpanel" aria-labelledby="tab-cdetails">
|
|
<div style="display:grid;grid-template-columns:160px 1fr;gap:8px;align-items:center">
|
|
<div>Model</div> <input class="input" id="f_model" placeholder="—"/>
|
|
<div>Plate</div> <input class="input" id="f_plate" placeholder="—"/>
|
|
<div>Status</div> <input class="input" id="f_status" placeholder="—"/>
|
|
<div>Weekly</div> <input class="input" id="f_weekly" placeholder="—"/>
|
|
</div>
|
|
<div style="margin-top:12px"><button class="btn primary">Save</button></div>
|
|
</div>
|
|
|
|
<div class="panel" id="panel-ctickets" role="tabpanel" aria-labelledby="tab-ctickets">
|
|
<h3>Tickets</h3>
|
|
<div class="tickets-toolbar">
|
|
<select id="ctixFilter" class="select" title="Status filter">
|
|
<option value="all">All</option>
|
|
<option value="unpaid">Unpaid</option>
|
|
<option value="paid">Paid</option>
|
|
<option value="pending">Pending</option>
|
|
</select>
|
|
<select id="ctixPageSize" class="select">
|
|
<option value="10">10 / page</option>
|
|
<option value="25">25 / page</option>
|
|
<option value="50">50 / page</option>
|
|
</select>
|
|
<div class="pager">
|
|
<button class="btn" id="ctixPrev" type="button">Prev</button>
|
|
<div class="small" id="ctixPageInfo">Page 1</div>
|
|
<button class="btn" id="ctixNext" type="button">Next</button>
|
|
</div>
|
|
<div class="right">
|
|
<button class="btn" id="ctixSync" type="button">Sync from CityPay</button>
|
|
<button class="btn" id="ctixExport" type="button">Export CSV</button>
|
|
</div>
|
|
</div>
|
|
<table class="table-mini" id="ctixTable">
|
|
<thead>
|
|
<tr>
|
|
<th data-sort="violation">Violation #</th>
|
|
<th data-sort="description">Description</th>
|
|
<th data-sort="issueDate">Issue Date</th>
|
|
<th data-sort="liability">Liability</th>
|
|
<th data-sort="status">Status</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody></tbody>
|
|
</table>
|
|
<div class="small" id="ctixSummary" style="margin-top:8px">—</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</main>
|
|
</div>
|
|
|
|
<script>
|
|
// ===== Demo data =====
|
|
const cars = [
|
|
{id:344, model:'Toyota Prius 2008', plate:'KVC112 VT', status:'recurrence', weekly:350.00, last:'2025-09-01', carId:'P12'},
|
|
{id:345, model:'Toyota Prius 2007', plate:'KTK620 VT', status:'available', weekly:350.00, last:'2025-09-02', carId:'P05'},
|
|
{id:346, model:'Toyota Prius 2006', plate:'KVC520 VT', status:'impound', weekly:350.00, last:'2025-08-30', carId:'P21'}
|
|
];
|
|
|
|
// Tickets keyed by carId
|
|
const ticketsByCar = {
|
|
'P12': [
|
|
{violation:'4944114000', description:'SPEED VIOLATION', issueDate:'2025-04-17', liability:75.43, status:'unpaid'},
|
|
{violation:'4938871123', description:'NO PARKING', issueDate:'2025-02-25', liability:76.47, status:'unpaid'},
|
|
{violation:'5703736804', description:'DOUBLE PARK', issueDate:'2025-04-03', liability:75.56, status:'pending'},
|
|
{violation:'9192150767', description:'FIRE HYDRANT', issueDate:'2025-03-19', liability:176.29, status:'paid'}
|
|
],
|
|
'P05': [
|
|
{violation:'8801122334', description:'NO STANDING', issueDate:'2025-08-10', liability:65.00, status:'paid'}
|
|
],
|
|
'P21': []
|
|
};
|
|
|
|
// ===== Helpers =====
|
|
function money(n){ return '$' + (Number(n||0)).toFixed(2); }
|
|
function carTicketSummary(carId){
|
|
const list = ticketsByCar[carId]||[];
|
|
const unpaidCount = list.filter(t=>String(t.status).toLowerCase()!=='paid').length;
|
|
const total = list.reduce((a,b)=>a+Number(b.liability||0),0);
|
|
return `${unpaidCount} / ${money(total)}`;
|
|
}
|
|
|
|
// ===== Cars table =====
|
|
const carsTbody = document.querySelector('#carsTable tbody');
|
|
function renderCars(){
|
|
carsTbody.innerHTML = cars.map(c=>`
|
|
<tr class="row-hover" data-car-id="${c.carId}">
|
|
<td>${c.id}</td>
|
|
<td>${c.model}</td>
|
|
<td>${c.plate}</td>
|
|
<td>${c.status}</td>
|
|
<td>${money(c.weekly)}</td>
|
|
<td>${c.last}</td>
|
|
<td>${carTicketSummary(c.carId)}</td>
|
|
</tr>`).join('');
|
|
}
|
|
renderCars();
|
|
|
|
// Select car
|
|
let selectedCarId = null;
|
|
carsTbody.addEventListener('click', (e)=>{
|
|
const tr = e.target.closest('tr'); if(!tr) return;
|
|
const id = tr.dataset.carId; selectCar(id);
|
|
});
|
|
|
|
function selectCar(carId){
|
|
selectedCarId = carId;
|
|
const car = cars.find(x=>x.carId===carId);
|
|
if(!car) return;
|
|
document.getElementById('detailHint').textContent = `Editing car #${car.id} (${car.plate})`;
|
|
document.getElementById('f_model').value = car.model;
|
|
document.getElementById('f_plate').value = car.plate;
|
|
document.getElementById('f_status').value = car.status;
|
|
document.getElementById('f_weekly').value = money(car.weekly);
|
|
activateTab('cdetails');
|
|
loadTicketsForCar(carId);
|
|
}
|
|
|
|
// ===== Tabs =====
|
|
function activateTab(key){
|
|
const buttons = { cdetails:'tab-cdetails', ctickets:'tab-ctickets' };
|
|
const panels = { cdetails:'panel-cdetails', ctickets:'panel-ctickets' };
|
|
Object.keys(buttons).forEach(k=>{
|
|
const b=document.getElementById(buttons[k]); if(b) b.setAttribute('aria-selected', k===key?'true':'false');
|
|
});
|
|
Object.keys(panels).forEach(k=>{
|
|
const p=document.getElementById(panels[k]); if(p) p.classList.toggle('active', k===key);
|
|
});
|
|
}
|
|
document.getElementById('tab-cdetails').addEventListener('click', ()=>activateTab('cdetails'));
|
|
document.getElementById('tab-ctickets').addEventListener('click', ()=>activateTab('ctickets'));
|
|
|
|
// ===== Tickets panel =====
|
|
let ctix=[], ctixFiltered=[];
|
|
let ctixSortKey='issueDate', ctixSortDir='desc', ctixPage=1, ctixPageSize=10;
|
|
const $ctixBody=document.querySelector('#ctixTable tbody');
|
|
const $ctixInfo=document.getElementById('ctixPageInfo');
|
|
const $ctixSummary=document.getElementById('ctixSummary');
|
|
const $ctixBadge=document.getElementById('ctixBadge');
|
|
|
|
function loadTicketsForCar(carId){
|
|
ctix = (ticketsByCar[carId]||[]).slice();
|
|
ctixPage=1; renderCtix();
|
|
}
|
|
|
|
function renderCtix(){
|
|
// filter
|
|
const status = document.getElementById('ctixFilter').value;
|
|
ctixFiltered = ctix.filter(t=> status==='all' ? true : String(t.status).toLowerCase()===status);
|
|
// sort
|
|
ctixFiltered.sort((a,b)=>{
|
|
const A=(a[ctixSortKey]??''), B=(b[ctixSortKey]??'');
|
|
if(ctixSortKey==='liability') return (ctixSortDir==='asc'?1:-1)*(Number(A)-Number(B));
|
|
return (ctixSortDir==='asc'?1:-1)*String(A).localeCompare(String(B));
|
|
});
|
|
// page
|
|
const size=ctixPageSize, pages=Math.max(1, Math.ceil(ctixFiltered.length/size));
|
|
ctixPage=Math.min(Math.max(1,ctixPage), pages);
|
|
const start=(ctixPage-1)*size, end=start+size;
|
|
const slice=ctixFiltered.slice(start,end);
|
|
|
|
$ctixBody.innerHTML = slice.map(t=>{
|
|
const st=String(t.status).toLowerCase();
|
|
return `<tr>
|
|
<td>${t.violation}</td>
|
|
<td>${t.description}</td>
|
|
<td>${t.issueDate}</td>
|
|
<td>${money(t.liability)}</td>
|
|
<td><span class="pill ${st}">${st}</span></td>
|
|
</tr>`;
|
|
}).join('') || '<tr><td colspan="5" class="small">No tickets</td></tr>';
|
|
|
|
$ctixInfo.textContent = `Page ${ctixPage} / ${pages}`;
|
|
|
|
const total = ctixFiltered.reduce((a,b)=>a+Number(b.liability||0),0);
|
|
const unpaid = ctixFiltered.filter(t=>String(t.status).toLowerCase()!=='paid').length;
|
|
$ctixSummary.textContent = `${ctixFiltered.length} ticket(s) • ${unpaid} unpaid • Total ${money(total)}`;
|
|
$ctixBadge.textContent = unpaid;
|
|
}
|
|
|
|
Array.from(document.querySelectorAll('#ctixTable thead th')).forEach(th=>{
|
|
th.addEventListener('click',()=>{
|
|
const key=th.dataset.sort; if(!key) return;
|
|
if(ctixSortKey===key){ ctixSortDir = (ctixSortDir==='asc'?'desc':'asc'); } else { ctixSortKey=key; ctixSortDir='asc'; }
|
|
renderCtix();
|
|
});
|
|
});
|
|
document.getElementById('ctixPrev').addEventListener('click',()=>{ ctixPage--; renderCtix(); });
|
|
document.getElementById('ctixNext').addEventListener('click',()=>{ ctixPage++; renderCtix(); });
|
|
document.getElementById('ctixPageSize').addEventListener('change',e=>{ ctixPageSize=+e.target.value; ctixPage=1; renderCtix(); });
|
|
document.getElementById('ctixFilter').addEventListener('change',()=>{ ctixPage=1; renderCtix(); });
|
|
|
|
document.getElementById('ctixExport').addEventListener('click',()=>{
|
|
const csv = 'Violation #,Description,Issue Date,Liability,Status\n' + ctixFiltered.map(t=>[
|
|
t.violation,
|
|
'"' + (t.description||'').replace(/"/g,'""') + '"',
|
|
t.issueDate, t.liability, t.status
|
|
].join(',')).join('\n');
|
|
const blob = new Blob([csv], {type:'text/csv'});
|
|
const a=document.createElement('a'); a.href=URL.createObjectURL(blob);
|
|
a.download = `car_tickets_${selectedCarId||'unknown'}.csv`; a.click();
|
|
});
|
|
|
|
document.getElementById('ctixSync').addEventListener('click',()=>{
|
|
alert('Demo: This would call your CityPay sync endpoint.\n(We can wire this to your API URL when ready.)');
|
|
});
|
|
|
|
// Init select first car
|
|
selectCar(cars[0].carId);
|
|
</script>
|
|
</body>
|
|
</html>
|