Likertskala

Grundfall

Beschriftung der ersten Spalte, die Skalenwerte und Bind – mehr braucht eine Likertskala nicht. Der zweite Parameter von Bind ist der Eigenschaftsname; ohne ihn kommen die Antworten beim POST nicht an.

@model LernzielViewModel

@* Die Skala steuert nur, welcher Wert geschrieben wird – sie sagt der bedienenden Person nicht,
   in welcher Rolle sie gerade ist. Dieser Hinweis gehört deshalb auf die Seite. *@
<div class="alert alert-secondary py-2 px-3 mb-3">
    <strong>Rolle: @Model.RolleName</strong><br />
    <span class="small">@Model.RolleHinweis</span>
</div>

@* Die Skala ist ein Eingabefeld und gehört deshalb in ein Formular – ohne das gehen die
   Radiobuttons beim Absenden nirgendwohin.

   Die Besonderheit steckt in Bind: Neben den Zeilen bekommt der Builder den *Namen* der
   Eigenschaft, an der sie hängen. Daraus baut er die Feldnamen Likertskala[0].SelfAssessmentValue,
   und nur mit diesen findet das Model-Binding die Antworten beim POST wieder. Html.NameFor liefert
   den Namen, statt ihn als Zeichenkette zu wiederholen.

   Die Rolle reist als Hidden-Feld mit: Der Controller muss wissen, welchen der beiden Werte er
   übernehmen darf. *@
<form method="post" asp-area="Kompetenzen" asp-controller="LikertSkalaBeispiel" asp-action="Grundfall">
    @Html.HiddenFor(m => m.Id)
    @Html.HiddenFor(m => m.Rolle)

    @Html.Fmh(Fmh<int>.LikertSkala()
        .Id("lk-grundfall")
        .DescriptionColumn("Lernziel")
        .AssessmentType(Model.Rolle)
        .Columns(spalten =>
        {
            spalten.For(1, "Trifft zu");
            spalten.For(2, "Teilweise");
            spalten.For(3, "Trifft nicht zu");
        })
        .Bind(Model.Likertskala, Html.NameFor(m => m.Likertskala).ToString()))

    @Html.Fmh(Fmh.Button()
        .Text("Bewerten")
        .Size(ButtonSize.Sm)
        .Attributes(new { @class = "mt-3" }))
</form>

@* Die Demo merkt sich den erfassten Stand, damit nach dem Absenden die Änderung zu sehen ist und
   nicht wieder die Ausgangsdaten. Von Hand zurücksetzen: *@
<div class="mt-3">
    @Html.Fmh(Fmh.Button()
        .Text("Ausgangslage wiederherstellen")
        .Size(ButtonSize.Sm)
        .Outline()
        .Action("Zuruecksetzen", "LikertSkalaBeispiel", new { area = "Kompetenzen", beispiel = "grundfall" }))
</div>
using System.Collections.Generic;
using FMH.Komponente.Kompetenzen.Ui.Controls.LikertSkala.Daten;

namespace FMH.Komponente.WebUI.Areas.Kompetenzen.Models
{
    /// <summary>
    /// Das ViewModel einer einzelnen Likertskala. Es enthält nur, was die Skala braucht: die Zeilen
    /// und – je nach Beispiel – die Spaltendefinition.
    /// </summary>
    /// <remarks>
    /// Die Zeilen sind vom Typ <see cref="LikertSkalaRow{T}" /> der Komponente. Fachliche Daten
    /// werden beim Aufbau der Seite darauf abgebildet; ein eigener Zeilentyp ist nicht vorgesehen,
    /// weil der Builder genau diesen Typ bindet.
    /// </remarks>
    public class LernzielViewModel
    {
        /// <summary>
        /// Holt oder setzt die fachliche Id, an der der POST hängt (Zeugnis, Revision, …). Sie
        /// gehört nicht zur Skala, wird aber wie in einem Projekt als Hidden-Feld mitgeführt.
        /// </summary>
        public int Id { get; set; }

        /// <summary>
        /// Holt oder setzt die Zeilen der Skala. Der Name dieser Eigenschaft ist der zweite
        /// Parameter von <c>Bind(...)</c> – daraus entstehen die Feldnamen
        /// <c>Likertskala[0].SelfAssessmentValue</c>, an denen das Model-Binding die Antworten
        /// wiederfindet.
        /// </summary>
        public IList<LikertSkalaRow<int>> Likertskala { get; set; } = new List<LikertSkalaRow<int>>();

        /// <summary>
        /// Holt oder setzt die Rolle, in der die Skala gerade ausgefüllt wird. Sie steuert die
        /// Skala (<c>AssessmentType</c>), den Hinweis über ihr und – nach dem Absenden – welchen
        /// der beiden Werte der Controller übernimmt. Als Hidden-Feld geht sie mit dem POST mit.
        /// </summary>
        public Editable Rolle { get; set; } = Editable.SelfAssessment;

        /// <summary>
        /// Holt den Hinweistext zur aktuellen Rolle.
        /// </summary>
        public string RolleHinweis => Rollen.Hinweis(this.Rolle);

        /// <summary>
        /// Holt den Kurznamen der aktuellen Rolle.
        /// </summary>
        public string RolleName => Rollen.Kurzname(this.Rolle);

        /// <summary>
        /// Holt oder setzt die Spalten der Skala. Nur die Beispiele, die ihre Spalten aus den Daten
        /// beziehen, füllen sie – sonst stehen die Spalten im Markup (<c>Columns(spalten => …)</c>).
        /// </summary>
        public IList<LikertSkalaHeaderColumn<int>> Spalten { get; set; } = new List<LikertSkalaHeaderColumn<int>>();
    }
}
using System.Collections.Generic;
using FMH.Komponente.Kompetenzen.Ui.Controls.LikertSkala;
using FMH.Komponente.Kompetenzen.Ui.Controls.LikertSkala.Daten;

namespace FMH.Komponente.WebUI.Areas.Kompetenzen.Models
{
    /// <summary>
    /// Die Testdaten der Demo. Ohne Datenbank, dafür fachlich nahe an einem Zeugnis: Lernziele mit
    /// Code, Text und den bereits erfassten Einschätzungen.
    /// </summary>
    /// <remarks>
    /// Jede Methode liefert bewusst eine **neue** Liste. Die Zeilen sind veränderlich, und der POST
    /// eines Beispiels bindet direkt auf sie – eine prozessweit geteilte Liste würde sich zwischen
    /// den Seiten gegenseitig überschreiben.
    /// </remarks>
    public static class LernzielDaten
    {
        /// <summary>
        /// Die Skalenwerte als Spaltendefinition, wie sie aus einer Konfigurationstabelle käme.
        /// </summary>
        /// <remarks>
        /// **Die Reihenfolge ist fachlich bedeutsam**: Der Erfüllungsgrad vergleicht die Position
        /// der Spalten, nicht deren Werte. Die beste Stufe steht deshalb zuvorderst, «nicht
        /// beurteilbar» als Nicht-Stufe zuhinterst.
        /// </remarks>
        /// <returns>Die Spalten der Skala.</returns>
        public static IList<LikertSkalaHeaderColumn<int>> Stufen()
        {
            return new List<LikertSkalaHeaderColumn<int>>
                   {
                       new LikertSkalaHeaderColumn<int> { Value = 4, Text = "Stufe 4", Width = 130 },
                       new LikertSkalaHeaderColumn<int> { Value = 3, Text = "Stufe 3", Width = 130 },
                       new LikertSkalaHeaderColumn<int> { Value = 2, Text = "Stufe 2", Width = 130 },
                       new LikertSkalaHeaderColumn<int> { Value = 1, Text = "Stufe 1", Width = 130 },
                       new LikertSkalaHeaderColumn<int> { Value = 99, Text = "nicht beurteilbar", Width = 150 },
                   };
        }

        /// <summary>
        /// Lernziele mit einer angefangenen Selbsteinschätzung – die Ausgangslage des Grundfalls.
        /// </summary>
        /// <returns>Die Zeilen der Skala.</returns>
        public static IList<LikertSkalaRow<int>> Lernziele()
        {
            return new List<LikertSkalaRow<int>>
                   {
                       new LikertSkalaRow<int> { Code = 5, Text = "Patientinnen und Patienten umfassend beraten und begleiten", SelfAssessmentValue = 1 },
                       new LikertSkalaRow<int> { Code = 7, Text = "Befunde strukturiert dokumentieren", SelfAssessmentValue = 2 },
                       new LikertSkalaRow<int> { Code = 9, Text = "Im interprofessionellen Team zusammenarbeiten" },
                       new LikertSkalaRow<int> { Code = 11, Text = "Eigene Grenzen erkennen und Hilfe anfordern", SelfAssessmentValue = 1 },
                       new LikertSkalaRow<int> { Code = 13, Text = "Regelmässig an Weiterbildungen teilnehmen" },
                   };
        }

        /// <summary>
        /// Lernziele mit Überschrift je Zeile – Grundlage des Beispiels «Texte um die Skala».
        /// </summary>
        /// <returns>Die Zeilen der Skala.</returns>
        public static IList<LikertSkalaRow<int>> LernzieleMitUeberschrift()
        {
            return new List<LikertSkalaRow<int>>
                   {
                       new LikertSkalaRow<int>
                       {
                           Code = 5,
                           Header = "Betreuung",
                           Text = "Kann Patientinnen und Patienten umfassend beraten, begleiten und betreuen und dabei deren Autonomie gebührend respektieren",
                           SelfAssessmentValue = 2,
                       },
                       new LikertSkalaRow<int>
                       {
                           Code = 7,
                           Header = "Kommunikation",
                           Text = "Kann komplexe Sachverhalte gegenüber Angehörigen verständlich darstellen",
                       },
                       new LikertSkalaRow<int>
                       {
                           Code = 9,
                           Header = "Zusammenarbeit",
                           Text = "Kann die eigene Rolle im interprofessionellen Team einnehmen und Aufgaben delegieren",
                           SelfAssessmentValue = 1,
                       },
                   };
        }

        /// <summary>
        /// Kompetenzen mit Selbst- und Fremdeinschätzung – Grundlage der Beispiele
        /// «Selbst- und Fremdeinschätzung» und «Erfüllungsgrad».
        /// </summary>
        /// <remarks>
        /// <c>DegreeOfAchievementValue</c> ist die **geforderte** Stufe. Bewusst gemischt: erfüllt,
        /// übererfüllt, nicht erreicht, ohne Anforderung und «nicht beurteilbar» – nur so lässt sich
        /// die Prozentzahl im Kopf der Skala nachrechnen.
        /// </remarks>
        /// <returns>Die Zeilen der Skala.</returns>
        public static IList<LikertSkalaRow<int>> Kompetenzen()
        {
            return new List<LikertSkalaRow<int>>
                   {
                       new LikertSkalaRow<int>
                       {
                           Code = 5,
                           Text = "Akutes Lungenödem «A»",
                           SelfAssessmentValue = 4,
                           ExternalAssessmentValue = 4,
                           DegreeOfAchievementValue = 4,
                       },
                       new LikertSkalaRow<int>
                       {
                           Code = 7,
                           Text = "Lungenembolie «A»",
                           SelfAssessmentValue = 3,
                           ExternalAssessmentValue = 4,
                           DegreeOfAchievementValue = 3,
                       },
                       new LikertSkalaRow<int>
                       {
                           Code = 9,
                           Text = "Akuter Myokardinfarkt ACS «A»",
                           SelfAssessmentValue = 3,
                           ExternalAssessmentValue = 2,
                           DegreeOfAchievementValue = 3,
                       },
                       new LikertSkalaRow<int>
                       {
                           Code = 11,
                           Text = "Hypertensive Krise «A»",
                           SelfAssessmentValue = 3,
                           ExternalAssessmentValue = 1,
                       },
                       new LikertSkalaRow<int>
                       {
                           Code = 13,
                           Text = "Arrhythmien und Rhythmusstörungen «A»",
                           SelfAssessmentValue = 3,
                           ExternalAssessmentValue = 99,
                           DegreeOfAchievementValue = 2,
                       },
                       new LikertSkalaRow<int>
                       {
                           Code = 15,
                           Text = "Aortendissektion (Erwachsene) «B»",
                           SelfAssessmentValue = 2,
                           ExternalAssessmentValue = 2,
                           DegreeOfAchievementValue = 2,
                       },
                   };
        }

        /// <summary>
        /// Zwei Skalen mit unterschiedlichen Spalten als eine Gruppe – Grundlage des Beispiels
        /// «Mehrere Skalen gruppieren».
        /// </summary>
        /// <returns>Das Modell der Gruppe.</returns>
        public static LikertSkalaGroupModel Gruppe()
        {
            var gruppe = new LikertSkalaGroupModel
                         {
                             // Der Bearbeitungsmodus steht an der Gruppe, nicht an der einzelnen
                             // Skala: Wer eine Gruppe ausfüllt, tut das durchgehend in einer Rolle.
                             AssessmentType = Editable.ExternalAssessment,
                             GroupHeader = "Zwischenevaluation 2026",
                             Header = "Kompetenzen und Verhalten",
                             Introduction = "Bitte beide Tabellen vollständig ausfüllen.",
                             Footer = "Die Einschätzung wird mit der Kandidatin oder dem Kandidaten besprochen.",
                         };

            var fachlich = new LikertSkalaModel
                           {
                               Id = 10,
                               LikerstSkalaHeader = "<h5>Fachliche Kompetenzen</h5>",
                               Header = "Kompetenz",
                               Introduction = "Vier Stufen, dazu «nicht beurteilbar».",
                               ShowDegreeOfAchievement = true,
                               Columns = Stufen(),
                               Likertskala = Kompetenzen(),
                           };

            var verhalten = new LikertSkalaModel
                            {
                                Id = 12,
                                LikerstSkalaHeader = "<h5>Verhalten im Team</h5>",
                                Header = "Merkmal",
                                Footer = "Erfüllt bedeutet: im Alltag durchgehend beobachtbar.",

                                // Kein Skalenwert 0: Eine noch nicht beurteilte Zeile hat als int
                                // ebenfalls 0 und sähe sonst aus wie eine bewusste Antwort.
                                Columns = new List<LikertSkalaHeaderColumn<int>>
                                          {
                                              new LikertSkalaHeaderColumn<int> { Value = 1, Text = "Erfüllt", Width = 150 },
                                              new LikertSkalaHeaderColumn<int> { Value = 2, Text = "Nicht erfüllt", Width = 150 },
                                          },
                                Likertskala = new List<LikertSkalaRow<int>>
                                              {
                                                  new LikertSkalaRow<int> { Code = 21, Text = "Pünktlichkeit", ExternalAssessmentValue = 1 },
                                                  new LikertSkalaRow<int> { Code = 23, Text = "Übergaben vollständig", ExternalAssessmentValue = 2 },
                                                  new LikertSkalaRow<int> { Code = 25, Text = "Rückfragen rechtzeitig gestellt" },
                                              },
                            };

            gruppe.LikertSkalaGroup.Add(fachlich);
            gruppe.LikertSkalaGroup.Add(verhalten);

            return gruppe;
        }
    }
}
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using FMH.Komponente.Kompetenzen.Ui.Controls.LikertSkala;
using FMH.Komponente.Kompetenzen.Ui.Controls.LikertSkala.Daten;

namespace FMH.Komponente.WebUI.Areas.Kompetenzen.Models
{
    /// <summary>
    /// Der Stand der Beispiele. Ohne ihn zeigte die Seite nach dem Absenden wieder die
    /// Ausgangsdaten – die Wirkung einer Änderung wäre nicht zu sehen.
    /// </summary>
    /// <remarks>
    /// Statt einer Datenbank eine prozessweite Ablage je Beispiel; nach einem Neustart der
    /// Anwendung steht wieder die Ausgangslage. In einem Projekt steht an dieser Stelle das
    /// Repository – der Ablauf ist derselbe.
    /// <para>
    /// <b>Übernommen wird über den <c>Code</c>, nicht die Position.</b> Aus dem Formular kommen nur
    /// Code und die Einschätzung der aktuellen Rolle zurück; die geforderte Stufe, die Überschriften
    /// und die Spalten stammen weiterhin aus der eigenen Quelle. Genau so gehört es auch in einem
    /// Projekt gemacht: Der POST liefert Antworten, nicht die Konfiguration.
    /// </para>
    /// </remarks>
    public static class LernzielZustand
    {
        private static readonly ConcurrentDictionary<string, LikertSkalaGroupModel> Gruppen =
            new ConcurrentDictionary<string, LikertSkalaGroupModel>();

        private static readonly ConcurrentDictionary<string, IList<LikertSkalaRow<int>>> Skalen =
            new ConcurrentDictionary<string, IList<LikertSkalaRow<int>>>();

        /// <summary>
        /// Der aktuelle Stand einer einzelnen Skala; beim ersten Aufruf die Ausgangslage.
        /// </summary>
        /// <param name="beispiel">Der Schlüssel des Beispiels.</param>
        /// <param name="ausgangslage">Liefert die Ausgangsdaten, wenn noch nichts erfasst wurde.</param>
        /// <returns>Die Zeilen der Skala.</returns>
        public static IList<LikertSkalaRow<int>> Skala(string beispiel, Func<IList<LikertSkalaRow<int>>> ausgangslage)
        {
            return Skalen.GetOrAdd(beispiel, _ => ausgangslage());
        }

        /// <summary>
        /// Der aktuelle Stand einer Gruppe; beim ersten Aufruf die Ausgangslage.
        /// </summary>
        /// <param name="beispiel">Der Schlüssel des Beispiels.</param>
        /// <param name="ausgangslage">Liefert die Ausgangsdaten, wenn noch nichts erfasst wurde.</param>
        /// <returns>Das Modell der Gruppe.</returns>
        public static LikertSkalaGroupModel Gruppe(string beispiel, Func<LikertSkalaGroupModel> ausgangslage)
        {
            return Gruppen.GetOrAdd(beispiel, _ => ausgangslage());
        }

        /// <summary>
        /// Übernimmt die Antworten eines POST in den Stand einer einzelnen Skala.
        /// </summary>
        /// <param name="beispiel">Der Schlüssel des Beispiels.</param>
        /// <param name="gepostet">Die Zeilen aus dem Formular.</param>
        /// <param name="rolle">Die Rolle, in der erfasst wurde.</param>
        /// <returns>Die Anzahl geänderter Zeilen.</returns>
        public static int Uebernehmen(string beispiel, IList<LikertSkalaRow<int>> gepostet, Editable rolle)
        {
            if (Skalen.TryGetValue(beispiel, out var bestand) == false)
            {
                return 0;
            }

            return Uebernehmen(bestand, gepostet, rolle);
        }

        /// <summary>
        /// Übernimmt die Antworten eines POST in den Stand einer Gruppe.
        /// </summary>
        /// <param name="beispiel">Der Schlüssel des Beispiels.</param>
        /// <param name="gepostet">Die Gruppe aus dem Formular.</param>
        /// <returns>Die Anzahl geänderter Zeilen über alle Skalen.</returns>
        public static int UebernehmenGruppe(string beispiel, LikertSkalaGroupModel gepostet)
        {
            if (Gruppen.TryGetValue(beispiel, out var bestand))
            {
                // Die Rolle steht am Bestand: AssessmentType ist Konfiguration und wird nicht
                // gepostet – am geposteten Modell stünde der Standardwert.
                var anzahl = 0;

                for (var idx = 0; idx < bestand.LikertSkalaGroup.Count; idx++)
                {
                    var geposteteSkala = gepostet?.LikertSkalaGroup.ElementAtOrDefault(idx);

                    anzahl += Uebernehmen(
                        bestand.LikertSkalaGroup[idx].Likertskala,
                        geposteteSkala?.Likertskala,
                        bestand.AssessmentType);
                }

                return anzahl;
            }

            return 0;
        }

        /// <summary>
        /// Verwirft den erfassten Stand eines Beispiels; der nächste Aufruf zeigt wieder die
        /// Ausgangslage.
        /// </summary>
        /// <param name="beispiel">Der Schlüssel des Beispiels.</param>
        public static void Zuruecksetzen(string beispiel)
        {
            Skalen.TryRemove(beispiel, out _);
            Gruppen.TryRemove(beispiel, out _);
        }

        private static int Uebernehmen(IList<LikertSkalaRow<int>> bestand, IList<LikertSkalaRow<int>> gepostet, Editable rolle)
        {
            // In dieser Rolle sind die Radiobuttons gesperrt und senden nichts – ein POST darf den
            // Stand deshalb nicht anfassen.
            if (rolle == Editable.ExternalAssessmentReadOnly || gepostet == null)
            {
                return 0;
            }

            var anzahl = 0;

            foreach (var zeile in gepostet)
            {
                var ziel = bestand.FirstOrDefault(vorhanden => vorhanden.Code == zeile.Code);

                if (ziel == null)
                {
                    continue;
                }

                if (rolle == Editable.ExternalAssessment)
                {
                    if (ziel.ExternalAssessmentValue != zeile.ExternalAssessmentValue)
                    {
                        ziel.ExternalAssessmentValue = zeile.ExternalAssessmentValue;
                        anzahl++;
                    }
                }
                else if (ziel.SelfAssessmentValue != zeile.SelfAssessmentValue)
                {
                    ziel.SelfAssessmentValue = zeile.SelfAssessmentValue;
                    anzahl++;
                }
            }

            return anzahl;
        }
    }
}
using FMH.Komponente.Basis.Erweiterungen;
using FMH.Komponente.Basis.Ui.Feedback.Alert;
using FMH.Komponente.Kompetenzen.Ui.Controls.LikertSkala.Daten;
using FMH.Komponente.WebUI.Areas.Kompetenzen.Models;
using Microsoft.AspNetCore.Mvc;

namespace FMH.Komponente.WebUI.Areas.Kompetenzen.Controllers.Beispiele
{
    /// <summary>
    /// Die Gegenstelle der Beispiele. Die Skala ist ein Eingabe-Steuerelement – ohne einen POST, der
    /// die Antworten übernimmt, bliebe die halbe Erklärung offen.
    /// </summary>
    /// <remarks>
    /// Alle Actions arbeiten nach dem PRG-Muster: übernehmen, Meldung merken, zurück auf die
    /// Demo-Seite. Der Stand liegt in LernzielZustand, damit die Seite nach dem Absenden das
    /// Erfasste zeigt und nicht wieder die Ausgangsdaten.
    /// <para>
    /// Übernommen wird über den Code der Zeile und nur der Wert der aktuellen Rolle. Alles andere –
    /// die geforderte Stufe, die Spalten, die Texte – steht in keinem Formularfeld und kommt
    /// weiterhin aus der eigenen Quelle.
    /// </para>
    /// </remarks>
    [Area("Kompetenzen")]
    public class LikertSkalaBeispielController : Controller
    {
        [HttpPost]
        [ValidateAntiForgeryToken]
        public IActionResult Einschaetzung(LernzielViewModel modell)
        {
            // Die Rolle kommt als Hidden-Feld mit und entscheidet, welcher der beiden Werte
            // geschrieben wird. Der jeweils andere reist unverändert in seinem Hidden-Feld mit.
            this.MeldeUebernahme(LernzielZustand.Uebernehmen("einschaetzung", modell.Likertskala, modell.Rolle), modell.Rolle);

            return this.RedirectToAction("Einschaetzung", "Demo", new { area = "Kompetenzen", rolle = modell.Rolle });
        }

        [HttpPost]
        [ValidateAntiForgeryToken]
        public IActionResult Erfuellungsgrad(LernzielViewModel modell)
        {
            this.MeldeUebernahme(LernzielZustand.Uebernehmen("erfuellungsgrad", modell.Likertskala, modell.Rolle), modell.Rolle);

            return this.Zurueck(nameof(this.Erfuellungsgrad));
        }

        [HttpPost]
        [ValidateAntiForgeryToken]
        public IActionResult Grundfall(LernzielViewModel modell)
        {
            this.MeldeUebernahme(LernzielZustand.Uebernehmen("grundfall", modell.Likertskala, modell.Rolle), modell.Rolle);

            return this.Zurueck(nameof(this.Grundfall));
        }

        [HttpPost]
        [ValidateAntiForgeryToken]
        public IActionResult Gruppe(KompetenzGruppeViewModel modell)
        {
            // Die Rolle steht bei einer Gruppe am Modell und wird nicht gepostet – der Zustand
            // nimmt sie deshalb aus dem eigenen Bestand.
            var anzahl = LernzielZustand.UebernehmenGruppe("gruppe", modell.Tabellen);

            this.AddAlert(
                anzahl == 0
                    ? "Keine Änderung – es wurde nichts anders bewertet als zuvor."
                    : $"{anzahl} Bewertung(en) über alle Skalen übernommen.",
                anzahl == 0 ? AlertType.Info : AlertType.Success,
                autoHide: true);

            return this.Zurueck(nameof(this.Gruppe));
        }

        [HttpPost]
        [ValidateAntiForgeryToken]
        public IActionResult Texte(LernzielViewModel modell)
        {
            this.MeldeUebernahme(LernzielZustand.Uebernehmen("texte", modell.Likertskala, modell.Rolle), modell.Rolle);

            return this.Zurueck(nameof(this.Texte));
        }

        /// <summary>
        /// Verwirft den erfassten Stand eines Beispiels und zeigt wieder die Ausgangsdaten.
        /// </summary>
        /// <param name="beispiel">Der Schlüssel des Beispiels, zugleich der Name der Demo-Action.</param>
        /// <param name="rolle">Die Rolle, in die zurückgekehrt wird (nur beim Beispiel «einschaetzung»).</param>
        /// <returns>Die Umleitung auf die Demo-Seite.</returns>
        public IActionResult Zuruecksetzen(string beispiel, Editable rolle = Editable.SelfAssessment)
        {
            LernzielZustand.Zuruecksetzen(beispiel);

            this.AddAlert("Ausgangslage wiederhergestellt.", AlertType.Info, autoHide: true);

            return this.RedirectToAction(DemoAction(beispiel), "Demo", new { area = "Kompetenzen", rolle });
        }

        /// <summary>
        /// Der Schlüssel des Beispiels ist der kleingeschriebene Name der Demo-Action.
        /// </summary>
        /// <param name="beispiel">Der Schluessel des Beispiels.</param>
        /// <returns>Der Name der Demo-Action.</returns>
        private static string DemoAction(string beispiel)
        {
            switch (beispiel)
            {
                case "einschaetzung":
                    return "Einschaetzung";
                case "erfuellungsgrad":
                    return "Erfuellungsgrad";
                case "gruppe":
                    return "Gruppe";
                case "texte":
                    return "Texte";
                default:
                    return "Grundfall";
            }
        }

        private void MeldeUebernahme(int anzahl, Editable rolle)
        {
            if (Rollen.IstBearbeitbar(rolle) == false)
            {
                // Gesperrte Radiobuttons senden nichts – ein POST aus dieser Rolle darf den Stand
                // nicht anfassen.
                this.AddAlert("Nur-Ansicht-Rolle – es wurde nichts übernommen.", AlertType.Warning, autoHide: true);

                return;
            }

            this.AddAlert(
                anzahl == 0
                    ? $"Keine Änderung an «{Rollen.Kurzname(rolle)}» – es wurde nichts anders bewertet als zuvor."
                    : $"{anzahl} Bewertung(en) für «{Rollen.Kurzname(rolle)}» übernommen.",
                anzahl == 0 ? AlertType.Info : AlertType.Success,
                autoHide: true);
        }

        private IActionResult Zurueck(string action)
        {
            return this.RedirectToAction(action, "Demo", new { area = "Kompetenzen" });
        }
    }
}

Darstellung

Rolle: Self-assessment
Erfasst wird «Self-assessment»; «definitive assessment» steht als Markierung daneben.
Lernziel Trifft zu Teilweise Trifft nicht zu
Patientinnen und Patienten umfassend beraten und begleiten
Befunde strukturiert dokumentieren
Im interprofessionellen Team zusammenarbeiten
Eigene Grenzen erkennen und Hilfe anfordern
Regelmässig an Weiterbildungen teilnehmen

Die Skala ist ein Eingabe-Steuerelement, kein Bericht: Sie gehört in ein <form>, und ihre Antworten kommen über das gewöhnliche Model-Binding zurück.

@Html.Fmh(Fmh<int>.LikertSkala()
    .DescriptionColumn("Lernziel")
    .Columns(spalten =>
    {
        spalten.For(1, "Trifft zu");
        spalten.For(2, "Teilweise");
        spalten.For(3, "Trifft nicht zu");
    })
    .Bind(Model.Likertskala, Html.NameFor(m => m.Likertskala).ToString()))

Der zweite Parameter von Bind

Das ist die Stelle, an der eine Likertskala von anderen Widgets abweicht. Der Builder rendert die Formularfelder selbst und kennt den Ausdruck nicht, aus dem die Zeilen stammen. Er braucht deshalb den Namen der Eigenschaft als Zeichenkette und baut daraus:

<input type="radio" name="Likertskala[0].SelfAssessmentValue" value="1" />
<input type="hidden" name="Likertskala[0].Code" value="5" />

Html.NameFor(m => m.Likertskala).ToString() liefert diesen Namen aus dem Modell, statt ihn als Zeichenkette zu wiederholen – bei einer Umbenennung fällt der Fehler dann beim Kompilieren auf und nicht erst beim leeren POST. Steht die Skala in einem verschachtelten Modell, liefert NameFor auch den Präfix mit (Abschnitt.Likertskala) – von Hand geschrieben wird das schnell falsch.

Was das Modell mitbringen muss

Die Zeilen sind vom Typ LikertSkalaRow<int> der Komponente; einen eigenen Zeilentyp gibt es nicht. Fachliche Daten werden beim Aufbau der Seite darauf abgebildet – Code ist der Schlüssel, an dem die Antwort später wieder zugeordnet wird.

Nach dem Absenden

Der POST liefert Code und Einschätzung, sonst nichts. Übernommen wird deshalb über den Code in die eigenen Zeilen – nicht über die Position und nicht, indem das gepostete Modell weiterverwendet wird:

var ziel = bestand.FirstOrDefault(zeile => zeile.Code == gepostet.Code);
ziel.SelfAssessmentValue = gepostet.SelfAssessmentValue;

Danach die Seite über einen Redirect neu aufbauen (PRG). Die Beispiele legen den Stand dafür in einer prozessweiten Ablage ab (Tab «Zustand»); in einem Projekt steht dort das Repository. Über die Schaltfläche «Ausgangslage wiederherstellen» lässt sich die Demo jederzeit zurücksetzen.

Die Rolle gehört auf die Seite

AssessmentType steuert die Skala, sagt der bedienenden Person aber nichts: Beide Rollen sehen dieselbe Tabelle, geschrieben wird ein anderer Wert. Über der Skala steht deshalb in jedem Beispiel ein Hinweis, welche Rolle gerade gilt und was sie erfasst. Die Begriffe dafür liefert AssessmentLegend, damit Hinweis und Legende dieselbe Sprache sprechen.

Was zu beachten ist

  • Ohne Klick kommt kein Wert. Ein int ist dann 0. Wer «unbeantwortet» von einer echten 0 unterscheiden muss, vergibt keinen Skalenwert 0 – oder prüft die Vollständigkeit fachlich.
  • .Id(...) setzen, sobald zwei Skalen auf einer Seite stehen. Ohne Angabe vergibt der Builder eine GUID; die Ids sind dann bei jedem Aufruf andere und taugen weder für CSS noch für Tests.
  • Die Vollständigkeit prüft der Controller. Die Zeilen sind Klassen der Komponente und tragen keine DataAnnotations – ob alle Zeilen beurteilt sind, ist eine fachliche Prüfung nach dem POST.
  • Der Bearbeitungsmodus ist ohne weitere Angabe Editable.SelfAssessment – geschrieben wird also SelfAssessmentValue. Siehe «Selbst- und Fremdeinschätzung».