Google Sheets Upload
Upload the Master Dispatch & GRN Tracker - in Google Sheets use File โ Download โ Microsoft Excel (.xlsx) or CSV, then drop it here. Each row is matched to its PO by PO Number and applied in one go:
- Dispatch - dispatch date, invoice number/value, docket/AWB, delivery partner, status, comment
- Delivery - appointment date and PO expiry date
- GRN - when GRN Status is Completed: outcome from shortage/damage/excess, discrepancy amount, credit note number. GRN number = invoice number; received value = invoice value less net discrepancy
Blank cells never overwrite existing data. Re-uploading the same sheet is safe.
Drag & drop the exported Excel/CSV file here, or click to browse
Automatic sync from Google Sheets
Free, and no Google Cloud account or payment details needed. A small script inside your sheet sends its rows here every 15 minutes - unchanged sheets are skipped, and a sheet that isn't the tracker is refused.
Last automatic sync
Not connected yet
Set it up (about 5 minutes)
- Ask whoever runs the server to set an environment variable
SHEET_SYNC_KEYto a long random secret (Railway โ backend service โ Variables). Sync stays switched off until it exists. - In your Google Sheet: Extensions โ Apps Script. Delete any code there and paste the script below.
- In the script, replace
PASTE_YOUR_SYNC_KEY_HEREwith that same secret, and setTAB_NAMEto the tab that holds the tracker. Click Run once and approve the permissions prompt. - Click the clock icon (Triggers) โ Add Trigger โ function
syncTrackerโ time-driven โ every 15 minutes.
Tip: format the PO Number and Invoice Number columns as Plain text in the sheet - numbers over 15 digits lose their last digits in any spreadsheet.
// Nootie Control Tower - sends this sheet's tracker rows to the app.
const ENDPOINT = 'https://api.mynootie.com/webhooks/google-sheets/tracker';
const SYNC_KEY = 'PASTE_YOUR_SYNC_KEY_HERE';
const TAB_NAME = 'DISPATCH + GRN'; // the tab that holds the tracker
function syncTracker() {
const ss = SpreadsheetApp.getActiveSpreadsheet();
const sheet = ss.getSheetByName(TAB_NAME);
if (!sheet) throw new Error('Tab not found: ' + TAB_NAME);
const tz = ss.getSpreadsheetTimeZone();
// getValues() returns the real cell values (not the displayed text), so long
// numbers arrive exactly. Dates are sent as yyyy-MM-dd so no timezone can
// shift them by a day.
const rows = sheet.getDataRange().getValues().map(function (row) {
return row.map(function (v) {
return v instanceof Date ? Utilities.formatDate(v, tz, 'yyyy-MM-dd') : v;
});
});
const res = UrlFetchApp.fetch(ENDPOINT, {
method: 'post',
contentType: 'application/json',
headers: { 'x-sync-key': SYNC_KEY },
payload: JSON.stringify({ rows: rows }),
muteHttpExceptions: true,
});
Logger.log(res.getResponseCode() + ' ' + res.getContentText());
}
All PO Data (one sheet, every PO, two-way)
A separate tab that mirrors every PO - not just the ones on the Dispatch & GRN Tracker - with all the same fields you can edit there, plus read-only columns (PO Status, PO Value, Fulfilment %, Total Quantity, ...) the app itself computes. Its script applies any edits first, then overwrites the tab with the fresh result, so it always ends up showing current data.
- Editable from the sheet - the same columns as the Tracker above, same names: Dispatch Date, Invoice Number, Channel, Location / Hub, Invoice Value, Docket / AWB Number, Delivery Partner, Status, Akshay's Comment, Expriy Date, Appointment Date, GRN Status + its shortage/damage/excess/credit-note fields, and Delivery Date.
- Read-only, refreshed every sync - PO Status, Fulfilment %, Fill Rate %, PO Value, Total Quantity, Location Type, Recommended/Planned Dispatch Date, Dispatch Plan Status, GRN Outcome, GRN Recorded Date.
Doesn't include S.No or GRN Alert from the real Tracker sheet - those aren't backed by any app field (they look like manual/formula columns), so there's nothing to sync for them here.
Set it up (about 5 minutes)
- Uses the same
SHEET_SYNC_KEYas the Tracker sync above - set that up first if you haven't. - In the same Google Sheet (or a new one, your choice): add a tab named exactly
ALL PO DATA. Leave it empty - the first sync fills it in. - Extensions โ Apps Script. Paste the script below alongside the Tracker script (don't delete that one) - they're independent functions in the same project. Replace
PASTE_YOUR_SYNC_KEY_HEREwith your secret. Click Run once, withsyncAllPoDataselected, and approve the permissions prompt. - Triggers โ Add Trigger โ function
syncAllPoDataโ time-driven โ every 15-30 minutes.
Each sync clears and rewrites the whole tab, so anything typed into it right before a sync runs is sent first and reflected in the same refresh. Don't add other columns or formulas to this tab - they'll be wiped on the next sync.
// Nootie Control Tower - two-way sync for the "ALL PO DATA" tab: sends this
// tab's edits in, then overwrites it with the fresh, full result.
const ENDPOINT_PULL = 'https://api.mynootie.com/webhooks/google-sheets/tracker'; // applies edits (same endpoint as the Tracker sync)
const ENDPOINT_PUSH = 'https://api.mynootie.com/webhooks/google-sheets/po-data'; // returns the current full PO dataset
const SYNC_KEY = 'PASTE_YOUR_SYNC_KEY_HERE';
const TAB_NAME = 'ALL PO DATA';
// Row 1 of the tab is a colored category band (PO INFO / DISPATCH &
// LOGISTICS / ...), row 2 is the real column headers, data starts at row 3.
// A category band color per group name - add one here if a new category
// name ever appears in the pushed data. Two of these are the app's own
// brand colors (Tailwind "nootie" orange-dark / gold-dark).
const CATEGORY_COLORS = {
'PO INFO': '#334155',
'DISPATCH & LOGISTICS': '#CC5620',
APPOINTMENT: '#DB8F13',
'GRN RECONCILIATION': '#047857',
};
// Light tint of each category's color, used on the header row so every
// column visibly belongs to its group without needing to look back up at
// the band row.
const CATEGORY_LIGHT_COLORS = {
'PO INFO': '#E2E8F0',
'DISPATCH & LOGISTICS': '#FDE9DC',
APPOINTMENT: '#FDF0D5',
'GRN RECONCILIATION': '#D1FAE5',
};
// Columns to force to plain text so Google Sheets never "smart"-converts a
// date-shaped or long numeric string inconsistently across rows (seen live:
// some PO dates rendered as "2026-09-03", others as "14/9/2026" because
// Sheets silently reinterpreted only the ones matching its locale pattern).
// Must be set BEFORE the values are written - formatting a cell that's
// already been auto-converted only changes its display, not its underlying
// type.
const PLAIN_TEXT_COLUMNS = [
'PO Number', 'Order Date', 'Expriy Date', 'Dispatch Date', 'Recommended Dispatch Date',
'Planned Dispatch Date', 'Invoice Number', 'Docket / AWB Number', 'Delivery Date',
'Appointment Date', "Credit Note #", 'GRN Recorded Date',
];
const CURRENCY_COLUMNS = ['PO Value', 'Invoice Value (โน)', 'Shortage Value (โน)', 'Damage Value (โน)', 'Excess Value (โน)', 'Net Discrepancy (โน)', 'Credit Note Value (โน)'];
// Stored as e.g. 76.21 meaning 76.21% (not a 0-1 fraction) - a plain "append
// a %" format, not Sheets' native percentage type, which would divide by 100.
const PERCENT_COLUMNS = ['Fulfilment %', 'Fill Rate %'];
const QTY_COLUMNS = ['Total Quantity', 'Shortage Qty', 'Damage Qty', 'Excess Qty'];
// Status-like columns read better centered than left-aligned.
const CENTER_COLUMNS = ['PO Status', 'GRN Outcome', 'Dispatch Plan Status', 'Location Type', 'GRN Status', 'Status'];
// Dropdowns for the columns ops actually choose a value from when editing
// the sheet (everything else here is free text or a date).
const DROPDOWN_COLUMNS = {
'GRN Status': ['', 'Pending', 'Completed'],
Status: ['', 'Dispatched', 'Invoiced', 'In Transit', 'Delivered', 'RTO', 'Returned', 'Cancelled'],
};
// Colored status "badges" for PO Status and GRN Outcome - a simple 4-tier
// scheme (neutral / in-progress-on-brand / done / negative).
const PO_STATUS_BADGES = {
RECEIVED: { bg: '#F1F5F9', fg: '#475569' },
APPOINTMENT_REQUESTED: { bg: '#F1F5F9', fg: '#475569' },
APPOINTMENT_CONFIRMED: { bg: '#F1F5F9', fg: '#475569' },
READY_FOR_DISPATCH: { bg: '#F1F5F9', fg: '#475569' },
DISPATCHED: { bg: '#FDE9DC', fg: '#CC5620' },
IN_TRANSIT: { bg: '#FDE9DC', fg: '#CC5620' },
DELIVERED: { bg: '#D1FAE5', fg: '#065F46' },
GRN_PENDING: { bg: '#D1FAE5', fg: '#065F46' },
RECONCILED: { bg: '#D1FAE5', fg: '#065F46' },
CLOSED: { bg: '#D1FAE5', fg: '#065F46' },
RETURNED: { bg: '#FEE2E2', fg: '#991B1B' },
CANCELLED: { bg: '#FEE2E2', fg: '#991B1B' },
};
const GRN_OUTCOME_BADGES = {
MATCHED: { bg: '#D1FAE5', fg: '#065F46' },
SHORTAGE: { bg: '#FEE2E2', fg: '#991B1B' },
DAMAGE: { bg: '#FEE2E2', fg: '#991B1B' },
MISMATCHED: { bg: '#FEE2E2', fg: '#991B1B' },
OTHER: { bg: '#FEF3C7', fg: '#92400E' },
NO_GRN: { bg: '#F1F5F9', fg: '#475569' },
};
function addBadgeRules(rules, sheet, headers, columnName, badgeMap, lastRow) {
var col = headers.indexOf(columnName) + 1;
if (col === 0 || lastRow < 3) return;
const range = sheet.getRange(3, col, lastRow - 2, 1);
Object.keys(badgeMap).forEach(function (value) {
const badge = badgeMap[value];
rules.push(
SpreadsheetApp.newConditionalFormatRule()
.whenTextEqualTo(value)
.setBackground(badge.bg)
.setFontColor(badge.fg)
.setBold(true)
.setRanges([range])
.build(),
);
});
}
function syncAllPoData() {
const ss = SpreadsheetApp.getActiveSpreadsheet();
const sheet = ss.getSheetByName(TAB_NAME);
if (!sheet) throw new Error('Tab not found: ' + TAB_NAME);
const tz = ss.getSpreadsheetTimeZone();
// 1) Send whatever's in the tab right now (including edits since the last
// sync) so they get applied before the refresh below overwrites anything.
// The backend finds the real header row on its own even with the band row
// above it, so the whole sheet (as-is) is sent unchanged.
if (sheet.getLastRow() > 2) {
const rows = sheet.getDataRange().getValues().map(function (row) {
return row.map(function (v) {
return v instanceof Date ? Utilities.formatDate(v, tz, 'yyyy-MM-dd') : v;
});
});
const applyRes = UrlFetchApp.fetch(ENDPOINT_PULL, {
method: 'post',
contentType: 'application/json',
headers: { 'x-sync-key': SYNC_KEY },
payload: JSON.stringify({ rows: rows }),
muteHttpExceptions: true,
});
Logger.log('Apply: ' + applyRes.getResponseCode() + ' ' + applyRes.getContentText());
// Give a small sheet's background apply a moment to finish before pulling
// the refreshed data below. A very large sheet may still be applying when
// this runs - any edits still mid-apply land on the NEXT sync instead.
Utilities.sleep(5000);
}
// 2) Pull the full, current dataset and overwrite the tab with it.
const refreshRes = UrlFetchApp.fetch(ENDPOINT_PUSH, {
method: 'get',
headers: { 'x-sync-key': SYNC_KEY },
muteHttpExceptions: true,
});
if (refreshRes.getResponseCode() !== 200) {
Logger.log('Refresh failed: ' + refreshRes.getResponseCode() + ' ' + refreshRes.getContentText());
return;
}
const data = JSON.parse(refreshRes.getContentText());
const colCount = data.headers.length;
const lastRow = data.rows.length ? 2 + data.rows.length : 2;
sheet.clear();
sheet.clearConditionalFormatRules();
// Force plain text on these columns BEFORE any values are written - must
// happen first, see the comment on PLAIN_TEXT_COLUMNS above.
PLAIN_TEXT_COLUMNS.forEach(function (name) {
const c = data.headers.indexOf(name) + 1;
if (c > 0) sheet.getRange(1, c, sheet.getMaxRows(), 1).setNumberFormat('@');
});
// Band row: category name in its first column, blank cells for the rest of
// its span (about to be merged) - this low non-empty-cell count is exactly
// what lets the backend's header-row detector skip past it automatically.
const bandRow = [];
data.categories.forEach(function (cat) {
bandRow.push(cat.name);
for (var i = 1; i < cat.span; i++) bandRow.push('');
});
sheet.getRange(1, 1, 1, colCount).setValues([bandRow]);
sheet.getRange(2, 1, 1, colCount).setValues([data.headers]);
if (data.rows.length) sheet.getRange(3, 1, data.rows.length, colCount).setValues(data.rows);
// Number formats for currency / percent / quantity columns.
CURRENCY_COLUMNS.forEach(function (name) {
const c = data.headers.indexOf(name) + 1;
if (c > 0 && data.rows.length) sheet.getRange(3, c, data.rows.length, 1).setNumberFormat('"โน"#,##0.00;[RED]-"โน"#,##0.00');
});
PERCENT_COLUMNS.forEach(function (name) {
const c = data.headers.indexOf(name) + 1;
if (c > 0 && data.rows.length) sheet.getRange(3, c, data.rows.length, 1).setNumberFormat('0.0"%"');
});
QTY_COLUMNS.forEach(function (name) {
const c = data.headers.indexOf(name) + 1;
if (c > 0 && data.rows.length) sheet.getRange(3, c, data.rows.length, 1).setNumberFormat('#,##0');
});
// Alignment: numbers/money right, statuses centered (everything else -
// text, dates - stays left, the default).
if (data.rows.length) {
CURRENCY_COLUMNS.concat(PERCENT_COLUMNS, QTY_COLUMNS).forEach(function (name) {
const c = data.headers.indexOf(name) + 1;
if (c > 0) sheet.getRange(3, c, data.rows.length, 1).setHorizontalAlignment('right');
});
CENTER_COLUMNS.forEach(function (name) {
const c = data.headers.indexOf(name) + 1;
if (c > 0) sheet.getRange(3, c, data.rows.length, 1).setHorizontalAlignment('center');
});
}
// Dropdowns on the columns ops picks a value from when editing. Delivery
// Partner's list comes live from the backend (the real transporter
// master), not a hardcoded array, so it can't go stale.
if (data.rows.length) {
Object.keys(DROPDOWN_COLUMNS).forEach(function (name) {
const c = data.headers.indexOf(name) + 1;
if (c === 0) return;
const rule = SpreadsheetApp.newDataValidation().requireValueInList(DROPDOWN_COLUMNS[name], true).setAllowInvalid(true).build();
sheet.getRange(3, c, data.rows.length, 1).setDataValidation(rule);
});
const deliveryPartnerCol = data.headers.indexOf('Delivery Partner') + 1;
if (deliveryPartnerCol > 0 && data.transporters && data.transporters.length) {
const partnerRule = SpreadsheetApp.newDataValidation().requireValueInList(data.transporters, true).setAllowInvalid(true).build();
sheet.getRange(3, deliveryPartnerCol, data.rows.length, 1).setDataValidation(partnerRule);
}
}
// Style the band: merge each category's span, color it, white bold
// centered text, taller row.
var col = 1;
data.categories.forEach(function (cat) {
const range = sheet.getRange(1, col, 1, cat.span);
if (cat.span > 1) range.merge();
range.setBackground(CATEGORY_COLORS[cat.name] || '#374151').setFontColor('#FFFFFF').setFontWeight('bold').setFontSize(10).setHorizontalAlignment('center');
col += cat.span;
});
sheet.setRowHeight(1, 30);
// Header row: each column tinted with a light version of its own
// category's color, so the grouping reads clearly column-by-column, not
// just from the band above. Bold dark text, bold orange underline across
// the whole row ties it back to the brand.
sheet.getRange(2, 1, 1, colCount).setFontWeight('bold').setFontColor('#334155').setBorder(null, null, true, null, null, null, '#EA6A2D', SpreadsheetApp.BorderStyle.SOLID_MEDIUM);
var headerCol = 1;
data.categories.forEach(function (cat) {
sheet.getRange(2, headerCol, 1, cat.span).setBackground(CATEGORY_LIGHT_COLORS[cat.name] || '#F3F4F6');
headerCol += cat.span;
});
// Vertical dividers between categories, spanning the band + header + data
// rows, so the grouping reads clearly even without the top band in view.
var boundary = 0;
data.categories.slice(0, -1).forEach(function (cat) {
boundary += cat.span;
sheet.getRange(1, boundary, lastRow, 1).setBorder(null, null, null, true, null, null, '#CBD5E1', SpreadsheetApp.BorderStyle.SOLID_MEDIUM);
});
// Colored status badges, then a zebra-stripe rule last so it only fills in
// cells the badge rules above didn't already color - earlier rules in this
// list win ties, so order matters. Done via a formula rule rather than the
// Banding API's row-banding, which turned out to silently ignore the
// custom colors and keep its built-in grey theme.
sheet.getBandings().forEach(function (b) {
b.remove();
});
const rules = [];
addBadgeRules(rules, sheet, data.headers, 'PO Status', PO_STATUS_BADGES, lastRow);
addBadgeRules(rules, sheet, data.headers, 'GRN Outcome', GRN_OUTCOME_BADGES, lastRow);
if (data.rows.length) {
rules.push(
SpreadsheetApp.newConditionalFormatRule()
.whenFormulaSatisfied('=ISEVEN(ROW())')
.setBackground('#FFF8EF')
.setRanges([sheet.getRange(3, 1, data.rows.length, colCount)])
.build(),
);
}
sheet.setConditionalFormatRules(rules);
// Freeze the band + header rows, hide gridlines, and size columns to fit
// (with a floor so a narrow status/qty column isn't squeezed unreadably
// thin). Can't also freeze a column here - the band row's merged cells
// span multiple columns, and Sheets refuses to freeze a column that would
// split a merge.
sheet.setFrozenRows(2);
sheet.setHiddenGridlines(true);
sheet.autoResizeColumns(1, colCount);
for (var i = 1; i <= colCount; i++) {
if (sheet.getColumnWidth(i) < 90) sheet.setColumnWidth(i, 90);
}
Logger.log('Refreshed ' + data.rows.length + ' POs');
}
Upload History
No uploads yet.