Call Schedule
+ New Call
Connecting to database…
🔍
Status
All
Scheduled
In Progress
Follow-Up
Complete
Estimate
Technician
All Techs
🚧
No calls found.
Tap
+ New Call
to start.
🗂 Archived calls are hidden from the main list but preserved here.
📭
No archived calls yet.
⬇ Export CSV
🔧
Calls
🗂
Archive
📊
Reports
New Service Call
Customer Name *
Phone
Service Date *
Start Time
End Time
Address *
Technician
-- Assign Tech --
Rick
Austin
Meagan
Rusty
Amount ($)
Call Type
Hinge Repair
Leveling
Operator Repair
Welding Repair
Gate Install
Operator Install
Fence Install
Maintenance
Estimate
Warranty
Status
Scheduled
In Progress
Complete
Follow-Up
Estimate
Gate Type
-- Select --
Sliding
Swing
Bi-fold
Vertical Lift
Barrier Arm
Other
Notes
Cancel
Save Call
function load() { try { calls = JSON.parse(localStorage.getItem(STORAGE_KEY)) || []; } catch(e) { calls = []; } // migrate old data that lacks archived field calls.forEach(c=>{ if(c.archived===undefined) c.archived=false; }); if (!calls.length) seedDemo(); } function save() { localStorage.setItem(STORAGE_KEY, JSON.stringify(calls)); } function uid() { return Date.now().toString(36) + Math.random().toString(36).slice(2); } function seedDemo() { const d=(dateStr,timeStr)=>{ const [y,mo,day]=dateStr.split('-').map(Number); const [h,m]=(timeStr||'08:00').split(':').map(Number); return new Date(y,mo-1,day,h,m).getTime(); }; calls = [ {id:uid(),customer:'Patricia Monroe',phone:'(818) 555-0192',address:'4421 Sycamore Ln, Burbank, CA',date:'2026-03-28',startTime:'08:00',endTime:'10:00',tech:'Rick',type:'Hinge Repair',status:'Complete',amount:320,gate:'Sliding',notes:'Motor replaced. Tested and working.',createdAt:d('2026-03-27','09:00'),completedAt:d('2026-03-28','10:15'),archived:false}, {id:uid(),customer:'James Okafor',phone:'(323) 555-0847',address:'912 Hilltop Dr, Glendale, CA',date:'2026-04-01',startTime:'09:00',endTime:'13:00',tech:'Austin',type:'Gate Install',status:'Complete',amount:1850,gate:'Swing',notes:'New dual swing gate install. Full wiring.',createdAt:d('2026-03-29','14:00'),completedAt:d('2026-04-01','13:30'),archived:false}, {id:uid(),customer:'Lucia Reyes',phone:'(747) 555-0334',address:'288 Valley View Rd, Pasadena, CA',date:'2026-04-03',startTime:'10:00',endTime:'11:00',tech:'Rick',type:'Maintenance',status:'Complete',amount:150,gate:'Sliding',notes:'Annual service. Lubed tracks, adjusted limits.',createdAt:d('2026-04-02','11:00'),completedAt:d('2026-04-03','11:20'),archived:false}, {id:uid(),customer:'Tom Whitfield',phone:'(626) 555-0011',address:'77 Elmwood Ct, Arcadia, CA',date:'2026-04-05',startTime:'08:00',endTime:'10:00',tech:'Meagan',type:'Operator Repair',status:'Scheduled',amount:0,gate:'Barrier Arm',notes:'Arm not lifting. Parts on order.',createdAt:d('2026-04-03','16:00'),completedAt:null,archived:false}, {id:uid(),customer:'Danielle Park',phone:'(818) 555-0765',address:'1500 Oakdale Ave, Studio City, CA',date:'2026-04-06',startTime:'13:00',endTime:'14:00',tech:'Rusty',type:'Estimate',status:'Estimate',amount:0,gate:'Bi-fold',notes:'Customer wants quote for bi-fold replacement.',createdAt:d('2026-04-04','10:00'),completedAt:null,archived:false}, {id:uid(),customer:'Robert Finch',phone:'(323) 555-0421',address:'349 Maple St, Burbank, CA',date:'2026-03-20',startTime:'11:00',endTime:'13:00',tech:'Rusty',type:'Welding Repair',status:'Follow-Up',amount:275,gate:'Swing',notes:'Loop detector replaced. Intermittent issue still reported.',createdAt:d('2026-03-18','09:00'),completedAt:null,archived:false}, {id:uid(),customer:'Angela Torres',phone:'(818) 555-0922',address:'650 Ridgecrest Blvd, Tarzana, CA',date:'2026-03-15',startTime:'07:00',endTime:'12:00',tech:'Austin',type:'Operator Install',status:'Complete',amount:2200,gate:'Vertical Lift',notes:'Commercial install completed on time.',createdAt:d('2026-03-13','08:00'),completedAt:d('2026-03-15','12:10'),archived:false}, ]; save(); } // NAV function showPage(page) { ['calls','archive','reports'].forEach(p => { const el = document.getElementById('page-'+p); if(el) el.style.display = p===page?'':'none'; }); document.querySelectorAll('.nav-btn').forEach(b=>b.classList.remove('active')); document.getElementById('nav-'+page).classList.add('active'); document.getElementById('scroll-area').scrollTop = 0; if (page==='reports') renderReports(); if (page==='archive') renderArchive(); } // FILTERS function setStatusFilter(el) { document.querySelectorAll('#page-calls .filter-bar:first-of-type .filter-pill, #page-calls .filter-bar').forEach(()=>{}); // target only status pills (first filter-bar) el.closest('.filter-bar').querySelectorAll('.filter-pill').forEach(p=>p.classList.remove('active')); el.classList.add('active'); activeStatusFilter = el.dataset.val; renderCards(); } function setTechFilter(el) { document.getElementById('tech-filter-bar').querySelectorAll('.filter-pill').forEach(p=>p.classList.remove('active')); el.classList.add('active'); activeTechFilter = el.dataset.val; renderCards(); } function buildTechPills() { const techs = [...new Set(calls.filter(c=>!c.archived&&c.tech).map(c=>c.tech))].sort(); const bar = document.getElementById('tech-filter-bar'); const current = activeTechFilter; bar.innerHTML = `
All Techs
`; techs.forEach(t => { const btn = document.createElement('button'); btn.className = 'filter-pill' + (current===t?' active':''); btn.dataset.val = t; btn.textContent = t; btn.onclick = function(){ setTechFilter(this); }; bar.appendChild(btn); }); } // METRICS function renderMetrics() { const active = calls.filter(c=>!c.archived); const total = active.length; const rev = active.filter(c=>c.status==='Complete').reduce((s,c)=>s+(parseFloat(c.amount)||0),0); const open = active.filter(c=>['Scheduled','In Progress','Follow-Up'].includes(c.status)).length; const done = active.filter(c=>c.status==='Complete').length; const fmt = n => n>=1000?'$'+(n/1000).toFixed(1)+'k':'$'+n; // ATC: avg of (completedAt - createdAt) for completed calls that have both timestamps const atcCalls = active.filter(c=>c.status==='Complete'&&c.completedAt&&c.createdAt); const atcMs = atcCalls.length ? atcCalls.reduce((s,c)=>s+(c.completedAt-c.createdAt),0)/atcCalls.length : null; const atcDisplay = atcMs ? fmtDuration(atcMs) : '—'; const atcSub = atcCalls.length ? `${atcCalls.length} completed call${atcCalls.length!==1?'s':''}` : 'No data yet'; document.getElementById('metrics-row').innerHTML = `
Total Calls
${total}
Active
Revenue
${fmt(rev)}
Completed
Open
${open}
Needs action
Done
${done}
${total?Math.round(done/total*100):0}% rate
ATC · Avg Time to Close
${atcDisplay}
${atcSub}
`; } // CARDS const statusClass = {Scheduled:'badge-scheduled','In Progress':'badge-inprogress',Complete:'badge-complete','Follow-Up':'badge-followup',Estimate:'badge-estimate'}; function renderCards() { buildTechPills(); const q = (document.getElementById('search-input').value||'').toLowerCase(); const filtered = calls.filter(c=>{ if (c.archived) return false; const ms = !q || c.customer.toLowerCase().includes(q)||c.address.toLowerCase().includes(q)||(c.tech||'').toLowerCase().includes(q); const mStatus = !activeStatusFilter || c.status===activeStatusFilter; const mTech = !activeTechFilter || c.tech===activeTechFilter; return ms && mStatus && mTech; }).sort((a,b)=>b.date.localeCompare(a.date)); const list = document.getElementById('calls-list'); const empty = document.getElementById('empty-state'); if (!filtered.length) { list.innerHTML=''; empty.style.display=''; return; } empty.style.display='none'; list.innerHTML = filtered.map(c=>buildCard(c, false)).join(''); } function buildCard(c, isArchived) { const id = c.id; const mainActions = isArchived ? `
↩ Restore
🗑 Delete
` : `
✏️ Edit
📅 Calendar
🗂 Archive
🗑 Delete
`; return `
${esc(c.customer)}
${esc(c.address)}
${c.phone?`
${esc(c.phone)}
`:''}
${isArchived?'Archived':esc(c.status)}
${c.tech?`
👤 ${esc(c.tech)}
`:''} ${c.type?`
${esc(c.type)}
`:''} ${c.gate?`
·
${esc(c.gate)}
`:''}
${parseFloat(c.amount)>0?'$'+parseFloat(c.amount).toLocaleString():'No amount set'}
${fmtDate(c.date)}${c.startTime?` · ${fmtTime(c.startTime)}${c.endTime?' – '+fmtTime(c.endTime):''}`:''}
${c.notes?`
${esc(c.notes.slice(0,100))}${c.notes.length>100?'…':''}
`:''} ${c.status==='Complete'&&c.completedAt&&c.createdAt?`
⏱ Time to Close: ${fmtDuration(c.completedAt-c.createdAt)}
`:''}
${mainActions}
Permanently delete
${esc(c.customer)}
?
Cancel
Yes, Delete
`; } function renderArchive() { const archived = calls.filter(c=>c.archived).sort((a,b)=>b.date.localeCompare(a.date)); const list = document.getElementById('archive-list'); const empty = document.getElementById('archive-empty'); if (!archived.length) { list.innerHTML=''; empty.style.display=''; return; } empty.style.display='none'; list.innerHTML = archived.map(c=>buildCard(c, true)).join(''); } function toggleCard(id) { const el = document.getElementById('actions-'+id); const card = document.getElementById('card-'+id); const open = el.style.display !== 'none'; document.querySelectorAll('[id^="actions-"]').forEach(e=>e.style.display='none'); document.querySelectorAll('.call-card').forEach(c=>c.style.borderColor=''); if (!open) { el.style.display='flex'; card.style.borderColor='var(--accent)'; } } function fmtDate(d) { if (!d) return '—'; const [y,m,day]=d.split('-'); return `${m}/${day}/${String(y).slice(2)}`; } function fmtTime(t) { if (!t) return ''; const [h,m]=t.split(':'); const hr=parseInt(h); return `${hr>12?hr-12:hr||12}:${m}${hr>=12?'pm':'am'}`; } function fmtDuration(ms){ if(!ms||ms<=0) return null; const totalMins=Math.round(ms/60000); const days=Math.floor(totalMins/1440); const hrs=Math.floor((totalMins%1440)/60); const mins=totalMins%60; if(days>0) return `${days}d ${hrs}h`; if(hrs>0) return `${hrs}h ${mins}m`; return `${mins}m`; } function esc(s){ return String(s||'').replace(/&/g,'&').replace(//g,'>'); } // ICS CALENDAR EXPORT function exportICS(id) { const c = calls.find(x=>x.id===id); if (!c) return; if (!c.date) { showToast('No date set on this call'); return; } const pad = n => String(n).padStart(2,'0'); const toICSDate = (dateStr, timeStr, fallbackHour) => { const [y,mo,d] = dateStr.split('-').map(Number); let h = fallbackHour, mi = 0; if (timeStr) { const parts=timeStr.split(':'); h=parseInt(parts[0]); mi=parseInt(parts[1])||0; } return `${y}${pad(mo)}${pad(d)}T${pad(h)}${pad(mi)}00`; }; const dtStart = toICSDate(c.date, c.startTime, 8); const dtEnd = toICSDate(c.date, c.endTime, 10); const now = new Date(); const stamp = `${now.getUTCFullYear()}${pad(now.getUTCMonth()+1)}${pad(now.getUTCDate())}T${pad(now.getUTCHours())}${pad(now.getUTCMinutes())}${pad(now.getUTCSeconds())}Z`; const uid_val = `trait-${c.id}@gatetechpros.com`; const summary = `${c.type||'Service Call'} – ${c.customer}`; const desc = [ c.type ? `Type: ${c.type}` : '', c.gate ? `Gate: ${c.gate}` : '', c.tech ? `Tech: ${c.tech}` : '', c.phone ? `Phone: ${c.phone}` : '', c.amount ? `Amount: $${c.amount}` : '', c.notes ? `Notes: ${c.notes}` : '', ].filter(Boolean).join('\\n'); const attendees = [ 'Austin@traitllc.com', 'Meagan@gatetechpros.com', 'Rick@gatetechpros.com', ].map(email => `ATTENDEE;CUTYPE=INDIVIDUAL;ROLE=REQ-PARTICIPANT;RSVP=TRUE:mailto:${email}`).join('\r\n'); const ics = [ 'BEGIN:VCALENDAR', 'VERSION:2.0', 'PRODID:-//Trait Gate CRM//EN', 'CALSCALE:GREGORIAN', 'METHOD:REQUEST', 'BEGIN:VEVENT', `UID:${uid_val}`, `DTSTAMP:${stamp}`, `DTSTART:${dtStart}`, `DTEND:${dtEnd}`, `SUMMARY:${summary}`, `DESCRIPTION:${desc}`, `LOCATION:${c.address||''}`, `ORGANIZER;CN=Trait CRM:mailto:Austin@traitllc.com`, attendees, 'STATUS:CONFIRMED', 'END:VEVENT', 'END:VCALENDAR', ].join('\r\n'); const blob = new Blob([ics], {type:'text/calendar;charset=utf-8'}); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href=url; a.download=`trait-${c.customer.replace(/\s+/g,'-').toLowerCase()}-${c.date}.ics`; a.click(); URL.revokeObjectURL(url); showToast('Calendar invite downloaded ✓'); } // MODAL function openModal(id) { editingId=id||null; document.getElementById('modal-title').textContent = id?'Edit Service Call':'New Service Call'; const c = id?calls.find(x=>x.id===id):{}; document.getElementById('f-customer').value=c.customer||''; document.getElementById('f-phone').value=c.phone||''; document.getElementById('f-address').value=c.address||''; document.getElementById('f-date').value=c.date||new Date().toISOString().slice(0,10); document.getElementById('f-start').value=c.startTime||''; document.getElementById('f-end').value=c.endTime||''; document.getElementById('f-tech').value=c.tech||''; document.getElementById('f-type').value=c.type||'Hinge Repair'; document.getElementById('f-status').value=c.status||'Scheduled'; document.getElementById('f-amount').value=c.amount||''; document.getElementById('f-gate').value=c.gate||''; document.getElementById('f-notes').value=c.notes||''; document.getElementById('modal').classList.add('open'); } function closeModal(){ document.getElementById('modal').classList.remove('open'); } document.getElementById('modal').addEventListener('click',e=>{if(e.target===document.getElementById('modal'))closeModal();}); function saveCall(){ const customer=document.getElementById('f-customer').value.trim(); const address=document.getElementById('f-address').value.trim(); const date=document.getElementById('f-date').value; if(!customer||!address||!date){alert('Name, address and date are required.');return;} const newStatus=document.getElementById('f-status').value; const existing=editingId?calls.find(c=>c.id===editingId):null; const obj={ id:editingId||uid(),customer, phone:document.getElementById('f-phone').value.trim(), address,date, startTime:document.getElementById('f-start').value, endTime:document.getElementById('f-end').value, tech:document.getElementById('f-tech').value.trim(), type:document.getElementById('f-type').value, status:newStatus, amount:parseFloat(document.getElementById('f-amount').value)||0, gate:document.getElementById('f-gate').value, notes:document.getElementById('f-notes').value.trim(), // preserve or set timestamps createdAt: existing?.createdAt || Date.now(), completedAt: existing?.completedAt || (newStatus==='Complete' ? Date.now() : null), }; // If editing and status just changed to Complete for the first time, stamp it now if(editingId && !existing?.completedAt && newStatus==='Complete'){ obj.completedAt = Date.now(); } if(editingId){const i=calls.findIndex(c=>c.id===editingId);calls[i]=obj;} else{calls.unshift(obj);} save();closeModal();refresh(); showToast(editingId?'Call updated ✓':'Call added ✓'); } function editCall(id){openModal(id);} function showDeleteConfirm(id){ // hide all other confirmations first document.querySelectorAll('[id^="del-confirm-"]').forEach(el=>el.style.display='none'); const el=document.getElementById('del-confirm-'+id); if(el) el.style.display='block'; } function cancelDelete(id){ const el=document.getElementById('del-confirm-'+id); if(el) el.style.display='none'; } function archiveCall(id){ const c=calls.find(x=>x.id===id); if(!c) return; c.archived=true; save(); refresh(); showToast('Call archived 🗂'); } function unarchiveCall(id){ const c=calls.find(x=>x.id===id); if(!c) return; c.archived=false; save(); refresh(); renderArchive(); showToast('Call restored ✓'); } function deleteCall(id){ calls=calls.filter(c=>c.id!==id); save(); refresh(); renderArchive(); showToast('Deleted'); } // REPORTS function renderReports(){ const active=calls.filter(c=>!c.archived); const totalRev=active.filter(c=>c.status==='Complete').reduce((s,c)=>s+(parseFloat(c.amount)||0),0); const doneJobs=active.filter(c=>c.status==='Complete'&&c.amount>0); const avg=doneJobs.length?doneJobs.reduce((s,c)=>s+parseFloat(c.amount),0)/doneJobs.length:0; const pending=active.filter(c=>c.status!=='Complete'&&c.amount>0).reduce((s,c)=>s+(parseFloat(c.amount)||0),0); const statuses={}; active.forEach(c=>{statuses[c.status]=(statuses[c.status]||0)+1;}); const maxSt=Math.max(...Object.values(statuses),1); const stColors={Scheduled:'#60a5fa','In Progress':'#f59e0b',Complete:'#22c55e','Follow-Up':'#ef4444',Estimate:'#a5b4fc'}; const techs={}; active.forEach(c=>{const t=c.tech||'Unassigned';if(!techs[t])techs[t]={calls:0,revenue:0};techs[t].calls++;if(c.status==='Complete')techs[t].revenue+=(parseFloat(c.amount)||0);}); const techSorted=Object.entries(techs).sort((a,b)=>b[1].revenue-a[1].revenue); const maxTech=Math.max(...techSorted.map(([,v])=>v.revenue),1); const types={}; active.forEach(c=>{const t=c.type||'Other';if(!types[t])types[t]={calls:0,revenue:0};types[t].calls++;if(c.status==='Complete')types[t].revenue+=(parseFloat(c.amount)||0);}); const typeSorted=Object.entries(types).sort((a,b)=>b[1].revenue-a[1].revenue); const maxType=Math.max(...typeSorted.map(([,v])=>v.revenue),1); const colors=['#f0a500','#3b82f6','#22c55e','#a78bfa','#f87171']; const fmtRev=n=>n>=1000?'$'+(n/1000).toFixed(1)+'k':'$'+n; document.getElementById('report-content').innerHTML=`
Revenue Overview
Total Revenue
$${totalRev.toLocaleString()}
Avg Job Value
$${Math.round(avg).toLocaleString()}
Pending (Open Jobs)
$${pending.toLocaleString()}
Total Calls
${calls.length}
Completed
${calls.filter(c=>c.status==='Complete').length}
Calls by Status
${Object.entries(statuses).map(([k,v])=>`
${k}
${v}
${v} call${v!==1?'s':''}
`).join('')}
By Technician
${techSorted.map(([name,v],i)=>`
${esc(name)}
${fmtRev(v.revenue)}
${v.calls} calls
`).join('')}
By Service Type
${typeSorted.map(([t,v],i)=>`
${esc(t)}
${fmtRev(v.revenue)}
${v.calls} calls
`).join('')}
Completed Jobs
${active.filter(c=>c.status==='Complete').sort((a,b)=>b.date.localeCompare(a.date)).map(c=>`
${esc(c.customer)}
${fmtDate(c.date)} · ${esc(c.type)} · ${esc(c.tech||'—')}
${c.completedAt&&c.createdAt?`
⏱ ${fmtDuration(c.completedAt-c.createdAt)}
`:''}
$${(parseFloat(c.amount)||0).toLocaleString()}
`).join('')}
`; } // EXPORT function exportCSV(){ const headers=['Customer','Phone','Address','Date','Technician','Type','Status','Amount','Gate Type','Notes']; const rows=calls.map(c=>[c.customer,c.phone,c.address,c.date,c.tech,c.type,c.status,c.amount,c.gate,(c.notes||'').replace(/\n/g,' ')].map(v=>`"${String(v||'').replace(/"/g,'""')}"`).join(',')); const csv=[headers.join(','),...rows].join('\n'); const blob=new Blob([csv],{type:'text/csv'}); const url=URL.createObjectURL(blob); const a=document.createElement('a'); a.href=url;a.download=`trait_gate_crm_${new Date().toISOString().slice(0,10)}.csv`; a.click();URL.revokeObjectURL(url); showToast('CSV exported ✓'); } // TOAST function showToast(msg){ const t=document.getElementById('toast'); t.textContent=msg;t.classList.add('show'); setTimeout(()=>t.classList.remove('show'),2500); } function refresh(){ renderMetrics(); renderCards(); } load(); refresh();