Tabelle
Spalten formatieren
Beschriftung, Format, Breite, Sichtbarkeit, Tooltip und das Zeichen für leere Zellen. Dazu SortByKey für Spalten, deren Anzeige nicht der Sortierreihenfolge entspricht.
@model IReadOnlyList<KursZeile>
@* Was sich je Spalte einstellen lässt: Beschriftung, Format, Breite, Sichtbarkeit, Sortierschlüssel,
Tooltip und das Zeichen für leere Zellen. *@
@Html.Fmh(Fmh<KursZeile>.Grid
.Id("kurse-spalten")
.BindTo(Model)
.EmptyColumnChar("–")
.Columns(spalten =>
{
@* Nicht sichtbar, aber vorhanden: Die Id wird für Sortierung und Skripte gebraucht,
soll den Anwender aber nicht stören. *@
spalten.For(x => x.Id)
.Named("Id")
.Visible(false);
spalten.For(x => x.Bezeichnung)
.Named("Kurs")
.Tooltip(x => x.Anbieter)
.ResponsivePriority(1)
.IsSortable();
spalten.For(x => x.Datum)
.Named("Datum")
.Format("{0:dd.MM.yyyy}")
.WidthInPixel(120)
.IsSortable();
@* Sortiert wird nach dem Datum, angezeigt der Monatsname – dafür ist SortByKey da.
Für einen berechneten Wert ist Custom die richtige Wahl: For erwartet eine Eigenschaft
und verliert bei einem Ausdruck Name und Datentyp der Spalte. *@
spalten.Custom(x => x.Datum.ToString("MMMM yyyy"), "Monat")
.SortByKey(x => x.Datum)
.IsSortable();
spalten.For(x => x.Punkte)
.Named("Punkte")
.Format("{0:N1}")
.WidthInPixel(90)
.IsSortable();
@* Teilweise leer: Hier greift EmptyColumnChar. *@
spalten.For(x => x.Bemerkung)
.Named("Bemerkung")
.ResponsivePriority(10);
}))
using System;
using System.Collections.Generic;
namespace FMH.Komponente.WebUI.Areas.Tabelle.Models
{
/// <summary>
/// Eine Zeile der Demo-Tabelle. Die Eigenschaften decken die Fälle ab, die im Grid
/// unterschiedlich behandelt werden: Text, Zahl, Datum, Wahrheitswert und ein leerer Wert.
/// </summary>
public class KursZeile
{
public int Id { get; set; }
public string Bezeichnung { get; set; }
public string Anbieter { get; set; }
public DateTime Datum { get; set; }
public decimal Punkte { get; set; }
public bool IstAnerkannt { get; set; }
/// <summary>
/// Bewusst teilweise leer: zeigt, was EmptyColumnChar aus einer leeren Zelle macht.
/// </summary>
public string Bemerkung { get; set; }
/// <summary>
/// Fachliche Sperre der Zeile: Ein abgeschlossener Kurs darf nicht mehr entfernt werden.
/// Grundlage für <c>Disabled(...)</c> und <c>Visible(...)</c> an den Zeilen-Aktionen.
/// </summary>
public bool IstAbgeschlossen { get; set; }
/// <summary>
/// Zustand des Umschalters. Wird über die Demo-Aktion «Bestanden» tatsächlich gekippt —
/// deshalb veränderlich und nicht nur lesend.
/// </summary>
public bool Bestanden { get; set; }
/// <summary>
/// Unterzeilen der Tabelle. Bewusst bei einigen Kursen leer: Ohne Einträge rendert das Grid
/// statt der Aufklapp-Schaltfläche das Zeichen aus EmptyColumnChar.
/// </summary>
public IReadOnlyList<KursDokument> Dokumente { get; set; } = new List<KursDokument>();
/// <summary>
/// Nur für den Tooltip der Aufklapp-Spalte — der Standardtext nennt sonst dieselbe Zahl
/// ohne Bezug zum Kurs.
/// </summary>
public int AnzahlDokumente => this.Dokumente.Count;
/// <summary>
/// Fertig formatierte Punktzahl für die Gruppe «Datasource (Ajax)». Auf dem Ajax-Weg wirkt
/// <c>Format(...)</c> nicht — der Wert geht so an den Browser, wie ihn der Endpunkt liefert.
/// </summary>
public string PunkteText => $"{this.Punkte:0.0} Punkte";
/// <summary>
/// Ersatz für <c>Format(GridValueFormat.YesNo)</c> auf dem Ajax-Weg. Sortiert wird über
/// <c>SortByKey(x => x.Bestanden)</c> weiterhin nach dem Wahrheitswert.
/// </summary>
public string BestandenText => this.Bestanden ? "Ja" : "Nein";
/// <summary>
/// Kontaktadresse des Anbieters — Grundlage der Mail-Schaltfläche im Beispiel «Aktionen ohne
/// Seitenwechsel». Sie setzt diesen Wert über <c>ClientDataKey("row.KontaktMail")</c> hinter
/// das <c>mailto:</c> ihrer Adresse, statt wie sonst die Id der Zeile.
/// </summary>
public string KontaktMail => $"kurse@{Kuerzel(this.Anbieter)}.example.ch";
/// <summary>
/// Ersatz für eine <c>Custom</c>-Spalte auf dem Ajax-Weg: Das Markup entsteht im Endpunkt
/// und nicht im Razor-Ausdruck, weil die Zeilen dort gar nicht vorliegen.
/// </summary>
public string StatusHtml => this.IstAbgeschlossen
? "<span class=\"badge text-bg-secondary\">abgeschlossen</span>"
: "<span class=\"badge text-bg-success\">offen</span>";
// Aus «Universität Zürich» wird «universitaet-zuerich»: Umlaute und Leerzeichen haben in
// einer Mailadresse nichts verloren.
private static string Kuerzel(string anbieter)
{
return (anbieter ?? string.Empty).ToLowerInvariant()
.Replace("ä", "ae")
.Replace("ö", "oe")
.Replace("ü", "ue")
.Replace(" ", "-");
}
}
}
using System;
using System.Collections.Generic;
using System.Linq;
namespace FMH.Komponente.WebUI.Areas.Tabelle.Models
{
/// <summary>
/// Die Testdaten der Demo. Statisch und ohne Datenbank — jede Seite arbeitet auf derselben
/// Liste, damit sich die Beispiele untereinander vergleichen lassen.
/// </summary>
/// <remarks>
/// Die Liste ist prozessweit dieselbe Instanz. Die Beispiele «Umschalt-Aktion» und
/// «Unterzeilen» verändern sie bewusst (Umschalten, Umbenennen), damit die Wirkung sichtbar
/// wird; nach einem Neustart der Anwendung steht wieder der Ausgangszustand.
/// </remarks>
public static class KursDaten
{
private static readonly IReadOnlyList<KursZeile> Alle = new List<KursZeile>
{
Zeile(
1,
"Grundkurs Notfallmedizin",
"FMH Bildung",
"2026-01-14",
12.5m,
true,
"Präsenzkurs",
istAbgeschlossen: true,
bestanden: true,
dokumente: new[] { Dokument(101, "Anmeldebestaetigung.pdf", 184320), Dokument(102, "Teilnahmebeleg.pdf", 96256) }),
Zeile(2, "Refresher Innere Medizin", "SIWF Akademie", "2026-02-03", 8m, true, null, istAbgeschlossen: true, bestanden: false),
Zeile(
3,
"Workshop Gesprächsführung",
"Kantonsspital Bern",
"2026-02-19",
4m,
false,
"Wartet auf Beleg",
istAbgeschlossen: false,
bestanden: false,
dokumente: new[] { Dokument(103, "Programm.pdf", 512000) }),
Zeile(4, "Fortbildung Radiologie", "Universität Zürich", "2026-03-07", 16m, true, null, istAbgeschlossen: true, bestanden: true),
Zeile(5, "Seminar Qualitätssicherung", "FMH Bildung", "2026-03-22", 6.5m, false, null, istAbgeschlossen: false, bestanden: false),
Zeile(
6,
"Kongress Kardiologie",
"Swiss Heart",
"2026-04-11",
20m,
true,
"Zweitägig",
istAbgeschlossen: false,
bestanden: true,
dokumente: new[]
{
Dokument(104, "Kongressprogramm.pdf", 1258291),
Dokument(105, "Hotelbestaetigung.pdf", 71680),
Dokument(106, "Quittung.pdf", 40960),
}),
Zeile(7, "Kurs Strahlenschutz", "Bundesamt für Gesundheit", "2026-04-28", 9m, true, null, istAbgeschlossen: true, bestanden: true),
Zeile(
8,
"Update Pädiatrie",
"Kinderspital Zürich",
"2026-05-15",
7.5m,
false,
"Anmeldung offen",
istAbgeschlossen: false,
bestanden: false,
dokumente: new[] { Dokument(107, "Anmeldung.pdf", 65536) }),
Zeile(
9,
"Fallseminar Onkologie",
"SIWF Akademie",
"2026-06-02",
11m,
true,
null,
istAbgeschlossen: true,
bestanden: false,
dokumente: new[] { Dokument(110, "Fallsammlung.pdf", 327680), Dokument(111, "Auswertungsbogen.pdf", 88064) }),
Zeile(10, "Praxiskurs Sonografie", "Universität Basel", "2026-06-24", 14m, true, null, istAbgeschlossen: false, bestanden: true),
Zeile(11, "Kurs Palliative Care", "FMH Bildung", "2026-07-09", 10m, false, null, istAbgeschlossen: false, bestanden: false),
Zeile(
12,
"Symposium Neurologie",
"Inselspital",
"2026-08-18",
18m,
true,
"Mit Poster-Session",
istAbgeschlossen: true,
bestanden: true,
dokumente: new[] { Dokument(108, "Posterabstract.pdf", 245760), Dokument(109, "Zertifikat.pdf", 133120) }),
};
public static IReadOnlyList<KursZeile> Laden()
{
return Alle;
}
public static KursZeile Laden(int id)
{
return Alle.FirstOrDefault(zeile => zeile.Id == id);
}
/// <summary>
/// Leere Liste für das Beispiel «Ohne Daten».
/// </summary>
public static IReadOnlyList<KursZeile> Leer()
{
return new List<KursZeile>();
}
/// <summary>
/// Kippt den Zustand für das Beispiel «Umschalt-Aktion» und liefert den neuen Wert.
/// </summary>
public static bool Umschalten(int id)
{
var zeile = Laden(id);
if (zeile == null)
{
return false;
}
zeile.Bestanden = !zeile.Bestanden;
return zeile.Bestanden;
}
public static KursDokument LadeDokument(int id)
{
return Alle.SelectMany(zeile => zeile.Dokumente)
.FirstOrDefault(dokument => dokument.Id == id);
}
/// <summary>
/// Umbenennen für das Beispiel «Unterzeilen». Der Aufruf kommt aus dem Umbenennen-Dialog
/// des Grids und erwartet eine Antwort im JSON-Format.
/// </summary>
public static KursDokument BenenneDokumentUm(int id, string dateiname)
{
var dokument = LadeDokument(id);
if (dokument == null || string.IsNullOrWhiteSpace(dateiname))
{
return null;
}
dokument.Dateiname = dateiname;
return dokument;
}
private static KursDokument Dokument(int id, string dateiname, int dateigroesse)
{
return new KursDokument
{
Id = id,
Dateiname = dateiname,
Dateigroesse = dateigroesse,
};
}
private static KursZeile Zeile(
int id,
string bezeichnung,
string anbieter,
string datum,
decimal punkte,
bool istAnerkannt,
string bemerkung,
bool istAbgeschlossen = false,
bool bestanden = false,
IReadOnlyList<KursDokument> dokumente = null)
{
return new KursZeile
{
Id = id,
Bezeichnung = bezeichnung,
Anbieter = anbieter,
Datum = DateTime.Parse(datum),
Punkte = punkte,
IstAnerkannt = istAnerkannt,
Bemerkung = bemerkung,
IstAbgeschlossen = istAbgeschlossen,
Bestanden = bestanden,
Dokumente = dokumente ?? new List<KursDokument>(),
};
}
}
}
Darstellung
| Id | Kurs | Datum | Monat | Punkte | Bemerkung | ||
|---|---|---|---|---|---|---|---|
| 1 | Grundkurs Notfallmedizin | 14.01.2026 | Januar 2026 | 12.5 | Präsenzkurs | ||
| 2 | Refresher Innere Medizin | 03.02.2026 | Februar 2026 | 8.0 | – | ||
| 3 | Workshop Gesprächsführung | 19.02.2026 | Februar 2026 | 4.0 | Wartet auf Beleg | ||
| 4 | Fortbildung Radiologie | 07.03.2026 | März 2026 | 16.0 | – | ||
| 5 | Seminar Qualitätssicherung | 22.03.2026 | März 2026 | 6.5 | – | ||
| 6 | Kongress Kardiologie | 11.04.2026 | April 2026 | 20.0 | Zweitägig | ||
| 7 | Kurs Strahlenschutz | 28.04.2026 | April 2026 | 9.0 | – | ||
| 8 | Update Pädiatrie | 15.05.2026 | Mai 2026 | 7.5 | Anmeldung offen | ||
| 9 | Fallseminar Onkologie | 02.06.2026 | Juni 2026 | 11.0 | – | ||
| 10 | Praxiskurs Sonografie | 24.06.2026 | Juni 2026 | 14.0 | – | ||
| 11 | Kurs Palliative Care | 09.07.2026 | Juli 2026 | 10.0 | – | ||
| 12 | Symposium Neurologie | 18.08.2026 | August 2026 | 18.0 | Mit Poster-Session | ||
Zeigt die Spalten-Optionen an einem Beispiel. Drei Punkte lohnen die Aufmerksamkeit:
Ausgeblendet ist nicht dasselbe wie weggelassen. .Visible(false) rendert die Spalte mit, blendet
sie aber aus — nötig, wenn ein Wert für Sortierung oder Skripte gebraucht wird, den Anwender aber
nicht interessiert.
Anzeige und Sortierung trennen. Die Monatsspalte zeigt «Januar 2026», sortiert aber über
.SortByKey(x => x.Datum) nach dem Datum. Ohne das stünde «April» vor «Januar». Weil der Monatsname
berechnet wird, ist die Spalte über Custom definiert und nicht über For.
ResponsivePriority steuert, was zuerst verschwindet. Kleinere Werte bleiben länger sichtbar;
die Bemerkung mit 10 klappt auf schmalen Geräten als Erstes weg, der Kurstitel mit 1 zuletzt.
Die erste Spalte bekommt ohnehin immer die höchste Priorität — eine eigene Angabe wirkt dort nicht.
EmptyColumnChar gilt für die ganze Tabelle: Wo ein Wert fehlt, steht das angegebene Zeichen statt
einer leeren Zelle — das hält die Spaltenkanten ruhig.