Skip to content

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-revenue slot with a range slider. The slot sets a number, the column's filter function interprets it as minimum.
  • Action buttons: the actions column 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: rowClass highlights 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>
  );
}

Released under the ISC License.