Job Name
Wednesday, March 4, 2026
Project Info
📍
Address
👷
Foreman
📅
Project Dates
Overall Progress
Shifts
Crew on Site
Equipment on Site
Phase Codes
Cost Codes
// ══════════════════════════════════════════════════════════ // MERGED INIT + COMPUTED DATA VIEWS // ══════════════════════════════════════════════════════════ function initApp(){ syncComputedData(); // S2 init populateFilters(); applyGroupByVisibility(); renderSchedule(); renderGantt(); renderEquipSched(); // S3 init populateJobDropdowns(); initTimeClock(); if(currentUser.permission==='foreman'||currentUser.permission==='admin') initTimecards(); renderMyHours(); if(currentUser.permission==='admin') renderApproval(); renderPayrollPreview(); } // Computed data views derived from master DB — call after any DB change function syncComputedData(){ // Build EMPLOYEES array (used by S2 and S3) from DB.employees window.EMPLOYEES = DB.employees.filter(e=>e.status==='active').map(e=>({ id: e.id, empNum: e.empNum||'', name: e.first+' '+e.last, color: e.color || empColor(e.id), job: e.job||'', schedulable: e.schedulable!==false, userId: e.id, permission: e.permission, username: e.username })); // Build JOBS array (used by S2 and S3) from DB.jobs window.JOBS = DB.jobs.map(j=>({ id: j.id, name: j.name, address: j.address||'', foreman: j.foreman||'', start: j.start||'', end: j.end||'', phases: j.phases||'', costs: j.costs||'', color: j.color||'#e8610a', progress: j.progress||0, status: j.status||'active', budgetHours: j.budgetHours||0, hoursToDate: j.hoursToDate||0, budgetYards: j.budgetYards||0, yardsToDate: j.yardsToDate||0 })); } const EMP_COLORS = ['#e8610a','#3ecf8e','#5b9bd5','#f5a623','#9b6bd5','#2ec4b6','#e85c4a','#90b4d0','#7a94ab','#f5a623']; function empColor(id){ const idx=parseInt((id||'e0').replace(/\D/g,''))||0; return EMP_COLORS[idx%EMP_COLORS.length]; } // ══════════════════════════════════════════════════════════ // SECTION 2 — SCHEDULING & GANTT // ══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════ // DATA // ═══════════════════════════════════════════ // USERS defined in S3 section below // Shifts: {id, empId, jobId, date(YYYY-MM-DD), start, end, task, notes} let SHIFTS = []; const today = new Date(); // Generate demo shifts for current week + next function generateDemoShifts(){ const base = new Date(today); base.setDate(base.getDate() - base.getDay() + 1); // Monday const empJobMap = [ {empId:'e1',jobId:'j1',task:'Pour',start:'06:00',end:'14:00'}, {empId:'e2',jobId:'j3',task:'Finishing',start:'07:00',end:'15:00'}, {empId:'e3',jobId:'j2',task:'Equipment Op.',start:'05:00',end:'13:00'}, {empId:'e4',jobId:'j4',task:'Pump Op.',start:'06:00',end:'14:00'}, {empId:'e5',jobId:'j1',task:'Finishing',start:'07:00',end:'15:00'}, {empId:'e6',jobId:'j3',task:'Form Setting',start:'08:00',end:'16:00'}, {empId:'e7',jobId:'j5',task:'Crew Lead',start:'06:00',end:'14:00'}, {empId:'e8',jobId:'j6',task:'Labor',start:'07:00',end:'15:00'}, {empId:'e9',jobId:'j1',task:'Safety',start:'06:00',end:'14:00'}, ]; let id=1; for(let week=0;week<3;week++){ for(let day=0;day<5;day++){ const d = addDays(base, week*7+day); const ds = dateStr(d); empJobMap.forEach(m=>{ if(Math.random()>0.15){ SHIFTS.push({id:'s'+(id++),empId:m.empId,jobId:m.jobId,date:ds,start:m.start,end:m.end,task:m.task,notes:''}); } }); } // Saturdays for Ray const sat = addDays(base, week*7+5); SHIFTS.push({id:'s'+(id++),empId:'e3',jobId:'j2',date:dateStr(sat),start:'06:00',end:'10:00',task:'OT',notes:''}); } } generateDemoShifts(); // Equipment const EQUIPMENT = [ {id:'o1',number:'EQ-001',desc:'Concrete Pump',status:'in-use'}, {id:'o2',number:'EQ-002',desc:'Mixer Truck #1',status:'in-use'}, {id:'o3',number:'EQ-003',desc:'Skid Steer Loader',status:'available'}, {id:'o4',number:'EQ-004',desc:'Vibrator - Honda',status:'in-use'}, ]; const RENTALS = [ {id:'r1',vendor:'Sunbelt',type:'Boom Lift 45ft',serial:'BL-45-2204',job:'j1'}, {id:'r2',vendor:'United Rentals',type:'Excavator Mini',serial:'EX-MINI-9901',job:'j4'}, ]; let EQUIP_ASSIGNMENTS = []; function generateEquipAssignments(){ const base = new Date(today); base.setDate(base.getDate() - base.getDay() + 1); const assigns = [ {equipId:'o1',jobId:'j1',start:dateStr(addDays(base,-7)),end:dateStr(addDays(base,14))}, {equipId:'o2',jobId:'j2',start:dateStr(addDays(base,-7)),end:dateStr(addDays(base,7))}, {equipId:'o4',jobId:'j3',start:dateStr(base),end:dateStr(addDays(base,7))}, ]; let id=1; assigns.forEach(a=>EQUIP_ASSIGNMENTS.push({...a,id:'ea'+(id++)})); } generateEquipAssignments(); // Gantt Projects let GANTT_PROJECTS = [ {id:'p1',name:'Downtown Pour – Full Schedule',job:'j1',start:'2026-01-15',end:'2026-03-20',desc:'Complete concrete pour schedule for downtown project'}, {id:'p2',name:'Hwy 40 Bridge – Project Plan',job:'j2',start:'2026-02-01',end:'2026-04-30',desc:'Bridge construction timeline'}, {id:'p3',name:'Westside Slab',job:'j3',start:'2026-01-28',end:'2026-03-08',desc:'Slab pour schedule'}, ]; let GANTT_TASKS = [ // Downtown Pour {id:'t1',projectId:'p1',name:'Site Preparation',start:'2026-01-15',end:'2026-01-22',type:'task',phase:'Site Prep',assigned:'Jake Rivera',progress:100,color:'#3ecf8e',predecessors:[],depType:'FS'}, {id:'t2',projectId:'p1',name:'Excavation & Grading',start:'2026-01-20',end:'2026-01-28',type:'task',phase:'Excavation',assigned:'Ray Chang',progress:100,color:'#5b9bd5',predecessors:['t1'],depType:'FS'}, {id:'t3',projectId:'p1',name:'Formwork – Phase 1',start:'2026-01-28',end:'2026-02-06',type:'task',phase:'Formwork',assigned:'Maria Lopez',progress:100,color:'#e8610a',predecessors:['t2'],depType:'FS'}, {id:'t4',projectId:'p1',name:'Rebar Placement',start:'2026-02-05',end:'2026-02-14',type:'task',phase:'Rebar',assigned:'Jake Rivera',progress:100,color:'#f5a623',predecessors:['t3'],depType:'SS'}, {id:'t5',projectId:'p1',name:'Concrete Pour – Phase 1',start:'2026-02-15',end:'2026-02-20',type:'milestone',phase:'Pour',assigned:'Jake Rivera',progress:100,color:'#e8610a',predecessors:['t4'],depType:'FS'}, {id:'t6',projectId:'p1',name:'Curing Period',start:'2026-02-20',end:'2026-03-05',type:'task',phase:'Cure & Strip',assigned:'Carlos Reyes',progress:75,color:'#9b6bd5',predecessors:['t5'],depType:'FS'}, {id:'t7',projectId:'p1',name:'Formwork Strip & Final Finish',start:'2026-03-05',end:'2026-03-15',type:'task',phase:'Finishing',assigned:'Luis Gomez',progress:20,color:'#2ec4b6',predecessors:['t6'],depType:'FS'}, {id:'t8',projectId:'p1',name:'Project Complete',start:'2026-03-20',end:'2026-03-20',type:'milestone',phase:'',assigned:'Mike Torres',progress:0,color:'#e85c4a',predecessors:['t7'],depType:'FS'}, // Hwy 40 {id:'t9',projectId:'p2',name:'Mobilization & Setup',start:'2026-02-01',end:'2026-02-08',type:'task',phase:'Site Prep',assigned:'Ray Chang',progress:100,color:'#3ecf8e',predecessors:[],depType:'FS'}, {id:'t10',projectId:'p2',name:'Foundation Excavation',start:'2026-02-08',end:'2026-02-22',type:'task',phase:'Excavation',assigned:'Ray Chang',progress:100,color:'#5b9bd5',predecessors:['t9'],depType:'FS'}, {id:'t11',projectId:'p2',name:'Abutment Formwork',start:'2026-02-20',end:'2026-03-10',type:'task',phase:'Formwork',assigned:'Steve Huang',progress:60,color:'#e8610a',predecessors:['t10'],depType:'SS'}, {id:'t12',projectId:'p2',name:'Abutment Pour',start:'2026-03-10',end:'2026-03-15',type:'milestone',phase:'Pour',assigned:'Steve Huang',progress:0,color:'#e85c4a',predecessors:['t11'],depType:'FS'}, {id:'t13',projectId:'p2',name:'Deck Formwork',start:'2026-03-15',end:'2026-04-01',type:'task',phase:'Formwork',assigned:'Steve Huang',progress:0,color:'#e8610a',predecessors:['t12'],depType:'FS'}, {id:'t14',projectId:'p2',name:'Bridge Deck Pour',start:'2026-04-10',end:'2026-04-15',type:'milestone',phase:'Pour',assigned:'Ray Chang',progress:0,color:'#e85c4a',predecessors:['t13'],depType:'FS'}, // Westside Slab {id:'t15',projectId:'p3',name:'Subgrade Prep',start:'2026-01-28',end:'2026-02-04',type:'task',phase:'Site Prep',assigned:'Luis Gomez',progress:100,color:'#3ecf8e',predecessors:[],depType:'FS'}, {id:'t16',projectId:'p3',name:'Vapor Barrier & Rebar',start:'2026-02-04',end:'2026-02-12',type:'task',phase:'Rebar',assigned:'Maria Lopez',progress:100,color:'#f5a623',predecessors:['t15'],depType:'FS'}, {id:'t17',projectId:'p3',name:'Slab Pour',start:'2026-02-14',end:'2026-02-16',type:'milestone',phase:'Pour',assigned:'Luis Gomez',progress:100,color:'#e85c4a',predecessors:['t16'],depType:'FS'}, {id:'t18',projectId:'p3',name:'Finishing & Curing',start:'2026-02-16',end:'2026-03-02',type:'task',phase:'Finishing',assigned:'Luis Gomez',progress:85,color:'#2ec4b6',predecessors:['t17'],depType:'FS'}, {id:'t19',projectId:'p3',name:'Final Inspection',start:'2026-03-05',end:'2026-03-08',type:'milestone',phase:'',assigned:'Mike Torres',progress:0,color:'#3ecf8e',predecessors:['t18'],depType:'FS'}, ]; let editingTaskId=null; let currentProjectId='p1'; // ═══════════════════════════════════════════ // AUTH // ═══════════════════════════════════════════ // currentUser declared in S1 // Clock setInterval(tickClock,1000);tickClock(); // ═══════════════════════════════════════════ // NAV // ═══════════════════════════════════════════ // ═══════════════════════════════════════════ // SCHEDULE STATE // ═══════════════════════════════════════════ let schedView='daily'; let groupBy='employee'; // 'employee' | 'job' let schedOffset=0; // days from today for daily, weeks for weekly, months for monthly function switchGroupBy(mode, el){ groupBy = mode; document.querySelectorAll('.gt-btn').forEach(b=>b.classList.remove('active')); el.classList.add('active'); // show/hide correct sub-views applyGroupByVisibility(); renderSchedule(); } function applyGroupByVisibility(){ const byEmp = groupBy==='employee'; ['daily','weekly','monthly'].forEach(v=>{ const empEl = document.getElementById(v+'-by-emp'); const jobEl = document.getElementById(v+'-by-job'); if(empEl) empEl.classList.toggle('hidden', !byEmp); if(jobEl) jobEl.classList.toggle('hidden', byEmp); }); } function switchSchedView(view,el){ schedView=view; schedOffset=0; document.querySelectorAll('.vtab').forEach(t=>t.classList.remove('active')); el.classList.add('active'); document.getElementById('sched-daily').classList.toggle('hidden',view!=='daily'); document.getElementById('sched-weekly').classList.toggle('hidden',view!=='weekly'); document.getElementById('sched-monthly').classList.toggle('hidden',view!=='monthly'); renderSchedule(); } function schedNav(dir){schedOffset+=dir;renderSchedule();} function schedToday(){schedOffset=0;renderSchedule();} function getCurrentDate(){ if(schedView==='daily'){ return addDays(today, schedOffset); } else if(schedView==='weekly'){ const mon=new Date(today); mon.setDate(mon.getDate()-mon.getDay()+1); return addDays(mon,schedOffset*7); } else { const m=new Date(today.getFullYear(),today.getMonth()+schedOffset,1); return m; } } function renderSchedule(){ const d=getCurrentDate(); if(schedView==='daily'){ const ds=d.toLocaleDateString('en-US',{weekday:'long',month:'long',day:'numeric',year:'numeric'}); document.getElementById('sched-period-label').textContent=ds; renderDailyView(); renderDailyJobView(); } else if(schedView==='weekly'){ const mon=new Date(d); if(mon.getDay()!==1){mon.setDate(mon.getDate()-mon.getDay()+1);} const sun=addDays(mon,6); document.getElementById('sched-period-label').textContent=`Week of ${mon.toLocaleDateString('en-US',{month:'short',day:'numeric'})} – ${sun.toLocaleDateString('en-US',{month:'short',day:'numeric',year:'numeric'})}`; renderWeeklyView(); renderWeeklyJobView(); } else { document.getElementById('sched-period-label').textContent=d.toLocaleDateString('en-US',{month:'long',year:'numeric'}); renderMonthlyView(); renderMonthlyJobView(); } } // ═══════════════════════════════════════════ // DAILY VIEW // ═══════════════════════════════════════════ function renderDailyView(){ const d=getCurrentDate(); const ds=dateStr(d); const jobFilter=document.getElementById('daily-job-filter')?.value||''; const dayShifts=SHIFTS.filter(s=>s.date===ds&&(!jobFilter||s.jobId===jobFilter)); // group by employee const empIds=[...new Set(dayShifts.map(s=>s.empId))]; const startHour=5, endHour=18, totalHours=endHour-startHour; document.getElementById('daily-count').textContent=`${dayShifts.length} shifts · ${empIds.length} workers`; const hours=[]; for(let h=startHour;h<=endHour;h+=2) hours.push(h); let html=`
Employee
${hours.map(h=>`
${h===12?'12PM':h<12?h+'AM':(h-12)+'PM'}
`).join('')}
`; if(empIds.length===0){ html+=`
📅
No shifts scheduled for this day
Click "+ Add Shift" to schedule workers
`; } else { empIds.forEach(eid=>{ const emp=EMPLOYEES.find(e=>e.id===eid)||{name:'Unknown',color:'#666'}; const empShifts=dayShifts.filter(s=>s.empId===eid); html+=`
${emp.name}
`; // today line if(ds===dateStr(today)){ const now=new Date(); const elapsed=(now.getHours()+now.getMinutes()/60)-startHour; const pct=(elapsed/totalHours)*100; if(pct>=0&&pct<=100) html+=`
`; } empShifts.forEach(s=>{ const [sh,sm]=s.start.split(':').map(Number); const [eh,em]=s.end.split(':').map(Number); const startFrac=((sh+sm/60)-startHour)/totalHours; const endFrac=((eh+em/60)-startHour)/totalHours; const left=Math.max(0,startFrac)*100; const width=Math.max(1,(endFrac-startFrac))*100; const job=JOBS.find(j=>j.id===s.jobId)||{name:'Unknown',color:'#666'}; html+=`
${s.start}–${s.end} ${s.task?'• '+s.task:''}
`; }); html+=`
`; }); } document.getElementById('daily-grid').innerHTML=html; } // ═══════════════════════════════════════════ // WEEKLY VIEW // ═══════════════════════════════════════════ function renderWeeklyView(){ const d=getCurrentDate(); const mon=new Date(d); if(mon.getDay()!==1){mon.setDate(mon.getDate()-mon.getDay()+1);} const days=[]; for(let i=0;i<7;i++) days.push(addDays(mon,i)); const dayStrs=days.map(dateStr); const todayStr=dateStr(today); const jobFilter=document.getElementById('weekly-job-filter')?.value||''; const dayLabels=['Mon','Tue','Wed','Thu','Fri','Sat','Sun']; // build employee list const weekShifts=SHIFTS.filter(s=>dayStrs.includes(s.date)&&(!jobFilter||s.jobId===jobFilter)); const empIds=[...new Set(weekShifts.map(s=>s.empId))]; const cols=8; // name + 7 days let html=`
`; // headers html+=`
Employee
`; days.forEach((d,i)=>{ const isToday=dayStrs[i]===todayStr; html+=`
${dayLabels[i]}
${d.toLocaleDateString('en-US',{month:'short',day:'numeric'})}
`; }); if(empIds.length===0){ html+=`
No shifts scheduled this week
`; } else { empIds.forEach(eid=>{ const emp=EMPLOYEES.find(e=>e.id===eid)||{name:'Unknown',color:'#666'}; html+=`
${emp.name}
`; dayStrs.forEach(ds=>{ const cell=weekShifts.filter(s=>s.empId===eid&&s.date===ds); html+=`
`; cell.forEach(s=>{ const job=JOBS.find(j=>j.id===s.jobId)||{name:'',color:'#666'}; html+=`
${s.start.replace(':00','')}–${s.end.replace(':00','')}
${job.name}
`; }); html+=`
`; }); }); } html+=`
`; document.getElementById('weekly-grid').innerHTML=html; } // ═══════════════════════════════════════════ // MONTHLY VIEW // ═══════════════════════════════════════════ function renderMonthlyView(){ const d=getCurrentDate(); const year=d.getFullYear(), month=d.getMonth(); const firstDay=new Date(year,month,1); const lastDay=new Date(year,month+1,0); const startDow=firstDay.getDay()||7; // 1=Mon const todayStr=dateStr(today); const dayLabels=['Mon','Tue','Wed','Thu','Fri','Sat','Sun']; let html=`
`; dayLabels.forEach(l=>html+=`
${l}
`); // pad start for(let i=1;i
${dd.getDate()}
`; } for(let day=1;day<=lastDay.getDate();day++){ const dd=new Date(year,month,day); const ds=dateStr(dd); const isToday=ds===todayStr; const dayShifts=SHIFTS.filter(s=>s.date===ds); const jobGroups={}; dayShifts.forEach(s=>{ if(!jobGroups[s.jobId]) jobGroups[s.jobId]=0; jobGroups[s.jobId]++; }); html+=`
${day}
`; const entries=Object.entries(jobGroups); const show=entries.slice(0,2); show.forEach(([jid,cnt])=>{ const job=JOBS.find(j=>j.id===jid)||{name:'Unknown',color:'#666'}; html+=`
${cnt} · ${job.name.split(' ')[0]}
`; }); if(entries.length>2) html+=`
+${entries.length-2} more
`; html+=`
`; } // pad end const endDow=lastDay.getDay()||7; for(let i=endDow+1;i<=7;i++){ const dd=addDays(lastDay,i-endDow); html+=`
${dd.getDate()}
`; } html+=``; document.getElementById('monthly-grid').innerHTML=html; } function goToDay(ds){ schedView='daily'; const d=new Date(ds+'T12:00:00'); const diff=Math.round((d-today)/(1000*60*60*24)); schedOffset=diff; document.querySelectorAll('.vtab').forEach((t,i)=>{t.classList.toggle('active',i===0);}); document.getElementById('sched-daily').classList.remove('hidden'); document.getElementById('sched-weekly').classList.add('hidden'); document.getElementById('sched-monthly').classList.add('hidden'); renderSchedule(); } // ═══════════════════════════════════════════ // SHIFT MODAL // ═══════════════════════════════════════════ let editShiftId=null; function populateFilters(){ // job filters const jobOpts=JOBS.map(j=>``).join(''); ['daily-job-filter','weekly-job-filter'].forEach(id=>{ const el=document.getElementById(id); if(el){el.innerHTML=''+jobOpts;} }); // employee/job selects in modals const empOpts=EMPLOYEES.filter(e=>e.schedulable!==false).map(e=>``).join(''); document.getElementById('sf-employee').innerHTML=empOpts; document.getElementById('sf-job').innerHTML=JOBS.map(j=>``).join(''); document.getElementById('gt-assigned').innerHTML=''+EMPLOYEES.map(e=>``).join(''); const npJob=document.getElementById('np-job'); if(npJob) npJob.innerHTML=''+JOBS.map(j=>``).join(''); const eaEquip=document.getElementById('ea-equip'); if(eaEquip) eaEquip.innerHTML=EQUIPMENT.map(e=>``).join(''); const eaJob=document.getElementById('ea-job'); if(eaJob) eaJob.innerHTML=JOBS.map(j=>``).join(''); // gantt project select renderGanttProjectSelect(); } function openShiftModal(empId='',date=''){ editShiftId=null; document.getElementById('shift-modal-title').textContent='Add Shift'; const d=getCurrentDate(); document.getElementById('sf-date').value=date||dateStr(d); if(empId) document.getElementById('sf-employee').value=empId; document.getElementById('sf-task').value=''; document.getElementById('sf-notes').value=''; document.getElementById('sf-repeat').checked=false; document.getElementById('sf-repeat-opts').classList.add('hidden'); document.getElementById('modal-shift').classList.remove('hidden'); } function openShiftModalFor(empId,date){openShiftModal(empId,date);} function editShift(id){ const s=SHIFTS.find(x=>x.id===id); if(!s)return; editShiftId=id; document.getElementById('shift-modal-title').textContent='Edit Shift'; document.getElementById('sf-employee').value=s.empId; document.getElementById('sf-job').value=s.jobId; document.getElementById('sf-date').value=s.date; document.getElementById('sf-start').value=s.start; document.getElementById('sf-end').value=s.end; document.getElementById('sf-task').value=s.task||''; document.getElementById('sf-notes').value=s.notes||''; document.getElementById('modal-shift').classList.remove('hidden'); } document.getElementById('sf-repeat').addEventListener('change',function(){ document.getElementById('sf-repeat-opts').classList.toggle('hidden',!this.checked); }); function saveShift(){ const empId=document.getElementById('sf-employee').value; const jobId=document.getElementById('sf-job').value; const date=document.getElementById('sf-date').value; const start=document.getElementById('sf-start').value; const end=document.getElementById('sf-end').value; if(!empId||!jobId||!date||!start||!end){showToast('Fill in required fields','error');return;} const shift={id:editShiftId||'s'+Date.now(),empId,jobId,date,start,end,task:document.getElementById('sf-task').value,notes:document.getElementById('sf-notes').value}; if(editShiftId){ const i=SHIFTS.findIndex(s=>s.id===editShiftId); SHIFTS[i]=shift; } else { SHIFTS.push(shift); // repeat if(document.getElementById('sf-repeat').checked){ const type=document.getElementById('sf-repeat-type').value; const until=document.getElementById('sf-repeat-until').value; if(until){ let cur=new Date(date+'T12:00:00'); const end=new Date(until+'T12:00:00'); let count=0; while(count<60){ cur=addDays(cur,type==='daily'?1:7); if(type==='weekdays'&&(cur.getDay()===0||cur.getDay()===6)){cur=addDays(cur,1);continue;} if(cur>end) break; SHIFTS.push({...shift,id:'s'+Date.now()+count,date:dateStr(cur)}); count++; } } } } closeModal('modal-shift'); renderSchedule(); showToast(editShiftId?'Shift updated ✓':'Shift added ✓'); } // ═══════════════════════════════════════════ // GANTT // ═══════════════════════════════════════════ function renderGanttProjectSelect(){ const sel=document.getElementById('gantt-project-sel'); if(!sel)return; sel.innerHTML=GANTT_PROJECTS.map(p=>``).join(''); sel.value=currentProjectId; } function renderGantt(){ const projId=document.getElementById('gantt-project-sel')?.value||currentProjectId; currentProjectId=projId; const project=GANTT_PROJECTS.find(p=>p.id===projId); if(!project)return; const tasks=GANTT_TASKS.filter(t=>t.projectId===projId).sort((a,b)=>a.start.localeCompare(b.start)); if(!tasks.length){ document.getElementById('gantt-container').innerHTML='
No tasks for this project yet. Click "+ Add Task" to begin.
'; return; } const ROW_H=52; // px per row — must match CSS .gantt-track height const NAME_W=220; // px — must match .gantt-name-col width const allDates=tasks.flatMap(t=>[new Date(t.start+'T12:00:00'),new Date(t.end+'T12:00:00')]); let minDate=new Date(Math.min(...allDates)); let maxDate=new Date(Math.max(...allDates)); minDate.setDate(minDate.getDate()-5); maxDate.setDate(maxDate.getDate()+10); const totalDays=Math.ceil((maxDate-minDate)/(1000*60*60*24))+1; const months=[]; let cur=new Date(minDate); while(cur<=maxDate){ const m=cur.toLocaleDateString('en-US',{month:'short',year:'2-digit'}); const daysInMonth=new Date(cur.getFullYear(),cur.getMonth()+1,0).getDate(); const dayOfMonth=cur.getDate(); const daysLeft=daysInMonth-dayOfMonth+1; const daysUntilEnd=Math.ceil((maxDate-cur)/(1000*60*60*24))+1; const clampedDays=Math.min(daysLeft,daysUntilEnd); months.push({label:m,pct:(clampedDays/totalDays)*100}); cur=new Date(cur.getFullYear(),cur.getMonth()+1,1); } const todayPct=(Math.max(0,Math.ceil((today-minDate)/(1000*60*60*24)))/totalDays)*100; // stats const done=tasks.filter(t=>t.progress===100).length; const inProg=tasks.filter(t=>t.progress>0&&t.progress<100).length; const milestones=tasks.filter(t=>t.type==='milestone').length; document.getElementById('gantt-stats').innerHTML=`
${tasks.filter(t=>t.type==='task').length}
Total Tasks
${done}
Complete
${inProg}
In Progress
${milestones}
Milestones
`; const phases=[...new Set(tasks.map(t=>t.phase).filter(Boolean))]; const phaseColors={'Site Prep':'#3ecf8e','Excavation':'#5b9bd5','Formwork':'#e8610a','Rebar':'#f5a623','Pour':'#e85c4a','Finishing':'#2ec4b6','Cure & Strip':'#9b6bd5','Backfill':'#90b4d0'}; document.getElementById('gantt-legend').innerHTML=phases.map(p=>`
${p}
`).join('')+`
FS
SS
`; // build rows HTML let html=`
Task  Pred · Succ
${months.map(m=>`
${m.label}
`).join('')}
`; // map task id → row index for arrow positioning const taskRowMap={}; tasks.forEach((t,i)=>taskRowMap[t.id]=i); // helper: get pixel X of a date within the track function dateToPct(ds){ const d=new Date(ds+'T12:00:00'); return Math.max(0,Math.min(100,(Math.ceil((d-minDate)/(1000*60*60*24))/totalDays)*100)); } tasks.forEach((task,rowIdx)=>{ const isPhase=task.type==='phase'; const isMilestone=task.type==='milestone'; const left=dateToPct(task.start); const rightPct=dateToPct(task.end); const width=Math.max(0.5,rightPct-left); // predecessor info for the info column const predNames=(task.predecessors||[]).map(pid=>{ const pt=tasks.find(t=>t.id===pid); return pt?pt.name.split(' ')[0]:'?'; }).join(', '); // successor info const succNames=tasks.filter(t=>(t.predecessors||[]).includes(task.id)).map(t=>t.name.split(' ')[0]).join(', '); const depLabel=task.depType==='SS'?'SS':'FS'; html+=`
${isPhase?'▶ ':isMilestone?'◆ ':''}${task.name}
${task.assigned||''} ${task.progress>0?`· ${task.progress}%`:''} ${predNames?`← ${predNames}`:''} ${succNames?`→ ${succNames}`:''}
`; // month grid lines let gl=0; months.forEach(m=>{ gl+=m.pct; if(gl<100) html+=`
`; }); if(isMilestone){ html+=`
`; } else { html+=`
${task.name}
`; } html+=`
`; }); html+=`
`; document.getElementById('gantt-container').innerHTML=html; // ── Draw dependency arrows via SVG overlay ── requestAnimationFrame(()=>{ const container=document.getElementById('gantt-inner'); if(!container) return; container.querySelectorAll('.dep-svg-overlay').forEach(s=>s.remove()); container.style.position='relative'; const containerRect=container.getBoundingClientRect(); const scrollLeft=document.getElementById('gantt-wrap').scrollLeft||0; // build id→track element map const trackMap={}; tasks.forEach(t=>{ const el=document.getElementById('track-'+t.id); if(el) trackMap[t.id]=el; }); // collect all arrows first so we can draw one SVG const arrows=[]; tasks.forEach((task)=>{ if(!(task.predecessors||[]).length) return; const depType=task.depType||'FS'; task.predecessors.forEach(predId=>{ const predTask=tasks.find(t=>t.id===predId); if(!predTask) return; const fromEl=trackMap[predId]; const toEl=trackMap[task.id]; if(!fromEl||!toEl) return; const fromRect=fromEl.getBoundingClientRect(); const toRect=toEl.getBoundingClientRect(); const cx=containerRect.left; const cy=containerRect.top; const trackW=fromRect.width; const predStartPct=dateToPct(predTask.start); const predEndPct=dateToPct(predTask.end); const succStartPct=dateToPct(task.start); let x1,y1,x2,y2; if(depType==='FS'){ // arrow from right edge of predecessor → left edge of successor x1=(fromRect.left-cx)+(predEndPct/100)*trackW; y1=(fromRect.top-cy)+fromRect.height/2; x2=(toRect.left-cx)+(succStartPct/100)*trackW; y2=(toRect.top-cy)+toRect.height/2; } else { // SS: arrow from left edge of predecessor → left edge of successor x1=(fromRect.left-cx)+(predStartPct/100)*trackW; y1=(fromRect.top-cy)+fromRect.height/2; x2=(toRect.left-cx)+(succStartPct/100)*trackW; y2=(toRect.top-cy)+toRect.height/2; } arrows.push({x1,y1,x2,y2,depType,predId,taskId:task.id}); }); }); if(!arrows.length) return; const totalH=container.scrollHeight; const totalW=container.scrollWidth; const svg=document.createElementNS('http://www.w3.org/2000/svg','svg'); svg.classList.add('dep-svg-overlay'); svg.setAttribute('style',`position:absolute;top:0;left:0;width:${totalW}px;height:${totalH}px;pointer-events:none;z-index:8;overflow:visible;`); let defs=''; arrows.forEach(a=>{ const color=a.depType==='SS'?'#f5a623':'#8aacbf'; defs+=` `; }); defs+=''; const paths=arrows.map(a=>{ const color=a.depType==='SS'?'#f5a623':'#8aacbf'; const dash=a.depType==='FS'?'6,3':'none'; const {x1,y1,x2,y2}=a; // elbow routing: if going down (normal), curve; if going up, route around let pathD; const dx=x2-x1, dy=y2-y1; if(Math.abs(dx)<8){ // nearly same x — dogleg horizontally to avoid overlap const bump=a.depType==='FS'?18:-18; pathD=`M${x1},${y1} L${x1+bump},${y1} L${x2+bump},${y2} L${x2},${y2}`; } else { const cx1=x1+dx*0.5, cy1=y1, cx2=x1+dx*0.5, cy2=y2; pathD=`M${x1},${y1} C${cx1},${cy1} ${cx2},${cy2} ${x2},${y2}`; } return ``; }).join(''); svg.innerHTML=defs+paths; container.appendChild(svg); }); // milestones list const ms=tasks.filter(t=>t.type==='milestone'); document.getElementById('gantt-milestones-list').innerHTML=ms.length?`
${ms.map(m=>`
◆ ${m.name}
${new Date(m.start+'T12:00:00').toLocaleDateString('en-US',{month:'short',day:'numeric',year:'numeric'})}
${m.assigned||''}
${(m.predecessors||[]).length?`
Pred: ${(m.predecessors||[]).map(pid=>{const pt=tasks.find(t=>t.id===pid);return pt?pt.name:'?';}).join(', ')}
`:''}
`).join('')}
`:'
No milestones defined for this project.
'; } function openGanttTaskModal(id=null){ editingTaskId=id; const projTasks=GANTT_TASKS.filter(t=>t.projectId===currentProjectId&&t.id!==id); const predSel=document.getElementById('gt-predecessors'); predSel.innerHTML=projTasks.map(t=>``).join(''); // successors panel const succWrap=document.getElementById('gt-successors-wrap'); const succList=document.getElementById('gt-successors-list'); if(id){ const t=GANTT_TASKS.find(x=>x.id===id); document.getElementById('gantt-task-modal-title').textContent='Edit Task'; document.getElementById('gt-name').value=t.name; document.getElementById('gt-start').value=t.start; document.getElementById('gt-end').value=t.end; document.getElementById('gt-assigned').value=t.assigned||''; document.getElementById('gt-phase').value=t.phase||''; document.getElementById('gt-progress').value=t.progress; document.getElementById('gt-type').value=t.type; document.getElementById('gt-color').value=t.color; document.getElementById('gt-deptype').value=t.depType||'FS'; const preds=t.predecessors||[]; Array.from(predSel.options).forEach(o=>{ o.selected=preds.includes(o.value); }); document.getElementById('gt-delete-btn').style.display=''; // show successors const succs=GANTT_TASKS.filter(x=>x.projectId===currentProjectId&&(x.predecessors||[]).includes(id)); if(succs.length){ succWrap.style.display=''; succList.innerHTML=succs.map(s=>` ${s.name} (${s.depType||'FS'}) `).join(''); } else { succWrap.style.display=''; succList.innerHTML='No tasks depend on this one yet.'; } } else { document.getElementById('gantt-task-modal-title').textContent='Add Task'; ['gt-name','gt-start','gt-end'].forEach(x=>document.getElementById(x).value=''); document.getElementById('gt-progress').value=0; document.getElementById('gt-type').value='task'; document.getElementById('gt-color').value='#e8610a'; document.getElementById('gt-deptype').value='FS'; Array.from(predSel.options).forEach(o=>o.selected=false); document.getElementById('gt-delete-btn').style.display='none'; succWrap.style.display='none'; } document.getElementById('modal-gantt-task').classList.remove('hidden'); } function editGanttTask(id){openGanttTaskModal(id);} function deleteGanttTask(){ if(!editingTaskId) return; if(!confirm('Delete this task?')) return; GANTT_TASKS=GANTT_TASKS.filter(t=>t.id!==editingTaskId); // remove from other tasks' predecessors GANTT_TASKS.forEach(t=>{ t.predecessors=(t.predecessors||[]).filter(p=>p!==editingTaskId); }); closeModal('modal-gantt-task'); renderGantt(); showToast('Task deleted','info'); } function saveGanttTask(){ const name=document.getElementById('gt-name').value.trim(); const start=document.getElementById('gt-start').value; const end=document.getElementById('gt-end').value; if(!name||!start||!end){showToast('Name, start, and end date required','error');return;} const predSel=document.getElementById('gt-predecessors'); const predecessors=Array.from(predSel.selectedOptions).map(o=>o.value); const task={ id:editingTaskId||'t'+Date.now(), projectId:currentProjectId, name,start,end, assigned:document.getElementById('gt-assigned').value, phase:document.getElementById('gt-phase').value, progress:parseInt(document.getElementById('gt-progress').value)||0, type:document.getElementById('gt-type').value, color:document.getElementById('gt-color').value, predecessors, depType:document.getElementById('gt-deptype').value, }; if(editingTaskId){const i=GANTT_TASKS.findIndex(t=>t.id===editingTaskId);GANTT_TASKS[i]=task;} else GANTT_TASKS.push(task); closeModal('modal-gantt-task'); renderGantt(); showToast(editingTaskId?'Task updated ✓':'Task added ✓'); } function openNewProjectModal(){ document.getElementById('np-name').value=''; document.getElementById('np-start').value=''; document.getElementById('np-end').value=''; document.getElementById('np-desc').value=''; document.getElementById('modal-new-project').classList.remove('hidden'); } function saveNewProject(){ const name=document.getElementById('np-name').value.trim(); const start=document.getElementById('np-start').value; const end=document.getElementById('np-end').value; if(!name||!start||!end){showToast('Name, start, end required','error');return;} const proj={id:'p'+Date.now(),name,job:document.getElementById('np-job').value,start,end,desc:document.getElementById('np-desc').value}; GANTT_PROJECTS.push(proj); currentProjectId=proj.id; renderGanttProjectSelect(); closeModal('modal-new-project'); renderGantt(); showToast('Project created ✓'); } function exportGanttPDF(){ showToast('Opening print dialog for PDF export...','info'); setTimeout(()=>window.print(),300); } // ═══════════════════════════════════════════ // EQUIPMENT SCHEDULE // ═══════════════════════════════════════════ let equipSchedOffset=0; function equipSchedNav(dir){equipSchedOffset+=dir;renderEquipSched();} function equipSchedToday(){equipSchedOffset=0;renderEquipSched();} function renderEquipSched(){ const mon=new Date(today); mon.setDate(mon.getDate()-mon.getDay()+1); const base=addDays(mon,equipSchedOffset*7); const days=[]; for(let i=0;i<7;i++) days.push(addDays(base,i)); const dayStrs=days.map(dateStr); const todayStr=dateStr(today); const dayLabels=['Mon','Tue','Wed','Thu','Fri','Sat','Sun']; const jobColors={'j1':'#e8610a','j2':'#5b9bd5','j3':'#3ecf8e','j4':'#f5a623','j5':'#9b6bd5','j6':'#2ec4b6'}; let html=`
`; html+=`
Equipment
`; days.forEach((d,i)=>{ const isToday=dayStrs[i]===todayStr; html+=`
${dayLabels[i]}
${d.toLocaleDateString('en-US',{month:'short',day:'numeric'})}
`; }); EQUIPMENT.forEach(eq=>{ html+=`
${eq.number}
${eq.desc}
`; dayStrs.forEach(ds=>{ const assign=EQUIP_ASSIGNMENTS.find(a=>a.equipId===eq.id&&a.start<=ds&&a.end>=ds); html+=`
`; if(assign){ const job=JOBS.find(j=>j.id===assign.jobId)||{name:'Unknown',color:'#666'}; html+=`
${job.name.split(' ')[0]}
`; } html+=`
`; }); }); html+=`
`; document.getElementById('equip-sched-grid').innerHTML=html; // rentals const rentalHtml=RENTALS.map(r=>{ const job=JOBS.find(j=>j.id===r.job)||{name:'Unknown',color:'#666'}; return `
${r.type}
${r.vendor} · S/N: ${r.serial}
${job.name}
Active
`; }).join(''); document.getElementById('rental-this-week').innerHTML=rentalHtml||'
No active rentals this week
'; } function openEquipAssignModal(){ document.getElementById('ea-start').value=dateStr(today); document.getElementById('ea-end').value=''; document.getElementById('ea-notes').value=''; document.getElementById('modal-equip-assign').classList.remove('hidden'); } function saveEquipAssign(){ const equipId=document.getElementById('ea-equip').value; const jobId=document.getElementById('ea-job').value; const start=document.getElementById('ea-start').value; const end=document.getElementById('ea-end').value||start; if(!equipId||!jobId||!start){showToast('Equipment, job, and start date required','error');return;} EQUIP_ASSIGNMENTS.push({id:'ea'+Date.now(),equipId,jobId,start,end,notes:document.getElementById('ea-notes').value}); closeModal('modal-equip-assign'); renderEquipSched(); showToast('Equipment assigned ✓'); } // ═══════════════════════════════════════════ // BY-JOB VIEWS // ═══════════════════════════════════════════ function renderDailyJobView(){ const d=getCurrentDate(); const ds=dateStr(d); const jobFilter=document.getElementById('daily-job-filter')?.value||''; const dayShifts=SHIFTS.filter(s=>s.date===ds&&(!jobFilter||s.jobId===jobFilter)); const jobIds=jobFilter?[jobFilter]:[...new Set(dayShifts.map(s=>s.jobId))]; const startHour=5,endHour=18,totalHours=endHour-startHour; const hours=[]; for(let h=startHour;h<=endHour;h+=2) hours.push(h); let html=`
Job Site
${hours.map(h=>`
${h===12?'12PM':h<12?h+'AM':(h-12)+'PM'}
`).join('')}
`; const activeJobIds=jobIds.filter(jid=>dayShifts.some(s=>s.jobId===jid)); if(!activeJobIds.length){ html+=`
📅
No shifts scheduled for this day
Click "+ Add Shift" to schedule workers
`; } else { activeJobIds.forEach(jid=>{ const job=JOBS.find(j=>j.id===jid)||{name:'Unknown',color:'#666',address:''}; const jobShifts=dayShifts.filter(s=>s.jobId===jid); // group by start|end|task so overlapping crew show as one bar const groups={}; jobShifts.forEach(s=>{ const key=`${s.start}|${s.end}|${s.task}`; if(!groups[key]) groups[key]={start:s.start,end:s.end,task:s.task,empIds:[]}; groups[key].empIds.push(s.empId); }); html+=`
${job.name}
${job.address.split(',')[0]}
`; // today line if(ds===dateStr(today)){ const now=new Date(); const elapsed=(now.getHours()+now.getMinutes()/60)-startHour; const pct=(elapsed/totalHours)*100; if(pct>=0&&pct<=100) html+=`
`; } // draw each distinct shift group as a bar — stacked vertically if multiple groups const grpArr=Object.values(groups); const barH=Math.max(16, Math.min(32, Math.floor(44/grpArr.length)-2)); const topBase=grpArr.length===1?6:2; grpArr.forEach((grp,gi)=>{ const [sh,sm]=grp.start.split(':').map(Number); const [eh,em]=grp.end.split(':').map(Number); const left=Math.max(0,((sh+sm/60)-startHour)/totalHours)*100; const width=Math.max(1,((eh+em/60)-(sh+sm/60))/totalHours)*100; const top=topBase+gi*(barH+2); const crewCount=grp.empIds.length; const label=`${grp.start}–${grp.end}${grp.task?' · '+grp.task:''}`; html+=`
${label} (${crewCount})
`; }); html+=`
`; }); } document.getElementById('daily-job-grid').innerHTML=`
${html}
`; } function renderWeeklyJobView(){ const d=getCurrentDate(); const mon=new Date(d); if(mon.getDay()!==1){mon.setDate(mon.getDate()-mon.getDay()+1);} const days=[]; for(let i=0;i<7;i++) days.push(addDays(mon,i)); const dayStrs=days.map(dateStr); const todayStr=dateStr(today); const dayLabels=['Mon','Tue','Wed','Thu','Fri','Sat','Sun']; const startHour=5,endHour=18,totalHours=endHour-startHour; const jobFilter=document.getElementById('weekly-job-filter')?.value||''; const weekShifts=SHIFTS.filter(s=>dayStrs.includes(s.date)&&(!jobFilter||s.jobId===jobFilter)); const jobIds=[...new Set(weekShifts.map(s=>s.jobId))].filter(jid=>!jobFilter||jid===jobFilter); // Same header structure as by-employee weekly (wg-header style) let html=`
`; // header row html+=`
Job Site
`; days.forEach((dd,i)=>{ const isToday=dayStrs[i]===todayStr; html+=`
${dayLabels[i]}
${dd.toLocaleDateString('en-US',{month:'short',day:'numeric'})}
`; }); if(!jobIds.length){ html+=`
No shifts scheduled this week
`; } else { jobIds.forEach(jid=>{ const job=JOBS.find(j=>j.id===jid)||{name:'Unknown',color:'#666',address:''}; // name cell — matches wg-name style html+=`
${job.name}
${job.address.split(',')[0]}
`; // 7 day cells — each is a mini timeline matching dr-timeline dayStrs.forEach(ds=>{ const isToday=ds===todayStr; const cellShifts=weekShifts.filter(s=>s.jobId===jid&&s.date===ds); const groups={}; cellShifts.forEach(s=>{ const key=`${s.start}|${s.end}|${s.task}`; if(!groups[key]) groups[key]={start:s.start,end:s.end,task:s.task,empIds:[]}; groups[key].empIds.push(s.empId); }); const grpArr=Object.values(groups); html+=`
`; if(isToday){ const now=new Date(); const pct=Math.max(0,Math.min(100,((now.getHours()+now.getMinutes()/60)-startHour)/totalHours*100)); html+=`
`; } const barH=grpArr.length>1?18:30; const topBase=grpArr.length>1?3:11; grpArr.forEach((grp,gi)=>{ const [sh,sm]=grp.start.split(':').map(Number); const [eh,em]=grp.end.split(':').map(Number); const left=Math.max(0,((sh+sm/60)-startHour)/totalHours)*100; const width=Math.max(2,((eh+em/60)-(sh+sm/60))/totalHours)*100; const top=topBase+gi*(barH+2); const label=`${grp.start.replace(':00','')}–${grp.end.replace(':00','')}${grp.task?' · '+grp.task:''}`; html+=`
${label} (${grp.empIds.length})
`; }); html+=`
`; }); }); } html+=`
`; document.getElementById('weekly-job-grid').innerHTML=html; } function renderMonthlyJobView(){ const d=getCurrentDate(); const year=d.getFullYear(), month=d.getMonth(); const firstDay=new Date(year,month,1); const lastDay=new Date(year,month+1,0); const startDow=firstDay.getDay()||7; const todayStr=dateStr(today); const dayLabels=['Mon','Tue','Wed','Thu','Fri','Sat','Sun']; // Same outer grid as by-employee monthly (month-grid class) let html=`
`; dayLabels.forEach(l=>html+=`
${l}
`); // leading padding days for(let i=1;i
${dd.getDate()}
`; } for(let day=1;day<=lastDay.getDate();day++){ const dd=new Date(year,month,day); const ds=dateStr(dd); const isToday=ds===todayStr; const dayShifts=SHIFTS.filter(s=>s.date===ds); // group by job, count unique shift groups per job const jobGroups={}; dayShifts.forEach(s=>{ if(!jobGroups[s.jobId]) jobGroups[s.jobId]=new Set(); jobGroups[s.jobId].add(`${s.start}|${s.end}|${s.task}`); }); const entries=Object.entries(jobGroups); html+=`
${isToday?`${day}`:day}
`; entries.slice(0,3).forEach(([jid,shiftSet])=>{ const job=JOBS.find(j=>j.id===jid)||{name:'Unknown',color:'#666'}; const workerCount=[...new Set(dayShifts.filter(s=>s.jobId===jid).map(s=>s.empId))].length; html+=`
${workerCount}w · ${job.name.split(' ')[0]}
`; }); if(entries.length>3) html+=`
+${entries.length-3} more
`; html+=`
`; } // trailing padding days const endDow=lastDay.getDay()||7; for(let i=endDow+1;i<=7;i++){ const dd=addDays(lastDay,i-endDow); html+=`
${dd.getDate()}
`; } html+=``; document.getElementById('monthly-job-grid').innerHTML=html; } // ═══════════════════════════════════════════ // DETAIL PANEL // ═══════════════════════════════════════════ let detailPanelOpen=false; let selectedShiftBlock=null; function openDetailPanel(jobId, dateStr2, shiftStart, shiftEnd, shiftTask, triggerEl){ const job=JOBS.find(j=>j.id===jobId); if(!job) return; const panel=document.getElementById('detail-panel'); const header=document.getElementById('dp-header'); // highlight selected block if(selectedShiftBlock) selectedShiftBlock.classList.remove('selected'); if(triggerEl){ triggerEl.classList.add('selected'); selectedShiftBlock=triggerEl; } // header document.getElementById('dp-job-name').textContent=job.name; header.style.borderBottomColor=job.color; const d=new Date(dateStr2+'T12:00:00'); document.getElementById('dp-date-label').textContent=d.toLocaleDateString('en-US',{weekday:'long',month:'long',day:'numeric',year:'numeric'}); // project info document.getElementById('dp-address').textContent=job.address||'—'; document.getElementById('dp-foreman').textContent=job.foreman||'—'; document.getElementById('dp-dates').textContent=`${job.start||'?'} → ${job.end||'?'}`; const pct=job.progress||0; document.getElementById('dp-progress-pct').textContent=pct+'%'; document.getElementById('dp-prog-fill').style.width=pct+'%'; document.getElementById('dp-prog-fill').style.background=pct>=80?'var(--green)':pct>=40?'var(--orange)':'var(--red)'; // shifts that day for this job const dayShifts=SHIFTS.filter(s=>s.jobId===jobId&&s.date===dateStr2); const shiftGroups={}; dayShifts.forEach(s=>{ const key=`${s.start}|${s.end}|${s.task}`; if(!shiftGroups[key]) shiftGroups[key]={start:s.start,end:s.end,task:s.task,empIds:[]}; shiftGroups[key].empIds.push(s.empId); }); const shiftEntries=Object.values(shiftGroups); const label = d.toLocaleDateString('en-US',{month:'short',day:'numeric'}); document.getElementById('dp-shifts-title').textContent=`Shifts – ${label}`; document.getElementById('dp-shifts-list').innerHTML=shiftEntries.length?shiftEntries.map(g=>`
${g.start} – ${g.end}
${g.task||'General Labor'} · ${g.empIds.length} workers
`).join(''):`
No shifts on this date.
`; // crew const empIds=[...new Set(dayShifts.map(s=>s.empId))]; document.getElementById('dp-crew-title').textContent=`Crew on Site (${empIds.length})`; document.getElementById('dp-crew-list').innerHTML=empIds.map(eid=>{ const emp=EMPLOYEES.find(e=>e.id===eid)||{name:'Unknown',role:'',color:'#666'}; return `
${emp.name}
${emp.role}
`; }).join('')||'
No crew assigned.
'; // equipment const equip=EQUIP_ASSIGNMENTS.filter(a=>a.jobId===jobId&&a.start<=dateStr2&&a.end>=dateStr2); const rentals=RENTALS.filter(r=>r.job===jobId); let equipHtml=''; equip.forEach(a=>{ const e=EQUIPMENT.find(x=>x.id===a.equipId); if(!e) return; equipHtml+=`
⚙️
${e.number} – ${e.desc}
Owned
`; }); rentals.forEach(r=>{ equipHtml+=`
🔄
${r.type}
Rental – ${r.vendor}
`; }); document.getElementById('dp-equipment-list').innerHTML=equipHtml||`
No equipment assigned to this site.
`; // phase & cost codes const phases=(job.phases||'').split(',').map(p=>p.trim()).filter(Boolean); const costs=(job.costs||'').split(',').map(c=>c.trim()).filter(Boolean); document.getElementById('dp-phases').innerHTML=phases.map(p=>`${p}`).join('')||''; document.getElementById('dp-costs').innerHTML=costs.map(c=>`${c}`).join('')||''; panel.classList.add('open'); detailPanelOpen=true; } function closeDetailPanel(){ document.getElementById('detail-panel').classList.remove('open'); detailPanelOpen=false; if(selectedShiftBlock){ selectedShiftBlock.classList.remove('selected'); selectedShiftBlock=null; } } // close panel when clicking outside document.addEventListener('click',function(e){ if(detailPanelOpen && !e.target.closest('.detail-panel') && !e.target.closest('.job-shift-block') && !e.target.closest('.mbj-job-pill') && !e.target.closest('.wbj-day-cell')){ closeDetailPanel(); } }); // ═══════════════════════════════════════════ // UTILS // ═══════════════════════════════════════════ document.querySelectorAll('.overlay').forEach(o=>o.addEventListener('click',e=>{if(e.target===o)o.classList.add('hidden');})); // ── Settings menu toggle ── function toggleSettings(){ const m=document.getElementById('settings-menu'); if(m) m.classList.toggle('hidden'); } document.addEventListener('click',function(e){ const w=document.getElementById('settings-wrap'); const m=document.getElementById('settings-menu'); if(w&&m&&!w.contains(e.target)) m.classList.add('hidden'); }); // ── Job import: additive only ── // (handled in loadDataFromSheet — jobs are merged not replaced) // ══════════════════════════════════════════════════════════ // SECTION 3 — TIMECARDS, CLOCK & PAYROLL // ══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════ // DATA // ═══════════════════════════════════════════ const USERS = [ {id:'u1',username:'mike.torres',password:'admin123',name:'Mike Torres',first:'Mike',permission:'admin',initials:'MT',color:'#e85c4a',role:'Superintendent'}, {id:'u2',username:'jake.rivera',password:'pass123',name:'Jake Rivera',first:'Jake',permission:'foreman',initials:'JR',color:'#e8610a',role:'Crew Lead'}, {id:'u3',username:'sarah.kim',password:'pass123',name:'Sarah Kim',first:'Sarah',permission:'hr',initials:'SK',color:'#5b9bd5',role:'HR Manager'}, {id:'u4',username:'dan.wolfe',password:'pass123',name:'Dan Wolfe',first:'Dan',permission:'worker',initials:'DW',color:'#f5a623',role:'Pump Operator'}, {id:'u5',username:'luis.gomez',password:'pass123',name:'Luis Gomez',first:'Luis',permission:'worker',initials:'LG',color:'#3ecf8e',role:'Finisher'}, ]; // Foremen manage specific crews const FOREMAN_CREWS = { 'u2': ['e1','e2','e5','e9'], // Jake Rivera's crew 'u4': ['e4','e8'], // Dan Wolfe's mini-crew }; const OPS_MANAGERS = [ {id:'m1',name:'Mike Torres',initials:'MT',color:'#e85c4a'}, {id:'m2',name:'Dave Steller',initials:'DS',color:'#5b9bd5'}, {id:'m3',name:'Pat Harmon',initials:'PH',color:'#9b6bd5'}, ]; // today already declared above function mondayOf(d){ const r=new Date(d); const day=r.getDay()||7; r.setDate(r.getDate()-day+1); return r; } function calcHours(start,end){ if(!start||!end) return 0; const [sh,sm]=start.split(':').map(Number); const [eh,em]=end.split(':').map(Number); return Math.max(0,((eh+em/60)-(sh+sm/60))); } function fmtHours(h){ return h.toFixed(1)+'h'; } function weekLabel(mon){ const sun=addDays(mon,6); return `${mon.toLocaleDateString('en-US',{month:'short',day:'numeric'})} – ${sun.toLocaleDateString('en-US',{month:'short',day:'numeric',year:'numeric'})}`; } // ── TIMECARD STORE ── // timecards[weekKey][empId][dateStr] = {jobId, phase, costCode, startTime, endTime, hours, notes} // weekKey = 'YYYY-MM-DD' (Monday of week) let TIMECARDS = {}; // weekSubmissions[weekKey] = {status:'pending'|'approved'|'rejected', submittedBy, submittedAt, approvedBy, approvedAt, rejectedBy, rejectNote} let WEEK_SUBMISSIONS = {}; // clockPunches = [{empId, date, punchIn, punchOut, jobId}] let CLOCK_PUNCHES = []; // seed some demo data for current week function seedDemoTimecards(){ const mon = mondayOf(today); const wk = dateStr(mon); TIMECARDS[wk] = {}; const empDays = [ {empId:'e1',jobId:'j1',phase:'100',costCode:'03-100',start:'06:00',end:'14:00'}, {empId:'e2',jobId:'j1',phase:'200',costCode:'03-200',start:'07:00',end:'15:00'}, {empId:'e5',jobId:'j1',phase:'300',costCode:'03-300',start:'07:00',end:'15:30'}, {empId:'e9',jobId:'j1',phase:'100',costCode:'03-100',start:'06:00',end:'14:00'}, ]; for(let d=0;d<5;d++){ const ds=dateStr(addDays(mon,d)); empDays.forEach(e=>{ if(!TIMECARDS[wk][e.empId]) TIMECARDS[wk][e.empId]={}; if(Math.random()>0.12){ TIMECARDS[wk][e.empId][ds]={ jobId:e.jobId,phase:e.phase,costCode:e.costCode, startTime:e.start,endTime:e.end, hours:calcHours(e.start,e.end),notes:'' }; } }); } // prior week — submitted + fully approved const lastMon=addDays(mon,-7); const lastWk=dateStr(lastMon); TIMECARDS[lastWk]={}; empDays.forEach(e=>{ TIMECARDS[lastWk][e.empId]={}; for(let d=0;d<5;d++){ const ds=dateStr(addDays(lastMon,d)); TIMECARDS[lastWk][e.empId][ds]={jobId:e.jobId,phase:e.phase,costCode:e.costCode,startTime:e.start,endTime:e.end,hours:calcHours(e.start,e.end),notes:''}; } }); WEEK_SUBMISSIONS[lastWk]={status:'approved',submittedBy:'u2',submittedAt:'2026-02-27',approvedBy:'Mike Torres',approvedAt:'2026-02-28',rejectNote:''}; // current week — pending approval so ops manager can see the buttons WEEK_SUBMISSIONS[wk]={status:'pending',submittedBy:'u2',submittedAt:dateStr(today),rejectNote:''}; } seedDemoTimecards(); // ═══════════════════════════════════════════ // AUTH // ═══════════════════════════════════════════ // currentUser declared in S1 // Clock setInterval(tickClock,1000); tickClock(); // Nav function populateJobDropdowns(){ const jobOpts=JOBS.map(j=>``).join(''); ['clock-job-select','export-job-filter'].forEach(id=>{ const el=document.getElementById(id); if(el){ el.innerHTML=(id==='export-job-filter'?'':'')+jobOpts; } }); updateClockPhaseCost(); } function updateClockPhaseCost(){ const jobId=document.getElementById('clock-job-select')?.value; const job=JOBS.find(j=>j.id===jobId)||JOBS[0]; const phases=(job.phases||'').split(',').map(p=>p.trim()).filter(Boolean); const costs=(job.costs||'').split(',').map(c=>c.trim()).filter(Boolean); const pSel=document.getElementById('clock-phase-select'); const cSel=document.getElementById('clock-cost-select'); if(pSel) pSel.innerHTML=phases.map(p=>``).join(''); if(cSel) cSel.innerHTML=costs.map(c=>``).join(''); } // ═══════════════════════════════════════════ // TIME CLOCK // ═══════════════════════════════════════════ // clockedInEmps: Set of empIds currently clocked in this session let clockedInEmps = new Set(); let clockInSessionStart = null; let clockInSessionJob = null; function initTimeClock(){ renderClockEmpList(); renderClockHistory(); updatePunchBtn(); } function renderClockEmpList(){ const list = document.getElementById('clock-emp-list'); if(!list) return; const crewIds = EMPLOYEES.filter(e=>e.schedulable!==false&&(currentUser.permission==='admin'||(FOREMAN_CREWS[currentUser.id]||EMPLOYEES.map(x=>x.id)).includes(e.id))).map(e=>e.id); const ds = dateStr(today); const alreadyClockedIn = new Set( CLOCK_PUNCHES.filter(p=>p.date===ds&&!p.punchOut).map(p=>p.empId) ); list.innerHTML = crewIds.map(eid=>{ const emp = EMPLOYEES.find(e=>e.id===eid); if(!emp) return ''; const isIn = alreadyClockedIn.has(eid); // clocked-in employees are pre-checked but NOT disabled — foreman can select them to clock out return ``; }).join(''); } function updateClockEmpItem(eid, checked){ const item = document.getElementById('cei-'+eid); if(item) item.classList.toggle('checked', checked); updatePunchBtn(); } function clockSelectAll(val){ document.querySelectorAll('#clock-emp-list input[type=checkbox]') .forEach(cb=>{ cb.checked=val; updateClockEmpItem(cb.value, val); }); } function getSelectedClockEmps(){ return [...document.querySelectorAll('#clock-emp-list input[type=checkbox]:checked')] .map(cb=>cb.value); } function updatePunchBtn(){ const ds = dateStr(today); const nowIn = new Set(CLOCK_PUNCHES.filter(p=>p.date===ds&&!p.punchOut).map(p=>p.empId)); const selected = getSelectedClockEmps(); const anyToClockIn = selected.some(id=>!nowIn.has(id)); const btn = document.getElementById('punch-btn'); if(!btn) return; if(!selected.length){ btn.textContent='Clock In Selected'; btn.className='btn clock-punch-btn punch-in'; } else if(anyToClockIn){ btn.textContent='Clock In Selected'; btn.className='btn clock-punch-btn punch-in'; } else { btn.textContent='Clock Out Selected'; btn.className='btn clock-punch-btn punch-out'; } } function doPunch(){ const jobId = document.getElementById('clock-job-select').value; const phase = document.getElementById('clock-phase-select')?.value||''; const costCode = document.getElementById('clock-cost-select')?.value||''; const selectedIds = getSelectedClockEmps(); const ds = dateStr(today); const alreadyIn = new Set(CLOCK_PUNCHES.filter(p=>p.date===ds&&!p.punchOut).map(p=>p.empId)); const toClockIn = selectedIds.filter(id=>!alreadyIn.has(id)); const toClockOut = selectedIds.filter(id=> alreadyIn.has(id)); const now = new Date(); const timeStr = now.toLocaleTimeString('en-US',{hour:'2-digit',minute:'2-digit'}); if(!selectedIds.length){ showToast('Select at least one employee','error'); return; } if(toClockIn.length){ if(!jobId){ showToast('Select a job site first','error'); return; } toClockIn.forEach(eid=>{ CLOCK_PUNCHES.push({empId:eid, date:ds, punchIn:timeStr, punchOut:null, jobId, phase, costCode}); }); const names = toClockIn.map(id=>EMPLOYEES.find(e=>e.id===id)?.name.split(' ')[0]).join(', '); showToast(`Clocked in: ${names} @ ${timeStr} ✓`); } if(toClockOut.length){ toClockOut.forEach(eid=>{ const punch = CLOCK_PUNCHES.filter(p=>p.empId===eid&&p.date===ds&&!p.punchOut).slice(-1)[0]; if(punch) punch.punchOut = timeStr; }); const names = toClockOut.map(id=>EMPLOYEES.find(e=>e.id===id)?.name.split(' ')[0]).join(', '); showToast(`Clocked out: ${names} @ ${timeStr} ✓`); } // update status indicator for current user's own employee record const myEmp = EMPLOYEES.find(e=>e.userId===currentUser.id); if(myEmp){ const myClockedIn = CLOCK_PUNCHES.some(p=>p.empId===myEmp.id&&p.date===ds&&!p.punchOut); const statusEl = document.getElementById('clock-status'); statusEl.className = 'clock-status '+(myClockedIn?'cs-in':'cs-out'); statusEl.textContent = myClockedIn?`● Clocked In — ${timeStr}`:'● Not Clocked In'; } renderClockEmpList(); renderClockHistory(); updatePunchBtn(); } function renderClockHistory(){ const ds=dateStr(today); // show all punches for today for any employee the current user can see const crewIds = EMPLOYEES.filter(e=>e.schedulable!==false&&(currentUser.permission==='admin'||(FOREMAN_CREWS[currentUser.id]||EMPLOYEES.map(x=>x.id)).includes(e.id))).map(e=>e.id); const todayPunches = CLOCK_PUNCHES.filter(p=>crewIds.includes(p.empId)&&p.date===ds); const el=document.getElementById('clock-history'); if(!el) return; if(!todayPunches.length){ el.innerHTML='
No punches recorded today.
'; return; } el.innerHTML=todayPunches.map(p=>{ const emp = EMPLOYEES.find(e=>e.id===p.empId)||{name:'Unknown',color:'#666'}; const job = JOBS.find(j=>j.id===p.jobId)||{name:'Unknown'}; let hrs='Active'; if(p.punchOut){ try{ const [ih,im]=p.punchIn.replace(/\s*(AM|PM)/i,'').split(':').map(Number); const [oh,om]=p.punchOut.replace(/\s*(AM|PM)/i,'').split(':').map(Number); hrs=((oh+om/60)-(ih+im/60)).toFixed(2)+'h'; }catch(e){hrs='—';} } return `
#${emp.empNum||'?'} ${emp.name.split(' ')[0]} ${p.punchIn} → ${p.punchOut||'Active'} ${job.name.split(' ')[0]}${p.phase?' · '+p.phase:''}${p.costCode?' · '+p.costCode:''} ${hrs}
`; }).join(''); } // ═══════════════════════════════════════════ // TIMECARD ENTRY // ═══════════════════════════════════════════ let tcWeekOffset=0; let tcActiveDayIdx=0; // 0=Mon … 6=Sun function tcWeekNav(dir){ if(dir===0) tcWeekOffset=0; else tcWeekOffset+=dir; renderTimecards(); } function getTcWeekDays(){ const mon=addDays(mondayOf(today),tcWeekOffset*7); return Array.from({length:7},(_,i)=>addDays(mon,i)); } function initTimecards(){ tcActiveDayIdx=Math.min(today.getDay()||7,5)-1; renderTimecards(); } function renderTimecards(){ const days=getTcWeekDays(); const mon=days[0]; const wk=dateStr(mon); if(!TIMECARDS[wk]) TIMECARDS[wk]={}; document.getElementById('tc-week-label').textContent=weekLabel(mon); // submission status const sub=WEEK_SUBMISSIONS[wk]; const statusEl=document.getElementById('tc-week-status'); if(sub){ const cls={pending:'b-yellow',approved:'b-green',rejected:'b-red'}[sub.status]||'b-gray'; statusEl.innerHTML=`${sub.status}`; } else { statusEl.innerHTML=''; } const submitBtn=document.getElementById('tc-submit-btn'); const submitLbl=document.getElementById('tc-submit-label'); const submitSub=document.getElementById('tc-submit-sub'); if(sub?.status==='approved'){ submitBtn.disabled=true; submitBtn.textContent='✓ Approved'; submitBtn.className='btn btn-ghost btn-sm'; submitLbl.textContent='This week has been approved'; submitSub.textContent='This week has been approved by ops manager.'; } else if(sub?.status==='pending'){ submitBtn.disabled=true; submitBtn.textContent='Submitted — Awaiting Approval'; submitBtn.className='btn btn-secondary btn-sm'; submitLbl.textContent='Submitted — Awaiting Approval'; submitSub.textContent='Your timecard is under review by ops managers.'; } else if(sub?.status==='rejected'){ submitBtn.disabled=false; submitBtn.textContent='Re-Submit →'; submitBtn.className='btn btn-primary'; submitLbl.textContent='⚠ Rejected — Corrections Needed'; submitSub.textContent=`Reason: ${sub.rejectNote||'See manager'}`; } else { submitBtn.disabled=false; submitBtn.textContent='Submit for Approval →'; submitBtn.className='btn btn-green'; submitLbl.textContent='Submit Week for Approval'; submitSub.textContent='Submit once all entries are complete.'; } // day tabs const dayNames=['Mon','Tue','Wed','Thu','Fri','Sat','Sun']; const tabs=document.getElementById('tc-day-tabs'); tabs.innerHTML=days.map((d,i)=>{ const ds=dateStr(d); const hasEntries=Object.keys(TIMECARDS[wk]).some(eid=>TIMECARDS[wk][eid][ds]); return `
${dayNames[i]}
${d.toLocaleDateString('en-US',{month:'short',day:'numeric'})}
`; }).join(''); renderTcDay(); renderTcWeekSummary(); } function tcSetDay(idx){ tcActiveDayIdx=idx; renderTimecards(); } function renderTcDay(){ const days=getTcWeekDays(); const day=days[tcActiveDayIdx]; const ds=dateStr(day); const wk=dateStr(days[0]); if(!TIMECARDS[wk]) TIMECARDS[wk]={}; document.getElementById('tc-day-label-full').textContent= day.toLocaleDateString('en-US',{weekday:'long',month:'long',day:'numeric'}); const crewIds = (currentUser.permission==='admin' ? EMPLOYEES : EMPLOYEES.filter(e=>(FOREMAN_CREWS[currentUser.id]||[]).includes(e.id)) ).filter(e=>e.schedulable!==false).map(e=>e.id); // calc day total let dayTotal=0; crewIds.forEach(eid=>{ const e=TIMECARDS[wk]?.[eid]?.[ds]; if(e) dayTotal+=e.hours||0; }); document.getElementById('tc-day-total-hrs').textContent=fmtHours(dayTotal)+' total'; const sub=WEEK_SUBMISSIONS[wk]; // foreman is locked out once submitted (pending or approved); admin can always edit const isForeman = currentUser.permission==='foreman'; const submitted = sub && (sub.status==='pending'||sub.status==='approved'); const foremanLocked = isForeman && submitted; const hardLocked = sub?.status==='approved'; // nobody edits approved const list=document.getElementById('tc-entries-list'); // ── READ-ONLY table for foreman on submitted weeks ── if(foremanLocked){ const statusLabel={pending:'Submitted — Awaiting Approval',approved:'Approved'}[sub.status]; const statusClass={pending:'b-yellow',approved:'b-green'}[sub.status]; let html=`
🔒
Timecard Submitted — View Only
${statusLabel}   To correct an entry, use the Request Change button on that row.
`; html+=`
`; crewIds.forEach(eid=>{ const emp=EMPLOYEES.find(e=>e.id===eid); if(!emp) return; const entry=TIMECARDS[wk]?.[eid]?.[ds]; if(!entry){ return; } // skip rows with no entry in read-only view const job=JOBS.find(j=>j.id===entry.jobId); const hrs=entry.hours||0; const isOT=hrs>8; // check if there's a pending change request for this row const hasCR=CHANGE_REQUESTS.some(r=>r.wk===wk&&r.eid===eid&&r.ds===ds&&r.status==='pending'); html+=``; }); // if no entries at all for this day if(!crewIds.some(eid=>TIMECARDS[wk]?.[eid]?.[ds])){ html+=``; } html+=`
Employee Job Site Phase # Cost Code Hours Change
${emp.name}
#${emp.empNum||'?'}
${job?.name||'—'} ${entry.phase||'—'} ${entry.costCode||'—'} ${hrs.toFixed(1)}h${isOT?`
${(hrs-8).toFixed(1)}OT
`:''}
${hasCR ?`⏳ Pending` :`` }
No entries for this day
`; list.innerHTML=html; // hide add button const addBtn=document.querySelector('.tc-add-btn'); if(addBtn) addBtn.style.display='none'; return; } // ── EDITABLE table (admin always, foreman on draft/rejected weeks) ── let html=`
${!hardLocked?'':''} `; crewIds.forEach(eid=>{ const emp=EMPLOYEES.find(e=>e.id===eid); if(!emp) return; const entry=TIMECARDS[wk]?.[eid]?.[ds]; const hasEntry=!!entry; const e=entry||{jobId:JOBS[0].id,phase:JOBS[0].phases.split(',')[0]||'',costCode:JOBS[0].costs.split(',')[0]||'',hours:0}; const jobPhases=(JOBS.find(j=>j.id===e.jobId)?.phases||'').split(',').map(p=>p.trim()).filter(Boolean); const jobCosts=(JOBS.find(j=>j.id===e.jobId)?.costs||'').split(',').map(c=>c.trim()).filter(Boolean); const phaseOpts=jobPhases.map(p=>``).join(''); const costOpts=jobCosts.map(c=>``).join(''); const selectedJobOpts=JOBS.map(j=>``).join(''); const hrs=e.hours||0; const isOT=hrs>8; html+=` ${!hardLocked?``:''} `; }); html+=`
Employee Job Site Phase # Cost Code Hours
${emp.name}
#${emp.empNum||'?'}
`; list.innerHTML=html; const addBtn=document.querySelector('.tc-add-btn'); if(addBtn) addBtn.style.display=hardLocked?'none':''; } function tcJobChanged(eid,ds,wk,jobId){ ensureTcEntry(eid,ds,wk); TIMECARDS[wk][eid][ds].jobId=jobId; // reset phase/cost to first option for new job const job=JOBS.find(j=>j.id===jobId); if(job){ TIMECARDS[wk][eid][ds].phase=(job.phases||'').split(',')[0]?.trim()||''; TIMECARDS[wk][eid][ds].costCode=(job.costs||'').split(',')[0]?.trim()||''; } renderTcDay(); } function tcFieldChanged(eid,ds,wk,field,val){ ensureTcEntry(eid,ds,wk); TIMECARDS[wk][eid][ds][field]=val; } function tcHoursChanged(eid,ds,wk,val){ const hrs=parseFloat(val)||0; if(hrs<=0){ // clear entry if(TIMECARDS[wk]?.[eid]?.[ds]) delete TIMECARDS[wk][eid][ds]; const row=document.getElementById('tcrow-'+eid); if(row){ row.style.opacity='0.45'; } const inp=document.getElementById('hrs-'+eid); if(inp){ inp.classList.remove('ot'); } } else { ensureTcEntry(eid,ds,wk); TIMECARDS[wk][eid][ds].hours=hrs; const row=document.getElementById('tcrow-'+eid); if(row) row.style.opacity='1'; const inp=document.getElementById('hrs-'+eid); if(inp) inp.classList.toggle('ot', hrs>8); } // update day total live let total=0; const crewIds=currentUser.permission==='admin'?EMPLOYEES.map(e=>e.id):(FOREMAN_CREWS[currentUser.id]||[]); crewIds.forEach(id=>{ const e=TIMECARDS[wk]?.[id]?.[ds]; if(e) total+=e.hours||0; }); document.getElementById('tc-day-total-hrs').textContent=fmtHours(total)+' total'; renderTcWeekSummary(); } function ensureTcEntry(eid,ds,wk){ if(!TIMECARDS[wk]) TIMECARDS[wk]={}; if(!TIMECARDS[wk][eid]) TIMECARDS[wk][eid]={}; if(!TIMECARDS[wk][eid][ds]){ const job=JOBS[0]; TIMECARDS[wk][eid][ds]={ jobId:job.id, phase:(job.phases||'').split(',')[0]?.trim()||'', costCode:(job.costs||'').split(',')[0]?.trim()||'', hours:8,notes:'' }; } } function removeTcEntry(eid,ds,wk){ if(TIMECARDS[wk]?.[eid]?.[ds]) delete TIMECARDS[wk][eid][ds]; renderTcDay(); renderTcWeekSummary(); } function addTcRow(){ // no-op in new design — all crew rows are always shown } function renderTcWeekSummary(){ const days=getTcWeekDays(); const wk=dateStr(days[0]); if(!TIMECARDS[wk]) return; const dayStrs=days.map(dateStr); const dayNames=['M','T','W','Th','F','Sa','Su']; const table=document.getElementById('tc-week-summary'); // get all employees with any entry this week const empIds=[...new Set(Object.keys(TIMECARDS[wk]))]; if(!empIds.length){table.innerHTML='No entries this week.';return;} let html=`Employee${dayNames.map(d=>`${d}`).join('')}Total`; empIds.forEach(eid=>{ const emp=EMPLOYEES.find(e=>e.id===eid); if(!emp) return; let total=0; const cells=dayStrs.map(ds=>{ const entry=TIMECARDS[wk][eid]?.[ds]; if(!entry) return '—'; const h=entry.hours||0; total+=h; const ot=h>8?h-8:0; return `${h.toFixed(1)}`; }).join(''); const ot=total>40?total-40:0; html+=`
${emp.name}
${cells} ${total.toFixed(1)}h${ot>0?` (+${ot.toFixed(1)}OT)`:''} `; }); html+=''; table.innerHTML=html; } function submitWeek(){ const days=getTcWeekDays(); const wk=dateStr(days[0]); if(!TIMECARDS[wk]||!Object.keys(TIMECARDS[wk]).length){showToast('No entries to submit','error');return;} WEEK_SUBMISSIONS[wk]={status:'pending',submittedBy:currentUser.id,submittedAt:dateStr(today),rejectNote:''}; renderTimecards(); showToast('Week submitted for approval ✓'); } // ═══════════════════════════════════════════ // CHANGE REQUESTS // ═══════════════════════════════════════════ // [{id, wk, eid, ds, field, currentVal, newVal, reason, requestedBy, requestedAt, status:'pending'|'approved'|'rejected'}] let CHANGE_REQUESTS = []; let crWeekKey=null, crEmpId=null, crDateStr=null; function openChangeRequest(eid, ds, wk){ crWeekKey=wk; crEmpId=eid; crDateStr=ds; const entry=TIMECARDS[wk]?.[eid]?.[ds]; if(!entry){ showToast('No entry found for this row','error'); return; } // populate employee select (fixed to this eid) const emp=EMPLOYEES.find(e=>e.id===eid); const empSel=document.getElementById('cr-emp'); empSel.innerHTML=``; // populate date select (all days in week that have entries for this emp) const days=getTcWeekDays(); const dateSel=document.getElementById('cr-date'); dateSel.innerHTML=days.map(d=>{ const dstr=dateStr(d); const hasEntry=!!TIMECARDS[wk]?.[eid]?.[dstr]; if(!hasEntry) return ''; return ``; }).join(''); dateSel.onchange=()=>updateCrCurrentVal(); updateCrCurrentVal(); document.getElementById('cr-new').value=''; document.getElementById('cr-reason').value=''; document.getElementById('modal-change-request').classList.remove('hidden'); } function updateCrCurrentVal(){ const ds=document.getElementById('cr-date')?.value||crDateStr; const field=document.getElementById('cr-field')?.value||'hours'; const entry=TIMECARDS[crWeekKey]?.[crEmpId]?.[ds]; if(!entry){ document.getElementById('cr-current').value='—'; return; } let val='—'; if(field==='hours') val=(entry.hours||0).toFixed(1); else if(field==='jobId') val=JOBS.find(j=>j.id===entry.jobId)?.name||entry.jobId; else val=entry[field]||'—'; document.getElementById('cr-current').value=val; } function submitChangeRequest(){ const reason=document.getElementById('cr-reason').value.trim(); const newVal=document.getElementById('cr-new').value.trim(); const field=document.getElementById('cr-field').value; const ds=document.getElementById('cr-date').value; if(!reason||!newVal){ showToast('Please fill in all fields','error'); return; } const entry=TIMECARDS[crWeekKey]?.[crEmpId]?.[ds]; let currentVal='—'; if(entry){ if(field==='hours') currentVal=(entry.hours||0).toFixed(1); else if(field==='jobId') currentVal=JOBS.find(j=>j.id===entry.jobId)?.name||entry.jobId; else currentVal=entry[field]||'—'; } CHANGE_REQUESTS.push({ id:'cr'+Date.now(), wk:crWeekKey, eid:crEmpId, ds, field, currentVal, newVal, reason, requestedBy:currentUser.id, requestedAt:dateStr(today), status:'pending' }); closeModal('modal-change-request'); renderTcDay(); showToast('Change request sent to ops manager ✓'); } // ═══════════════════════════════════════════ // MY HOURS (worker view) // ═══════════════════════════════════════════ let myHoursOffset=0; function myHoursNav(dir){ if(dir===0) myHoursOffset=0; else myHoursOffset+=dir; renderMyHours(); } function renderMyHours(){ const mon=addDays(mondayOf(today),myHoursOffset*7); const wk=dateStr(mon); document.getElementById('mh-week-label').textContent=weekLabel(mon); // find emp record for current user const emp=EMPLOYEES.find(e=>e.userId===currentUser.id)||EMPLOYEES[0]; const wkData=TIMECARDS[wk]?.[emp.id]||{}; const days=Array.from({length:7},(_,i)=>addDays(mon,i)); const sub=WEEK_SUBMISSIONS[wk]; const statusEl=document.getElementById('mh-status-badge'); if(sub){ const cls={pending:'b-yellow',approved:'b-green',rejected:'b-red'}[sub.status]||'b-gray'; statusEl.innerHTML=`${sub.status}`; } else statusEl.innerHTML=''; let totalHrs=0, rows=''; days.forEach(d=>{ const ds=dateStr(d); const e=wkData[ds]; if(!e) return; totalHrs+=e.hours||0; const ot=e.hours>8?e.hours-8:0; const job=JOBS.find(j=>j.id===e.jobId); rows+=` ${d.toLocaleDateString('en-US',{weekday:'short',month:'short',day:'numeric'})} ${job?.name||'—'} ${e.phase||'—'} ${e.costCode||'—'} ${(e.hours||0).toFixed(1)}h${ot>0?` (${ot.toFixed(1)}OT)`:''} ${sub?sub.status:'draft'} `; }); const ot=totalHrs>40?totalHrs-40:0; const reg=Math.min(totalHrs,40); document.getElementById('mh-total').textContent=totalHrs.toFixed(1); document.getElementById('mh-regular').textContent=reg.toFixed(1); document.getElementById('mh-ot').textContent=ot.toFixed(1); const tbody=document.getElementById('mh-tbody'); const empty=document.getElementById('mh-empty'); if(!rows){ tbody.innerHTML=''; empty.classList.remove('hidden'); } else { empty.classList.add('hidden'); tbody.innerHTML=rows; } } // ═══════════════════════════════════════════ // APPROVAL // ═══════════════════════════════════════════ let approvalWeekOffset=0; let rejectingWeekKey=null; let rejectingEmpId=null; function approvalWeekNav(dir){ if(dir===0) approvalWeekOffset=0; else approvalWeekOffset+=dir; renderApproval(); } function renderApproval(){ const mon=addDays(mondayOf(today),approvalWeekOffset*7); const wk=dateStr(mon); document.getElementById('appr-week-label').textContent=weekLabel(mon); const sub=WEEK_SUBMISSIONS[wk]||{status:'draft'}; const wkData=TIMECARDS[wk]||{}; const days=Array.from({length:7},(_,i)=>addDays(mon,i)); const dayStrs=days.map(dateStr); const dayLabels=['Mon','Tue','Wed','Thu','Fri','Sat','Sun']; const empIds=Object.keys(wkData); let totalHrs=0; empIds.forEach(eid=>{ dayStrs.forEach(ds=>{ if(wkData[eid]?.[ds]) totalHrs+=wkData[eid][ds].hours||0; }); }); const empApprovals = sub.empApprovals||{}; const approvedCount = empIds.filter(id=>empApprovals[id]==='approved').length; const pendingCount = empIds.filter(id=>!empApprovals[id] && sub.status==='pending').length; document.getElementById('appr-pending').textContent=pendingCount; document.getElementById('appr-approved').textContent=approvedCount; document.getElementById('appr-total-hrs').textContent=totalHrs.toFixed(1); // top action bar const actionsEl=document.getElementById('appr-week-actions'); if(sub.status==='pending'&¤tUser.permission==='admin'){ actionsEl.innerHTML=` `; } else if(sub.status==='approved'){ actionsEl.innerHTML=`✓ Approved by ${sub.approvedBy||'Ops Manager'}`; } else if(sub.status==='rejected'){ actionsEl.innerHTML=`✕ Rejected — Sent Back to Foreman`; } else { actionsEl.innerHTML=`No submission yet for this week`; } if(!empIds.length){ document.getElementById('approval-list').innerHTML=`
📋
No timecard submissions for this week
Foreman must submit the week before it can be approved.
`; const crSection=document.getElementById('appr-change-requests'); if(crSection){ crSection.innerHTML=''; crSection.classList.add('hidden'); } return; } let html=''; empIds.forEach(eid=>{ const emp=EMPLOYEES.find(e=>e.id===eid); if(!emp) return; let empTotal=0; const dayRows=dayStrs.map((ds,di)=>{ const e=wkData[eid]?.[ds]; if(!e) return ''; empTotal+=e.hours||0; const job=JOBS.find(j=>j.id===e.jobId); const ot=(e.hours||0)>8?((e.hours||0)-8):0; const d=days[di]; return ` ${dayLabels[di]} ${d.toLocaleDateString('en-US',{month:'numeric',day:'numeric'})} ${job?.name||'—'} ${e.phase||'—'} ${e.costCode||'—'} ${(e.hours||0).toFixed(1)}h${ot>0?` (${ot.toFixed(1)}OT)`:''} `; }).join(''); const empApprovals = sub.empApprovals||{}; const empApproved = empApprovals[eid]==='approved'; const empRejected = empApprovals[eid]==='rejected'; const empPending = sub.status==='pending' && !empApproved && !empRejected; const cardBorder = empApproved?'border-color:rgba(62,207,142,0.35);':empRejected?'border-color:rgba(232,92,74,0.35);':''; html+=`
${emp.name}
#${emp.empNum||'?'}
${empTotal.toFixed(1)}h ${empTotal>40?`+${(empTotal-40).toFixed(1)}OT`:''} ${empApproved?`✓ Approved`:''} ${empRejected?`✕ Rejected`:''} ${empPending?`Pending`:''}
${dayRows||``}
Day Date Job Site Phase Cost Code Hours
No entries this week
${empPending?`
`:''} ${empApproved?`
✓ Approved
`:''} ${empRejected?`
✕ Rejected — Sent Back
`:''}
`; }); document.getElementById('approval-list').innerHTML=html; // Change Requests panel const pendingCRs=CHANGE_REQUESTS.filter(r=>r.wk===wk&&r.status==='pending'); const crSection=document.getElementById('appr-change-requests')||createCRSection(); if(pendingCRs.length){ crSection.innerHTML=`
⚠ Pending Change Requests (${pendingCRs.length})
`+ pendingCRs.map(cr=>{ const emp=EMPLOYEES.find(e=>e.id===cr.eid); const d=new Date(cr.ds+'T12:00:00'); const fieldLabels={hours:'Hours',jobId:'Job Site',phase:'Phase #',costCode:'Cost Code'}; return `
${emp?.name||cr.eid} — ${d.toLocaleDateString('en-US',{weekday:'short',month:'short',day:'numeric'})}
Change ${fieldLabels[cr.field]||cr.field} from ${cr.currentVal}${cr.newVal}
Reason: ${cr.reason}
`; }).join(''); crSection.classList.remove('hidden'); } else { crSection.innerHTML=''; crSection.classList.add('hidden'); } } function createCRSection(){ const el=document.createElement('div'); el.id='appr-change-requests'; document.getElementById('approval-list').after(el); return el; } function actionCR(crId, action){ const cr=CHANGE_REQUESTS.find(r=>r.id===crId); if(!cr) return; cr.status=action; if(action==='approved'){ // apply the change to the timecard const entry=TIMECARDS[cr.wk]?.[cr.eid]?.[cr.ds]; if(entry){ if(cr.field==='hours') entry.hours=parseFloat(cr.newVal)||entry.hours; else if(cr.field==='jobId'){ const job=JOBS.find(j=>j.name===cr.newVal||j.id===cr.newVal); if(job) entry.jobId=job.id; } else entry[cr.field]=cr.newVal; } showToast('Change applied to timecard ✓'); } else { showToast('Change request denied','info'); } renderApproval(); } function toggleApprovalCard(id){ const el=document.getElementById(id); if(el) el.classList.toggle('open'); } function approveEmp(eid, wk){ const sub = WEEK_SUBMISSIONS[wk]; if(!sub) return; if(!sub.empApprovals) sub.empApprovals = {}; sub.empApprovals[eid] = 'approved'; // check if ALL employees are now approved → mark week approved const empIds = Object.keys(TIMECARDS[wk]||{}); const allApproved = empIds.length > 0 && empIds.every(id => sub.empApprovals[id] === 'approved'); if(allApproved){ sub.status = 'approved'; sub.approvedBy = currentUser.name; sub.approvedAt = dateStr(today); showToast('All employees approved — week ready for payroll ✓'); } else { const remaining = empIds.filter(id => sub.empApprovals[id] !== 'approved' && sub.empApprovals[id] !== 'rejected').length; const emp = EMPLOYEES.find(e=>e.id===eid); showToast(`${emp?.name||'Employee'} approved ✓ — ${remaining} remaining`); } renderApproval(); } function rejectEmp(eid, wk){ rejectingWeekKey = wk; rejectingEmpId = eid; document.getElementById('reject-note').value = ''; document.getElementById('modal-reject').classList.remove('hidden'); } function approveWeek(wk){ // bulk approve all at once (top-level button) const sub = WEEK_SUBMISSIONS[wk]; if(!sub) return; if(!sub.empApprovals) sub.empApprovals = {}; const empIds = Object.keys(TIMECARDS[wk]||{}); empIds.forEach(id => { sub.empApprovals[id] = 'approved'; }); sub.status = 'approved'; sub.approvedBy = currentUser.name; sub.approvedAt = dateStr(today); showToast('All timecards approved — ready for payroll export ✓'); renderApproval(); } function openRejectModal(eid,wk){ rejectingWeekKey=wk; rejectingEmpId=eid; document.getElementById('reject-note').value=''; document.getElementById('modal-reject').classList.remove('hidden'); } function confirmReject(){ const note = document.getElementById('reject-note').value.trim(); if(!note){ showToast('Please enter a reason','error'); return; } if(rejectingWeekKey){ if(!WEEK_SUBMISSIONS[rejectingWeekKey]) WEEK_SUBMISSIONS[rejectingWeekKey]={}; const sub = WEEK_SUBMISSIONS[rejectingWeekKey]; if(rejectingEmpId){ // per-employee reject — mark that employee rejected, send note back if(!sub.empApprovals) sub.empApprovals = {}; sub.empApprovals[rejectingEmpId] = 'rejected'; if(!sub.empRejectNotes) sub.empRejectNotes = {}; sub.empRejectNotes[rejectingEmpId] = note; // if any employee is rejected, week goes back to foreman sub.status = 'rejected'; sub.rejectNote = note; sub.rejectedBy = currentUser.name; const emp = EMPLOYEES.find(e=>e.id===rejectingEmpId); showToast(`${emp?.name||'Employee'} rejected — sent back for corrections`,'info'); } else { // whole-week reject sub.status = 'rejected'; sub.rejectNote = note; sub.rejectedBy = currentUser.name; showToast('Timecard rejected — sent back for corrections','info'); } } closeModal('modal-reject'); renderApproval(); } // ═══════════════════════════════════════════ // PAYROLL EXPORT // ═══════════════════════════════════════════ function getPayrollRows(weekEndingStr, jobFilter, statusFilter){ const rows=[]; // find week key from week-ending (Sunday) → Monday const weekEnding=new Date(weekEndingStr+'T12:00:00'); const mon=addDays(weekEnding,-6); const wk=dateStr(mon); const days=Array.from({length:7},(_,i)=>addDays(mon,i)); const sub=WEEK_SUBMISSIONS[wk]; if(statusFilter==='approved'&&sub?.status!=='approved') return rows; if(statusFilter==='all'&&!sub) return rows; const wkData=TIMECARDS[wk]||{}; Object.keys(wkData).forEach(eid=>{ const emp=EMPLOYEES.find(e=>e.id===eid); if(!emp) return; days.forEach(d=>{ const ds=dateStr(d); const e=wkData[eid]?.[ds]; if(!e) return; if(jobFilter&&e.jobId!==jobFilter) return; const job=JOBS.find(j=>j.id===e.jobId); const reg=Math.min(e.hours||0,8); const ot=Math.max(0,(e.hours||0)-8); rows.push({ Employee:emp.name, Date:ds, Job:job?.name||'', 'Job Address':job?.address||'', 'Phase #':e.phase||'', 'Cost Code #':e.costCode||'', 'Start Time':e.startTime||'', 'End Time':e.endTime||'', 'Total Hours':(e.hours||0).toFixed(2), 'Regular Hours':reg.toFixed(2), 'OT Hours':ot.toFixed(2), Notes:e.notes||'' }); }); }); return rows.sort((a,b)=>a.Employee.localeCompare(b.Employee)||a.Date.localeCompare(b.Date)); } function renderPayrollPreview(){ const weekEnding=document.getElementById('export-week')?.value||dateStr(today); const jobFilter=document.getElementById('export-job-filter')?.value||''; const statusFilter=document.getElementById('export-status-filter')?.value||'approved'; const rows=getPayrollRows(weekEnding,jobFilter,statusFilter); const container=document.getElementById('payroll-preview'); if(!rows.length){ container.innerHTML=`
No ${statusFilter==='approved'?'approved':'submitted'} timecards found for this week. ${statusFilter==='approved'?'Try "All Submitted" or navigate to an approved week.':''}
`; return; } let html=`
`; let grandTotal=0; rows.forEach(r=>{ grandTotal+=parseFloat(r['Total Hours']); const ot=parseFloat(r['OT Hours']); html+=``; }); html+=``; html+=`
Employee Date Job Site Phase # Cost Code Reg Hrs OT Hrs Total
${r.Employee} ${new Date(r.Date+'T12:00:00').toLocaleDateString('en-US',{month:'short',day:'numeric'})} ${r.Job} ${r['Phase #']||'—'} ${r['Cost Code #']||'—'} ${r['Regular Hours']} ${ot>0?r['OT Hours']:'—'} ${r['Total Hours']}
Total — ${rows.length} entries ${grandTotal.toFixed(2)}h
`; container.innerHTML=html; } function exportPayroll(){ const weekEnding=document.getElementById('export-week')?.value||dateStr(today); const jobFilter=document.getElementById('export-job-filter')?.value||''; const statusFilter=document.getElementById('export-status-filter')?.value||'approved'; const rows=getPayrollRows(weekEnding,jobFilter,statusFilter); if(!rows.length){showToast('No data to export for selected filters','error');return;} const wb=XLSX.utils.book_new(); const ws=XLSX.utils.json_to_sheet(rows); // column widths ws['!cols']=[{wch:18},{wch:12},{wch:22},{wch:28},{wch:10},{wch:14},{wch:10},{wch:10},{wch:12},{wch:14},{wch:10},{wch:20}]; XLSX.utils.book_append_sheet(wb,ws,'Timecard Export'); XLSX.writeFile(wb,`Steller_Payroll_WeekEnding_${weekEnding}.xlsx`); showToast('Payroll Excel exported ✓'); } function exportSummaryReport(){ const weekEnding=document.getElementById('export-week')?.value||dateStr(today); const jobFilter=document.getElementById('export-job-filter')?.value||''; const statusFilter=document.getElementById('export-status-filter')?.value||'approved'; const rows=getPayrollRows(weekEnding,jobFilter,statusFilter); if(!rows.length){showToast('No data to export','error');return;} // aggregate by job+phase+cost const summary={}; rows.forEach(r=>{ const key=`${r.Job}|${r['Phase #']}|${r['Cost Code #']}`; if(!summary[key]) summary[key]={Job:r.Job,'Phase #':r['Phase #'],'Cost Code #':r['Cost Code #'],'Total Hours':0,'Regular Hours':0,'OT Hours':0,Employees:new Set()}; summary[key]['Total Hours']+=parseFloat(r['Total Hours']); summary[key]['Regular Hours']+=parseFloat(r['Regular Hours']); summary[key]['OT Hours']+=parseFloat(r['OT Hours']); summary[key].Employees.add(r.Employee); }); const summaryRows=Object.values(summary).map(r=>({...r,'Total Hours':r['Total Hours'].toFixed(2),'Regular Hours':r['Regular Hours'].toFixed(2),'OT Hours':r['OT Hours'].toFixed(2),'Employee Count':r.Employees.size,'Employees':[...r.Employees].join(', ')})); summaryRows.forEach(r=>delete r.Employees); const wb=XLSX.utils.book_new(); const ws=XLSX.utils.json_to_sheet(summaryRows); ws['!cols']=[{wch:22},{wch:10},{wch:14},{wch:14},{wch:14},{wch:12},{wch:40}]; XLSX.utils.book_append_sheet(wb,ws,'Job Cost Summary'); XLSX.writeFile(wb,`Steller_JobCost_WeekEnding_${weekEnding}.xlsx`); showToast('Job cost summary exported ✓'); } // ═══════════════════════════════════════════ // UTILS // ═══════════════════════════════════════════ document.querySelectorAll('.overlay').forEach(o=>o.addEventListener('click',e=>{if(e.target===o)o.classList.add('hidden');})); // ── Settings menu toggle ── function toggleSettings(){ const m=document.getElementById('settings-menu'); if(m) m.classList.toggle('hidden'); } document.addEventListener('click',function(e){ const w=document.getElementById('settings-wrap'); const m=document.getElementById('settings-menu'); if(w&&m&&!w.contains(e.target)) m.classList.add('hidden'); }); // ── Job import: additive only ── // (handled in loadDataFromSheet — jobs are merged not replaced) // ── Settings menu toggle ── function toggleSettings(){ const m=document.getElementById('settings-menu'); if(m) m.classList.toggle('hidden'); } document.addEventListener('click',function(e){ const w=document.getElementById('settings-wrap'); const m=document.getElementById('settings-menu'); if(w&&m&&!w.contains(e.target)) m.classList.add('hidden'); }); // ── Job import: additive only ── // (handled in loadDataFromSheet — jobs are merged not replaced)