Opening your workspace…
`; if(!DL){toast('Downloads are not available in this view.','bad');return} try{await DL.save({filename:`${x.number}.html`,data:new Blob([html],{type:'text/html'})});}catch(e){if(e&&e.code!=='declined')toast('Download failed: '+(e.message||e.code),'bad')} } /* ============================== VENDOR BILLS (AP) ============================== */ VIEWS.bills=()=>{ const all=vals('bills').sort((a,b)=>(b.billDate||'').localeCompare(a.billDate||'')); const f=UI.billFilter; const list=all.filter(x=>f==='all'?true:f==='paid'?billState(x)[0]==='Paid':x.status!=='void'&&invBalance(x)>0.004); const open=all.filter(x=>x.status!=='void'&&invBalance(x)>0.004); const b=[0,0,0,0,0]; open.forEach(x=>b[agingBucket(x)]+=invBalance(x)); return `

${role()==='vendor'?'Bills & payments':'Vendor bills'}

${isFull()?'Accounts payable. Record bills from Ready to bill so each vendor invoice is matched to approved hours.':'Your invoices to '+esc(org().companyName)+', matched against approved hours, and their payment status.'}
${BUCKETS.map((l,i)=>`
${l}
${m0(b[i])}
`).join('')}
Total owed
${m0(sum(b))}
${[['open','Open'],['paid','Paid'],['all','All']].map(([k,l])=>``).join('')}
${list.length?`
${list.map(x=>{const [l,k]=billState(x);const diff=Math.abs(num(x.total)-num(x.expected))>0.01;return ``}).join('')}
BillVendorBill dateDueExpectedBilledBalanceStatus
${esc(x.number)}
${esc(x.vendorRef||'')}
${esc(nm('vendors',x.vendorId))}${fmtDs(x.billDate)}${fmtDs(x.dueDate)}${money(x.expected)}${money(x.total)}${money(invBalance(x))}${l}
`:emptyState('No vendor bills here','')}
`; }; function openBill(id){ const x=S.bills[id]; if(!x)return; const [l,k]=billState(x); const diff=r2(num(x.total)-num(x.expected)); openModal(` `,{wide:true}); } /* ============================== PROJECTS ============================== */ VIEWS.projects=()=>{ if(UI.projectId&&S.projects[UI.projectId])return projectDetail(S.projects[UI.projectId]); const ps=vals('projects').sort((a,b)=>(a.status==='completed')-(b.status==='completed')||byName(a,b)); return `

Projects

${isFull()?'Client → project → team. Each team member carries their own bill and cost rates.':'Projects you manage or work on.'}
${isFull()?'':''}
${ps.length?`
${ps.map(p=>{const s=projectStats(p.id);return ``}).join('')}
ProjectStatusHours vs budgetPORevenueMargin
${esc(p.name)}
${esc(nm('clients',p.clientId))} · ${billingLabel(p.billing)}
${statusPill(p.status)}${meter(s.hours,num(p.budgetHours),`${hrs(s.hours)} / ${p.budgetHours?hrs(p.budgetHours):'—'} h`)}${meter(s.poUsed,num(p.poAmount),p.poNumber?esc(p.poNumber):'No PO')}${m0(s.rev)}${s.rev?pct(s.margin/s.rev):'—'}
`:emptyState('No projects yet','Projects link a client to the consultants who work on it.','')}
`; }; const statusPill=s=>s==='completed'?'Completed':s==='hold'?'On hold':'Active'; function projectDetail(p){ const s=projectStats(p.id); const t=todayISO(); const team=vals('assignments').filter(a=>a.projectId===p.id).sort((a,b)=>nm('consultants',a.consultantId).localeCompare(nm('consultants',b.consultantId))||(b.effectiveFrom||'').localeCompare(a.effectiveFrom||'')); const byC={}; lines().filter(l=>l.projectId===p.id&&l.status==='approved').forEach(l=>{const x=byC[l.consultantId]=byC[l.consultantId]||{h:0,rev:0,cost:0};x.h+=l.hours;x.rev+=l.rev;x.cost+=l.cost}); const teamRows=team.map(a=>{const c=S.consultants[a.consultantId]||{};const rc=rateChain(a.consultantId,p.id,a.effectiveFrom||t);const current=assignmentFor(p.id,a.consultantId,t)===a; return `
${esc(c.name||'?')}
${esc(c.title||'')}${c.vendorId?' · '+esc(nm('vendors',c.vendorId)):''}
${fmtDs(a.effectiveFrom)} – ${a.effectiveTo?fmtDs(a.effectiveTo):'open'}${current?' current':''} ${money(rc.base)}${rc.markup?pct(rc.markup,0):'—'}${money(rc.cost)}${money(rc.bill)}${money(rc.margin)}
${rc.bill?pct(rc.margin/rc.bill):''}
${canManageProject(p)?``:''}`}).join(''); const ex=team.find(a=>assignmentFor(p.id,a.consultantId,t)===a&&S.consultants[a.consultantId]?.vendorId)||team[0]; const exRc=ex?rateChain(ex.consultantId,p.id,t):null; return `

${esc(p.name)}

${esc(nm('clients',p.clientId))} · ${billingLabel(p.billing)} · ${statusPill(p.status)}
${isFull()?`
`:''}
Revenue to date
${m0(s.rev)}
${p.budgetAmount?`of ${m0(p.budgetAmount)} budget`:p.billing==='fixed'&&p.fixedFee?`of ${m0(p.fixedFee)} fee`:'No revenue budget'}
Delivery cost
${m0(s.cost)}
${hrs(s.hours)} approved hours
Gross margin
${s.rev?pct(s.margin/s.rev):'—'}
${m0(s.margin)}
Pending approval
${hrs(s.pending)} h
submitted, not yet approved

Team & rate cards

${canManageProject(p)?``:''}
${teamRows?`
${teamRows}
ConsultantEffectivePayVendor markupOur costBill rateMargin / h
`:emptyState('No one assigned yet','Add consultants with their bill rate for this project.')}

Budget & PO

Hours
${meter(s.hours,num(p.budgetHours),`${hrs(s.hours)} of ${p.budgetHours?hrs(p.budgetHours):'—'} h`)}
Revenue budget
${meter(s.rev,num(p.budgetAmount),`${m0(s.rev)} of ${p.budgetAmount?m0(p.budgetAmount):'—'}`)}
Purchase order ${esc(p.poNumber||'')}
${meter(s.poUsed,num(p.poAmount),`${m0(s.poUsed)} of ${p.poAmount?m0(p.poAmount):'—'}`)}
Dates
${fmtD(p.startDate)} – ${fmtD(p.endDate)}
Manager
${esc(nm('consultants',p.managerId))}
${p.notes?`
Notes
${esc(p.notes)}
`:''}
${exRc&&ex?`

How one hour of ${esc(nm('consultants',ex.consultantId))}'s time adds up

Consultant pay
${money(exRc.base)}
+ Vendor markup ${exRc.markup?pct(exRc.markup,0):''}
${money(exRc.cost-exRc.base)}
= Our cost
${money(exRc.cost)}
Client bill rate
${money(exRc.bill)}
Gross margin
${money(exRc.margin)} · ${exRc.bill?pct(exRc.margin/exRc.bill):'—'}
`:''}

Approved time by consultant

${Object.keys(byC).length?`
${Object.entries(byC).map(([c,x])=>``).join('')}
ConsultantHoursRevenueCostMargin
${esc(nm('consultants',c))}${hrs(x.h)}${money(x.rev)}${money(x.cost)}${money(x.rev-x.cost)}
`:emptyState('No approved time yet','')}
`; } function editAssignment(id,pid){ const cur=id?S.assignments[id]:{projectId:pid,effectiveFrom:S.projects[pid]?.startDate||todayISO(),otMultiplier:1.5,holidayMultiplier:1.5}; const p=S.projects[cur.projectId]; openForm({title:(id?'Edit rate card · ':'Add team member · ')+p.name,value:cur,submit:id?'Save':'Add',intro:`
To change a rate from a date, add a new rate card for the same consultant with a later start date. Past time keeps its original rate.
`,fields:[ {k:'consultantId',label:'Consultant',type:'select',req:true,options:()=>[['','Choose…'],...vals('consultants').sort(byName).map(c=>[c.id,c.name+(c.vendorId?' · '+nm('vendors',c.vendorId):'')])]}, {k:'role',label:'Role on project'}, {k:'billRate',label:'Client bill rate / hour',type:'number',step:'0.01',req:true,min:0}, {k:'costRate',label:'Pay rate override / hour',type:'number',step:'0.01',min:0,help:'Leave blank to use the consultant\'s standard rate.'}, {k:'otMultiplier',label:'Overtime multiplier',type:'number',step:'0.05',def:1.5},{k:'holidayMultiplier',label:'Holiday multiplier',type:'number',step:'0.05',def:1.5}, {k:'effectiveFrom',label:'Effective from',type:'date',req:true},{k:'effectiveTo',label:'Effective to',type:'date'}], onSave:async v=>{if(v.effectiveTo&&v.effectiveTo{ const cs=vals('clients').sort(byName); const y=todayISO().slice(0,4)+'-01-01'; return `

Clients

Billing details, terms and tax for each client.
${cs.length?`
${cs.map(c=>{const rev=sum(approvedIn(y,todayISO()).filter(l=>l.clientId===c.id),l=>l.rev)+sum(fixedRevenue(y,todayISO()).filter(f=>f.clientId===c.id),f=>f.amount);const bal=sum(vals('invoices').filter(i=>i.clientId===c.id&&i.status!=='draft'),invBalance); return ``}).join('')}
ClientContactTermsProjectsRevenue YTDBalance due
${esc(c.name)}${c.taxId?`
Tax ID ${esc(c.taxId)}
`:''}
${esc(c.contact||'')}
${esc(c.email||'')}
Net ${esc(c.terms??org().defaultTerms)}${vals('projects').filter(p=>p.clientId===c.id).length}${m0(rev)}${bal?money(bal):'—'}
`:emptyState('No clients yet','','')}
`; }; VIEWS.vendors=()=>{ const vs=vals('vendors').sort(byName); const t=todayISO(); return `

Vendors

Staffing firms and subcontractors who supply consultants. Their markup sets what you pay per hour.
${vs.length?`
${vs.map(v=>{const exp=v.complianceExpiry;const cp=!exp?'Not set':expExpired ${fmtDs(exp)}`:daysBetween(t,exp)<=30?`Expires ${fmtDs(exp)}`:`Valid to ${fmtDs(exp)}`; return ``}).join('')}
VendorMarkupTermsComplianceConsultantsOwed
${esc(v.name)}
${esc(v.contact||'')} ${esc(v.email||'')}
${num(v.markupPct)}%Net ${esc(v.terms??30)}${cp} ${esc(v.taxDoc||'No tax form')}${vals('consultants').filter(c=>c.vendorId===v.id).length}${money(sum(vals('bills').filter(b=>b.vendorId===v.id),invBalance))}
`:emptyState('No vendors yet','Add staffing firms or subcontractors if any consultants come through them.','')}
`; }; VIEWS.consultants=()=>{ const cs=vals('consultants').sort((a,b)=>(a.active===false)-(b.active===false)||byName(a,b)); const [from,to]=periodRange('month'); return `

Consultants

Employees, contractors and vendor-supplied consultants. Utilization is billable hours this month over capacity to date.
${cs.length?`
${cs.map(c=>{const ls=approvedIn(from,to).filter(l=>l.consultantId===c.id);const av=availableHours(c,from,to);const bh=sum(ls.filter(l=>l.billable),l=>l.hours);const rc=rateChain(c.id,null,todayISO());const projs=[...new Set(vals('assignments').filter(a=>a.consultantId===c.id).map(a=>a.projectId))]; return ``}).join('')}
ConsultantEngagementCost / hProjectsUtilizationDates
${esc(c.name)} ${c.active===false?'Inactive':''}
${esc(c.title||'')}${c.skills?' · '+esc(c.skills):''}
${c.vendorId?esc(nm('vendors',c.vendorId)):esc((kindOpts.find(k=>k[0]===c.kind)||['','Employee'])[1])}${money(rc.cost)}${rc.markup?`
${money(num(c.costRate))} + ${pct(rc.markup,0)}
`:''}
${projs.map(p=>esc(S.projects[p]?.name||'')).join(', ')||'None'}${av?meter(bh,av,hrs(bh)+' h'):'—'}${c.startDate?fmtDs(c.startDate):'—'} – ${c.endDate?fmtDs(c.endDate):'open'}
`:emptyState('No consultants yet','','')}
`; }; /* ============================== REPORTS ============================== */ function aggregate(from,to,dim){ const m={}; const key=l=>dim==='client'?(l.clientId||'_none'):dim==='project'?(l.projectId&&l.projectId[0]!=='_'?l.projectId:'_none'):dim==='consultant'?l.consultantId:(l.vendorId||'_direct'); const acc=k=>m[k]=m[k]||{h:0,bh:0,rev:0,cost:0}; approvedIn(from,to).forEach(l=>{const a=acc(key(l));if(!l.special)a.h+=l.hours;if(l.billable)a.bh+=l.hours;a.rev+=l.rev;a.cost+=l.cost}); if(dim==='client'||dim==='project')fixedRevenue(from,to).forEach(f=>{acc(dim==='client'?f.clientId:f.projectId).rev+=f.amount}); nbExpenses(from,to).forEach(e=>{const p=S.projects[e.projectId];const c=S.consultants[e.consultantId];const k=dim==='client'?(p?.clientId||'_none'):dim==='project'?(e.projectId||'_none'):dim==='consultant'?e.consultantId:(c?.vendorId||'_direct');acc(k).cost+=num(e.amount)}); const label=k=>k==='_none'?'Internal & time off':k==='_direct'?'Employees & contractors (direct)':dim==='client'?nm('clients',k):dim==='project'?nm('projects',k):dim==='consultant'?nm('consultants',k):nm('vendors',k); return Object.entries(m).map(([k,x])=>({k,label:label(k),...x,margin:x.rev-x.cost})).sort((a,b)=>b.rev-a.rev||b.cost-a.cost); } function utilRows(from,to){ return vals('consultants').filter(c=>c.active!==false||approvedIn(from,to).some(l=>l.consultantId===c.id)).sort(byName).map(c=>{const ls=approvedIn(from,to).filter(l=>l.consultantId===c.id);const av=availableHours(c,from,to);const worked=sum(ls.filter(l=>!l.special),l=>l.hours);const bh=sum(ls.filter(l=>l.billable),l=>l.hours);const off=sum(ls.filter(l=>l.special&&l.projectId!=='_int'),l=>l.hours);const ot=sum(ls.filter(l=>l.type==='OT'),l=>l.hours); return{c,av,worked,bh,nb:worked-bh,off,ot,util:av?worked/av:NaN,butil:av?bh/av:NaN,bench:Math.max(0,av-worked-off)}}); } VIEWS.reports=()=>{ const [from,to]=periodRange(UI.period); const dim=UI.reportDim; const rows=aggregate(from,to,dim); const T=rows.reduce((a,r)=>({h:a.h+r.h,bh:a.bh+r.bh,rev:a.rev+r.rev,cost:a.cost+r.cost}),{h:0,bh:0,rev:0,cost:0}); const ur=utilRows(from,to); return `

Reports

${fmtD(from)} – ${fmtD(to)} · approved time only

Profitability

${[['client','Client'],['project','Project'],['consultant','Consultant'],['vendor','Vendor']].map(([k,l])=>``).join('')}
${rows.length?`
${rows.map(r=>``).join('')}
${dim[0].toUpperCase()+dim.slice(1)}HoursBillableRevenueCostGross profitMargin
${esc(r.label)}${hrs(r.h)}${hrs(r.bh)}${money(r.rev)}${money(r.cost)}${money(r.margin)}${r.rev?pct(r.margin/r.rev):'—'}
Total${hrs(T.h)}${hrs(T.bh)}${money(T.rev)}${money(T.cost)}${money(T.rev-T.cost)}${T.rev?pct((T.rev-T.cost)/T.rev):'—'}
`:emptyState('No approved time in this period','Try a longer period.')} ${dim==='consultant'||dim==='vendor'?'
Fixed-fee revenue is reported by client and project only.
':''}

Utilization

Available = capacity × weekdays to date
${ur.map(u=>``).join('')}
ConsultantAvailableWorkedBillableNon-billableTime offOvertimeBenchBillable utilization
${esc(u.c.name)}
${esc(u.c.vendorId?nm('vendors',u.c.vendorId):'Direct')}
${hrs(u.av)}${hrs(u.worked)}${hrs(u.bh)}${hrs(u.nb)}${hrs(u.off)}${hrs(u.ot)}${hrs(u.bench)}${u.av?meter(u.bh,u.av,pct(u.butil,0)+' billable'):'—'}
`; }; async function exportCSV(name,head,rows){ const q=v=>{const s=String(v??'');return /[",\n]/.test(s)?'"'+s.replace(/"/g,'""')+'"':s}; const csv=[head,...rows].map(r=>r.map(q).join(',')).join('\n'); if(!DL){toast('Downloads are not available in this view.','bad');return} try{await DL.save({filename:name,data:new Blob([csv],{type:'text/csv'})})}catch(e){if(e&&e.code!=='declined')toast('Download failed: '+(e.message||e.code),'bad')} } /* ============================== SETTINGS ============================== */ VIEWS.settings=()=>{ const o=org(); const counts=COLS.filter(c=>c!=='settings').map(c=>[c,Object.keys(S[c]).length]); return `

Settings

Company details, numbering and the approval workflow.

Company & invoicing

${[ {k:'companyName',label:'Company name',full:true,req:true},{k:'address',label:'Address (shown on invoices)',type:'textarea',full:true},{k:'email',label:'Billing email',type:'email'}, {k:'currency',label:'Currency',type:'select',options:[['USD','USD — US dollar'],['CAD','CAD — Canadian dollar'],['EUR','EUR — Euro'],['GBP','GBP — British pound'],['AUD','AUD — Australian dollar'],['INR','INR — Indian rupee'],['LKR','LKR — Sri Lankan rupee']]}, {k:'invoicePrefix',label:'Invoice prefix'},{k:'nextInvoiceNo',label:'Next invoice number',type:'number'}, {k:'billPrefix',label:'Vendor bill prefix'},{k:'nextBillNo',label:'Next bill number',type:'number'}, {k:'defaultTerms',label:'Default payment terms (days)',type:'number'},{k:'taxRate',label:'Default sales tax %',type:'number',step:'0.01'}, {k:'approvalLevels',label:'Timesheet approval',type:'select',options:[['1','One step — manager approves'],['2','Two steps — manager, then client']]}, {k:'weeklyHoursAlert',label:'Flag weeks over (hours)',type:'number'}].map(f=>fieldHTML(f,o[f.k])).join('')}

Your data

${counts.map(([c,n])=>`${n} ${c}`).join(' · ')}
${o.sample?`
`:''}

How billing is calculated

Our cost per hour = consultant pay rate × (1 + vendor markup). Employees and direct contractors have no markup.
Revenue = billable approved hours × the client bill rate on the project's rate card in effect on that day. Overtime and holiday hours use the rate card's multipliers.
Fixed-fee and retainer projects earn revenue when their milestone or retainer invoices are issued. Their hours still count as cost and utilization.
Billable expenses pass through to the client at cost and don't affect margin. Non-billable expenses count as cost.
`; }; /* ============================== HOME (portals) ============================== */ VIEWS.home=()=>{const r=role();return r==='client'?clientHome():r==='vendor'?vendorHome():consultantHome()}; function hello(){const h=new Date().getHours();return (h<12?'Good morning':h<18?'Good afternoon':'Good evening')+(myName&&!PREVIEW?', '+esc(myName.split(' ')[0]):'')} function notLinked(what){return `

${hello()}

${emptyState(`Your account isn't linked to a ${what} yet`,'Ask an administrator to link your sign-in to your record in Users & access.')}
`} function consultantHome(){ const M=me(); const c=S.consultants[M.consultantId]; if(!c)return notLinked('consultant record'); const wk=weekStartOf(todayISO()); const lw=addDays(wk,-7); const sheet=w=>S.timesheets[c.id+'__'+w]; const tot=t=>t?sum((t.rows||[]).filter(Boolean),r=>sum(r.hours||[])):0; const hist=[];for(let i=0;i<8;i++){const w=addDays(wk,-7*i);hist.push([w,sheet(w)])} const [mf,mt]=periodRange('month'); const ml=lines().filter(l=>l.consultantId===c.id&&l.date>=mf&&l.date<=mt); const asg=vals('assignments').filter(a=>a.consultantId===c.id&&(!a.effectiveTo||a.effectiveTo>=todayISO())); const projs=[...new Map(asg.map(a=>[a.projectId,a])).values()]; const exp=vals('expenses').filter(e=>e.consultantId===c.id).sort((a,b)=>b.date.localeCompare(a.date)).slice(0,5); const lwS=sheet(lw); const lwOpen=!lwS||lwS.status==='draft'||lwS.status==='rejected'; return `

${hello()}

${esc(c.title||'')}${c.vendorId?' · via '+esc(nm('vendors',c.vendorId)):''}
${lwOpen&&(!c.startDate||c.startDate<=addDays(lw,6))?``:''}
This week
${hrs(tot(sheet(wk)))} h
${sheet(wk)?tsPill(sheet(wk).status):'Not started'}
This month
${hrs(sum(ml.filter(l=>!l.special),l=>l.hours))} h
${hrs(sum(ml.filter(l=>l.billable),l=>l.hours))} billable
Time off this month
${hrs(sum(ml.filter(l=>l.special&&l.projectId!=='_int'),l=>l.hours))} h
PTO, sick and holidays
Open expenses
${vals('expenses').filter(e=>e.consultantId===c.id&&e.status==='submitted').length}
waiting for approval

My projects

${projs.length?`
${projs.map(a=>{const p=S.projects[a.projectId]||{};return ``}).join('')}
ProjectRoleManager
${esc(p.name)}
${esc(nm('clients',p.clientId))}
${esc(a.role||'')}${esc(nm('consultants',p.managerId))}
`:emptyState('No project assignments','Your manager adds you to projects.')}

Recent timesheets

${hist.map(([w,t])=>``).join('')}
${fmtDs(w)} – ${fmtDs(addDays(w,6))}${hrs(tot(t))} h${t?tsPill(t.status):'Not started'}

My expenses

${exp.length?`
${exp.map(e=>``).join('')}
${fmtDs(e.date)}${esc(e.category)}
${esc(e.description||'')}
${money(e.amount)}${e.status==='submitted'?'Waiting':e.status==='approved'?'Approved':'Rejected'}
`:emptyState('No expenses','')}
`; } function clientHome(){ const M=me(); const cl=S.clients[M.clientId]; if(!cl)return notLinked('client'); const ps=vals('projects').filter(p=>p.clientId===cl.id).sort(byName); const [mf,mt]=periodRange('month'); const all=lines().filter(l=>l.clientId===cl.id); const month=all.filter(l=>l.date>=mf&&l.date<=mt&&l.status==='approved'); const waiting=vals('timesheets').filter(canApproveTS); const inv=vals('invoices').filter(i=>i.clientId===cl.id).sort((a,b)=>b.issueDate.localeCompare(a.issueDate)); const open=inv.filter(i=>i.status!=='void'&&invBalance(i)>0.004); const byCons={}; all.filter(l=>l.date>=mf&&l.date<=mt&&l.status!=='draft'&&l.status!=='rejected').forEach(l=>{const k=l.projectId+'|'+l.consultantId;const x=byCons[k]=byCons[k]||{p:l.projectId,c:l.consultantId,appr:0,pend:0,val:0};if(l.status==='approved'){x.appr+=l.hours;x.val+=l.rev}else x.pend+=l.hours}); return `

${esc(cl.name)}

Your projects with ${esc(org().companyName)}
${waiting.length?``:''}
Hours this month
${hrs(sum(month,l=>l.hours))}
approved
Fees this month
${m0(sum(month,l=>l.rev))}
time & materials, approved
Awaiting your sign-off
${waiting.length}
timesheets
Balance due
${m0(sum(open,invBalance))}
${open.length} open invoice${open.length===1?'':'s'}

Projects

${ps.length?`
${ps.map(p=>{const s=projectStats(p.id);return ``}).join('')}
ProjectStatusHours vs budgetPO usedBilled to date
${esc(p.name)}
${billingLabel(p.billing)} · ${fmtDs(p.startDate)} – ${fmtDs(p.endDate)}
${statusPill(p.status)}${meter(s.hours,num(p.budgetHours),`${hrs(s.hours)} / ${p.budgetHours?hrs(p.budgetHours):'—'} h`)}${meter(s.poUsed,num(p.poAmount),p.poNumber?esc(p.poNumber):'No PO')}${m0(s.rev)}
`:emptyState('No projects yet','')}

Consultant hours this month

${Object.keys(byCons).length?`
${Object.values(byCons).map(x=>``).join('')}
ConsultantApprovedPendingFees
${esc(nm('consultants',x.c))}
${esc(nm('projects',x.p))}
${hrs(x.appr)}${x.pend?hrs(x.pend):'—'}${money(x.val)}
`:emptyState('No hours yet this month','')}

Invoices

${inv.length?`
${inv.slice(0,6).map(x=>{const [l,k]=invState(x);return ``}).join('')}
${esc(x.number)}Due ${fmtDs(x.dueDate)}${money(invBalance(x))}${l}
`:emptyState('No invoices yet','')}
`; } function vendorHome(){ const M=me(); const v=S.vendors[M.vendorId]; if(!v)return notLinked('vendor'); const cons=vals('consultants').filter(c=>c.vendorId===v.id).sort(byName); const wk=weekStartOf(todayISO()); const lw=addDays(wk,-7); const [mf,mt]=periodRange('month'); const ml=lines().filter(l=>l.vendorId===v.id&&l.date>=mf&&l.date<=mt&&l.status==='approved'&&!l.special); const bills=vals('bills').filter(b=>b.vendorId===v.id).sort((a,b)=>b.billDate.localeCompare(a.billDate)); const owed=sum(bills.filter(b=>b.status!=='void'),invBalance); const y=todayISO().slice(0,4); const paidY=sum(bills.flatMap(b=>(b.payments||[]).filter(p=>(p.date||'').startsWith(y))),p=>p.amount); const unbilled=lines().filter(l=>l.vendorId===v.id&&l.status==='approved'&&!l.billId&&!l.special); const st=(c,w)=>{const t=S.timesheets[c.id+'__'+w];return t?tsPill(t.status):'Not started'}; const th=(c,w)=>{const t=S.timesheets[c.id+'__'+w];return t?sum((t.rows||[]).filter(Boolean),r=>sum(r.hours||[])):0}; return `

${esc(v.name)}

Your consultants placed with ${esc(org().companyName)}
Active consultants
${cons.filter(c=>c.active!==false).length}
Approved hours this month
${hrs(sum(ml,l=>l.hours))}
${money(sum(ml,l=>l.cost))} at your rates
Approved, not yet billed
${m0(sum(unbilled,l=>l.cost))}
${hrs(sum(unbilled,l=>l.hours))} h ready to invoice
Owed to you
${m0(owed)}
${m0(paidY)} paid this year

Consultants

${cons.length?`
${cons.map(c=>{const ps=[...new Set(raw('assignments').filter(a=>a.consultantId===c.id).map(a=>a.projectId))];const rc=rateChain(c.id,null,todayISO());return ``}).join('')}
ConsultantProjectsRateLast weekThis weekEnds
${esc(c.name)}
${esc(c.title||'')}
${ps.map(p=>esc(nm('projects',p))).join(', ')}${money(rc.cost)}${st(c,lw)} ${hrs(th(c,lw))} h${st(c,wk)} ${hrs(th(c,wk))} h${c.endDate?fmtDs(c.endDate):'Open'}
`:emptyState('No consultants linked','')}

Bills & payments

${bills.length?`
${bills.slice(0,6).map(b=>{const [l,k]=billState(b);return ``}).join('')}
${esc(b.vendorRef||b.number)}${fmtDs(b.billDate)}${money(b.total)}${l}
`:emptyState('No bills recorded yet','')}
`; } /* ============================== USERS & ACCESS ============================== */ VIEWS.members=()=>{ const ms=raw('members').filter(m=>m.id!==myId||!isOwner).sort((a,b)=>(a.role||'').localeCompare(b.role||'')); const reqs=raw('requests').filter(q=>!S.members[q.id]).sort((a,b)=>(b.at||'').localeCompare(a.at||'')); const ownerRec=S.members[myId]; const row=m=>`
${esc(whoName(m.id))}
${m.id===myId?'You':''}
${esc(ROLES[m.role]||m.role)}${m.disabled?' Suspended':''}${esc(linkLabel(m))||'—'}${fmtD((m.addedAt||'').slice(0,10))} `; return `

Users & access

Everyone signs in with their Claude account. Their role decides what they see and can do here.
${!shareAdmin&&MODE==='live'?``:''}

Access requests

${reqs.length}
${reqs.length?`
${reqs.map(q=>``).join('')}
${esc(whoName(q.id))}
${esc(q.note||'No note')} · ${fmtTS(q.at)}
`:emptyState('No requests','People you share this page with can ask for access the first time they open it.')}

How to invite someone

Colleagues in your organization: share the page with them, then use Add person to find them and set their role.
Clients and vendors: share the page with their email from the Share menu. When they open it they can request access, and the request appears here.
Share-menu access to give: Editor for administrators, Contributor for everyone who enters time, approves or records payments, Viewer for read-only.

People

${isOwner?``:''} ${ms.map(row).join('')}
PersonRoleLinked recordAdded
${esc(myName||'You')}
You · page owner
Administrator${ownerRec&&ownerRec.consultantId?esc(nm('consultants',ownerRec.consultantId)):'—'}
${!ms.length&&!isOwner?emptyState('No one added yet',''):''}

What each role can do

${Object.entries(ROLES).map(([k,l])=>``).join('')}
${l}${esc(ROLE_HELP[k])}
Roles control what each person sees and can change inside Timeledger. Anyone you share the page with can technically read its underlying records with browser tools, so share only with people you trust with this data.
`; }; function memberDialog(id,{search=false,title}={}){ const cur=(id&&S.members[id])||{role:'consultant'}; let pick=id||null; const opts=(c,sel)=>``+raw(c).sort(byName).map(x=>``).join(''); openModal(`
`); const sync=()=>{const r=$('#m-role').value;$('#m-link-c').hidden=!['consultant','manager','admin','finance'].includes(r);$('#m-link-cl').hidden=r!=='client';$('#m-link-v').hidden=r!=='vendor';$('#m-help').textContent=ROLE_HELP[r]+(['admin','finance'].includes(r)?' Link a consultant record if they also log time.':'')}; $('#m-role').onchange=sync; sync(); if(!search){const w=$('#m-who');if(w)w.textContent=whoName(id)} if(search){ const q=$('#m-q'),res=$('#m-res'); const paint=list=>{res.innerHTML='';if(!list.length){res.innerHTML='
No matches in your organization.
';return} list.forEach(p=>{const b=document.createElement('button');b.type='button';b.className='person'+(p.id===pick?' on':'');const n=document.createElement('span');n.className='strong';n.textContent=p.name;b.appendChild(n);if(p.email){const e=document.createElement('span');e.className='small muted';e.textContent=' '+p.email;b.appendChild(e)} if(S.members[p.id]){const e=document.createElement('span');e.className='pill';e.style.marginLeft='6px';e.textContent=ROLES[S.members[p.id].role]||'member';b.appendChild(e)} b.onclick=()=>{pick=p.id;NAMES[p.id]=p.name;q.value=p.name;paint([p])};res.appendChild(b)})}; const go=()=>{if(!USER){res.innerHTML='
People search needs sign-in.
';return}USER.search(q.value).then(paint)}; q.addEventListener('focus',go);q.addEventListener('input',()=>{pick=null;go()}); } $('#mform').addEventListener('submit',async e=>{e.preventDefault(); if(!pick){$('#m-err').textContent='Choose a person first.';return} const r=$('#m-role').value; const m={id:pick,role:r,consultantId:['consultant','manager','admin','finance'].includes(r)?$('#m-cons').value:'',clientId:r==='client'?$('#m-client').value:'',vendorId:r==='vendor'?$('#m-vendor').value:'',disabled:$('#m-dis').checked,addedAt:cur.addedAt||new Date().toISOString(),addedBy:cur.addedBy||myId,updatedAt:new Date().toISOString()}; if((r==='consultant'||r==='manager')&&!m.consultantId){$('#m-err').textContent='Link this person to their consultant record.';return} if(r==='client'&&!m.clientId){$('#m-err').textContent='Choose which client they belong to.';return} if(r==='vendor'&&!m.vendorId){$('#m-err').textContent='Choose which vendor they belong to.';return} if(!(await put('members',m)))return; audit(id?'Access changed':'Access granted',`${whoName(pick)}: ${ROLES[r]}${linkLabel(m)?' · '+linkLabel(m):''}${m.disabled?' (suspended)':''}`,pick); if(S.requests[pick])await del('requests',pick); closeModal();toast('Access saved for '+whoName(pick))}); } function selfLinkDialog(){ const cur=S.members[myId]||{}; openForm({title:'Link yourself to a consultant',intro:'
As the page owner you are always an administrator. Link a consultant record if you also log your own time.
',fields:[{k:'consultantId',label:'Consultant record',type:'select',options:()=>[['','— none —'],...raw('consultants').sort(byName).map(c=>[c.id,c.name])]}],value:cur, onSave:async v=>{await put('members',Object.assign({},cur,{id:myId,role:'admin',consultantId:v.consultantId,addedAt:cur.addedAt||new Date().toISOString()}));toast('Saved')}}); } /* ============================== AUDIT LOG ============================== */ VIEWS.audit=()=>{ const es=Object.values(S.audit).sort((a,b)=>(b.at||'').localeCompare(a.at||'')); return `

Audit log

Approvals, invoicing, payments, access and settings changes, newest first.
${es.length?`
${es.slice(0,300).map(e=>``).join('')}
WhenWhoActionDetails
${fmtTS(e.at)}${e.by?esc(whoName(e.by)):'System'}
${esc(ROLES[e.role]||'')}
${esc(e.action)}${esc(e.detail)}
`:emptyState('Nothing recorded yet','Actions appear here as people approve time, send invoices and change access.')}
`; }; function previewDialog(){ const cur=PREVIEW||{role:'consultant'}; const opts=(c,sel)=>raw(c).sort(byName).map(x=>``).join(''); openModal(`
`); const sync=()=>{const r=$('#p-role').value;$('#p-c').hidden=!['consultant','manager','finance'].includes(r);$('#p-cl').hidden=r!=='client';$('#p-v').hidden=r!=='vendor'}; $('#p-role').onchange=sync;sync(); $('#pform').addEventListener('submit',e=>{e.preventDefault();const r=$('#p-role').value;PREVIEW={role:r,consultantId:$('#p-cons').value,clientId:r==='client'?$('#p-client').value:'',vendorId:r==='vendor'?$('#p-vendor').value:''};UI.view=(ROLE_VIEWS[r]||[])[0];UI.tsConsultant=null;UI.projectId=null;closeModal();scheduleRender(true)}); } async function clearSample(){ let n=0; for(const c of COLS){if(['settings','counters','members','requests'].includes(c))continue;for(const x of raw(c).filter(x=>x.sample)){await del(c,x.id);n++}} const o=clone(org()); o.sample=false; o.id='org'; if(S.settings.org&&S.settings.org.sampleCompany){o.companyName='Your company';o.address='';o.email='';o.nextInvoiceNo=1001;o.nextBillNo=1;delete o.sampleCompany} await put('settings',o); await put('counters',{id:'main',nextInvoiceNo:1001,nextBillNo:1}); audit('Sample data cleared',n+' records'); toast(`Removed ${n} sample records`); } /* ============================== actions ============================== */ const ACT={ nav:d=>{if(UI.view==='timesheets')flushTS(tsKey());UI.view=d.v;UI.navOpen=false;if(d.v!=='projects')UI.projectId=null;saveUI();closeModal();scheduleRender(true);window.scrollTo(0,0)}, menu:()=>{UI.navOpen=!UI.navOpen;scheduleRender(true)}, overlay:(d,el,e)=>{if(e.target===el)closeModal()}, close:()=>closeModal(), new:d=>editEntity(d.col), edit:d=>editEntity(d.col,d.id), del:d=>deleteEntity(d.col,d.id), 'edit-vendor':d=>editEntity('vendors',d.id), 'edit-cons':d=>editEntity('consultants',d.id), 'ts-week':d=>{flushTS(tsKey());UI.tsWeek=+d.v===0?weekStartOf(todayISO()):addDays(UI.tsWeek,+d.v);scheduleRender(true)}, 'ts-add':()=>{tsMutate(ts=>{ts.rows=ts.rows||[];ts.rows.push({projectId:'',task:'',type:'REG',billable:true,hours:[0,0,0,0,0,0,0],notes:''})});flushTS(tsKey());scheduleRender(true)}, 'ts-del-row':d=>{tsMutate(ts=>ts.rows.splice(+d.ri,1));flushTS(tsKey());scheduleRender(true)}, 'ts-copy':()=>{const prev=S.timesheets[UI.tsConsultant+'__'+addDays(UI.tsWeek,-7)];if(!prev||!(prev.rows||[]).length){toast('No lines on last week\'s sheet to copy.');return} tsMutate(ts=>{const have=new Set((ts.rows||[]).map(r=>r.projectId+'|'+r.task+'|'+r.type));ts.rows=(ts.rows||[]).concat(prev.rows.filter(r=>!have.has(r.projectId+'|'+r.task+'|'+r.type)).map(r=>({projectId:r.projectId,task:r.task||'',type:r.type||'REG',billable:!!r.billable,hours:[0,0,0,0,0,0,0],notes:''})))});flushTS(tsKey());scheduleRender(true)}, 'ts-note':d=>{const ts=currentTS();const r=ts.rows[+d.ri];openForm({title:'Line note',fields:[{k:'notes',label:'Notes for the approver',type:'textarea',full:true}],value:{notes:r.notes||''},onSave:async v=>{tsMutate(t=>{t.rows[+d.ri].notes=v.notes});await flushTS(tsKey())}})}, 'ts-submit':async()=>{const ts=clone(currentTS());if((ts.rows||[]).some(r=>!r.projectId&&sum(r.hours||[])>0)){toast('Choose a project for every line with hours.','bad');return} ts.rows=(ts.rows||[]).filter(r=>r.projectId||sum(r.hours||[])>0);ts.status='submitted';ts.updatedAt=new Date().toISOString();ts.submittedAt=ts.updatedAt;ts.history=(ts.history||[]).concat({at:ts.updatedAt,action:'Submitted',by:myId});delete TS_DRAFT[ts.id];clearTimeout(tsTimer);await put('timesheets',ts);toast('Timesheet submitted for approval')}, 'ts-recall':async()=>{const ts=clone(currentTS());ts.status='draft';ts.updatedAt=new Date().toISOString();ts.history=(ts.history||[]).concat({at:ts.updatedAt,action:'Recalled',by:myId});await put('timesheets',ts)}, 'open-ts':d=>{const ts=S.timesheets[d.id];UI.tsConsultant=ts.consultantId;UI.tsWeek=ts.weekStart;UI.view='timesheets';closeModal();scheduleRender(true)}, 'appr-tab':d=>{UI.apprTab=d.v;scheduleRender(true)}, 'appr-ok':d=>approveTS(d.id), 'appr-view':d=>viewTS(d.id), 'appr-all':async()=>{const ids=vals('timesheets').filter(t=>t.status==='submitted'||t.status==='mgr_approved').map(t=>t.id);for(const id of ids)await approveTS(id,true);toast(`Approved ${ids.length} timesheet${ids.length>1?'s':''} at their current step`)}, 'appr-reject':d=>{const ts=S.timesheets[d.id];openForm({title:'Return timesheet',submit:'Return for correction',intro:`
${esc(nm('consultants',ts.consultantId))}, week of ${fmtD(ts.weekStart)}. The consultant can edit and resubmit.
`,fields:[{k:'note',label:'What needs fixing?',type:'textarea',full:true,req:true}],onSave:async v=>{const t=clone(ts);t.status='rejected';t.updatedAt=new Date().toISOString();t.history=(t.history||[]).concat({at:t.updatedAt,action:'Returned',by:myId,note:v.note});await put('timesheets',t);audit('Timesheet returned',`${nm('consultants',t.consultantId)}, week of ${fmtDs(t.weekStart)}: ${v.note}`,t.id);toast('Returned for correction')}})}, 'exp-new':()=>editExpense(),'exp-edit':d=>editExpense(d.id), 'exp-del':d=>confirmBox('Delete this expense?','','Delete',()=>del('expenses',d.id)), 'exp-ok':async d=>{const e=clone(S.expenses[d.id]);e.status='approved';e.approvedBy=myId;e.approvedAt=new Date().toISOString();await put('expenses',e);audit('Expense approved',`${nm('consultants',e.consultantId)} · ${e.category} · ${money(e.amount)}`,e.id);toast('Expense approved')}, 'exp-reject':async d=>{const e=clone(S.expenses[d.id]);e.status='rejected';await put('expenses',e);audit('Expense rejected',`${nm('consultants',e.consultantId)} · ${money(e.amount)}`,e.id);toast('Expense rejected')}, 'exp-f':d=>{UI.expFilter=d.v;scheduleRender(true)}, 'inv-create':d=>createInvoiceDialog(d.id),'inv-fixed':d=>fixedInvoiceDialog(d.id),'bill-create':d=>createBillDialog(d.id), 'inv-open':d=>openInvoice(d.id||d.v),'inv-f':d=>{UI.invFilter=d.v;saveUI();scheduleRender(true)}, 'inv-send':async d=>{const x=clone(S.invoices[d.id]);x.status='sent';x.sentAt=new Date().toISOString();x.approvedBy=myId;await put('invoices',x);audit('Invoice sent',`${x.number} · ${nm('clients',x.clientId)} · ${money(x.total)}`,x.id);toast(`${x.number} marked as sent`);openInvoice(d.id)}, 'inv-pay':d=>payDialog('invoices',d.id), 'inv-unpay':async d=>{const x=clone(S.invoices[d.id]);x.payments.splice(+d.v,1);x.paidDate=null;await put('invoices',x);openInvoice(d.id)}, 'inv-void':d=>{const x=S.invoices[d.id];confirmBox('Void '+x.number+'?','The invoice stays on record as void, and its time and expenses go back to Ready to bill.','Void invoice',async()=>{const y=clone(x);y.status='void';y.voidedAt=new Date().toISOString();await put('invoices',y);await releaseRefs('invoiceId',x.id);audit('Invoice voided',`${x.number} · ${money(x.total)}`,x.id);toast(x.number+' voided')})}, 'inv-del':d=>{const x=S.invoices[d.id];confirmBox('Delete draft '+x.number+'?','Its time and expenses go back to Ready to bill.','Delete draft',async()=>{await releaseRefs('invoiceId',x.id);await del('invoices',x.id);audit('Draft invoice deleted',x.number,x.id);toast('Draft deleted')})}, 'inv-dl':d=>downloadDoc('invoices',d.id), 'bill-open':d=>openBill(d.id),'bill-f':d=>{UI.billFilter=d.v;saveUI();scheduleRender(true)}, 'bill-approve':async d=>{const x=clone(S.bills[d.id]);x.status='approved';x.approvedBy=myId;x.approvedAt=new Date().toISOString();await put('bills',x);audit('Vendor bill approved',`${x.number} · ${nm('vendors',x.vendorId)} · ${money(x.total)}`,x.id);toast(`${x.number} approved for payment`);openBill(d.id)}, 'bill-pay':d=>payDialog('bills',d.id), 'bill-unpay':async d=>{const x=clone(S.bills[d.id]);x.payments.splice(+d.v,1);await put('bills',x);openBill(d.id)}, 'bill-void':d=>{const x=S.bills[d.id];confirmBox('Void '+x.number+'?','Its hours go back to the vendor reconciliation list.','Void bill',async()=>{const y=clone(x);y.status='void';await put('bills',y);await releaseRefs('billId',x.id);audit('Vendor bill voided',x.number,x.id);toast(x.number+' voided')})}, 'bill-dl':d=>downloadDoc('bills',d.id), 'proj-open':d=>{UI.projectId=d.id||d.v;UI.view='projects';closeModal();scheduleRender(true);window.scrollTo(0,0)}, 'proj-back':()=>{UI.projectId=null;scheduleRender(true)}, 'asg-new':d=>editAssignment(null,d.id),'asg-edit':d=>editAssignment(d.id), 'asg-del':d=>{const a=S.assignments[d.id];confirmBox('Remove this rate card?',`Time already logged by ${esc(nm('consultants',a.consultantId))} on this project will have no bill rate until a new rate card covers it.`,'Remove',()=>del('assignments',d.id))}, 'rep-dim':d=>{UI.reportDim=d.v;saveUI();scheduleRender(true)}, 'rep-csv':()=>{const [f,t]=periodRange(UI.period);const rows=aggregate(f,t,UI.reportDim);exportCSV(`profitability-by-${UI.reportDim}-${f}-to-${t}.csv`,[UI.reportDim,'hours','billable_hours','revenue','cost','gross_profit','margin_pct'],rows.map(r=>[r.label,r2(r.h),r2(r.bh),r2(r.rev),r2(r.cost),r2(r.margin),r.rev?r2(r.margin/r.rev*100):'']))}, 'util-csv':()=>{const [f,t]=periodRange(UI.period);exportCSV(`utilization-${f}-to-${t}.csv`,['consultant','vendor','available','worked','billable','non_billable','time_off','overtime','bench','billable_utilization_pct'],utilRows(f,t).map(u=>[u.c.name,u.c.vendorId?nm('vendors',u.c.vendorId):'Direct',r2(u.av),r2(u.worked),r2(u.bh),r2(u.nb),r2(u.off),r2(u.ot),r2(u.bench),isFinite(u.butil)?r2(u.butil*100):'']))}, 'export-json':async()=>{if(!DL){toast('Downloads are not available in this view.','bad');return}try{await DL.save({filename:`timeledger-backup-${todayISO()}.json`,data:new Blob([JSON.stringify(S,null,1)],{type:'application/json'})})}catch(e){if(e&&e.code!=='declined')toast('Download failed','bad')}}, preview:()=>previewDialog(), 'preview-exit':()=>{PREVIEW=null;UI.view='members';UI.tsConsultant=null;closeModal();scheduleRender(true)}, 'member-add':()=>memberDialog(null,{search:true}), 'member-edit':d=>memberDialog(d.id), 'member-grant':d=>memberDialog(d.id,{title:'Give access'}), 'member-self':()=>selfLinkDialog(), 'member-remove':d=>{const m=S.members[d.id];confirmBox('Remove '+whoName(d.id)+'?','They lose access to Timeledger immediately. Their timesheets and history stay. To keep them out of the page entirely, also remove them in the Share menu.','Remove access',async()=>{await del('members',d.id);audit('Access removed',whoName(d.id)+' ('+(ROLES[m.role]||m.role)+')',d.id);toast('Access removed')})}, 'req-dismiss':async d=>{await del('requests',d.id);audit('Access request dismissed',whoName(d.id),d.id)}, 'open-week':d=>{UI.tsConsultant=me().consultantId;UI.tsWeek=d.v;UI.view='timesheets';scheduleRender(true);window.scrollTo(0,0)}, 'open-cons-week':d=>{UI.tsConsultant=d.id;UI.tsWeek=d.v;UI.view='timesheets';scheduleRender(true);window.scrollTo(0,0)}, 'sample-clear':()=>confirmBox('Clear all sample data?','Every record marked as a sample is deleted for everyone using this workspace. Records you created stay.','Clear sample data',clearSample), }; /* permission checks run before every action, so hidden buttons can't be triggered another way */ const tsOf=d=>S.timesheets[d.id]; const prjOfAsg=d=>S.projects[(S.assignments[d.id]||{}).projectId]; const isRealAdmin=()=>realMe().role==='admin'; const GUARD={ new:()=>isFull(),edit:()=>isFull(),del:()=>isFull(),'edit-vendor':()=>isFull(),'edit-cons':()=>isFull(), 'inv-create':()=>isFull(),'inv-fixed':()=>isFull(),'bill-create':()=>isFull(),'inv-send':()=>isFull(),'inv-pay':()=>isFull(),'inv-unpay':()=>isFull(),'inv-void':()=>isFull(),'inv-del':()=>isFull(), 'bill-approve':()=>isFull(),'bill-pay':()=>isFull(),'bill-unpay':()=>isFull(),'bill-void':()=>isFull(), 'appr-ok':d=>!!tsOf(d)&&canApproveTS(tsOf(d)),'appr-reject':d=>!!tsOf(d)&&canApproveTS(tsOf(d)), 'exp-ok':d=>!!S.expenses[d.id]&&canApproveExp(S.expenses[d.id]),'exp-reject':d=>!!S.expenses[d.id]&&canApproveExp(S.expenses[d.id]), 'exp-edit':d=>isFull()||S.expenses[d.id]?.consultantId===me().consultantId,'exp-del':d=>isFull()||(S.expenses[d.id]?.consultantId===me().consultantId&&S.expenses[d.id]?.status==='submitted'), 'asg-new':d=>canManageProject(S.projects[d.id]),'asg-edit':d=>canManageProject(prjOfAsg(d)),'asg-del':d=>canManageProject(prjOfAsg(d)), 'sample-clear':()=>isRealAdmin()&&!PREVIEW,'export-json':()=>isRealAdmin()&&!PREVIEW,'preview':()=>isRealAdmin(), 'member-add':()=>role()==='admin','member-edit':()=>role()==='admin','member-remove':()=>role()==='admin','member-grant':()=>role()==='admin','member-self':()=>isOwner,'req-dismiss':()=>role()==='admin', nav:d=>allowedViews().includes(d.v),'proj-open':()=>allowedViews().includes('projects'), }; document.addEventListener('click',e=>{const b=e.target.closest('[data-act]');if(!b)return;if(b.tagName==='A')e.preventDefault();const a=b.dataset.act;const f=ACT[a];if(!f)return;if(a!=='overlay')e.stopPropagation(); const g=GUARD[a];if(g&&!g(b.dataset)){toast("Your role doesn't allow that.",'bad');return} f(b.dataset,b,e)}); document.addEventListener('submit',async e=>{ if(e.target.id==='reqform'){e.preventDefault();const note=String(new FormData(e.target).get('note')||'').trim().slice(0,200); if(!DB||!myId)return;try{await DB.collection('requests').doc(myId).set({id:myId,at:new Date().toISOString(),note});S.requests[myId]={id:myId,at:new Date().toISOString(),note};render()}catch(err){toast('Could not send your request. You may need Contributor access to this page — ask the person who shared it.','bad')}return} if(e.target.id!=='orgform')return;e.preventDefault();const fd=new FormData(e.target);const o=clone(org()); for(const [k,v] of fd.entries())o[k]=['nextInvoiceNo','nextBillNo','defaultTerms','taxRate','approvalLevels','weeklyHoursAlert'].includes(k)?num(v):String(v).trim(); if(!o.companyName){toast('Company name is required.','bad');return} if(+o.approvalLevels===1&&vals('timesheets').some(t=>t.status==='mgr_approved'))toast('Timesheets already manager-approved still need one more approval.'); o.id='org';const cn={id:'main',nextInvoiceNo:o.nextInvoiceNo,nextBillNo:o.nextBillNo};delete o.nextInvoiceNo;delete o.nextBillNo; if(!(await put('settings',o)))return;await put('counters',cn);audit('Settings changed',o.companyName);toast('Settings saved')}); /* ============================== boot ============================== */ async function boot(){ const use=n=>(window.claude&&typeof window.claude.use==='function')?window.claude.use(n).catch(()=>null):Promise.resolve(null); const [db,user,dl]=await Promise.all([use('db'),use('user'),use('downloads')]); DB=db; USER=user; DL=dl; if(USER){ try{myId=await USER.id()}catch(e){} try{isOwner=!!(await USER.isOwner())}catch(e){} try{shareAdmin=!!(await USER.canEdit())}catch(e){} try{const m=await USER.me();myName=(m&&m.name)||'';if(myId)NAMES[myId]=myName}catch(e){} try{const w=await USER.can('data.write');if(w===false)canWrite=false}catch(e){} } if(!DB){MODE='local';render();return} if(!USER||!myId){signedIn=false} const done=()=>{if(MODE==='loading'&&loaded.size===COLS.length){MODE='live';render()}}; for(const c of COLS){ DB.collection(c).onSnapshot(snap=>{const m={};snap.docs.forEach(d=>{m[d.id]=Object.assign({},d.data(),{id:d.id})});S[c]=m; // keep unsaved timesheet drafts loaded.add(c);if(MODE==='loading')done();else scheduleRender()}, err=>{loaded.add(c);done();if(err&&err.code!=='revoked')toast('Live updates paused for '+c+'. Reload to reconnect.','bad')}); } DB.collection('audit').orderBy('at','desc').limit(300).onSnapshot(snap=>{const m={};snap.docs.forEach(d=>{m[d.id]=Object.assign({},d.data(),{id:d.id})});S.audit=m;if(UI.view==='audit')scheduleRender()},()=>{}); setTimeout(()=>{if(MODE==='loading'){MODE='live';render()}},9000); } if(!window.TL_HOSTED)boot();