Project

General

Profile

Feature #1179 » Rental App – Tickets Mockup.html

Andre Mene, 09/24/2025 07:34 AM

 
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width,initial-scale=1"/>
<title>Rental App – Tickets Mockup</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700;800&display=swap" rel="stylesheet">
<style>
:root{ --blue:#1f7ae0; --bg:#ffffff; --line:#e8e8ef; --card:#fff; --sidebar:#0f2642; --sidebar-2:#122b4a; --accent:#208dee; --shadow:0 8px 24px rgba(16,24,40,.08); --sidebarW:280px; --catW:320px; --splitterW:10px; --topRowH: 260px; --hSplitH: 10px; --tabW:240px; --miniSplitW:10px; }
*{box-sizing:border-box} html,body{height:100%}
body{margin:0;font-family:Inter,system-ui,-apple-system,Segoe UI,Roboto,"Helvetica Neue",Arial;background:var(--bg);color:#0b1324}
.app{display:grid;grid-template-columns:var(--sidebarW) 1fr;min-height:100vh;transition:grid-template-columns .12s ease}
.sidebar{background:linear-gradient(180deg,var(--sidebar),var(--sidebar-2)); color:#e8eef9; height:100vh; position:relative; overflow:auto; box-shadow:2px 0 0 rgba(0,0,0,.06); display:flex; flex-direction:column}
.brand{display:flex; align-items:center; gap:12px; padding:16px}
.avatar{width:48px; height:48px; border-radius:50%; background:#fff; display:grid; place-items:center; color:#0f2642; font-weight:800}
.who{display:flex; flex-direction:column}
.menu{padding:8px}
.menu a{display:flex; align-items:center; gap:12px; padding:10px 12px; margin:4px; border-radius:12px; color:#e8eef9; text-decoration:none; transition:background .15s ease}
.menu a:hover{background:rgba(255,255,255,.08)}
.menu a.active{background:rgba(255,255,255,.16)}
.menu a svg{min-width:22px}
main{display:grid;grid-template-rows:auto 1fr}
.topbar{background:var(--blue);color:#fff;display:flex;align-items:center;justify-content:center;height:64px;box-shadow:var(--shadow)}
.topbar .title{font-size:26px;font-weight:800}
.content{padding:20px;display:grid;grid-template-columns: 1fr;grid-template-rows: var(--topRowH) var(--hSplitH) 1fr;gap:16px;align-items:stretch}
.section{display:flex;flex-direction:column;min-height:0}
.section .hd{display:flex;align-items:center;justify-content:space-between;padding:6px 0;border-bottom:1px solid var(--line)}
.section .h2{font-size:16px;font-weight:800}
.section .body{padding:10px 0;min-height:0;overflow:auto}
.table{width:100%;border-collapse:collapse;font-size:13px}
.table th,.table td{padding:8px 8px;border-bottom:1px solid #eef0f5;text-align:left; vertical-align:middle}
.table th{font-size:12px;text-transform:uppercase;letter-spacing:.02em;color:#6b7280;background:#fafafa; user-select:none; cursor:pointer}
.row-hover:hover{background:#f3f6ff}
.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}
.table-mini th{background:#fafafa; color:#6b7280; text-transform:uppercase; font-size:12px; user-select:none; cursor:pointer}

.details-grid{display:grid;grid-template-columns:var(--tabW) var(--miniSplitW) 1fr 440px;gap:24px;align-items:start}
.tabs{border:1px solid var(--line); border-radius:12px; overflow:hidden; background:#fff; height:100%}
.tab-item{display:flex; align-items:center; gap:10px; padding:10px 12px; cursor:pointer; user-select:none; border-bottom:1px solid #f1f3f7; font-size:14px}
.tab-item:last-child{border-bottom:none}
.tab-item:hover{background:#f7f9fc}
.tab-item.active{background:#e8f1ff; font-weight:700}
.tab-title{flex:1}
.badge{font-size:11px; background:#1f7ae0; color:#fff; padding:2px 6px; border-radius:999px; font-weight:800}
.mini-splitter{width:var(--miniSplitW); border:1px solid #dbe4ee; background:#e9eef5; border-radius:12px; height:100%; display:flex; align-items:center; justify-content:center; user-select:none}
.details-wrap{grid-column:3 / 5; display:grid; grid-template-columns:1fr 440px; gap:24px; align-items:start}
.panel-wrap{grid-column:3 / 5; display:none}
.panel{display:none}
.panel.active{display:block}
.panel h3{margin:0 0 12px 0; font-size:16px}
.actions{display:flex;gap:8px;margin:10px 0}
.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}
.btn.ghost{background:#fff;color:#1f2937}
.small{font-size:12px;color:#6b7280}

/* Tickets filters */
.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}
.select, .input{padding:8px 10px;border:1px solid var(--line);border-radius:8px;background:#fff;min-width:140px}
.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}
.pager{display:flex;gap:8px;align-items:center}
.pager .btn{padding:6px 10px}

@media(max-width: 1180px){.details-grid{grid-template-columns:1fr}.details-wrap,.panel-wrap{grid-column:1}.mini-splitter{display:none}}
</style>
</head>
<body>
<div class="app" id="appRoot">
<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 href="#" class="active"><svg width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="7" rx="2"/><path d="M5 11V7a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v4"/></svg><span>Cars List</span></a>
</nav>
</aside>

<main>
<header class="topbar"><div class="title">Car List – Tickets Mockup</div></header>

<section class="content">
<!-- Cars summary table -->
<div class="section" style="grid-column:1/-1; grid-row:1/2">
<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 class="section" style="grid-column:1/-1; grid-row:3/4">
<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">
<div class="tab-item active" data-tab="details"><span class="tab-title">Car details</span></div>
<div class="tab-item" data-tab="tickets"><span class="tab-title">Tickets</span><span class="badge" id="ticketsBadge">0</span></div>
</div>
<div class="mini-splitter"></div>
<div id="detailsWrap" class="details-wrap">
<div>
<div class="form-grid">
<div>Model</div><input id="d_model" class="input" placeholder="Toyota Prius 2008"/>
<div>Plate</div><input id="d_plate" class="input" placeholder="KVC112 VT"/>
<div>VIN</div><input id="d_vin" class="input" placeholder="JTDKB20..."/>
<div>Status</div><input id="d_status" class="input" placeholder="recurrence"/>
<div>Customer</div><input id="d_customer" class="input" placeholder="—"/>
<div>Weekly Rate</div><input id="d_weekly" class="input" placeholder="$350.00"/>
</div>
<div class="actions"><button class="btn primary">Save</button><button class="btn">Start Rental</button></div>
</div>
<div class="panel" style="border:1px dashed var(--line);border-radius:10px;padding:16px;color:#6b7280">Gallery placeholder</div>
</div>

<div id="panelWrap" class="panel-wrap">
<div class="panel active" id="panel-tickets">
<h3>Tickets</h3>
<div class="tickets-toolbar">
<select id="filterStatus" class="select">
<option value="all">All</option>
<option value="unpaid">Unpaid</option>
<option value="paid">Paid</option>
<option value="pending">Pending</option>
</select>
<select id="pageSize" 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="prevPage">Prev</button>
<div class="small" id="pageInfo">Page 1</div>
<button class="btn" id="nextPage">Next</button>
</div>
<div class="right">
<button class="btn" id="btnRefreshTickets">Sync from CityPay</button>
<button class="btn ghost" id="btnExportTickets">Export CSV</button>
</div>
</div>
<table class="table-mini" id="ticketsTable">
<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="ticketsSummary" style="margin-top:8px"></div>
</div>
</div>

</div>
</div>
</div>

</section>
</main>
</div>

<script>
// Mock data only – for design/demo
const cars=[{id:344,model:'Toyota Prius 2008',plate:'KVC112',state:'VT',status:'recurrence',customer:'Carlos M.',weekly:350,updated:'2025-09-01'},
{id:345,model:'Toyota Prius 2007',plate:'KTK620',state:'VT',status:'available',customer:'',weekly:350,updated:'2025-09-02'},
{id:346,model:'Toyota Prius 2006',plate:'KVC520',state:'VT',status:'impound',customer:'',weekly:350,updated:'2025-08-30'}];
let selected = cars[0];

const $carsTbody=document.querySelector('#carsTable tbody');
const totalsMap=new Map();
function carKey(c){return `${c.plate}|${c.state}`}
function money(n){return `$${Number(n||0).toFixed(2)}`}
function renderCars(){
$carsTbody.innerHTML = cars.map(c=>{
const t=totalsMap.get(carKey(c))||{unpaid:0,total:0};
return `<tr class="row-hover" data-id="${c.id}"><td>${c.id}</td><td>${c.model}</td><td>${c.plate} ${c.state}</td><td>${c.status}</td><td>${money(c.weekly)}</td><td>${c.updated}</td><td><strong>${t.unpaid}</strong> / ${money(t.total)}</td></tr>`
}).join('');
}
$carsTbody.addEventListener('click',e=>{const tr=e.target.closest('tr'); if(!tr) return; const id=+tr.dataset.id; selected=cars.find(x=>x.id===id); loadTickets(selected)});

// Tickets state (filters / sort / pagination)
const ticketsStore=new Map();
let tickets=[], filtered=[], sortKey='issueDate', sortDir='desc', page=1, pageSize=10;
const $badge=document.getElementById('ticketsBadge');
const $tbody=document.querySelector('#ticketsTable tbody');
const $summary=document.getElementById('ticketsSummary');
const $pageInfo=document.getElementById('pageInfo');

function applyFilter(){
const status=document.getElementById('filterStatus').value;
filtered = tickets.filter(t=>{
if(status==='all') return true;
return String(t.status||'').toLowerCase()===status;
});
}
function applySort(){
filtered.sort((a,b)=>{
const A=(a[sortKey]??'');
const B=(b[sortKey]??'');
if(sortKey==='liability') return (sortDir==='asc'?1:-1)*(Number(A)-Number(B));
return (sortDir==='asc'?1:-1)*String(A).localeCompare(String(B));
});
}
function applyPage(){
const totalPages=Math.max(1, Math.ceil(filtered.length/pageSize));
page=Math.min(Math.max(1,page), totalPages);
const start=(page-1)*pageSize, end=start+pageSize;
const slice=filtered.slice(start,end);
$tbody.innerHTML = slice.map(t=>{
const st=(t.status||'unpaid').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>';
$pageInfo.textContent=`Page ${page} / ${totalPages}`;
}
function renderSummaryAndBadge(){
const total = filtered.reduce((a,b)=>a+Number(b.liability||0),0);
const unpaid = filtered.filter(t=>String(t.status||'').toLowerCase()!=='paid').length;
$summary.textContent=`${filtered.length} ticket(s) • ${unpaid} unpaid • Total ${money(total)}`;
$badge.textContent=unpaid;
}
function recalcCarTotals(car){
const list = ticketsStore.get(carKey(car))||[];
const total = list.reduce((a,b)=>a+Number(b.liability||0),0);
const unpaid = list.filter(t=>String(t.status||'').toLowerCase()!=='paid').length;
totalsMap.set(carKey(car), {unpaid,total});
}
function renderTicketsPipeline(){
applyFilter(); applySort(); applyPage(); renderSummaryAndBadge(); recalcCarTotals(selected); renderCars();
}

// Sorting handlers on header click
Array.from(document.querySelectorAll('#ticketsTable thead th')).forEach(th=>{
th.addEventListener('click',()=>{
const key=th.dataset.sort; if(!key) return;
if(sortKey===key){ sortDir = (sortDir==='asc'?'desc':'asc'); } else { sortKey=key; sortDir='asc'; }
renderTicketsPipeline();
});
});

// Pagination + page size
document.getElementById('prevPage').addEventListener('click',()=>{ page--; renderTicketsPipeline();});
document.getElementById('nextPage').addEventListener('click',()=>{ page++; renderTicketsPipeline();});
document.getElementById('pageSize').addEventListener('change',e=>{ pageSize=+e.target.value; page=1; renderTicketsPipeline();});
document.getElementById('filterStatus').addEventListener('change',()=>{ page=1; renderTicketsPipeline();});

// Mock backend — static data for demo
async function fetchTicketsFor(car){
if(car.plate==='KVC112') return [
{violation:'4944114000', description:'SPEED VIOLATION', issueDate:'2025-04-17', liability:75.43, status:'unpaid'},
{violation:'9192150767', description:'FIRE HYDRANT', issueDate:'2025-03-19', liability:176.29, status:'paid'},
{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'}
];
if(car.plate==='KTK620') return [
{violation:'5703736804', description:'DOUBLE PARK', issueDate:'2025-04-03', liability:75.56, status:'unpaid'}
];
return [];
}
async function loadTickets(car){
const key=carKey(car);
if(!ticketsStore.has(key)) ticketsStore.set(key, await fetchTicketsFor(car));
tickets = ticketsStore.get(key);
page=1;
renderTicketsPipeline();
}

// Export (CSV of filtered view)
document.getElementById('btnExportTickets').addEventListener('click',()=>{
const csv = 'Violation #,Description,Issue Date,Liability,Status\n'+filtered.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=`tickets_${selected.plate}_${selected.state}.csv`; a.click();
});
document.getElementById('btnRefreshTickets').addEventListener('click', async ()=>{
ticketsStore.set(carKey(selected), await fetchTicketsFor(selected));
loadTickets(selected);
});

// Init
renderCars();
loadTickets(selected);
</script>
<script>
document.querySelectorAll('.tab-item').forEach(tab => {
tab.addEventListener('click', () => {
// remove active from all tabs
document.querySelectorAll('.tab-item').forEach(t => t.classList.remove('active'));
tab.classList.add('active');

// hide all panels
document.querySelectorAll('#detailsWrap, #panelWrap').forEach(p => p.style.display = 'none');

// show correct panel
if (tab.dataset.tab === 'details') {
document.getElementById('detailsWrap').style.display = 'grid';
} else if (tab.dataset.tab === 'tickets') {
document.getElementById('panelWrap').style.display = 'grid';
}
});
});
</script>
</body>
</html>
(2-2/3)