Project

General

Profile

Feature #1180 » Car_List_Tickets_with_Summary_v1.html

Andre Mene, 09/24/2025 09:33 AM

 
<!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>
(3-3/3)