import { DEFAULTS, ORIGINAL_EXAMPLE, DIRECT_DEFAULTS, calculateTransaction, aggregateStats, estimatedPipeline, muscatDate, formatINRScale } from './model.mjs'; const $ = id => document.getElementById(id); const esc = value => String(value ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); const money = value => new Intl.NumberFormat('en-IN',{style:'currency',currency:'INR',minimumFractionDigits:2,maximumFractionDigits:2}).format(Number(Number(value || 0).toFixed(8))); const compactMoney = value => Math.abs(Number(value)) < 100000 ? money(value) : formatINRScale(value).label; const num = (value,digits=3) => new Intl.NumberFormat('en-IN',{minimumFractionDigits:digits,maximumFractionDigits:digits}).format(Number(value || 0)); const dateLabel = value => new Intl.DateTimeFormat('en-GB',{day:'numeric',month:'short',year:'numeric',timeZone:'Asia/Muscat'}).format(new Date(value+'T12:00:00Z')); const state = {csrf:null,today:muscatDate(),settings:{debitDefaults:{...DEFAULTS},directDefaults:{...DIRECT_DEFAULTS}},transactions:[],prospects:[],stats:null}; const controllers = {}; let installEvent, toastTimer; function toast(message){$('toast').textContent=message;$('toast').hidden=false;clearTimeout(toastTimer);toastTimer=setTimeout(()=>$('toast').hidden=true,5000);} async function api(action,payload){ const response=await fetch(payload ? '/api' : '/api?action='+encodeURIComponent(action),{method:payload?'POST':'GET',cache:'no-store',credentials:'same-origin',headers:payload?{'Content-Type':'application/json'}:undefined,body:payload?JSON.stringify({action,csrf:state.csrf,...payload}):undefined}); const data=await response.json(); if(response.status===401&&data.code==='unauthenticated'){location.assign('/login');throw new Error('Please sign in again.');} if(!response.ok){const error=new Error(data.error || 'This request could not be saved. Try again.');error.status=response.status;error.code=data.code;error.transaction=data.transaction;throw error;} if(data.csrf)state.csrf=data.csrf; return data; } async function refresh(){ const data=await api('bootstrap'); Object.assign(state,data);state.today=data.today || data.stats?.today || muscatDate(); state.settings.debitDefaults={...DEFAULTS,...state.settings.debitDefaults};state.settings.directDefaults={...DIRECT_DEFAULTS,...state.settings.directDefaults}; state.stats=aggregateStats(state.transactions,{today:state.today}); renderDashboard();renderCustomers();renderLedger();fillSettings(); $('connection').textContent='Synced';$('connection').classList.remove('offline');$('global-error').hidden=true; } const pages={dashboard:'Overview',debit:'Debit card deals',direct:'Direct OMR deals',customers:'Customers',ledger:'Deal history',settings:'Settings'}; function navigate(){ const page=Object.hasOwn(pages,location.hash.slice(1))?location.hash.slice(1):'dashboard'; for(const key of Object.keys(pages))$('page-'+key).hidden=key!==page; document.querySelectorAll('[data-page]').forEach(link=>{link.classList.toggle('active',link.dataset.page===page);if(link.dataset.page===page)link.setAttribute('aria-current','page');else link.removeAttribute('aria-current');}); $('page-label').textContent=pages[page];window.scrollTo({top:0,behavior:'instant'}); } window.addEventListener('hashchange',navigate); function renderDashboard(){ const stats=state.stats || aggregateStats([],{today:state.today});const period=$('period').value; const totals=period==='week'?stats.currentWeek:period==='month'?stats.currentMonth:stats.totals; $('period-caption').textContent=period==='week'?'THIS WEEK':period==='month'?'THIS MONTH':'ALL TIME'; $('stat-profit').textContent=compactMoney(totals.profit); $('stat-profit-note').textContent=totals.count+' completed deal'+(totals.count===1?'':'s'); $('stat-roi').textContent=totals.roi===null?'—':num(totals.roi,2)+'%'; $('stat-bank').textContent=totals.bankChargedINR>=10000000?formatINRScale(totals.bankChargedINR).label:'₹'+num(totals.bankChargedINR/100000,2)+' lakh'; $('stat-bank-exact').textContent=money(totals.bankChargedINR)+' including card fees'; $('stat-debit-count').textContent=totals.debitCount; $('stat-payout').textContent=compactMoney(totals.customerPayoutINR); $('stat-direct-count').textContent=totals.directCount; const pipeline=estimatedPipeline(state.prospects,state.transactions,{inputs:state.settings.directDefaults}); $('pipeline-omr').textContent=num(pipeline.totals.omr)+' OMR'; $('pipeline-count').textContent=pipeline.prospects.length+' open customer opportunit'+(pipeline.prospects.length===1?'y':'ies'); $('pipeline-profit').textContent=money(pipeline.totals.profit);$('planned-count').textContent=stats.plans.length; const weeks=stats.weekly.slice(0,8); $('weekly-rows').innerHTML=weeks.length?weeks.map(w=>`${esc(dateLabel(w.weekStart))}${w.count}${esc(formatINRScale(w.bankChargedINR).label)}${esc(money(w.customerPayoutINR))}${esc(money(w.profit))}`).join(''):'Your weekly results appear after your first completed deal.'; if(weeks.length){const max=Math.max(1,...weeks.map(w=>Math.abs(w.profit)));$('weekly-chart').innerHTML=weeks.slice().reverse().map(w=>`
${esc(formatINRScale(w.profit).label)}
${esc(dateLabel(w.weekStart).replace(/ \d{4}$/,''))}
`).join('');} else $('weekly-chart').innerHTML='

No completed profit recorded yet.

'; const recent=stats.completedEntries.slice().sort((a,b)=>b.occurredOn.localeCompare(a.occurredOn)||String(b.createdAt||'').localeCompare(String(a.createdAt||''))).slice(0,4); $('recent-deals').innerHTML=recent.length?recent.map(t=>dealRow(t,true)).join(''):'
Start with a debit-card or direct-OMR deal. Save it as a plan, or record it as completed.
'; wireDealButtons($('recent-deals')); } $('period').addEventListener('change',renderDashboard); function dealRow(entry,compact=false){ const result=entry.result?.valid!==false&&entry.result?.profit!==undefined?entry.result:calculateTransaction(entry.kind,entry.inputs); const costLabel=entry.kind==='debit'?'Bank '+money(result.bankChargedINR ?? result.debit+result.cardFees):'Customer payout '+money(result.customerPayoutINR ?? result.payoutINR); return `

${esc(entry.name)} ${esc(entry.status==='planned'?'Plan':entry.status)}

${esc(dateLabel(entry.occurredOn))} · ${entry.kind==='debit'?'Debit card':'Direct OMR'} · ${esc(num(result.withdrawal))} OMR · ${esc(num(result.quantity,4))} USDT
${esc(costLabel)}

${esc(money(result.profit))}${entry.status==='completed'?'Recorded profit':entry.status==='void'?'Excluded from stats':'Estimated profit'}
${!compact&&entry.status==='planned'?``:''}${!compact&&entry.status!=='void'?``:''}${entry.prospectId?'Linked customer deal':''}
`; } function wireDealButtons(container){ container.querySelectorAll('[data-load-deal]').forEach(button=>button.addEventListener('click',()=>loadDeal(button.dataset.loadDeal))); container.querySelectorAll('[data-complete-deal]').forEach(button=>button.addEventListener('click',()=>loadDeal(button.dataset.completeDeal,true))); container.querySelectorAll('[data-void-deal]').forEach(button=>button.addEventListener('click',async()=>{const entry=state.transactions.find(t=>t.id===button.dataset.voidDeal);if(!confirm('Void “'+entry.name+'”? It will stay in history and be excluded from profit statistics.'))return;button.disabled=true;try{await api('void_transaction',{id:entry.id});await refresh();toast('Record voided. Statistics updated.');}catch(error){toast(error.message);button.disabled=false;}})); } function renderLedger(){ const search=$('ledger-search').value.trim().toLowerCase(),kind=$('ledger-kind').value,status=$('ledger-status').value; const entries=state.transactions.filter(t=>(kind==='all'||t.kind===kind)&&(status==='all'||t.status===status)&&String(t.name).toLowerCase().includes(search)).slice().sort((a,b)=>b.occurredOn.localeCompare(a.occurredOn)||String(b.createdAt||'').localeCompare(String(a.createdAt||''))); $('ledger-list').innerHTML=entries.length?entries.map(t=>dealRow(t)).join(''):'
No deals match this view.
';wireDealButtons($('ledger-list')); } for(const id of ['ledger-search','ledger-kind','ledger-status'])$(id).addEventListener(id.endsWith('search')?'input':'change',renderLedger); function selectControl(kind,key,label,options,help=''){return `
${help?`

${esc(help)}

`:''}
`;} function numberControl(kind,key,label,unit='',help=''){return `
${esc(unit)}
${help?`

${esc(help)}

`:''}
`;} function calculatorHTML(kind){ const n=(key,label,unit='',help='')=>numberControl(kind,key,label,unit,help),s=(key,label,opts,help='')=>selectControl(kind,key,label,opts,help); const isDebit=kind==='debit'; const inputHTML=isDebit?` ${s('amountMode','Start with',[['cash','OMR amount — calculate INR and USDT'],['usdt','USDT quantity — calculate OMR and INR'],['actual','Actual trade — enter actual amounts']])}
${n('withdrawal','OMR withdrawal','OMR')}${n('inrRate','Exact card conversion rate','INR / OMR')}
${s('conversionMode','Card INR debit',[['rate','Calculate from INR/OMR rate'],['debit','Enter exact statement debit']])}${n('debit','Card debit','INR')} ${s('forexMode','International markup',[['percent','Percentage of card debit'],['fixed','Exact fee from statement']])}
${n('forexPercent','Markup percentage','%')}${n('forexFee','Exact markup fee','INR')}${n('atmFee','ATM fee','INR')}
${n('buyRate','USDT buy price','OMR / USDT')}${n('sellRate','USDT sell price','INR / USDT')}
${n('estimatedQuantity','Calculated USDT quantity','USDT')}${n('targetQuantity','USDT quantity to buy / sell','USDT')}${n('actualQuantity','Actual USDT received / sold','USDT')}
Other costs and sale fees
${n('buyFee','Buy fee paid from OMR cash','OMR')}${n('otherCost','Other INR costs','INR')}${n('sellPercent','Sale fee percentage','%')}${n('sellFee','Fixed sale fee','INR')}
`:` ${n('receivedOMR','OMR received from customer','OMR')} ${s('payoutMode','Your promise to the customer',[['rate','Google rate + bonus per OMR'],['total','Exact INR amount to send']])}
${n('googleRate','Google INR/OMR rate','INR / OMR')}${n('bonusPerOMR','Bonus you promise per OMR','INR / OMR')}
${n('payoutINR','INR you will send the customer','INR')}

Enter today’s Google quote yourself. Payout = OMR × (Google rate + bonus). The prefilled rate is an example, not a live quote.

${n('buyRate','USDT buy price','OMR / USDT')}${n('sellRate','USDT sell price in India','INR / USDT')}
${s('quantityMode','USDT quantity',[['estimated','Calculate from the OMR received'],['actual','Enter the actual USDT received']])} ${n('estimatedQuantity','Calculated USDT quantity','USDT')}${n('actualQuantity','Actual USDT received / sold','USDT')}
Optional other costs / sale fees

Bank, card markup and ATM fees are zero in this section.

${n('otherCost','Other INR costs','INR')}${n('sellPercent','Sale fee percentage','%')}${n('sellFee','Fixed sale fee','INR')}
`; return `

${isDebit?'Your withdrawal & exchange':'Your customer quote & exchange'}

${inputHTML}

Save this deal

${!isDebit?'':''}

Plans are saved for later. Only completed deals count in earnings.

`; } function createController(kind){ $(kind+'-calculator').innerHTML=calculatorHTML(kind); const base=kind==='debit'?DEFAULTS:DIRECT_DEFAULTS; const c={kind,defaults:base,form:$(kind+'-form'),result:null,id:crypto.randomUUID(),prospectId:null,isExisting:false,saving:false}; c.values=()=>Object.fromEntries(Object.keys(base).map(key=>[key,key==='feesIncluded'?$(kind+'-'+key).checked:$(kind+'-'+key).value])); c.fill=inputs=>{const values={...base,...inputs};for(const key of Object.keys(base)){const input=$(kind+'-'+key);if(key==='feesIncluded')input.checked=values[key]===true;else input.value=values[key];}}; c.update=()=>{ const values=c.values(),d=kind==='debit'; const show=(key,visible)=>$(kind+'-'+key+'-field').hidden=!visible; const derived=(key,yes)=>{const input=$(kind+'-'+key);input.readOnly=yes;input.closest('.input-unit')?.classList.toggle('derived',yes);}; if(d){ show('inrRate',values.conversionMode==='rate');show('forexPercent',values.forexMode==='percent');show('forexFee',values.forexMode==='fixed');show('feesIncluded',values.conversionMode==='debit'); if(values.conversionMode==='rate'){$('debit-feesIncluded').checked=false;values.feesIncluded=false;} show('estimatedQuantity',values.amountMode==='cash');show('targetQuantity',values.amountMode==='usdt');show('actualQuantity',values.amountMode==='actual'); derived('withdrawal',values.amountMode==='usdt');derived('debit',values.conversionMode==='rate'); }else{ show('googleRate',values.payoutMode==='rate');show('bonusPerOMR',values.payoutMode==='rate');$('direct-bonus-presets').hidden=values.payoutMode!=='rate'; show('estimatedQuantity',values.quantityMode==='estimated');show('actualQuantity',values.quantityMode==='actual');derived('payoutINR',values.payoutMode==='rate'); $('direct-confirmations').hidden=$('direct-status').value!=='completed'; } derived('estimatedQuantity',true); c.result=calculateTransaction(kind,values); for(const key of Object.keys(base)){const input=$(kind+'-'+key);input.closest('.field')?.classList.toggle('invalid',!!c.result.errors[key]);input.setAttribute('aria-invalid',c.result.errors[key]?'true':'false');if($(kind+'-'+key+'-error'))$(kind+'-'+key+'-error').textContent=c.result.errors[key]||'';} $(kind+'-save').disabled=c.saving||!c.result.valid; $(kind+'-error').hidden=c.result.valid; const completed=$(kind+'-status').value==='completed'; $(kind+'-save-guidance').textContent=completed?'This actual completed deal will count in profit statistics.':'A saved plan or voided record does not count as earned profit.'; if(!c.result.valid){for(const key of ['profit','quantity','roi','break-even'])$(kind+'-'+key).textContent='—';$(kind+'-result-lines').replaceChildren();$(kind+'-cash-note').textContent='Enter valid values to see the full breakdown.';$(kind+'-profit-label').textContent='CHECK YOUR INPUTS';return;} const r=c.result; if(d){if(values.amountMode==='usdt')$('debit-withdrawal').value=r.withdrawal;if(values.conversionMode==='rate')$('debit-debit').value=r.debit;} else if(values.payoutMode==='rate')$('direct-payoutINR').value=r.payoutINR; $(kind+'-estimatedQuantity').value=r.quantity; $(kind+'-profit').textContent=money(Math.abs(r.profit)<0.005?0:r.profit);$(kind+'-quantity').textContent=num(r.quantity,4);$(kind+'-roi').textContent=r.roi===null?'—':num(r.roi,2)+'%'; $(kind+'-profit-box').classList.toggle('loss',r.profit<-.005);$(kind+'-profit-label').textContent=(completed?'DEAL ':'ESTIMATED ')+(r.profit<-.005?'LOSS':Math.abs(r.profit)<.005?'BREAK-EVEN':'PROFIT'); $(kind+'-profit-note').textContent=d?'After bank/card charges and selling costs':'After the customer’s INR payout and entered costs'; const lines=d?[['Card debit',r.debit],['Forex / markup'+(r.input.feesIncluded?' (included)':''),r.forex],['ATM fee'+(r.input.feesIncluded?' (included)':''),Number(r.data.atmFee)],['Other costs',Number(r.data.otherCost)],['Total cost',r.cost,true],['Gross USDT sale',r.gross],['Sale fees',r.saleFees],['Net sale proceeds',r.net,true]]:[['INR promised to customer',r.payoutINR],['Other costs',Number(r.data.otherCost)],['Total payout / cost',r.cost,true],['Gross USDT sale',r.gross],['Sale fees',r.saleFees],['Net sale proceeds',r.net,true]]; $(kind+'-result-lines').innerHTML=lines.map(([label,value,total])=>`
${esc(label)}
${esc(money(value))}
`).join(''); $(kind+'-break-even').textContent='₹'+num(r.breakEven,4)+' / USDT'; const mismatch=r.remainingOMR < -0.0000001; $(kind+'-cash-note').classList.toggle('warning',mismatch); $(kind+'-cash-note').textContent=mismatch?'The actual USDT entered costs more OMR than the available cash. Check your quantity or buy price.':r.remainingOMR>0.0000001?num(r.remainingOMR)+' OMR remains, separate from profit.':'All '+num(r.withdrawal)+' OMR is used in this calculation.'; }; c.reset=(inputs,meta={})=>{ c.id=meta.id||crypto.randomUUID();c.isExisting=!!meta.id;c.prospectId=meta.prospectId||null; c.fill(inputs||state.settings[kind==='debit'?'debitDefaults':'directDefaults']); $(kind+'-name').value=meta.name||'';$(kind+'-occurredOn').value=meta.occurredOn||state.today;$(kind+'-status').value=meta.status||'planned';$(kind+'-save-status').textContent=''; if(kind==='direct'){$('direct-fundsReceived').checked=false;$('direct-payoutCompleted').checked=false;$('prospect-banner').hidden=!c.prospectId;if(c.prospectId){const p=state.prospects.find(p=>p.id===c.prospectId);$('prospect-banner').textContent='Customer opportunity: '+(p?.name||meta.name)+'. This will enter profit stats only after receipt and payout are completed.';}} c.update(); }; c.form.addEventListener('input',event=>{if(event.target.name==='amountMode')return;$(kind+'-save-status').textContent='';c.update();}); c.form.addEventListener('change',event=>{if(kind==='debit'&&event.target.name==='amountMode'&&c.result?.valid){if(event.target.value==='usdt')$('debit-targetQuantity').value=c.result.quantity;if(event.target.value==='actual')$('debit-actualQuantity').value=c.result.quantity;}c.update();}); c.form.addEventListener('submit',async event=>{ event.preventDefault();c.update();const status=$(kind+'-save-status'); if(!c.result.valid){status.textContent='Check the highlighted inputs.';$(kind+'-'+Object.keys(c.result.errors)[0])?.focus();return;} const name=$(kind+'-name').value.trim(),occurredOn=$(kind+'-occurredOn').value,recordStatus=$(kind+'-status').value; if(!name){status.textContent='Give the deal a name first.';$(kind+'-name').focus();return;} if(!occurredOn){status.textContent='Choose a deal date.';return;} if(recordStatus==='completed'&&occurredOn>state.today){status.textContent='Future deals should be saved as plans, then completed on the actual day.';return;} const flags=kind==='direct'?{fundsReceived:$('direct-fundsReceived').checked,payoutCompleted:$('direct-payoutCompleted').checked}:{}; if(kind==='direct'&&recordStatus==='completed'&&(!flags.fundsReceived||!flags.payoutCompleted)){status.textContent='Confirm OMR received and INR payout completed before recording real profit.';return;} const inputs={...c.result.input}; for(const key of Object.keys(inputs))if(!Object.hasOwn(base,key))delete inputs[key]; for(const [key,fallback]of Object.entries(base))if(typeof fallback==='number'&&(inputs[key]===''||!Number.isFinite(Number(inputs[key]))||Number(inputs[key])<0))inputs[key]=fallback; const transaction={id:c.id,kind,status:recordStatus,name,occurredOn,inputs,...(c.prospectId?{prospectId:c.prospectId}:{})}; c.saving=true;c.update();status.textContent='Saving to your server…'; try{ const previous=state.transactions.find(entry=>entry.id===c.id); const firstProspectCompletion=c.prospectId&&recordStatus==='completed'&&(!previous||previous.status==='planned')&&state.prospects.find(p=>p.id===c.prospectId)?.stage!=='completed'; const action=firstProspectCompletion?'complete_prospect':c.isExisting?'update_transaction':'save_transaction'; const payload=action==='complete_prospect'?{prospectId:c.prospectId,transaction,...flags}:{transaction,...flags}; const saved=await api(action,payload);rememberTransaction(c,saved.transaction);await refresh();status.textContent=recordStatus==='completed'?'Completed deal saved. Your statistics are updated.':'Record saved. Plans stay separate from earned profit.';toast(status.textContent); }catch(error){const duplicate=['already_saved','already_completed','already_linked'].includes(error.code)&&error.transaction&&(error.transaction.id===c.id||(c.prospectId&&error.transaction.prospectId===c.prospectId));if(duplicate){rememberTransaction(c,error.transaction);try{await refresh();status.textContent='This deal is already saved and has not been counted twice. Review your values and save again to update it.';}catch{status.textContent='The server already has this deal. Reconnect and save again to update it.';}}else status.textContent=error.message;} finally{c.saving=false;c.update();} }); $(kind+'-new').addEventListener('click',()=>c.reset()); controllers[kind]=c;return c; } function rememberTransaction(controller,entry){if(!entry)return;controller.id=entry.id;controller.isExisting=true;const position=state.transactions.findIndex(t=>t.id===entry.id);if(position<0)state.transactions.push(entry);else state.transactions[position]=entry;} function loadDeal(id,complete=false){const entry=state.transactions.find(t=>t.id===id);if(!entry)return;controllers[entry.kind].reset(entry.inputs,{...entry,status:complete?'completed':entry.status});location.hash=entry.kind;toast(complete?'Review the actual amounts and confirm completion, then save.':'Saved deal loaded. Changes require Save deal.');} function renderCustomers(){ const search=$('customer-search').value.trim().toLowerCase(); const records=state.prospects.filter(p=>(p.name+' '+(p.contact||'')).toLowerCase().includes(search)); $('customer-list').innerHTML=records.length?records.map(p=>{ const r=calculateTransaction('direct',{...state.settings.directDefaults,receivedOMR:p.expectedAmountOMR,quantityMode:'estimated'}); const complete=p.stage==='completed'||state.transactions.some(t=>t.prospectId===p.id&&t.status==='completed'); return `

${esc(p.name)}

${esc(p.contact||'No contact added')} · Salary day ${esc(p.payday||'—')}

${complete?'Completed':esc(p.stage)}
Monthly salary${esc(num(p.salaryOMR))} OMRPotential amount${esc(num(p.expectedAmountOMR))} OMR${complete?'Recorded separately':'Estimated profit'}${complete?'See deal history':r.valid?esc(money(r.profit)):'Enter valid amount'}
${p.notes?`

${esc(p.notes)}

`:''}
${complete?'Receipt and payout recorded.':``}
`; }).join(''):'
No customers yet. Add their salary, expected amount and salary day.
'; $('customer-list').querySelectorAll('[data-prospect-deal]').forEach(button=>button.addEventListener('click',()=>{ const prospect=state.prospects.find(p=>p.id===button.dataset.prospectDeal),planned=state.transactions.find(t=>t.prospectId===prospect.id&&t.status==='planned'); controllers.direct.reset(planned?.inputs||{...state.settings.directDefaults,receivedOMR:prospect.expectedAmountOMR},{id:planned?.id,prospectId:prospect.id,name:planned?.name||prospect.name+' — OMR deal',status:'planned',occurredOn:planned?.occurredOn||state.today});location.hash='direct'; })); $('customer-list').querySelectorAll('[data-edit-prospect]').forEach(button=>button.addEventListener('click',()=>{ const p=state.prospects.find(p=>p.id===button.dataset.editProspect);$('customer-id').value=p.id;$('customer-name').value=p.name;$('customer-contact').value=p.contact||'';$('customer-salary').value=p.salaryOMR||0;$('customer-amount').value=p.expectedAmountOMR;$('customer-payday').value=p.payday||1;$('customer-notes').value=p.notes||'';$('customer-stage').value=p.stage==='completed'?'ready':p.stage;$('customer-stage').disabled=p.stage==='completed';$('customer-form-title').textContent='Edit '+p.name;$('customer-form').scrollIntoView({behavior:'smooth',block:'start'}); })); } $('customer-search').addEventListener('input',renderCustomers); function clearCustomer(){ $('customer-form').reset();$('customer-id').value=crypto.randomUUID();$('customer-stage').disabled=false;$('customer-form-title').textContent='Add a potential customer';$('customer-status').textContent=''; } $('customer-clear').addEventListener('click',clearCustomer); $('customer-form').addEventListener('submit',async event=>{ event.preventDefault();const button=$('customer-form').querySelector('button[type=submit]'); if(!$('customer-id').value)$('customer-id').value=crypto.randomUUID(); const existing=state.prospects.find(p=>p.id===$('customer-id').value); const prospect={...($('customer-id').value?{id:$('customer-id').value}:{}),name:$('customer-name').value.trim(),contact:$('customer-contact').value.trim(),salaryOMR:Number($('customer-salary').value),expectedAmountOMR:Number($('customer-amount').value),payday:Number($('customer-payday').value),notes:$('customer-notes').value.trim(),stage:existing?.stage==='completed'?'completed':$('customer-stage').value}; button.disabled=true;$('customer-status').textContent='Saving customer…'; try{await api('save_prospect',{prospect});await refresh();clearCustomer();toast('Customer saved. Expected amounts do not count as profit.');} catch(error){$('customer-status').textContent=error.message;}finally{button.disabled=false;} }); function fillSettings(){ const d=state.settings.debitDefaults,t=state.settings.directDefaults; const values={inrRate:d.inrRate,googleRate:t.googleRate,buyRate:t.buyRate,sellRate:t.sellRate,bonus:t.bonusPerOMR,markup:d.forexPercent,atm:d.atmFee}; for(const[key,value]of Object.entries(values))$('settings-'+key).value=value; } $('settings-form').addEventListener('submit',async event=>{ event.preventDefault();const n=id=>Number($('settings-'+id).value),button=$('settings-form').querySelector('button');button.disabled=true; const settings={...state.settings,googleRate:n('googleRate'),bonusPerOMR:n('bonus'),debitDefaults:{...state.settings.debitDefaults,inrRate:n('inrRate'),buyRate:n('buyRate'),sellRate:n('sellRate'),forexPercent:n('markup'),atmFee:n('atm')},directDefaults:{...state.settings.directDefaults,googleRate:n('googleRate'),bonusPerOMR:n('bonus'),buyRate:n('buyRate'),sellRate:n('sellRate')}}; try{await api('settings',{settings});await refresh();$('settings-status').textContent='Defaults saved. New deals use these rates; saved deals retain their recorded inputs.';toast('Default rates saved.');} catch(error){$('settings-status').textContent=error.message;}finally{button.disabled=false;} }); $('password-form').addEventListener('submit',async event=>{ event.preventDefault();const newPassword=$('new-password').value,button=$('password-form').querySelector('button'); if(newPassword!==$('confirm-password').value){$('password-status').textContent='The new passwords do not match.';return;} button.disabled=true; try{await api('change_password',{currentPassword:$('current-password').value,newPassword});$('password-form').reset();$('password-status').textContent='Password changed. Other sessions are signed out.';toast('Your password is updated.');} catch(error){$('password-status').textContent=error.message;}finally{button.disabled=false;} }); $('logout').addEventListener('click',async()=>{try{await api('logout',{});sessionStorage.clear();location.assign('/login');}catch(error){toast(error.message);}}); function download(name,content,type){const url=URL.createObjectURL(new Blob([content],{type}));const link=document.createElement('a');link.href=url;link.download=name;document.body.append(link);link.click();link.remove();setTimeout(()=>URL.revokeObjectURL(url),1000);} $('download-backup').addEventListener('click',async()=>{try{const data=await api('export');download('rial-backup-'+state.today+'.json',JSON.stringify(data,null,2),'application/json');toast('Backup downloaded. It contains your private records.');}catch(error){toast(error.message);}}); $('download-csv').addEventListener('click',()=>{ const rows=[['Name','Date (Muscat)','Kind','Status','OMR','USDT','Bank debit INR','Customer payout INR','Total cost INR','Sale proceeds INR','Profit INR']]; for(const entry of state.transactions){const r=calculateTransaction(entry.kind,entry.inputs);if(r.valid)rows.push([entry.name,entry.occurredOn,entry.kind,entry.status,r.withdrawal,r.quantity,r.bankChargedINR||0,r.customerPayoutINR||0,r.cost,r.net,r.profit]);} const cell=value=>{let str=String(value);if(/^[=+@\t\r]/.test(str))str="'"+str;return '"'+str.replaceAll('"','""')+'"';}; download('rial-ledger-'+state.today+'.csv','\ufeff'+rows.map(row=>row.map(cell).join(',')).join('\r\n'),'text/csv;charset=utf-8');toast('Deal history exported.'); }); function parseOldCSV(source){ const rows=source.replace(/^\ufeff/,'').trim().split(/\r?\n/).map(line=>{const match=line.match(/^"((?:[^"]|"")*)","((?:[^"]|"")*)"$/);return match?match.slice(1).map(cell=>cell.replaceAll('""','"')):line.split(',');}); const values=Object.fromEntries(rows.slice(1)),required=['Withdrawal (OMR)','Card debit (INR)','Buy rate (OMR/USDT)','USDT sold','Sell rate (INR/USDT)']; if(!required.every(key=>Object.hasOwn(values,key)))throw new Error('Use a JSON backup or the CSV summary downloaded from your earlier Rial calculator.'); const value=(key,fallback=0)=>Object.hasOwn(values,key)?Number(values[key]):fallback; return {...ORIGINAL_EXAMPLE,withdrawal:value('Withdrawal (OMR)'),debit:value('Card debit (INR)'),forexFee:value('Forex / markup (INR)'),atmFee:value('ATM fee (INR)'),feesIncluded:values['Card fees already included']==='Yes',buyRate:value('Buy rate (OMR/USDT)'),actualQuantity:value('USDT sold'),sellRate:value('Sell rate (INR/USDT)'),otherCost:value('Other costs (INR)'),buyFee:value('Buy fee from cash (OMR)'),sellPercent:value('Sale fee (%)'),sellFee:value('Fixed sale fee (INR)')}; } $('import-file').addEventListener('change',async event=>{ const file=event.target.files[0];if(!file)return; if(file.size>5*1024*1024){$('import-status').textContent='Use a backup smaller than 5 MB.';return;} $('import-status').textContent='Reading your file…'; try{const contents=await file.text();if(file.name.toLowerCase().endsWith('.csv')){const inputs=parseOldCSV(contents);controllers.debit.reset(inputs,{name:file.name.replace(/\.csv$/i,'')+' — imported plan'});location.hash='debit';toast('CSV loaded as a plan. Review it and Save deal to keep it.');$('import-status').textContent='CSV loaded as a plan for review.';}else{const backup=JSON.parse(contents);const result=await api('import_backup',{backup});await refresh();$('import-status').textContent=result.message||'Backup imported. Existing records were kept and duplicate IDs skipped.';toast('Backup imported.');}} catch(error){$('import-status').textContent=error.message;}finally{event.target.value='';} }); $('original-example').addEventListener('click',()=>{controllers.debit.reset(ORIGINAL_EXAMPLE,{name:'Original 370 OMR ATM trade'});toast('Original actual amounts loaded. Choose its actual date and status before saving.');}); document.addEventListener('click',event=>{const button=event.target.closest('[data-bonus]');if(button){$('direct-bonusPerOMR').value=button.dataset.bonus;controllers.direct.update();}}); window.addEventListener('beforeinstallprompt',event=>{event.preventDefault();installEvent=event;$('install-app').textContent='Install app';}); $('install-app').addEventListener('click',async()=>{if(installEvent){await installEvent.prompt();await installEvent.userChoice;installEvent=null;}else $('install-dialog').showModal();}); $('close-install').addEventListener('click',()=>$('install-dialog').close()); window.addEventListener('offline',()=>{$('connection').textContent='Offline';$('connection').classList.add('offline');toast('You are offline. Sign-in and saving need a connection.');}); window.addEventListener('online',()=>refresh().catch(error=>toast(error.message))); if('serviceWorker'in navigator)navigator.serviceWorker.register('/sw.js').catch(()=>{}); async function start(){ try{await refresh();createController('debit').reset();createController('direct').reset();navigate();} catch(error){$('global-error').textContent=error.message;$('global-error').hidden=false;$('connection').textContent='Could not sync';navigate();} } start();