/* global CRQQB_SETTINGS, CRQQB_VARS */ const { useState, useMemo, useEffect, useRef } = React; const fmt2 = (n)=> new Intl.NumberFormat('en-IN',{minimumFractionDigits:2, maximumFractionDigits:2}).format(+n||0); /** * Request the next quotation number from the server. This call * posts to admin‑ajax.php with the action * `crq_next_quotation_number` and passes the front‑end nonce. On * success the returned object contains a `number` property with * the formatted CRQ/yyMMdd/serial string. In case of failure * the promise rejects with the error message. * * @returns {Promise} */ async function fetchNextQuoteNumber() { if (!CRQQB_VARS || !CRQQB_VARS.ajax_url) { return Promise.reject('Missing AJAX configuration'); } const body = new URLSearchParams(); body.append('action', 'crq_next_quotation_number'); if (CRQQB_VARS.nonce) body.append('nonce', CRQQB_VARS.nonce); const resp = await fetch(CRQQB_VARS.ajax_url, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' }, body: body.toString() }); let json; try { json = await resp.json(); } catch (e) { throw new Error('Invalid response'); } if (!json || !json.success || !json.data || !json.data.number) { throw new Error(json && json.data && json.data.message ? json.data.message : 'Failed to fetch number'); } return json.data.number; } function parseStateFromGSTIN(gstin){ if (!gstin || gstin.length < 2) return {code:"", state:""}; const map = {"27":"Maharashtra","24":"Gujarat","09":"Uttar Pradesh","07":"Delhi"}; // minimal map const code = gstin.slice(0,2); return {code, state: map[code] || ""}; } const INDIA_STATES = [ "Andhra Pradesh","Arunachal Pradesh","Assam","Bihar","Chhattisgarh","Goa","Gujarat","Haryana","Himachal Pradesh", "Jharkhand","Karnataka","Kerala","Madhya Pradesh","Maharashtra","Manipur","Meghalaya","Mizoram","Nagaland", "Odisha","Punjab","Rajasthan","Sikkim","Tamil Nadu","Telangana","Tripura","Uttar Pradesh","Uttarakhand","West Bengal", "Andaman and Nicobar Islands","Chandigarh","Dadra and Nagar Haveli and Daman and Diu","Delhi","Jammu and Kashmir", "Ladakh","Lakshadweep","Puducherry" ]; function inrWords(n){ const a=["","One","Two","Three","Four","Five","Six","Seven","Eight","Nine","Ten","Eleven","Twelve","Thirteen","Fourteen","Fifteen","Sixteen","Seventeen","Eighteen","Nineteen"]; const b=["","","Twenty","Thirty","Forty","Fifty","Sixty","Seventy","Eighty","Ninety"]; n = Math.floor(+n||0); const two = (x)=> x<20?a[x]:b[Math.floor(x/10)]+(x%10?(" "+a[x%10]):""); const three = (x)=> x<100?two(x):(a[Math.floor(x/100)]+" Hundred"+(x%100?(" "+two(x%100)):"")); const crore=Math.floor(n/1e7), lakh=Math.floor((n%1e7)/1e5), th=Math.floor((n%1e5)/1e3), h=n%1e3; return [crore?two(crore)+" Crore":"", lakh?two(lakh)+" Lakh":"", th?two(th)+" Thousand":"", h?three(h):""].filter(Boolean).join(" ")+" Rupees Only"; } function defaultsFromSettings() { const s = CRQQB_SETTINGS || {}; const today = new Date(); const y = String(today.getFullYear()).slice(-2); const m = String(today.getMonth()+1).padStart(2,'0'); const defaults = { header: { doc_type: 'Quotation', number_label: '', cr_no: `CRQ/${y}${m}${String(today.getDate()).padStart(2,'0')}/0021`, cr_date: today.toISOString().slice(0,10), chip: s.seal_text, place_of_supply: s.place_of_supply, display_currency: 'INR', fx_rate: '' }, job: { job_type: s.default_job_type || 'Re-rubberisation', // The selected machine can be a key from the machines list or the // sentinel value '__custom__' indicating that the user is // specifying their own machine. The custom machine name is // stored separately in custom_machine. machine: '', model: '', rubber_grade: s.default_rubber_grade || '', employee_id: '', custom_rates: { inking: '', dampening: '' }, global_rates: { ink: '', damp: '', other: '' }, // When machine === '__custom__' these fields hold the user // provided values. They remain empty otherwise. custom_machine: '', custom_model: '' }, customer: { name: '', gstin: '', phone:'', email:'', contact_person:'', billing_address:'', pin:'', country:'India', state:'', state_code:'', shipping_same:true, shipping_address:'' }, items: [], default_rate: 0, quote_discount: 0, freight: 0, packaging: 0, freight_taxable: true, packaging_taxable: true, gst_enabled: true, warranty_text: '', settings: { company: { name: s.company_name, website: s.company_website, office: s.office_address, plant: s.plant_address, branch: s.branch_address, phone: s.company_phone, email: s.company_email, gstin: s.gstin, logo_url: s.logo_url }, banking: { bank_name: s.bank_name, account_name: s.account_name, account_no: s.account_no, ifsc: s.ifsc, branch: s.branch, upi_id: s.upi_id }, tax: { mode: s.tax_mode || 'CGST+SGST', percent: +s.tax_percent || 18 }, labels: { warranty: s.warranty_label || 'Warranty' }, // Split terms on real newlines so each line becomes its own list item terms: (s.terms || '').split(/\r?\n/).filter(Boolean), footnote: s.footnote || '', print_theme: s.print_theme || 'classic', // Pass through signature and designation for the print template signature_url: s.signature_url, designation: s.designation } }; // Override the default document type when the container defines // data-default-doc-type. The alias shortcode sets this attribute // so Automatic Invoices start with the correct doc type. try { const rootEl = document.getElementById('crqqb-root'); const attr = rootEl && rootEl.parentElement ? rootEl.parentElement.getAttribute('data-default-doc-type') : null; if (attr) { defaults.header.doc_type = attr; } } catch (e) {} return defaults; } function computeTotals(payload) { const isRe = payload.job?.job_type === 'Re-rubberisation'; const defaultRate = +payload.default_rate || 0; const globalRates = payload.job?.global_rates || {}; const subTotal = Math.round(payload.items.reduce((acc, it) => { const base = (Number(it.length_mm)||0) * (Number(it.diameter_mm)||0) / 100; const unit = (it.unit || 'Inking'); let effRate; if (it.rate === '' || it.rate === undefined || it.rate === null) { const u = unit.toLowerCase(); const gKey = u.startsWith('damp') ? 'damp' : (u.startsWith('ink') ? 'ink' : 'other'); const gVal = globalRates[gKey]; if (!(gVal === '' || gVal === undefined || gVal === null)) { effRate = Number(gVal) || 0; } else { effRate = defaultRate; } } else { effRate = Number(it.rate) || 0; } const line = base * effRate * (Number(it.qty)||0); const afterDisc = it.disc_type === '%' ? (line - (line * (Number(it.discount)||0) / 100)) : (line - (Number(it.discount)||0)); const spindle = isRe ? 0 : (Number(it.spindle)||0); const withSpindle = afterDisc + spindle; return acc + Math.max(0, withSpindle); }, 0) * 100) / 100; const freight = Number(payload.freight)||0; const packaging = Number(payload.packaging)||0; const quoteDisc = Number(payload.quote_discount)||0; const taxableCharges = (payload.freight_taxable ? freight : 0) + (payload.packaging_taxable ? packaging : 0); const nontaxCharges = (payload.freight_taxable ? 0 : freight) + (payload.packaging_taxable ? 0 : packaging); const afterQuoteDisc = Math.max(0, subTotal - quoteDisc); const taxable = Math.round((afterQuoteDisc + taxableCharges) * 100) / 100; const gstEnabled = !!payload.gst_enabled; const taxPct = Number(payload.settings?.tax?.percent)||0; const isIGST = (payload.settings?.tax?.mode === 'IGST'); let tax=0, cgst=0, sgst=0, igst=0; if (gstEnabled && taxPct>0) { tax = Math.round(taxable * taxPct) / 100; if (isIGST) { igst = tax; } else { cgst = Math.round((tax/2) * 100)/100; sgst = Math.round((tax/2) * 100)/100; } } const preRound = taxable + (gstEnabled ? tax : 0) + nontaxCharges; const grand = Math.round(preRound); const roundOff = Math.round((grand - preRound) * 100) / 100; return { subTotal, taxable, tax: (gstEnabled?tax:0), cgst, sgst, igst, nontaxCharges, roundOff, grand, words: inrWords(grand) }; } function App() { const [payload, setPayload] = useState(defaultsFromSettings()); // Pull in data structures from localized CRQQB_VARS. These objects // contain nested machines and models, rubber grade rates, and // spindle rates per model defined in the settings. They may be // undefined if not configured. const machines = (typeof CRQQB_VARS !== 'undefined' && CRQQB_VARS.machines) || {}; const rubberGrades = (typeof CRQQB_VARS !== 'undefined' && CRQQB_VARS.rubberGrades) || {}; const spindleRates = (typeof CRQQB_VARS !== 'undefined' && CRQQB_VARS.spindleRates) || {}; const totals = useMemo(()=>computeTotals(payload), [payload]); const [customerSuggestions, setCustomerSuggestions] = useState([]); const [isSavingDoc, setIsSavingDoc] = useState(false); const [lastSavedUrl, setLastSavedUrl] = useState(''); // Ref to ensure that the initial quotation number is fetched only // once when the component first renders or when the document type // switches back to Quotation. Without this guard the number would // increment repeatedly on each state change. const hasFetchedInitialNo = useRef(false); useEffect(() => { // Automatically fetch the next quotation number on initial load // when the document type is Quotation and no custom number has // been set yet. This ensures each new quotation across devices // receives a globally unique number without relying on // localStorage. if (payload.header?.doc_type === 'Quotation' && !hasFetchedInitialNo.current) { fetchNextQuoteNumber() .then(num => { setPayload(p => ({ ...p, header: { ...p.header, cr_no: num } })); hasFetchedInitialNo.current = true; }) .catch(err => { console.error('Failed to fetch initial quotation number', err); }); } }, [payload.header?.doc_type]); // Track the previously selected machine and model so that we can // detect when the selection changes. When the machine or model // changes the items array should be rebuilt using the dataset; when // only the grade, custom rates or job type change we adjust rates // and spindle charges in place without overwriting user‑edited HSN // values. const selectionRef = useRef({ machine: '', model: '' }); const addRow = ()=> setPayload(p=>{ const grade = p.job?.rubber_grade || ''; let rateVal = ''; // Determine rate for a new row assuming unit is Inking if (grade && grade !== 'Custom Rubber' && rubberGrades[grade]) { rateVal = rubberGrades[grade]['Inking'] !== undefined ? rubberGrades[grade]['Inking'] : ''; } else if (grade === 'Custom Rubber') { const cr = p.job?.custom_rates || {}; const inking = cr.inking; rateVal = (inking === '' || inking === undefined) ? '' : Number(inking); } // Determine spindle based on job type and selected model const model = p.job?.model || ''; const spVal = p.job?.job_type === 'Re-rubberisation' ? 0 : (spindleRates[model] || 0); // New rows default the HSN code to 84439100 per specification const newRow = { unit:'Inking', type:'', hsn:'84439100', qty:1, diameter_mm:0, length_mm:0, rubber_grade: grade, rate: rateVal, discount:0, disc_type:'%', spindle: spVal }; return { ...p, items: [...p.items, newRow] }; }); const delRow = (idx)=> setPayload(p=>({...p, items: p.items.filter((_,i)=>i!==idx)})); const saveDocument = ()=>{ if (!CRQQB_VARS || !CRQQB_VARS.ajax_url) return; const data = {...payload, totals}; setIsSavingDoc(true); setLastSavedUrl(''); const body = new URLSearchParams(); body.append('action', 'crq_save_document'); body.append('nonce', CRQQB_VARS.nonce || ''); body.append('data', JSON.stringify(data)); fetch(CRQQB_VARS.ajax_url, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' }, body: body.toString() }).then(r=>r.json()).then(resp=>{ if (resp && resp.success && resp.data && resp.data.view_url) { setLastSavedUrl(resp.data.view_url); } else { console.error('Save failed', resp); } }).catch(err=>console.error('Save error', err)).finally(()=>setIsSavingDoc(false)); }; const updateRow = (idx, key, val)=> setPayload(p=>{ const items = p.items.slice(); const row = { ...items[idx] }; row[key] = val; // If unit changes, update rate based on selected grade/custom rates if (key === 'unit') { const grade = p.job?.rubber_grade || ''; let newRate = row.rate; if (grade && grade !== 'Custom Rubber' && rubberGrades[grade]) { const k = val.toString().toLowerCase().startsWith('damp') ? 'Dampening' : 'Inking'; const gRates = rubberGrades[grade] || {}; newRate = (gRates[k] !== undefined ? gRates[k] : ''); } else if (grade === 'Custom Rubber') { const cr = p.job?.custom_rates || {}; const k = val.toString().toLowerCase().startsWith('damp') ? 'dampening' : 'inking'; const v = cr[k]; newRate = (v === '' || v === undefined) ? '' : Number(v); } row.rate = newRate; } items[idx] = row; return { ...p, items }; }); const postToPrint = ()=> { const w = window.open(CRQQB_VARS?.print_url, '_blank'); const data = {...payload, totals}; let tries = 0; const send = ()=> { try { w.postMessage({type:'CRQ_PAYLOAD', payload: data}, '*'); } catch(e){} tries++; if (tries<14 && w && !w.closed) setTimeout(send, 250); }; setTimeout(send, 350); }; const gst = parseStateFromGSTIN(payload.customer.gstin||""); // Auto‑populate items when the machine/model selection changes and // update existing rows when only grade/custom rates/job type change. useEffect(() => { const m = payload.job?.machine || ''; const model = payload.job?.model || ''; const grade = payload.job?.rubber_grade || ''; const custom = payload.job?.custom_rates || {}; const globalRates = payload.job?.global_rates || {}; const jobType = payload.job?.job_type || ''; const prev = selectionRef.current; const changedMachine = (prev.machine !== m) || (prev.model !== model); selectionRef.current = { machine: m, model: model }; // Determine if a dataset exists for the current selection. When the // user selects a custom machine (denoted by '__custom__') or a // machine/model that does not appear in the dataset, skip the // automatic row rebuild. In these cases the items array should // remain untouched; the user manually adds and edits rows. const hasDataset = m && m !== '__custom__' && machines[m] && model && machines[m][model]; if (hasDataset && changedMachine) { // Build a fresh items array from rollers, defaulting HSN and // computing rates/spindles based on current grade/custom rates. const rollers = machines[m][model] || []; const newItems = rollers.map(r => { const unit = r.unit || 'Inking'; let rateVal = ''; const u = unit.toLowerCase(); const gKey = u.startsWith('damp') ? 'damp' : (u.startsWith('ink') ? 'ink' : 'other'); const gVal = globalRates[gKey]; if (!(gVal === '' || gVal === undefined || gVal === null)) { rateVal = Number(gVal) || 0; } else if (grade && grade !== 'Custom Rubber' && rubberGrades[grade]) { const key = unit.toLowerCase().startsWith('damp') ? 'Dampening' : 'Inking'; const gRates = rubberGrades[grade] || {}; rateVal = (gRates[key] !== undefined ? gRates[key] : ''); } else if (grade === 'Custom Rubber') { const key = unit.toLowerCase().startsWith('damp') ? 'dampening' : 'inking'; const cVal = custom[key]; rateVal = (cVal === '' || cVal === undefined) ? '' : Number(cVal); } const spindleVal = jobType === 'Re-rubberisation' ? 0 : (spindleRates[model] || 0); return { unit: unit, type: r.type || '', hsn: '84439100', qty: r.qty || 0, diameter_mm: r.diameter_mm || 0, length_mm: r.length_mm || 0, rubber_grade: grade || '', rate: rateVal, discount: 0, disc_type: '%', spindle: spindleVal }; }); setPayload(p => ({ ...p, items: newItems })); } else { // Update rates and spindle charges on existing rows. Do not // overwrite HSN or other user‑edited fields. This runs for // custom machines as well as dataset machines when only grade // or other job fields change. setPayload(p => { const items = p.items.map(row => { const unit = row.unit || 'Inking'; let newRate = row.rate; const u = unit.toLowerCase(); const gKey = u.startsWith('damp') ? 'damp' : (u.startsWith('ink') ? 'ink' : 'other'); const gVal = globalRates[gKey]; if (!(gVal === '' || gVal === undefined || gVal === null)) { newRate = Number(gVal) || 0; } else if (grade && grade !== 'Custom Rubber' && rubberGrades[grade]) { const key = unit.toLowerCase().startsWith('damp') ? 'Dampening' : 'Inking'; const gRates = rubberGrades[grade] || {}; if (gRates[key] !== undefined) newRate = gRates[key]; } else if (grade === 'Custom Rubber') { const key = unit.toLowerCase().startsWith('damp') ? 'dampening' : 'inking'; const val = custom[key]; if (!(val === '' || val === undefined)) newRate = Number(val); } // Determine spindle based on dataset model when available; // custom machines do not have spindle charges unless user // enters values manually in the row. When dataset exists // we use the spindleRates map; otherwise leave as is. let newSpindle = row.spindle; if (hasDataset) { newSpindle = jobType === 'Re-rubberisation' ? 0 : (spindleRates[model] || 0); } else { // For custom machines keep spindle as user entered or zero if re-rubberisation if (jobType === 'Re-rubberisation') newSpindle = 0; } return { ...row, rubber_grade: grade || '', rate: newRate, spindle: newSpindle }; }); return { ...p, items }; }); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [payload.job.machine, payload.job.model, payload.job.rubber_grade, payload.job.custom_rates, payload.job.job_type]); return (
{/* Header */}
Header
setPayload(p=>({...p, header:{...p.header, cr_no:e.target.value}}))} />
setPayload(p=>({...p, header:{...p.header, number_label:e.target.value}}))} />
setPayload(p=>({...p, header:{...p.header, cr_date:e.target.value}}))} />
setPayload(p=>({...p, header:{...p.header, place_of_supply:e.target.value}}))} />
setPayload(p=>({...p, header:{...p.header, fx_rate:e.target.value}}))} />
{/* Customer */}
Customer
{ const v = e.target.value; setPayload(p=>({...p, customer:{...p.customer, name:v}})); if (v && v.length >= 2 && CRQQB_VARS && CRQQB_VARS.ajax_url) { const params = new URLSearchParams(); params.append('action', 'crq_search_customers'); params.append('nonce', CRQQB_VARS.nonce || ''); params.append('term', v); fetch(CRQQB_VARS.ajax_url + '?' + params.toString(), { method:'GET' }) .then(r=>r.json()) .then(resp=>{ if (resp && resp.success && resp.data && Array.isArray(resp.data.items)) { setCustomerSuggestions(resp.data.items); } else { setCustomerSuggestions([]); } }) .catch(()=>setCustomerSuggestions([])); } else { setCustomerSuggestions([]); } }} onBlur={()=>setTimeout(()=>setCustomerSuggestions([]), 200)} /> {customerSuggestions && customerSuggestions.length>0 && (
{customerSuggestions.map(c=>( ))}
)}
setPayload(p=>({...p, customer:{...p.customer, contact_person:e.target.value}}))} />
setPayload(p=>({...p, customer:{...p.customer, phone:e.target.value}}))} />