BUSINESS MANAGER

Secure your workspace

Create a password to protect this app on this computer.

This is a local, offline password gate. It is not suitable for protecting highly sensitive data against someone with access to the app files or browser storage.

EuroTorque / Dashboard
Business overview
● Offline mode
ET
`,data:d,isInv};} function exportDocument(type,id){let doc=standaloneDocument(type,id);if(!doc)return;let blob=new Blob([doc.html],{type:'text/html;charset=utf-8'}),url=URL.createObjectURL(blob),a=document.createElement('a');a.href=url;a.download=doc.title+'.html';document.body.appendChild(a);a.click();a.remove();setTimeout(()=>URL.revokeObjectURL(url),1000);toast('Document exported as a printable HTML file')} function shareText(type,doc){let d=doc.data;if(doc.isInv){return `${db.settings.businessName}\nINVOICE ${d.number}\nDate: ${d.date}\nCustomer: ${d.customerName}\n${d.vehicleReg?'Vehicle: '+d.vehicleReg+'\n':''}Total: ${money(d.total)}\nPaid: ${money(paidFor(d.id))}\nBalance: ${money(Math.max(0,d.total-paidFor(d.id)))}\n\nPlease see the attached invoice. Export the document or save it as PDF before attaching.`}return `${db.settings.businessName}\nRECEIPT ${d.number}\nDate: ${d.date}\nReceived from: ${d.customerName}\nAmount received: ${money(d.amount)}\nPayment method: ${d.method}\n${d.invoiceNumber?'Invoice: '+d.invoiceNumber+'\n':''}\nPlease see the attached receipt. Export the document or save it as PDF before attaching.`} async function shareDocument(type,id,channel='native'){let doc=standaloneDocument(type,id);if(!doc)return;let text=shareText(type,doc);if(channel==='whatsapp'){window.open('https://wa.me/?text='+encodeURIComponent(text),'_blank');toast('WhatsApp opened. Attach the exported file separately.');return}if(channel==='email'){let subject=doc.title+' - '+db.settings.businessName;window.location.href='mailto:?subject='+encodeURIComponent(subject)+'&body='+encodeURIComponent(text);return}try{let file=new File([doc.html],doc.title+'.html',{type:'text/html'});if(navigator.share&&navigator.canShare&&navigator.canShare({files:[file]})){await navigator.share({files:[file],title:doc.title,text:`${db.settings.businessName} ${doc.isInv?'invoice':'receipt'} ${doc.data.number}`});return}}catch(e){if(e&&e.name==='AbortError')return}exportDocument(type,id);toast('File exported. You can attach it in WhatsApp or email.');} function viewInvoice(id,printIt=false){let i=db.invoices.find(x=>x.id===id);if(!i)return;openModal('Invoice • '+i.number,invoiceDoc(i),`${btn('Close','closeModal()','soft')}${btn('Edit invoice',"editInvoice('"+i.id+"')",'soft')}${btn('Record payment',"closeModal();openReceipt('"+i.id+"')",'darkbtn')}${btn('Export document','exportDocument(\'invoice\',\''+i.id+'\')','soft')}${btn('WhatsApp','shareDocument(\'invoice\',\''+i.id+'\',\'whatsapp\')','soft')}${btn('Email','shareDocument(\'invoice\',\''+i.id+'\',\'email\')','soft')}${btn('Share file','shareDocument(\'invoice\',\''+i.id+'\')','darkbtn')}${btn('Print / Save PDF','window.print()')}`);if(printIt)setTimeout(()=>window.print(),250)} function openReceipt(invoiceId=''){editingReceiptId='';let inv=db.invoices.find(i=>i.id===invoiceId),customer=inv?inv.customerName:'',max=inv?Math.max(0,inv.total-paidFor(inv.id)):0;receiptDraft={invoiceId:inv?.id||'',customerName:customer,date:dateNow(),amount:max,method:'Cash',reference:'',notes:''};receiptForm()} function editReceipt(id){let r=db.receipts.find(x=>x.id===id);if(!r)return;verifyDocumentEditPassword(()=>{editingReceiptId=id;receiptDraft={invoiceId:r.invoiceId||'',customerName:r.customerName||'',date:r.date||dateNow(),amount:Number(r.amount)||0,method:r.method||'Cash',reference:r.reference||'',notes:r.notes||''};receiptForm()})} function cancelReceiptEdit(){editingReceiptId='';closeModal()} function receiptForm(){let d=receiptDraft,opts=db.invoices.filter(i=>status(i)!=='paid'||(editingReceiptId&&i.id===d.invoiceId)).map(i=>``).join('');openModal(editingReceiptId?'Edit receipt • '+(db.receipts.find(x=>x.id===editingReceiptId)?.number||''):'Record payment / issue receipt',`
A receipt records money actually received. Check the amount and payment method before saving.
`,`${btn('Cancel','cancelReceiptEdit()','soft')}${btn(editingReceiptId?'Save changes & print':'Save payment & print','saveReceipt(true)')}${btn(editingReceiptId?'Save changes':'Save payment','saveReceipt(false)','darkbtn')}`)} function chooseReceiptInvoice(id){let i=db.invoices.find(x=>x.id===id);receiptDraft.invoiceId=id;if(i){document.getElementById('r_name').value=i.customerName;document.getElementById('r_amount').value=Math.max(0,i.total-paidFor(i.id))}} function saveReceipt(printIt){let name=val('r_name'),amount=Number(val('r_amount'));if(!name){toast('Enter the customer name');return}if(!(amount>0)){toast('Enter an amount greater than zero');return}let inv=db.invoices.find(i=>i.id===val('r_invoice')),existing=editingReceiptId?db.receipts.find(x=>x.id===editingReceiptId):null;let available=inv?Math.max(0,inv.total-paidFor(inv.id)+(existing&&existing.invoiceId===inv.id?Number(existing.amount)||0:0)):Infinity;if(inv&&amount>available+0.0001&&!confirm('This payment exceeds the remaining invoice balance. Save it anyway?'))return;let fields={date:val('r_date')||dateNow(),customerName:name,invoiceId:inv?.id||'',invoiceNumber:inv?.number||'',amount,method:val('r_method'),reference:val('r_reference'),notes:val('r_notes')};let r;if(existing){let before=structuredClone(existing);r={...existing,...fields,editedAt:new Date().toISOString(),editHistory:[...(existing.editHistory||[]),{date:new Date().toISOString(),before:{date:before.date,customerName:before.customerName,invoiceId:before.invoiceId,invoiceNumber:before.invoiceNumber,amount:before.amount,method:before.method,reference:before.reference,notes:before.notes},after:structuredClone(fields)}]};db.receipts=db.receipts.map(x=>x.id===existing.id?r:x)}else{r={id:uid(),number:db.settings.receiptPrefix+String(db.settings.nextReceipt).padStart(4,'0'),...fields,created:new Date().toISOString()};db.settings.nextReceipt++;db.receipts.push(r)}saveDB();editingReceiptId='';closeModal();render();toast(existing?'Receipt changes saved; invoice balance updated':'Payment saved');if(printIt)viewReceipt(r.id,true)} function receiptDoc(r){let inv=db.invoices.find(i=>i.id===r.invoiceId);return `
${docBusiness()}

RECEIPT

${esc(r.number)}

Date: ${esc(r.date)}

Received from

${esc(r.customerName)}
${r.invoiceNumber?'Invoice: '+esc(r.invoiceNumber)+'
':''}${r.method?'Payment method: '+esc(r.method)+'
':''}${r.reference?'Reference: '+esc(r.reference):''}

Payment details

Amount received
${money(r.amount)}
${inv?'Invoice total: '+money(inv.total)+'
Balance after payment: '+money(Math.max(0,inv.total-paidFor(inv.id))):'Payment recorded on account'}

Received the sum of ${money(r.amount)} from ${esc(r.customerName)}${r.notes?' for '+esc(r.notes):''}.
${esc(db.settings.terms||'Thank you for your business.')}Authorised signature: __________________
`} function viewReceipt(id,printIt=false){let r=db.receipts.find(x=>x.id===id);if(!r)return;openModal('Receipt • '+r.number,receiptDoc(r),`${btn('Close','closeModal()','soft')}${btn('Edit receipt',"editReceipt('"+r.id+"')",'soft')}${btn('Export document','exportDocument(\'receipt\',\''+r.id+'\')','soft')}${btn('WhatsApp','shareDocument(\'receipt\',\''+r.id+'\',\'whatsapp\')','soft')}${btn('Email','shareDocument(\'receipt\',\''+r.id+'\',\'email\')','soft')}${btn('Share file','shareDocument(\'receipt\',\''+r.id+'\')','darkbtn')}${btn('Print / Save PDF','window.print()')}`);if(printIt)setTimeout(()=>window.print(),250)} function exportBackup(){let blob=new Blob([JSON.stringify(db,null,2)],{type:'application/json'}),url=URL.createObjectURL(blob),a=document.createElement('a');a.href=url;a.download='EuroTorque-Backup-'+dateNow()+'.json';a.click();URL.revokeObjectURL(url);toast('Backup exported')} function importBackup(e){let f=e.target.files[0];if(!f)return;let r=new FileReader();r.onload=()=>{try{let d=JSON.parse(r.result);if(!d.settings||!Array.isArray(d.invoices)||!Array.isArray(d.customers))throw Error('Invalid');if(!confirm('Importing will replace all current EuroTorque data. Continue?'))return;db={...structuredClone(defaults),...d,settings:{...defaults.settings,...d.settings}};saveDB();go(page);toast('Backup imported')}catch(err){alert('This file is not a valid EuroTorque backup.')}e.target.value=''};r.readAsText(f)} function clearData(){if(!confirm('This permanently clears all customers, items, invoices, receipts and settings saved in this browser. Export a backup first. Continue?'))return;if(!confirm('Are you absolutely sure? This cannot be undone without a backup.'))return;db=structuredClone(defaults);saveDB();go('dashboard');toast('Local data cleared')} // Supabase cloud transfer helpers. Manual and revision-guarded; no automatic merge. let etSupabaseClient=null,etSupabaseClientUrl='',etSupabaseClientKey=''; function cloudClient(){if(!window.supabase||!window.supabase.createClient)throw new Error('Supabase library did not load. Check internet access and restart the app.');let url=(document.getElementById('cloud_url')?.value||localStorage.getItem('et_cloud_url')||'').trim(),key=(document.getElementById('cloud_key')?.value||localStorage.getItem('et_cloud_key')||'').trim();if(!/^https:\/\/[a-z0-9-]+\.supabase\.co$/i.test(url)||!key)throw new Error('Enter a valid Supabase project URL and publishable/anon key.');localStorage.setItem('et_cloud_url',url);localStorage.setItem('et_cloud_key',key);if(!etSupabaseClient||url!==etSupabaseClientUrl||key!==etSupabaseClientKey){etSupabaseClient=window.supabase.createClient(url,key,{auth:{persistSession:true,autoRefreshToken:true,detectSessionInUrl:false}});etSupabaseClientUrl=url;etSupabaseClientKey=key;}return etSupabaseClient} function cloudStatus(msg){let el=document.getElementById('cloud_status');if(el)el.textContent=msg} async function cloudSignIn(){try{let c=cloudClient(),email=(document.getElementById('cloud_email')?.value||'').trim(),password=document.getElementById('cloud_password')?.value||'';if(!email||!password)throw new Error('Enter your Supabase Auth email and password.');cloudStatus('Signing in…');let {data,error}=await c.auth.signInWithPassword({email,password});if(error)throw error;document.getElementById('cloud_password').value='';cloudStatus('Connected as '+data.user.email+'. Click Check cloud before uploading or downloading.');toast('Supabase sign-in successful')}catch(e){cloudStatus('Connection error: '+(e.message||String(e)));}} async function cloudSignOut(){try{let c=cloudClient();let {error}=await c.auth.signOut();if(error)throw error;cloudStatus('Signed out of Supabase.');toast('Cloud account signed out')}catch(e){cloudStatus('Sign-out error: '+(e.message||String(e)))}} async function cloudRequireUser(){let c=cloudClient(),{data,error}=await c.auth.getUser();if(error)throw error;if(!data.user)throw new Error('Please sign in to Supabase first.');return {c,user:data.user}} async function cloudRead(){let {c,user}=await cloudRequireUser();let {data,error}=await c.from('eurotorque_app_state').select('payload,revision,updated_at').eq('user_id',user.id).maybeSingle();if(error)throw error;return {c,user,row:data}} async function cloudCheck(){try{cloudStatus('Checking cloud…');let {row,user}=await cloudRead();if(!row){localStorage.setItem('et_cloud_revision','0');cloudStatus('Signed in as '+user.email+'. Cloud has no saved app data yet. Upload local data to initialize it.');return}localStorage.setItem('et_cloud_revision',String(row.revision));cloudStatus('Signed in as '+user.email+'. Cloud revision '+row.revision+', last updated '+new Date(row.updated_at).toLocaleString()+'. Upload/download is available.')}catch(e){cloudStatus('Cloud check failed: '+(e.message||String(e)))}} async function cloudUpload(){try{let {c,user,row}=await cloudRead();let known=Number(localStorage.getItem('et_cloud_revision')||0);if(row&&Number(row.revision)!==known){cloudStatus('Upload stopped: cloud revision is '+row.revision+' but this device last checked '+known+'. Click Check cloud, then download or resolve the difference first.');return}if(!row&&known!==0){cloudStatus('Upload stopped: saved revision is missing. Click Check cloud and investigate first.');return}if(row&&!confirm('Replace the current cloud snapshot with the local data on this computer? This replaces the full cloud snapshot, not just changed records. Continue only if this is intended.'))return;let nextRev=row?Number(row.revision)+1:1;let payload=JSON.parse(JSON.stringify(db));let {error}=await c.from('eurotorque_app_state').upsert({user_id:user.id,payload,revision:nextRev,updated_at:new Date().toISOString()},{onConflict:'user_id'});if(error)throw error;localStorage.setItem('et_cloud_revision',String(nextRev));cloudStatus('Upload successful. Cloud revision '+nextRev+'. This is a manual snapshot, not live sync.');toast('Local data uploaded to cloud')}catch(e){cloudStatus('Upload failed: '+(e.message||String(e)))}} async function cloudDownload(){try{let {row,user}=await cloudRead();if(!row){cloudStatus('No cloud snapshot exists yet. Upload local data first.');return}if(!confirm('Replace ALL data currently in this app with the cloud snapshot from revision '+row.revision+'? This cannot be undone unless you have a backup.'))return;let d=row.payload;if(!d||!d.settings||!Array.isArray(d.customers)||!Array.isArray(d.items)||!Array.isArray(d.invoices)||!Array.isArray(d.receipts))throw new Error('The cloud snapshot does not match the expected EuroTorque data format.');db={...structuredClone(defaults),...d,settings:{...defaults.settings,...d.settings}};saveDB();localStorage.setItem('et_cloud_revision',String(row.revision));go(page);cloudStatus('Downloaded cloud revision '+row.revision+' for '+user.email+'. Local app data was replaced.');toast('Cloud data downloaded')}catch(e){cloudStatus('Download failed: '+(e.message||String(e)))}} // Experimental snapshot sync: debounce local edits and poll for remote changes. let etAutoTimer=null,etPollTimer=null,etSyncBusy=false,etApplyingRemote=false,etLastSyncedSnapshot='',etAutoPaused=false; function snapshotString(){return JSON.stringify(db)} function autoSyncEnabled(){return localStorage.getItem('et_auto_sync')==='1'} function toggleAutoSync(){if(autoSyncEnabled()){localStorage.setItem('et_auto_sync','0');clearTimeout(etAutoTimer);clearInterval(etPollTimer);etAutoPaused=false;cloudStatus('Automatic sync disabled. Manual upload/download remain available.');render();return}try{cloudClient();}catch(e){cloudStatus('Cannot enable automatic sync: '+e.message);return}localStorage.setItem('et_auto_sync','1');etAutoPaused=false;startAutoSync();cloudStatus('Starting automatic sync… checking cloud revision first.');render()} function scheduleAutoSync(){if(etApplyingRemote||!autoSyncEnabled()||etAutoPaused)return;clearTimeout(etAutoTimer);etAutoTimer=setTimeout(()=>autoPushSnapshot(),1200)} async function startAutoSync(){clearInterval(etPollTimer);if(!autoSyncEnabled())return;try{let {row,user}=await cloudRead();if(!row){cloudStatus('Automatic sync paused: upload local data once to initialize the cloud.');return}let known=Number(localStorage.getItem('et_cloud_revision')||0);if(known!==Number(row.revision)){cloudStatus('Automatic sync paused: cloud revision changed. Click Check cloud and resolve it before enabling sync again.');etAutoPaused=true;return}etLastSyncedSnapshot=snapshotString();cloudStatus('Automatic sync is ON for '+user.email+'. Watching for local and cloud changes.');etPollTimer=setInterval(()=>pollAutoCloud(),20000)}catch(e){cloudStatus('Automatic sync could not start: '+(e.message||String(e)));etAutoPaused=true}} async function autoPushSnapshot(){if(!autoSyncEnabled()||etAutoPaused||etSyncBusy)return;etSyncBusy=true;try{let {c,user,row}=await cloudRead();let known=Number(localStorage.getItem('et_cloud_revision')||0);if(!row||Number(row.revision)!==known){etAutoPaused=true;cloudStatus('Automatic sync PAUSED: cloud revision changed. No data was overwritten. Click Check cloud and resolve the conflict.');clearInterval(etPollTimer);return}let current=snapshotString();if(current===etLastSyncedSnapshot)return;let nextRev=Number(row.revision)+1;let {error}=await c.from('eurotorque_app_state').upsert({user_id:user.id,payload:JSON.parse(current),revision:nextRev,updated_at:new Date().toISOString()},{onConflict:'user_id'});if(error)throw error;localStorage.setItem('et_cloud_revision',String(nextRev));etLastSyncedSnapshot=current;cloudStatus('Automatic sync ON. Latest local changes saved to cloud revision '+nextRev+'.')}catch(e){etAutoPaused=true;clearInterval(etPollTimer);cloudStatus('Automatic sync paused after an error: '+(e.message||String(e)))}finally{etSyncBusy=false}} async function pollAutoCloud(){if(!autoSyncEnabled()||etAutoPaused||etSyncBusy)return;etSyncBusy=true;try{let {row,user}=await cloudRead();if(!row)throw new Error('Cloud snapshot disappeared.');let known=Number(localStorage.getItem('et_cloud_revision')||0);if(Number(row.revision)===known)return;if(snapshotString()!==etLastSyncedSnapshot){etAutoPaused=true;clearInterval(etPollTimer);cloudStatus('Automatic sync PAUSED: both local and cloud data may have changed. No automatic overwrite was made. Resolve manually.');return}let d=row.payload;if(!d||!d.settings||!Array.isArray(d.customers)||!Array.isArray(d.items)||!Array.isArray(d.invoices)||!Array.isArray(d.receipts))throw new Error('Cloud snapshot format is not recognized.');etApplyingRemote=true;db={...structuredClone(defaults),...d,settings:{...defaults.settings,...d.settings}};localStorage.setItem(KEY,JSON.stringify(db));localStorage.setItem('et_cloud_revision',String(row.revision));etLastSyncedSnapshot=snapshotString();go(page);cloudStatus('Automatic sync ON. Downloaded cloud revision '+row.revision+'.');}catch(e){etAutoPaused=true;clearInterval(etPollTimer);cloudStatus('Automatic sync paused: '+(e.message||String(e)))}finally{etApplyingRemote=false;etSyncBusy=false}} setupLockScreen(); render(); if(autoSyncEnabled()) cloudStatus('Automatic sync is enabled but must reconnect. Open Business Settings and click Check cloud; if needed, disable and re-enable automatic sync.');