Appearance
Customer management
A customer list with actions, a custom filter and German localization.
How it works
- Localization:
locale: "de"translates all texts of the grid (filters, menus, pagination), the column headers and the date formatter switch with the selected language. - Custom filter: the revenue column uses the
#filter-revenueslot with a range slider. The slot sets a number, the column'sfilterfunction interprets it as minimum. - Action buttons: the
actionscolumn is a slot with buttons, the data is changed immutably (customers.value = …). - Selection: checkboxes with "select all" (all rows matching the filters, on all pages), the selected rows can be deleted.
- Row classes:
rowClasshighlights VIP customers by overriding the row background variables. - Empty slot: a custom message when the search finds nothing.
vue
<template>
<div class="demo">
<div class="demo-toolbar">
<input v-model="search" type="search" :placeholder="locale === 'de' ? 'Kunden suchen…' : 'Search customers…'" />
<label>
<select v-model="locale">
<option value="en">English</option>
<option value="de">Deutsch</option>
</select>
</label>
<span>{{ selected.length }} selected</span>
<button :disabled="!selected.length" @click="removeSelected">Delete selected</button>
</div>
<Datagrid
:columns="columns"
v-model:rows="customers"
:options="options"
v-model:globalFilter="search"
v-model:selectedItems="selected"
>
<!-- custom filter: range slider for the revenue -->
<template #filter-revenue="{ value, setValue }">
<span class="range-filter">
<input
type="range"
min="0"
max="500"
step="10"
:value="value ?? 0"
@input="setValue(Number(($event.target as HTMLInputElement).value) || undefined)"
/>
<span>≥ {{ value ?? 0 }}k</span>
</span>
</template>
<template #cell-company="{ data }">
<span class="company">
<strong>{{ data.company }}</strong>
<span v-if="data.vip" class="vip">VIP</span>
</span>
</template>
<!-- action buttons, the class ut-ignore is not needed for cells -->
<template #cell-actions="{ row, data }">
<button class="action" title="Toggle VIP" @click="toggleVip(data)">★</button>
<button class="action" title="Delete" @click="remove(row.uid)">🗑</button>
</template>
<template #empty>
{{ locale === "de" ? "Keine Kunden gefunden" : "No customers found" }} 🔍
</template>
</Datagrid>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from "vue";
import {
Datagrid,
formatters,
type ColumnConfig,
type GridOptions,
type ItemUid,
} from "@datagrid/vue-ui";
import { createRandom } from "./data";
type Customer = {
id: number;
company: string;
contact: string;
email: string;
city: string;
segment: "Enterprise" | "SMB" | "Startup";
revenue: number;
lastOrder: string;
vip: boolean;
};
const random = createRandom(7);
const names = ["Nordwind", "Alpenglück", "Rheinwerk", "Sonnenhof", "Blautech", "Hafenkontor", "Waldmann", "Silberfuchs", "Bergblick", "Seewind"];
const kinds = ["GmbH", "AG", "KG", "Solutions", "Digital", "Logistik"];
const people = ["Anna Schmidt", "Ben Müller", "Clara Fischer", "David Weber", "Eva Wagner", "Felix Becker"];
const cities = ["Berlin", "Hamburg", "München", "Köln", "Frankfurt", "Stuttgart", "Leipzig"];
const segments: Array<Customer["segment"]> = ["Enterprise", "SMB", "Startup"];
const pick = <T,>(list: Array<T>) => list[Math.floor(random() * list.length)];
const customers = ref<Array<Customer>>(
Array.from({ length: 150 }, (_, i) => {
const company = `${pick(names)} ${pick(kinds)}`;
const month = String(1 + Math.floor(random() * 9)).padStart(2, "0");
return {
id: i + 1,
company,
contact: pick(people),
email: `info@${company.toLowerCase().replace(/[^a-z]/g, "")}.de`,
city: pick(cities),
segment: pick(segments),
revenue: Math.round(random() * 500),
lastOrder: `2026-${month}-${String(1 + Math.floor(random() * 28)).padStart(2, "0")}`,
vip: random() > 0.85,
};
})
);
const search = ref("");
const selected = ref<Array<ItemUid>>([]);
const locale = ref<"en" | "de">("de");
function remove(uid: ItemUid) {
customers.value = customers.value.filter((customer) => customer.id !== uid);
}
function removeSelected() {
const ids = new Set(selected.value);
customers.value = customers.value.filter((customer) => !ids.has(customer.id));
}
function toggleVip(customer: Customer) {
customers.value = customers.value.map((el) => (el === customer ? { ...el, vip: !el.vip } : el));
}
const columns = computed<Array<ColumnConfig<Customer>>>(() => {
const de = locale.value === "de";
return [
{ field: "company", text: de ? "Firma" : "Company", width: 220 },
{ field: "contact", text: de ? "Ansprechpartner" : "Contact", width: 160 },
{ field: "email", text: "E-Mail", width: 220, tooltip: true },
{ field: "city", text: de ? "Stadt" : "City", width: 120, filterType: "set", groupable: true },
{ field: "segment", text: "Segment", width: 120, filterType: "set", groupable: true },
{
field: "revenue",
text: de ? "Umsatz (k€)" : "Revenue (k€)",
type: "number",
width: 170,
aggFunc: "sum",
// the custom filter slot sets a number, the filter function interprets it as minimum
filter: (value, minimum) => value >= minimum,
},
{
field: "lastOrder",
text: de ? "Letzte Bestellung" : "Last order",
type: "date",
width: 150,
valueFormatter: formatters.date({ dateStyle: "medium", locale: de ? "de-DE" : "en-US" }),
},
{ id: "actions", text: "", width: 80, sortable: false, filterable: false, pinned: "right" },
];
});
const options = computed<GridOptions<Customer>>(() => ({
locale: locale.value,
rowId: (customer) => customer.id,
checkboxSelection: true,
columnMenu: true,
pagination: { enabled: true, pageSize: 25 },
rowClass: (row) => (row.data.vip ? "vip-row" : undefined),
defaultColumn: { sortable: true, filterable: true, resizeable: true, moveable: true },
}));
</script>
<style>
.range-filter {
display: flex;
align-items: center;
gap: 6px;
width: 100%;
font-size: 12px;
white-space: nowrap;
}
.range-filter input {
flex: 1;
min-width: 0;
accent-color: var(--ut-accent-color);
}
.company {
display: inline-flex;
align-items: center;
gap: 6px;
}
.vip {
padding: 0 5px;
border-radius: 4px;
font-size: 10px;
font-weight: 700;
color: #7c4a00;
background: #ffe8a3;
}
.vip-row {
--ut-row-background: color-mix(in srgb, #ffe8a3 25%, var(--ut-background));
--ut-row-alt-background: color-mix(in srgb, #ffe8a3 25%, var(--ut-background));
}
.action {
padding: 0 4px;
border: none;
background: none;
cursor: pointer;
opacity: 0.6;
}
.action:hover {
opacity: 1;
}
</style>tsx
import { useMemo, useState } from "react";
import {
Datagrid,
formatters,
type ColumnConfig,
type DatagridProps,
type GridOptions,
type ItemUid,
} from "@datagrid/react-ui";
import { createRandom } from "../data";
type Customer = {
id: number;
company: string;
contact: string;
email: string;
city: string;
segment: "Enterprise" | "SMB" | "Startup";
revenue: number;
lastOrder: string;
vip: boolean;
};
const random = createRandom(7);
const names = ["Nordwind", "Alpenglück", "Rheinwerk", "Sonnenhof", "Blautech", "Hafenkontor", "Waldmann", "Silberfuchs", "Bergblick", "Seewind"];
const kinds = ["GmbH", "AG", "KG", "Solutions", "Digital", "Logistik"];
const people = ["Anna Schmidt", "Ben Müller", "Clara Fischer", "David Weber", "Eva Wagner", "Felix Becker"];
const cities = ["Berlin", "Hamburg", "München", "Köln", "Frankfurt", "Stuttgart", "Leipzig"];
const segments: Array<Customer["segment"]> = ["Enterprise", "SMB", "Startup"];
const pick = <T,>(list: Array<T>) => list[Math.floor(random() * list.length)];
const initialCustomers: Array<Customer> = Array.from({ length: 150 }, (_, i) => {
const company = `${pick(names)} ${pick(kinds)}`;
const month = String(1 + Math.floor(random() * 9)).padStart(2, "0");
return {
id: i + 1,
company,
contact: pick(people),
email: `info@${company.toLowerCase().replace(/[^a-z]/g, "")}.de`,
city: pick(cities),
segment: pick(segments),
revenue: Math.round(random() * 500),
lastOrder: `2026-${month}-${String(1 + Math.floor(random() * 28)).padStart(2, "0")}`,
vip: random() > 0.85,
};
});
// custom filter cell: range slider for the revenue
const filterRenderers: DatagridProps<Customer>["filterRenderers"] = {
revenue: ({ value, setValue }) => (
<span className="range-filter">
<input
type="range"
min="0"
max="500"
step="10"
value={Number(value ?? 0)}
onChange={(e) => setValue(Number(e.target.value) || undefined)}
/>
<span>≥ {Number(value ?? 0)}k</span>
</span>
),
};
export default function ShowcaseCrm() {
const [customers, setCustomers] = useState(initialCustomers);
const [search, setSearch] = useState<unknown>("");
const [selected, setSelected] = useState<Array<ItemUid>>([]);
const [locale, setLocale] = useState<"en" | "de">("de");
function remove(uid: ItemUid) {
setCustomers((list) => list.filter((customer) => customer.id !== uid));
}
function removeSelected() {
const ids = new Set(selected);
setCustomers((list) => list.filter((customer) => !ids.has(customer.id)));
}
function toggleVip(customer: Customer) {
setCustomers((list) => list.map((el) => (el === customer ? { ...el, vip: !el.vip } : el)));
}
const columns = useMemo<Array<ColumnConfig<Customer>>>(() => {
const de = locale === "de";
return [
{ field: "company", text: de ? "Firma" : "Company", width: 220 },
{ field: "contact", text: de ? "Ansprechpartner" : "Contact", width: 160 },
{ field: "email", text: "E-Mail", width: 220, tooltip: true },
{ field: "city", text: de ? "Stadt" : "City", width: 120, filterType: "set", groupable: true },
{ field: "segment", text: "Segment", width: 120, filterType: "set", groupable: true },
{
field: "revenue",
text: de ? "Umsatz (k€)" : "Revenue (k€)",
type: "number",
width: 170,
aggFunc: "sum",
// the custom filter cell sets a number, the filter function interprets it as minimum
filter: (value, minimum) => value >= minimum,
},
{
field: "lastOrder",
text: de ? "Letzte Bestellung" : "Last order",
type: "date",
width: 150,
valueFormatter: formatters.date({ dateStyle: "medium", locale: de ? "de-DE" : "en-US" }),
},
{ id: "actions", text: "", width: 80, sortable: false, filterable: false, pinned: "right" },
];
}, [locale]);
const options = useMemo<GridOptions<Customer>>(
() => ({
locale,
rowId: (customer) => customer.id,
checkboxSelection: true,
columnMenu: true,
pagination: { enabled: true, pageSize: 25 },
rowClass: (row) => (row.data.vip ? "vip-row" : undefined),
defaultColumn: { sortable: true, filterable: true, resizeable: true, moveable: true },
}),
[locale]
);
// custom cells by column id, they only use state setters, so they are created once
const cellRenderers = useMemo<DatagridProps<Customer>["cellRenderers"]>(
() => ({
company: ({ data }) => (
<span className="company">
<strong>{data.company}</strong>
{data.vip && <span className="vip">VIP</span>}
</span>
),
// action buttons, the class ut-ignore is not needed for cells
actions: ({ row, data }) => (
<>
<button className="action" title="Toggle VIP" onClick={() => toggleVip(data)}>
★
</button>
<button className="action" title="Delete" onClick={() => remove(row.uid)}>
🗑
</button>
</>
),
}),
[]
);
return (
<div className="demo">
<div className="demo-toolbar">
<input
type="search"
placeholder={locale === "de" ? "Kunden suchen…" : "Search customers…"}
value={String(search ?? "")}
onChange={(e) => setSearch(e.target.value)}
/>
<label>
<select value={locale} onChange={(e) => setLocale(e.target.value as "en" | "de")}>
<option value="en">English</option>
<option value="de">Deutsch</option>
</select>
</label>
<span>{selected.length} selected</span>
<button disabled={!selected.length} onClick={removeSelected}>
Delete selected
</button>
</div>
<Datagrid
columns={columns}
rows={customers}
onRowsChange={setCustomers}
options={options}
globalFilter={search}
onGlobalFilterChange={setSearch}
selectedItems={selected}
onSelectedItemsChange={setSelected}
cellRenderers={cellRenderers}
filterRenderers={filterRenderers}
renderEmpty={() => <>{locale === "de" ? "Keine Kunden gefunden" : "No customers found"} 🔍</>}
/>
<style>{`
.range-filter {
display: flex;
align-items: center;
gap: 6px;
width: 100%;
font-size: 12px;
white-space: nowrap;
}
.range-filter input {
flex: 1;
min-width: 0;
accent-color: var(--ut-accent-color);
}
.company {
display: inline-flex;
align-items: center;
gap: 6px;
}
.vip {
padding: 0 5px;
border-radius: 4px;
font-size: 10px;
font-weight: 700;
color: #7c4a00;
background: #ffe8a3;
}
.vip-row {
--ut-row-background: color-mix(in srgb, #ffe8a3 25%, var(--ut-background));
--ut-row-alt-background: color-mix(in srgb, #ffe8a3 25%, var(--ut-background));
}
.action {
padding: 0 4px;
border: none;
background: none;
cursor: pointer;
opacity: 0.6;
}
.action:hover {
opacity: 1;
}
`}</style>
</div>
);
}