Skip to content

Budget planner ​

A spreadsheet like budget with monthly values, computed totals and a plan comparison.

How it works ​

  • Spreadsheet editing: cell selection with ranges, typing starts editing, Enter moves down (enterMovesDown). Values can be pasted from Excel or Google Sheets, a single value is pasted into all selected cells.
  • Column groups: months are grouped into quarters, quarters into "Actual" — three header levels.
  • Custom column type: money extends number with editing, aggregation (sum) and a currency formatter. The number parser understands 1.234,56 and 1,234.56.
  • Computed columns: Total and Used are calculated with valueGetter and update immediately after editing.
  • Conditional styles: cellStyle colors the usage (green, orange, red).
  • Pinned columns: category and account stay on the left, totals on the right.
  • Row numbers and total row: rowNumbers: true, totalRow: "bottom".
  • Export: CSV with ; as separator for German Excel.
vue
<template>
  <div class="demo">
    <div class="demo-toolbar">
      <span>
        Edit the monthly values like in a spreadsheet: type to edit, <kbd>Enter</kbd> moves down, select a range and
        paste values from Excel (<kbd>Ctrl</kbd>+<kbd>V</kbd>), <kbd>Delete</kbd> clears cells.
      </span>
      <button @click="grid?.exportCsv({ fileName: 'budget.csv', separator: ';' })">Export CSV</button>
    </div>
    <Datagrid ref="grid" :columns="columns" v-model:rows="rows" :options="options" />
  </div>
</template>

<script setup lang="ts">
import { ref } from "vue";
import { Datagrid, formatters, type ColumnConfig, type GridOptions } from "@datagrid/vue-ui";

const months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] as const;
type Month = (typeof months)[number];

type BudgetLine = { category: string; account: string; plan: number } & Record<Month, number | null>;

const grid = ref<InstanceType<typeof Datagrid>>();

function line(category: string, account: string, plan: number, base: number): BudgetLine {
  const values = Object.fromEntries(
    months.map((month, i) => [month, i < 9 ? Math.round(base * (0.8 + ((i * 7 + account.length) % 5) / 10)) : null])
  );
  return { category, account, plan, ...(values as Record<Month, number | null>) };
}

const rows = ref<Array<BudgetLine>>([
  line("Personnel", "Salaries", 480000, 38000),
  line("Personnel", "Training", 24000, 1800),
  line("Personnel", "Recruiting", 30000, 2600),
  line("Office", "Rent", 96000, 8000),
  line("Office", "Utilities", 18000, 1400),
  line("Office", "Supplies", 9000, 700),
  line("IT", "Cloud hosting", 60000, 5200),
  line("IT", "Licenses", 42000, 3100),
  line("IT", "Hardware", 36000, 2500),
  line("Marketing", "Campaigns", 120000, 11000),
  line("Marketing", "Events", 45000, 2900),
  line("Travel", "Flights", 28000, 2300),
  line("Travel", "Hotels", 22000, 1900),
]);

const euro = formatters.currency("EUR", { maximumFractionDigits: 0 });

const sum = (data: BudgetLine) => months.reduce((total, month) => total + (data[month] ?? 0), 0);

const columns: Array<ColumnConfig<BudgetLine>> = [
  { field: "category", text: "Category", width: 120, pinned: "left", groupable: true, editable: false },
  { field: "account", text: "Account", width: 140, pinned: "left" },
  {
    id: "months",
    text: "Actual",
    // quarters as column groups
    children: [0, 1, 2, 3].map((quarter) => ({
      id: `q${quarter + 1}`,
      text: `Q${quarter + 1}`,
      children: months.slice(quarter * 3, quarter * 3 + 3).map((month) => ({ field: month, text: month, type: "money" })),
    })),
  },
  // computed columns
  { id: "total", text: "Total", type: "money", editable: false, pinned: "right", valueGetter: ({ data }) => sum(data), cellClass: "total-cell" },
  { field: "plan", text: "Plan", type: "money", pinned: "right" },
  {
    id: "usage",
    text: "Used",
    type: "number",
    width: 90,
    editable: false,
    pinned: "right",
    valueGetter: ({ data }) => (data.plan ? sum(data) / data.plan : null),
    valueFormatter: formatters.percent({ maximumFractionDigits: 0 }),
    cellStyle: ({ value }) =>
      value == null
        ? undefined
        : { color: value > 0.9 ? "#cf222e" : value > 0.75 ? "#9a6700" : "#1a7f37", fontWeight: "600" },
  },
];

const options: GridOptions<BudgetLine> = {
  selection: "Cell",
  singleSelect: false,
  enterMovesDown: true,
  rowNumbers: true,
  totalRow: "bottom",
  columnTypes: {
    // "money" extends the built-in number type
    money: { type: "number", width: 95, editable: true, aggFunc: "sum", valueFormatter: euro },
  },
  defaultColumn: { resizeable: true, editable: true },
};
</script>

<style>
.total-cell {
  font-weight: 700;
}
</style>
tsx
import { useRef, useState } from "react";
import {
  Datagrid,
  formatters,
  type ColumnConfig,
  type DatagridHandle,
  type GridOptions,
} from "@datagrid/react-ui";

const months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] as const;
type Month = (typeof months)[number];

type BudgetLine = { category: string; account: string; plan: number } & Record<Month, number | null>;

function line(category: string, account: string, plan: number, base: number): BudgetLine {
  const values = Object.fromEntries(
    months.map((month, i) => [month, i < 9 ? Math.round(base * (0.8 + ((i * 7 + account.length) % 5) / 10)) : null])
  );
  return { category, account, plan, ...(values as Record<Month, number | null>) };
}

const initialRows: Array<BudgetLine> = [
  line("Personnel", "Salaries", 480000, 38000),
  line("Personnel", "Training", 24000, 1800),
  line("Personnel", "Recruiting", 30000, 2600),
  line("Office", "Rent", 96000, 8000),
  line("Office", "Utilities", 18000, 1400),
  line("Office", "Supplies", 9000, 700),
  line("IT", "Cloud hosting", 60000, 5200),
  line("IT", "Licenses", 42000, 3100),
  line("IT", "Hardware", 36000, 2500),
  line("Marketing", "Campaigns", 120000, 11000),
  line("Marketing", "Events", 45000, 2900),
  line("Travel", "Flights", 28000, 2300),
  line("Travel", "Hotels", 22000, 1900),
];

const euro = formatters.currency("EUR", { maximumFractionDigits: 0 });

const sum = (data: BudgetLine) => months.reduce((total, month) => total + (data[month] ?? 0), 0);

const columns: Array<ColumnConfig<BudgetLine>> = [
  { field: "category", text: "Category", width: 120, pinned: "left", groupable: true, editable: false },
  { field: "account", text: "Account", width: 140, pinned: "left" },
  {
    id: "months",
    text: "Actual",
    // quarters as column groups
    children: [0, 1, 2, 3].map((quarter) => ({
      id: `q${quarter + 1}`,
      text: `Q${quarter + 1}`,
      children: months.slice(quarter * 3, quarter * 3 + 3).map((month) => ({ field: month, text: month, type: "money" })),
    })),
  },
  // computed columns
  { id: "total", text: "Total", type: "money", editable: false, pinned: "right", valueGetter: ({ data }) => sum(data), cellClass: "total-cell" },
  { field: "plan", text: "Plan", type: "money", pinned: "right" },
  {
    id: "usage",
    text: "Used",
    type: "number",
    width: 90,
    editable: false,
    pinned: "right",
    valueGetter: ({ data }) => (data.plan ? sum(data) / data.plan : null),
    valueFormatter: formatters.percent({ maximumFractionDigits: 0 }),
    cellStyle: ({ value }) =>
      value == null
        ? undefined
        : { color: value > 0.9 ? "#cf222e" : value > 0.75 ? "#9a6700" : "#1a7f37", fontWeight: "600" },
  },
];

const options: GridOptions<BudgetLine> = {
  selection: "Cell",
  singleSelect: false,
  enterMovesDown: true,
  rowNumbers: true,
  totalRow: "bottom",
  columnTypes: {
    // "money" extends the built-in number type
    money: { type: "number", width: 95, editable: true, aggFunc: "sum", valueFormatter: euro },
  },
  defaultColumn: { resizeable: true, editable: true },
};

export default function ShowcaseBudget() {
  const grid = useRef<DatagridHandle>(null);
  // edited and pasted values are written back with onRowsChange
  const [rows, setRows] = useState(initialRows);

  return (
    <div className="demo">
      <div className="demo-toolbar">
        <span>
          Edit the monthly values like in a spreadsheet: type to edit, <kbd>Enter</kbd> moves down, select a range and
          paste values from Excel (<kbd>Ctrl</kbd>+<kbd>V</kbd>), <kbd>Delete</kbd> clears cells.
        </span>
        <button onClick={() => grid.current?.exportCsv({ fileName: "budget.csv", separator: ";" })}>Export CSV</button>
      </div>
      <Datagrid ref={grid} columns={columns} rows={rows} onRowsChange={setRows} options={options} />
      <style>{`
        .total-cell {
          font-weight: 700;
        }
      `}</style>
    </div>
  );
}

Released under the ISC License.