Skip to main content

Starter Templates

This page provides ready-to-use starter templates for building NetSuite Suitelets with Python (Pyodide) integration. These templates include all the essential components you need to get started quickly.

Overview

The starter templates consist of two files:

FilePurpose
starter_suitelet.jsNetSuite Suitelet script (server-side)
starter_template.htmlHTML template with Pyodide integration (client-side)

Suitelet Script Template

This SuiteScript 2.1 Suitelet provides:

  • Paginated query execution (handles > 5000 rows)
  • Period selection dropdown
  • Form creation with parameters
  • Data injection for frontend consumption
/**
* @NApiVersion 2.1
* @NScriptType Suitelet
* @description Starter template for Python-based reports
*/
define(['N/ui/serverWidget', 'N/query', 'N/runtime', 'N/file', 'N/log'],
(serverWidget, query, runtime, file, log) => {

// ╔════════════════════════════════════════════════════════════════╗
// ║ DATA QUERY FUNCTIONS ║
// ╚════════════════════════════════════════════════════════════════╝

/**
* Paginated query execution (handles > 5000 rows)
*/
function execPaginatedQuery(baseQuery) {
let rows = [];
let paginatedRowBegin = 1;
let paginatedRowEnd = 5000;
let queryResults;

baseQuery = baseQuery.trim().replace(/;$/, '');

do {
const paginatedSQL = `
SELECT * FROM (
SELECT a.*, ROWNUM rnum FROM (${baseQuery}) a
WHERE ROWNUM <= ${paginatedRowEnd}
) WHERE rnum >= ${paginatedRowBegin}
`;

queryResults = query.runSuiteQL({ query: paginatedSQL }).asMappedResults();
rows = rows.concat(queryResults);
paginatedRowBegin += 5000;
paginatedRowEnd += 5000;

} while (queryResults.length === 5000);

return rows;
}

/**
* Get accounting periods for dropdown
*/
function getPeriods() {
return query.runSuiteQL({
query: `
SELECT id, periodname, startdate, enddate
FROM accountingperiod
WHERE isposting = 'T'
ORDER BY startdate DESC
`
}).asMappedResults();
}

/**
* Get main report data
* TODO: Customize this query for your report
*/
function getReportData(startDate, endDate) {
const sql = `
SELECT
t.tranid,
t.trandate,
BUILTIN.DF(t.entity) AS entity_name,
tl.amount
FROM transaction t
INNER JOIN transactionline tl ON t.id = tl.transaction
WHERE t.trandate >= TO_DATE('${startDate}', 'DD/MM/YYYY')
AND t.trandate <= TO_DATE('${endDate}', 'DD/MM/YYYY')
AND tl.mainline = 'T'
ORDER BY t.trandate
`;
return execPaginatedQuery(sql);
}


// ╔════════════════════════════════════════════════════════════════╗
// ║ HELPER FUNCTIONS ║
// ╚════════════════════════════════════════════════════════════════╝

function parseMultiSelectParam(param) {
if (!param) return [];
return String(param)
.split('\u0005')
.map(s => s.trim())
.filter(s => s.length > 0);
}


// ╔════════════════════════════════════════════════════════════════╗
// ║ MAIN ENTRY POINT ║
// ╚════════════════════════════════════════════════════════════════╝

const onRequest = (context) => {
try {
const params = context.request.parameters;
const periodStart = params.custpage_periodstart;
const periodEnd = params.custpage_periodend;

// Load dropdown data
const periodData = getPeriods();

// ──────────────────────────────────────────────────────
// Create Form
// ──────────────────────────────────────────────────────
const form = serverWidget.createForm({
title: 'My Report' // TODO: Change title
});

form.addFieldGroup({
id: 'grp_params',
label: 'Parameters'
});

// Period Start
const fldStart = form.addField({
id: 'custpage_periodstart',
type: serverWidget.FieldType.SELECT,
label: 'Period Start',
container: 'grp_params'
});
fldStart.isMandatory = true;
fldStart.addSelectOption({ value: '', text: '' });
periodData.forEach(p => {
fldStart.addSelectOption({ value: String(p.id), text: p.periodname });
});
fldStart.defaultValue = periodStart;

// Period End
const fldEnd = form.addField({
id: 'custpage_periodend',
type: serverWidget.FieldType.SELECT,
label: 'Period End',
container: 'grp_params'
});
fldEnd.isMandatory = true;
fldEnd.addSelectOption({ value: '', text: '' });
periodData.forEach(p => {
fldEnd.addSelectOption({ value: String(p.id), text: p.periodname });
});
fldEnd.defaultValue = periodEnd;

// Submit button
form.addSubmitButton({ label: 'Generate Report' });

// HTML wrapper for results
const wrapperField = form.addField({
id: 'custpage_wrapper',
type: serverWidget.FieldType.INLINEHTML,
label: ' '
});

// ──────────────────────────────────────────────────────
// Handle POST (Form Submitted)
// ──────────────────────────────────────────────────────
if (context.request.method === 'POST') {

// Get selected periods
const startPeriod = periodData.find(p => p.id == periodStart);
const endPeriod = periodData.find(p => p.id == periodEnd);

// Fetch report data
const reportData = getReportData(
startPeriod.startdate,
endPeriod.enddate
);

// Prepare data for frontend
const frontendData = {
tableData: reportData,
params: {
periodStart: startPeriod.periodname,
periodEnd: endPeriod.periodname
}
};

// Inject data as JavaScript variable
wrapperField.defaultValue = `
<script type="text/javascript">
var reportData = ${JSON.stringify(frontendData)};
</script>
`;

// Load and append HTML template
const htmlTemplate = file.load({
id: './my_report_template.html' // TODO: Change filename
}).getContents();

wrapperField.defaultValue += htmlTemplate;
}

context.response.writePage(form);

} catch (e) {
log.error('Suitelet Error', e);
}
};

return { onRequest };
}
);

Key Customization Points

LocationWhat to Change
title: 'My Report'Change report title (around line 138)
getReportData() functionCustomize SQL query for your data (around line 90-105)
file.load({ id: './my_report_template.html' })Update HTML template filename (around line 217)

HTML Template

This HTML template provides:

  • Tabulator.js data table with pagination
  • Pyodide integration for Excel generation
  • Loading overlay during processing
  • Download functionality
<!-- ═══════════════════════════════════════════════════════════════════════════ -->
<!-- EXTERNAL LIBRARIES -->
<!-- ═══════════════════════════════════════════════════════════════════════════ -->

<!-- Tabulator CSS & JS -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/tabulator-tables@5.5.0/dist/css/tabulator_bootstrap4.min.css" />
<script src="https://cdn.jsdelivr.net/npm/tabulator-tables@5.5.0/dist/js/tabulator.min.js"></script>

<!-- Pyodide (Python in Browser) -->
<script src="https://cdn.jsdelivr.net/pyodide/v0.28.1/full/pyodide.js"></script>


<!-- ═══════════════════════════════════════════════════════════════════════════ -->
<!-- STYLES -->
<!-- ═══════════════════════════════════════════════════════════════════════════ -->

<style>
.tabulator {
font-size: 12px;
}
.tabulator .tabulator-header .tabulator-col .tabulator-col-content .tabulator-col-title {
white-space: normal;
text-overflow: clip;
}
#report-container {
margin-top: 20px;
}
#report-info {
margin-bottom: 15px;
padding: 10px;
background-color: #f5f5f5;
border-radius: 4px;
}
</style>


<!-- ═══════════════════════════════════════════════════════════════════════════ -->
<!-- PAGE LAYOUT -->
<!-- ═══════════════════════════════════════════════════════════════════════════ -->

<div id="report-container">

<!-- Report Info -->
<div id="report-info">
<strong>Period:</strong> <span id="period-display"></span>
</div>

<!-- Data Table -->
<div id="data-table"></div>

</div>


<!-- ═══════════════════════════════════════════════════════════════════════════ -->
<!-- MAIN SCRIPT -->
<!-- ═══════════════════════════════════════════════════════════════════════════ -->

<script type="text/javascript">
// ──────────────────────────────────────────────────────────────────────────
// reportData is injected from Suitelet:
// var reportData = { tableData: [...], params: {...} }
// ──────────────────────────────────────────────────────────────────────────

console.log('Report data received:', reportData);

// Display period info
document.getElementById('period-display').textContent =
`${reportData.params.periodStart} - ${reportData.params.periodEnd}`;

// ──────────────────────────────────────────────────────────────────────────
// Initialize Tabulator Table
// ──────────────────────────────────────────────────────────────────────────
const table = new Tabulator("#data-table", {
data: reportData.tableData,
layout: "fitDataStretch",
height: "400px",
pagination: "local",
paginationSize: 50,
paginationCounter: "rows",

// TODO: Customize columns for your report
columns: [
{
title: "Transaction",
field: "tranid",
headerHozAlign: "center"
},
{
title: "Date",
field: "trandate",
headerHozAlign: "center"
},
{
title: "Entity",
field: "entity_name",
headerHozAlign: "center"
},
{
title: "Amount",
field: "amount",
headerHozAlign: "center",
hozAlign: "right",
formatter: "money",
formatterParams: {
decimal: ",",
thousand: ".",
precision: 0
}
},
],
});


// ──────────────────────────────────────────────────────────────────────────
// Helper Functions
// ──────────────────────────────────────────────────────────────────────────

function toggleLoading(show) {
if (show) {
const overlay = document.createElement('div');
overlay.id = 'loadingOverlay';
overlay.style.cssText = `
position: fixed; top: 0; left: 0; width: 100%; height: 100%;
background: rgba(0,0,0,0.5); z-index: 9999;
display: flex; align-items: center; justify-content: center;
`;
overlay.innerHTML = `
<div style="text-align:center;color:white;font-size:18px;">
<div style="width:40px;height:40px;margin:0 auto 10px;
border:6px solid #fff;border-top-color:transparent;
border-radius:50%;animation:spin 1s linear infinite;">
</div>
Generating Excel...
</div>
`;
document.body.appendChild(overlay);

const style = document.createElement('style');
style.id = 'spinnerStyle';
style.innerHTML = '@keyframes spin { to { transform: rotate(360deg); } }';
document.head.appendChild(style);
} else {
document.getElementById('loadingOverlay')?.remove();
document.getElementById('spinnerStyle')?.remove();
}
}

function base64ToArrayBuffer(base64) {
const binaryString = atob(base64);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return bytes;
}

function downloadFile(filename, bytes) {
const blob = new Blob([bytes], {
type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
});
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = filename;
link.click();
}


// ──────────────────────────────────────────────────────────────────────────
// Download Report with Pyodide
// ──────────────────────────────────────────────────────────────────────────

async function downloadReport() {
const btn = document.getElementById('btn_download_report');
btn.disabled = true;
toggleLoading(true);

try {
// Initialize Pyodide
const pyodide = await loadPyodide();
await pyodide.loadPackage("micropip");
const micropip = pyodide.pyimport("micropip");
await micropip.install('pandas');
await micropip.install('xlsxwriter');

// Pass data to Python
window.pythonData = JSON.stringify(reportData.tableData).replaceAll(null, '"null"');
window.pythonParams = JSON.stringify(reportData.params);

// Run Python code
const base64Excel = pyodide.runPython(`
import io
import json
import base64
import pandas as pd
import xlsxwriter
from xlsxwriter.utility import xl_range

# ═══════════════════════════════════════════════════════════════════════════
# GET DATA FROM JAVASCRIPT
# ═══════════════════════════════════════════════════════════════════════════
from js import pythonData, pythonParams

data = json.loads(pythonData)
params = json.loads(pythonParams)

# Create DataFrame
df = pd.DataFrame(data)
if 'rownumber' in df.columns:
df = df.drop(columns=['rownumber'])
df = df.fillna(0)

# ═══════════════════════════════════════════════════════════════════════════
# CREATE EXCEL
# ═══════════════════════════════════════════════════════════════════════════
bio = io.BytesIO()
workbook = xlsxwriter.Workbook(bio)
worksheet = workbook.add_worksheet('Report')

# FORMATS
title_fmt = workbook.add_format({'bold': True, 'font_size': 16, 'align': 'center'})
header_fmt = workbook.add_format({
'bold': True, 'bg_color': '#4472C4', 'font_color': 'white',
'align': 'center', 'valign': 'vcenter', 'border': 1
})
cell_fmt = workbook.add_format({'border': 1})
num_fmt = workbook.add_format({'border': 1, 'num_format': '#,##0', 'align': 'right'})
total_fmt = workbook.add_format({
'bold': True, 'bg_color': '#D9E1F2', 'border': 1, 'num_format': '#,##0'
})

# COLUMN WIDTHS
worksheet.set_column('A:A', 15) # Transaction
worksheet.set_column('B:B', 12) # Date
worksheet.set_column('C:C', 25) # Entity
worksheet.set_column('D:D', 15) # Amount

# TITLE
worksheet.merge_range(0, 0, 0, 3, 'MY REPORT', title_fmt)
worksheet.merge_range(1, 0, 1, 3, f"Period: {params.get('periodStart', '')} - {params.get('periodEnd', '')}", title_fmt)

# HEADERS
headers = ['Transaction', 'Date', 'Entity', 'Amount']
for col, h in enumerate(headers):
worksheet.write(3, col, h, header_fmt)

# DATA
for idx, row in df.iterrows():
r = idx + 4
worksheet.write(r, 0, row.get('tranid', ''), cell_fmt)
worksheet.write(r, 1, row.get('trandate', ''), cell_fmt)
worksheet.write(r, 2, row.get('entity_name', ''), cell_fmt)
worksheet.write(r, 3, row.get('amount', 0), num_fmt)

# TOTAL
total_row = len(df) + 4
worksheet.write(total_row, 0, 'TOTAL', total_fmt)
worksheet.write(total_row, 1, '', total_fmt)
worksheet.write(total_row, 2, '', total_fmt)
worksheet.write(total_row, 3, df['amount'].sum() if 'amount' in df.columns else 0, total_fmt)

# CLOSE & RETURN
workbook.close()
bio.seek(0)
base64.b64encode(bio.getvalue()).decode()
`);

// Download file
const bytes = base64ToArrayBuffer(base64Excel);
downloadFile('Report.xlsx', bytes);

} catch (error) {
console.error('Error:', error);
alert('Error generating report. Check console for details.');
} finally {
toggleLoading(false);
btn.disabled = false;
}
}

// Attach to download button
document.getElementById('btn_download_report')?.addEventListener('click', downloadReport);
</script>

Key Customization Points

SectionWhat to Change
Tabulator columnsUpdate field names to match your query
Excel column widthsAdjust for your data
Excel headersMatch your report columns
Excel data writingMap to your DataFrame columns

Quick Start Guide

Step 1: Create Files in NetSuite

  1. Navigate to Documents > Files > File Cabinet
  2. Create a new folder for your report (e.g., SuiteScripts/MyReport)
  3. Upload both files:
    • my_report_suitelet.js
    • my_report_template.html

Step 2: Create Script Record

  1. Go to Customization > Scripting > Scripts > New
  2. Select your Suitelet file
  3. Create a deployment with desired URL

Step 3: Customize for Your Report

  1. Update the SQL query in getReportData() function
  2. Update column definitions in both Tabulator and Excel sections
  3. Update field mappings in the Python Excel generation code
  4. Update the HTML template filename in the Suitelet

Adding a Download Button

To enable the Excel download functionality, add a download button to your HTML template:

<!-- Add this in the PAGE LAYOUT section -->
<button id="btn_download_report" class="btn btn-primary" style="margin-bottom: 15px;">
Download Excel
</button>

File Naming Convention

For consistency, follow this naming pattern:

File TypePatternExample
Suitelet{report_name}_suitelet.jssales_report_suitelet.js
Template{report_name}_template.htmlsales_report_template.html

Next Steps