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:
| File | Purpose |
|---|---|
starter_suitelet.js | NetSuite Suitelet script (server-side) |
starter_template.html | HTML 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
| Location | What to Change |
|---|---|
title: 'My Report' | Change report title (around line 138) |
getReportData() function | Customize 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
| Section | What to Change |
|---|---|
| Tabulator columns | Update field names to match your query |
| Excel column widths | Adjust for your data |
| Excel headers | Match your report columns |
| Excel data writing | Map to your DataFrame columns |
Quick Start Guide
Step 1: Create Files in NetSuite
- Navigate to Documents > Files > File Cabinet
- Create a new folder for your report (e.g.,
SuiteScripts/MyReport) - Upload both files:
my_report_suitelet.jsmy_report_template.html
Step 2: Create Script Record
- Go to Customization > Scripting > Scripts > New
- Select your Suitelet file
- Create a deployment with desired URL
Step 3: Customize for Your Report
- Update the SQL query in
getReportData()function - Update column definitions in both Tabulator and Excel sections
- Update field mappings in the Python Excel generation code
- 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 Type | Pattern | Example |
|---|---|---|
| Suitelet | {report_name}_suitelet.js | sales_report_suitelet.js |
| Template | {report_name}_template.html | sales_report_template.html |
Next Steps
- Review the Suitelet Structure Guide for detailed architecture explanation
- Check Pandas Guide for data manipulation patterns
- See XlsxWriter Guide for Excel formatting options