← All projects

Node.js · Vercel Functions · Chart.js · Dashboard

Orbit Analytics

A 3-page SaaS admin dashboard — KPI cards, a live revenue chart, and a searchable/sortable/paginated customer table.

An overview page with KPI cards and a 30-day revenue chart, a customer directory with server-side search, sorting and pagination (not client-side array filtering), and a settings page that actually persists. The ~64 customers and the revenue history are generated with a seeded PRNG, so the data is identical across every request without needing a database — the kind of dashboard a SaaS client would actually ask for.

Live demo

embed
const { generateCustomers } = require('./_customers');

const ALL = generateCustomers();

module.exports = (req, res) => {
  if (req.method !== 'GET') {
    res.status(405).json({ error: 'method not allowed' });
    return;
  }

  const q = (req.query.q || '').toString().toLowerCase().trim();
  const status = (req.query.status || '').toString();
  const sort = (req.query.sort || 'joinedAt:desc').toString();
  const page = Math.max(1, parseInt(req.query.page, 10) || 1);
  const perPage = Math.min(50, Math.max(5, parseInt(req.query.perPage, 10) || 10));

  let rows = ALL;
  if (q) {
    rows = rows.filter((c) =>
      c.name.toLowerCase().includes(q) ||
      c.email.toLowerCase().includes(q) ||
      c.company.toLowerCase().includes(q)
    );
  }
  if (status && status !== 'all') {
    rows = rows.filter((c) => c.status === status);
  }

  const [field, dir] = sort.split(':');
  const sorted = [...rows].sort((a, b) => {
    let av = a[field];
    let bv = b[field];
    if (typeof av === 'string') { av = av.toLowerCase(); bv = bv.toLowerCase(); }
    if (av < bv) return dir === 'asc' ? -1 : 1;
    if (av > bv) return dir === 'asc' ? 1 : -1;
    return 0;
  });

  const total = sorted.length;
  const start = (page - 1) * perPage;
  const pageRows = sorted.slice(start, start + perPage);

  res.status(200).json({
    customers: pageRows,
    total,
    page,
    perPage,
    totalPages: Math.max(1, Math.ceil(total / perPage)),
  });
};
const rowsEl = document.getElementById('rows');
const paginationEl = document.getElementById('pagination');
const searchEl = document.getElementById('search');
const statusEl = document.getElementById('status-filter');

let state = { q: '', status: 'all', sortField: 'joinedAt', sortDir: 'desc', page: 1 };
let debounceTimer = null;

function updateSortArrows() {
  document.querySelectorAll('th[data-sort]').forEach((th) => {
    const arrow = th.querySelector('.arrow');
    if (th.dataset.sort === state.sortField) {
      arrow.textContent = state.sortDir === 'asc' ? '↑' : '↓';
    } else {
      arrow.textContent = '';
    }
  });
}

async function load() {
  const params = new URLSearchParams({
    q: state.q,
    status: state.status,
    sort: `${state.sortField}:${state.sortDir}`,
    page: state.page,
    perPage: 10,
  });
  const res = await fetch(`/api/customers?${params}`);
  const data = await res.json();
  render(data);
}

function render(data) {
  if (!data.customers.length) {
    rowsEl.innerHTML = '<tr><td colspan="6"><p class="empty">No customers match your filters.</p></td></tr>';
  } else {
    rowsEl.innerHTML = data.customers.map((c) => `
      <tr>
        <td>
          <div class="cell-name">${c.name}</div>
          <div class="cell-sub">${c.email}</div>
        </td>
        <td>${c.company}</td>
        <td>${c.plan}</td>
        <td>${c.mrr ? money(c.mrr) : '—'}</td>
        <td><span class="pill ${c.status}">${c.status}</span></td>
        <td class="cell-sub">${c.joinedAt}</td>
      </tr>
    `).join('');
  }

  paginationEl.innerHTML = `
    <span>${data.total} customer${data.total === 1 ? '' : 's'} · page ${data.page} of ${data.totalPages}</span>
    <div class="btns">
      <button type="button" id="prev-page" ${data.page <= 1 ? 'disabled' : ''}>← Prev</button>
      <button type="button" id="next-page" ${data.page >= data.totalPages ? 'disabled' : ''}>Next →</button>
    </div>
  `;
  document.getElementById('prev-page').addEventListener('click', () => { state.page--; load(); });
  document.getElementById('next-page').addEventListener('click', () => { state.page++; load(); });

  updateSortArrows();
}

document.querySelectorAll('th[data-sort]').forEach((th) => {
  th.addEventListener('click', () => {
    const field = th.dataset.sort;
    if (state.sortField === field) {
      state.sortDir = state.sortDir === 'asc' ? 'desc' : 'asc';
    } else {
      state.sortField = field;
      state.sortDir = 'asc';
    }
    state.page = 1;
    load();
  });
});

searchEl.addEventListener('input', () => {
  clearTimeout(debounceTimer);
  debounceTimer = setTimeout(() => {
    state.q = searchEl.value;
    state.page = 1;
    load();
  }, 250);
});

statusEl.addEventListener('change', () => {
  state.status = statusEl.value;
  state.page = 1;
  load();
});

load();