Appearance
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:
moneyextendsnumberwith editing, aggregation (sum) and a currency formatter. The number parser understands1.234,56and1,234.56. - Computed columns:
TotalandUsedare calculated withvalueGetterand update immediately after editing. - Conditional styles:
cellStylecolors 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>
);
}