Likertskala

Rollen

AktuelleRolle entscheidet allein, was das Template rendert: welche Antwort bearbeitet wird, welche als Vorbefund darübersteht und ob es überhaupt eine Speichern-Schaltfläche gibt.

@model BeurteilungViewModel
@{
    // Die Rolle steht an jeder Frage (AktuelleRolle) und entscheidet allein, was das Template
    // rendert. In einem Projekt kommt sie aus der Berechtigung; hier ist sie umschaltbar, damit
    // sich die drei Ansichten an denselben Daten vergleichen lassen.
    var rolle = (Rolle)ViewBag.Rolle;

    var rollen = new[] { Rolle.FachDelegierter, Rolle.FachFremdDelegierter, Rolle.Abgeschlossen };
}

<div class="btn-group btn-group-sm mb-3" role="group" aria-label="Rolle wechseln">
    @foreach (var eintrag in rollen)
    {
        <a class="btn @(eintrag == rolle ? "btn-primary" : "btn-outline-primary")"
           asp-area="Kompetenzen" asp-controller="Demo" asp-action="FrageAntwortRollen" asp-route-rolle="@eintrag">
            @FrageAntwortRollen.Kurzname(eintrag)
        </a>
    }
</div>

<div class="alert alert-secondary py-2 px-3 mb-3">
    <strong>Rolle: @FrageAntwortRollen.Kurzname(rolle)</strong>
    <span class="text-muted small">(Rolle.@rolle)</span><br />
    <span class="small">@FrageAntwortRollen.Hinweis(rolle)</span>
</div>

@for (var idx = 0; idx < Model.FrageAntwortListe.Count; idx++)
{
    @Html.EditorFor(x => x.FrageAntwortListe[idx], UiEditor.Kompetenzen.FrageAnwort)
    <hr />
}

<div class="mt-3">
    @Html.Fmh(Fmh.Button()
        .Text("Ausgangslage wiederherstellen")
        .Size(ButtonSize.Sm)
        .Outline()
        .Action("FrageAntwortZuruecksetzen", "Demo", new { area = "Kompetenzen", ziel = "FrageAntwortRollen", rolle }))
</div>
using System.Collections.Generic;
using FMH.Komponente.Kompetenzen.Ui.Controls.FrageAnwort.Daten;

namespace FMH.Komponente.WebUI.Areas.Kompetenzen.Models
{
    /// <summary>
    /// Die Ausgangsdaten der FrageAntwort-Beispiele: eine Beurteilung mit drei Fragen.
    /// </summary>
    /// <remarks>
    /// Bewusst gemischt: eine Frage ist vom fachfremden Delegierten bereits beantwortet (sie
    /// erscheint dem Fachdelegierten als Vorbefund), eine ist offen, eine trägt eine Antwort «Nein»
    /// mit Begründung. Nur so zeigen die Beispiele alle drei Zustände einer Frage nebeneinander.
    /// </remarks>
    public static class FrageAntwortBeispielDaten
    {
        /// <summary>
        /// Die Beurteilung mit ihren Fragen in der Ausgangslage.
        /// </summary>
        /// <returns>Die Beurteilung; jeder Aufruf liefert eine neue Instanz.</returns>
        public static BeurteilungViewModel Beurteilung()
        {
            return new BeurteilungViewModel
                   {
                       FachDelegierterFmhId = 4711,
                       FachDelegierterName = "Dr. med. Anna Kern",
                       FachfremdDelegierterFmhId = 4812,
                       FachfremdDelegierterName = "Dr. med. Beat Roth",
                       FrageAntwortListe = Fragen(),
                   };
        }

        /// <summary>
        /// Die Fragen der Beurteilung in der Ausgangslage.
        /// </summary>
        /// <returns>Die Fragen; jeder Aufruf liefert eine neue Liste.</returns>
        public static IList<FrageAntwortViewModel> Fragen()
        {
            return new List<FrageAntwortViewModel>
                   {
                       new FrageAntwortViewModel
                       {
                           Id = 1,
                           Reihenfolge = 1,
                           Frage = "Wurde die geforderte Anzahl Weiterbildungsjahre am Zentrum absolviert?",
                           AntwortFachFremdDelegierter = AntwortTyp.Ja,
                           AntwortTextFachFremdDelegierter = "Belege liegen vollständig vor.",
                       },
                       new FrageAntwortViewModel
                       {
                           Id = 2,
                           Reihenfolge = 2,
                           Frage = "Sind die Lernziele des Weiterbildungsprogramms nachweislich erreicht?",
                       },
                       new FrageAntwortViewModel
                       {
                           Id = 3,
                           Reihenfolge = 3,
                           Frage = "Bestehen Vorbehalte gegen die Erteilung des Facharzttitels?",
                           AntwortFachFremdDelegierter = AntwortTyp.Nein,
                           AntwortTextFachFremdDelegierter = "Die Beurteilung der Klinik weicht in Punkt 4 ab.",
                       },
                   };
        }
    }
}
using System.Collections.Generic;
using System.Linq;
using FMH.Komponente.Kompetenzen.Ui.Controls.FrageAnwort.Daten;

namespace FMH.Komponente.WebUI.Areas.Kompetenzen.Models
{
    /// <summary>
    /// Der Stand der FrageAntwort-Beispiele: die Antworten, die über die Endpunkte eingegangen sind.
    /// </summary>
    /// <remarks>
    /// Alle drei Beispiele teilen sich denselben Stand. Das ist Absicht: Wer im Grundfall als
    /// Fachdelegierter antwortet und danach auf «Rollen» wechselt, sieht dort genau diese Antwort
    /// als Vorbefund, das Zusammenspiel der beiden Rollen ist der Kern der Komponente.
    /// <para>
    /// Statt einer Datenbank eine prozessweite Ablage; nach einem Neustart der Anwendung steht
    /// wieder die Ausgangslage. In einem Projekt steht an dieser Stelle das Repository.
    /// </para>
    /// </remarks>
    public static class FrageAntwortZustand
    {
        private static readonly object Sperre = new object();

        private static IList<FrageAntwortViewModel> fragen = FrageAntwortBeispielDaten.Fragen();

        /// <summary>
        /// Der aktuelle Stand der Fragen.
        /// </summary>
        /// <returns>Die Fragen mit den bisher erfassten Antworten.</returns>
        public static IList<FrageAntwortViewModel> Fragen()
        {
            lock (Sperre)
            {
                return fragen;
            }
        }

        /// <summary>
        /// Übernimmt eine Antwort in den Stand.
        /// </summary>
        /// <param name="gepostet">Die Frage aus dem Formular des EditorTemplates.</param>
        /// <returns>Die aktualisierte Frage oder <c>null</c>, wenn es die Id nicht gibt.</returns>
        public static FrageAntwortViewModel Uebernehmen(FrageAntwortViewModel gepostet)
        {
            lock (Sperre)
            {
                var ziel = fragen.FirstOrDefault(frage => frage.Id == gepostet.Id);

                if (ziel == null)
                {
                    return null;
                }

                // Geschrieben wird nur, was die postende Rolle bearbeiten durfte. Die Antwort der
                // Gegenseite steht im Formular gar nicht drin und darf hier nicht überschrieben
                // werden.
                if (gepostet.AktuelleRolle == Rolle.FachFremdDelegierter)
                {
                    ziel.AntwortFachFremdDelegierter = gepostet.AntwortFachFremdDelegierter;
                    ziel.AntwortTextFachFremdDelegierter = gepostet.AntwortTextFachFremdDelegierter;
                }
                else
                {
                    ziel.AntwortFachDelegierter = gepostet.AntwortFachDelegierter;
                    ziel.AntwortTextFachDelegierter = gepostet.AntwortTextFachDelegierter;
                }

                return ziel;
            }
        }

        /// <summary>
        /// Der Gesamtstand der Beurteilung aus Sicht einer Rolle.
        /// </summary>
        /// <param name="rolle">Die Rolle, deren Antworten gezählt werden.</param>
        /// <returns>Unbeantwortet, teilweise oder komplett beantwortet.</returns>
        public static AntwortStatus Status(Rolle rolle)
        {
            lock (Sperre)
            {
                var beantwortet = fragen.Count(frage => IstBeantwortet(frage, rolle));

                if (beantwortet == 0)
                {
                    return AntwortStatus.Unbeantwortet;
                }

                return beantwortet == fragen.Count ? AntwortStatus.KomplettBeantwortet : AntwortStatus.TeilweiseBeantwortet;
            }
        }

        /// <summary>
        /// Verwirft die erfassten Antworten; der nächste Aufruf zeigt wieder die Ausgangslage.
        /// </summary>
        public static void Zuruecksetzen()
        {
            lock (Sperre)
            {
                fragen = FrageAntwortBeispielDaten.Fragen();
            }
        }

        private static bool IstBeantwortet(FrageAntwortViewModel frage, Rolle rolle)
        {
            var antwort = rolle == Rolle.FachFremdDelegierter
                ? frage.AntwortFachFremdDelegierter
                : frage.AntwortFachDelegierter;

            return antwort.HasValue && antwort != AntwortTyp.Unbeantwortet;
        }
    }
}
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Text.Json;
using FMH.Komponente.Infrastruktur.Schnittstellen.Ui;
using FMH.Komponente.Kompetenzen.Ui.Controls.FrageAnwort.Daten;
using FMH.Komponente.Kompetenzen.Ui.Controls.LikertSkala.Daten;
using FMH.Komponente.WebUI.Areas.Kompetenzen.Models;
using FMH.Komponente.WebUI.Erweiterungen;
using FMH.Komponente.WebUI.Models.Demos;
using Microsoft.AspNetCore.Mvc;

namespace FMH.Komponente.WebUI.Areas.Kompetenzen.Controllers
{
    /// <summary>
    /// Demos des Pakets Kompetenzen: die Likertskala und das EditorTemplate FrageAntwort.
    /// </summary>
    /// <remarks>
    /// Eine Seite je Beispiel: Eine Skala beansprucht die volle Breite, und zwei davon untereinander
    /// sind als getrennte Fälle nicht mehr auseinanderzuhalten – sie sehen sich zu ähnlich.
    /// <para>
    /// Die Zeilen kommen aus <see cref="LernzielZustand" /> und nicht direkt aus
    /// <see cref="LernzielDaten" />: Was ein Beispiel bewertet hat, bleibt erhalten. Die
    /// **Konfiguration** – Spalten, Rolle, Texte – wird dagegen bei jedem Aufruf neu gesetzt, weil
    /// sie in keinem Formularfeld steht und den POST nicht überlebt.
    /// </para>
    /// <para>
    /// Die Endpunkte <see cref="AntwortFD" /> und <see cref="AntwortFFD" /> stehen bewusst hier und
    /// nicht in einem eigenen Beispiel-Controller: Das EditorTemplate FrageAntwort baut die Adresse
    /// seines Formulars ohne Controllernamen. Es postet damit immer an **den Controller, der die
    /// Seite gerendert hat**, hier also an den DemoController.
    /// </para>
    /// </remarks>
    [Area("Kompetenzen")]
    public class DemoController : Controller
    {
        /// <summary>
        /// Pflicht für die Antworten der FrageAntwort-Endpunkte: Der Json-Serializer arbeitet
        /// standardmässig mit Camel Case, das Skript der Komponente liest die Antwort aber in
        /// Pascal Case (<c>Nachricht</c>, <c>IconTyp</c>).
        /// </summary>
        /// <remarks>
        /// Ohne diese Einstellung zeigt die Snackbar «undefined» und das Ereignis
        /// <c>FrageAntwortStatus</c> liefert ein Objekt, dessen Eigenschaften nicht die der
        /// Klasse <c>AntwortResultat</c> sind.
        /// </remarks>
        private static readonly JsonSerializerOptions JsonPascalCase = new JsonSerializerOptions { PropertyNamingPolicy = null };

        /// <summary>
        /// Die Vorbedingungen als eigene Seite, erster Navigationspunkt des Pakets: was die
        /// Hostanwendung einmal einrichten muss. Fehlt davon etwas, meldet meist niemand einen Fehler.
        /// Inhalt: der Abschnitt «vorbedingungen» aus Anleitungen/Vorbedingungen.md.
        /// </summary>
        /// <returns>Die Seite mit den Vorbedingungen.</returns>
        public IActionResult Vorbedingungen()
        {
            return this.View(
                "Vorbedingungen",
                AnleitungLeser.LiesAbschnitte("Areas/Kompetenzen/Anleitungen/Vorbedingungen.md")
                    .GetValueOrDefault("vorbedingungen"));
        }

        /// <summary>
        /// Endpunkt für die Antwort des Fachdelegierten. Das EditorTemplate postet hierher, sobald
        /// die Rolle <see cref="Rolle.FachDelegierter" /> ist.
        /// </summary>
        /// <remarks>
        /// Erwartet wird ein **flaches** <see cref="FrageAntwortViewModel" />: Das Skript der
        /// Komponente entfernt den Sammlungs-Präfix, aus <c>FrageAntwortListe[0].Id</c> wird beim
        /// Absenden <c>Id</c>. Eine Bindung an die Liste wäre deshalb leer.
        /// </remarks>
        /// <param name="modell">Die beantwortete Frage aus dem Formular des EditorTemplates.</param>
        /// <returns>Das <see cref="AntwortResultat" /> als JSON, im Fehlerfall Status 400.</returns>
        [HttpPost]
        public IActionResult AntwortFD(FrageAntwortViewModel modell)
        {
            return this.Speichere(modell, Rolle.FachDelegierter);
        }

        /// <summary>
        /// Endpunkt für die Antwort des fachfremden Delegierten. Das EditorTemplate postet hierher,
        /// sobald die Rolle <see cref="Rolle.FachFremdDelegierter" /> ist.
        /// </summary>
        /// <param name="modell">Die beantwortete Frage aus dem Formular des EditorTemplates.</param>
        /// <returns>Das <see cref="AntwortResultat" /> als JSON, im Fehlerfall Status 400.</returns>
        [HttpPost]
        public IActionResult AntwortFFD(FrageAntwortViewModel modell)
        {
            return this.Speichere(modell, Rolle.FachFremdDelegierter);
        }

        /// <summary>
        /// Der Grundfall des EditorTemplates FrageAntwort.
        /// </summary>
        /// <returns>Die Beispiel-Seite.</returns>
        public IActionResult FrageAntwortGrundfall()
        {
            return this.ZeigeFrageAntwort(
                Rolle.FachDelegierter,
                ErstelleFrageAntwortBeispiel(
                    "frageantwortgrundfall",
                    "Grundfall",
                    "Eine Liste von Fragen, je Frage ein eigenes Formular: Die Antwort geht einzeln und asynchron an den Server. Der Endpunkt antwortet mit einem AntwortResultat, die Komponente blendet dessen Nachricht als Snackbar ein.",
                    "_BeispielFrageAntwortGrundfall.cshtml",
                    "9-13"));
        }

        /// <summary>
        /// Das Ereignis <c>FrageAntwortStatus</c> im Browser.
        /// </summary>
        /// <returns>Die Beispiel-Seite.</returns>
        public IActionResult FrageAntwortEreignis()
        {
            return this.ZeigeFrageAntwort(
                Rolle.FachDelegierter,
                ErstelleFrageAntwortBeispiel(
                    "frageantwortereignis",
                    "Ereignis FrageAntwortStatus",
                    "Nach jeder gespeicherten Antwort meldet die Komponente ein CustomEvent am window. Sein detail ist das AntwortResultat des Endpunkts – inklusive der frei belegbaren Metadaten.",
                    "_BeispielFrageAntwortEreignis.cshtml",
                    "17-18,25",
                    "frageantwort-status.js"));
        }

        /// <summary>
        /// Der Referenzteil zum EditorTemplate FrageAntwort.
        /// </summary>
        /// <returns>Die Referenz-Seite.</returns>
        public IActionResult FrageAntwortReferenz()
        {
            this.ViewBag.Baustein = "FrageAntwort";

            return this.View(
                "Referenz",
                AnleitungLeser.LiesAbschnitte("Areas/Kompetenzen/Anleitungen/FrageAntwort.md")
                    .GetValueOrDefault("frageantwortreferenz"));
        }

        /// <summary>
        /// Dieselben Fragen in den drei Rollen des Templates.
        /// </summary>
        /// <param name="rolle">Die Rolle, in der die Fragen gezeigt werden.</param>
        /// <returns>Die Beispiel-Seite.</returns>
        public IActionResult FrageAntwortRollen(Rolle rolle = Rolle.FachDelegierter)
        {
            return this.ZeigeFrageAntwort(
                rolle,
                ErstelleFrageAntwortBeispiel(
                    "frageantwortrollen",
                    "Rollen",
                    "AktuelleRolle entscheidet allein, was das Template rendert: welche Antwort bearbeitet wird, welche als Vorbefund darübersteht und ob es überhaupt eine Speichern-Schaltfläche gibt.",
                    "_BeispielFrageAntwortRollen.cshtml",
                    "5,10-18,20-24"));
        }

        /// <summary>
        /// Verwirft die erfassten Antworten der FrageAntwort-Beispiele.
        /// </summary>
        /// <param name="ziel">Die Demo-Action, auf die zurückgekehrt wird.</param>
        /// <param name="rolle">Die Rolle, in die zurückgekehrt wird (nur beim Beispiel «Rollen»).</param>
        /// <returns>Die Umleitung auf die Demo-Seite.</returns>
        public IActionResult FrageAntwortZuruecksetzen(string ziel, Rolle rolle = Rolle.FachDelegierter)
        {
            FrageAntwortZustand.Zuruecksetzen();

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

        /// <summary>
        /// Selbst- und Fremdeinschätzung. Die Rolle steht in der Adresse, damit sich die drei
        /// Editable-Werte an denselben Daten vergleichen lassen.
        /// </summary>
        /// <param name="rolle">Die Rolle, in der die Skala gezeigt wird.</param>
        /// <returns>Die Beispiel-Seite.</returns>
        public IActionResult Einschaetzung(Editable rolle = Editable.SelfAssessment)
        {
            return this.ZeigeBeispiel(
                ErstelleBeispiel(
                    "likertskalaeinschaetzung",
                    "Selbst- und Fremdeinschätzung",
                    "AssessmentType bestimmt, welcher der beiden Werte bearbeitet wird und welcher nur als Markierung danebensteht. Der Umschalter zeigt dieselben Daten in allen drei Rollen; das Bewertete bleibt beim Wechsel erhalten.",
                    new LernzielViewModel
                    {
                        Id = 10,
                        Rolle = rolle,
                        Likertskala = LernzielZustand.Skala("einschaetzung", LernzielDaten.Kompetenzen),
                        Spalten = LernzielDaten.Stufen(),
                    },
                    "_BeispielLikertSkalaEinschaetzung.cshtml",
                    "14-22,24-27,43,48"));
        }

        public IActionResult Erfuellungsgrad()
        {
            return this.ZeigeBeispiel(
                ErstelleBeispiel(
                    "likertskalaerfuellungsgrad",
                    "Erfüllungsgrad",
                    "ShowDegreeOfAchievement stellt der Skala eine zweite Kopfzeile mit Prozentwerten voran und hebt je Zeile die geforderte Stufe hervor. Die Werte werden bei jedem Rendern aus dem aktuellen Stand gerechnet – eine Bewertung ändert sie sofort.",
                    new LernzielViewModel
                    {
                        Id = 10,
                        Rolle = Editable.ExternalAssessment,
                        Likertskala = LernzielZustand.Skala("erfuellungsgrad", LernzielDaten.Kompetenzen),
                        Spalten = LernzielDaten.Stufen(),
                    },
                    "_BeispielLikertSkalaErfuellungsgrad.cshtml",
                    "3-6,25-26"));
        }

        public IActionResult Grundfall()
        {
            return this.ZeigeBeispiel(
                ErstelleBeispiel(
                    "likertskalagrundfall",
                    "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.",
                    new LernzielViewModel
                    {
                        Id = 10,
                        Rolle = Editable.SelfAssessment,
                        Likertskala = LernzielZustand.Skala("grundfall", LernzielDaten.Lernziele),
                    },
                    "_BeispielLikertSkalaGrundfall.cshtml",
                    "5-8,22,24,26-34"));
        }

        public IActionResult Gruppe()
        {
            return this.ZeigeBeispiel(
                ErstelleBeispiel(
                    "likertskalagruppe",
                    "Mehrere Skalen gruppieren",
                    "LikertSkalaGroup rendert mehrere Skalen als einen Block. Konfiguriert wird nicht mehr im Markup, sondern am LikertSkalaGroupModel – dafür ist der Aufruf in der View immer derselbe.",
                    new KompetenzGruppeViewModel { Id = 10, Tabellen = LernzielZustand.Gruppe("gruppe", LernzielDaten.Gruppe) },
                    "_BeispielLikertSkalaGruppe.cshtml",
                    "3-7,17-19",
                    "KompetenzGruppeViewModel.cs"));
        }

        public IActionResult Index()
        {
            return this.View(DemoRegistry.Pakete.Single(paket => paket.Area == "Kompetenzen"));
        }

        /// <summary>
        /// Der Referenzteil als eigene Seite, er gilt für alle Beispiele und steckt deshalb nicht
        /// im Anleitungs-Tab eines einzelnen. Inhalt: der Abschnitt «likertskalareferenz» derselben
        /// Markdown-Datei, aus der die Beispiele ihre Anleitung beziehen.
        /// </summary>
        /// <returns>Die Referenz-Seite.</returns>
        public IActionResult Referenz()
        {
            this.ViewBag.Baustein = "Likertskala";

            return this.View(
                "Referenz",
                AnleitungLeser.LiesAbschnitte("Areas/Kompetenzen/Anleitungen/LikertSkala.md")
                    .GetValueOrDefault("likertskalareferenz"));
        }

        public IActionResult Texte()
        {
            return this.ZeigeBeispiel(
                ErstelleBeispiel(
                    "likertskalatexte",
                    "Texte um die Skala",
                    "Header, Introduction und Footer rahmen die Skala, DescriptionColumn beschriftet die erste Spalte und LikertSkalaRow.Header setzt eine Überschrift in die Zeile. Alle vier nehmen HTML entgegen.",
                    new LernzielViewModel
                    {
                        Id = 10,
                        Rolle = Editable.SelfAssessment,
                        Likertskala = LernzielZustand.Skala("texte", LernzielDaten.LernzieleMitUeberschrift),
                    },
                    "_BeispielLikertSkalaTexte.cshtml",
                    "20-23,25-35"));
        }

        /// <summary>
        /// Fünf Tabs: die cshtml mit der Konfiguration der Skala, das ViewModel, die Ausgangsdaten,
        /// die Ablage des erfassten Stands und der Controller, der den POST übernimmt.
        /// </summary>
        /// <param name="id">Die eindeutige Id des Beispiels; zugleich der Schluessel seines Anleitungs-Abschnitts.</param>
        /// <param name="titel">Die Ueberschrift des Beispiels.</param>
        /// <param name="beschreibung">Der erklaerende Text unter der Ueberschrift.</param>
        /// <param name="model">Das Modell, das an das Beispiel-Partial uebergeben wird.</param>
        /// <param name="partialDatei">Der Dateiname des Beispiel-Partials.</param>
        /// <param name="hervorhebung">Die hervorzuhebenden Zeilen des ersten Code-Tabs.</param>
        /// <param name="modellDatei">Der Dateiname des ViewModels.</param>
        /// <returns>Das fertige Beispiel fuer die Demo-Seite.</returns>
        /// <remarks>
        /// Die Hervorhebung sitzt auf dem cshtml-Tab: Die Skala wird nicht über Attribute am Modell
        /// konfiguriert, sondern im Markup. Hervorgehoben ist, worum es im jeweiligen Beispiel geht –
        /// der Rest ist immer derselbe Rahmen aus Formular und Absenden-Schaltfläche.
        /// </remarks>
        private static KonfigurationsBeispielViewModel ErstelleBeispiel(
            string id,
            string titel,
            string beschreibung,
            object model,
            string partialDatei,
            string hervorhebung,
            string modellDatei = "LernzielViewModel.cs")
        {
            var assembly = typeof(DemoController).Assembly;

            var tabs = new List<CodeTab>
                       {
                           new CodeTab
                           {
                               Name = "cshtml",
                               Sprache = "markup",
                               Quelltext = DemoQuelltextLeser.LiesEingebetteteDatei(
                                   assembly,
                                   $"Areas/Kompetenzen/Views/Demo/{partialDatei}"),
                               Hervorhebung = hervorhebung,
                           },
                           new CodeTab
                           {
                               Name = modellDatei.Replace(".cs", string.Empty),
                               Sprache = "csharp",
                               Quelltext = DemoQuelltextLeser.LiesEingebetteteDatei(
                                   assembly,
                                   $"Areas/Kompetenzen/Models/{modellDatei}"),
                           },
                           new CodeTab
                           {
                               Name = "Daten",
                               Sprache = "csharp",
                               Quelltext = DemoQuelltextLeser.LiesEingebetteteDatei(
                                   assembly,
                                   "Areas/Kompetenzen/Models/LernzielDaten.cs"),
                           },
                           new CodeTab
                           {
                               Name = "Zustand",
                               Sprache = "csharp",
                               Quelltext = DemoQuelltextLeser.LiesEingebetteteDatei(
                                   assembly,
                                   "Areas/Kompetenzen/Models/LernzielZustand.cs"),
                           },
                           new CodeTab
                           {
                               Name = "Controller (POST)",
                               Sprache = "csharp",
                               Quelltext = DemoQuelltextLeser.LiesEingebetteteDatei(
                                   assembly,
                                   "Areas/Kompetenzen/Controllers/Beispiele/LikertSkalaBeispielController.cs"),
                           },
                       };

            return new KonfigurationsBeispielViewModel
                   {
                       Id = id,
                       Titel = titel,
                       Beschreibung = beschreibung,
                       Model = model,
                       PartialName = $"~/Areas/Kompetenzen/Views/Demo/{partialDatei}",
                       Tabs = tabs,
                   };
        }

        /// <summary>
        /// Vier Tabs: die cshtml mit der Einbindung des Templates, die Ausgangsdaten, die Ablage der
        /// eingegangenen Antworten und der Controller mit den beiden Ajax-Endpunkten.
        /// </summary>
        /// <param name="id">Die eindeutige Id des Beispiels; zugleich der Schluessel seines Anleitungs-Abschnitts.</param>
        /// <param name="titel">Die Ueberschrift des Beispiels.</param>
        /// <param name="beschreibung">Der erklaerende Text unter der Ueberschrift.</param>
        /// <param name="partialDatei">Der Dateiname des Beispiel-Partials.</param>
        /// <param name="hervorhebung">Die hervorzuhebenden Zeilen des cshtml-Tabs.</param>
        /// <param name="skriptDatei">Der Dateiname des Demo-Skripts; ohne Angabe entfallen die Skript-Tabs.</param>
        /// <returns>Das fertige Beispiel fuer die Demo-Seite.</returns>
        /// <remarks>
        /// Ein ViewModel-Tab entfällt: Das Beispiel bindet die Modellklassen der Komponente
        /// unverändert ein, was sie mitbringen, steht in der Referenz.
        /// </remarks>
        private static KonfigurationsBeispielViewModel ErstelleFrageAntwortBeispiel(
            string id,
            string titel,
            string beschreibung,
            string partialDatei,
            string hervorhebung,
            string skriptDatei = null)
        {
            var assembly = typeof(DemoController).Assembly;

            var tabs = new List<CodeTab>
                       {
                           new CodeTab
                           {
                               Name = "cshtml",
                               Sprache = "markup",
                               Quelltext = DemoQuelltextLeser.LiesEingebetteteDatei(
                                   assembly,
                                   $"Areas/Kompetenzen/Views/Demo/{partialDatei}"),
                               Hervorhebung = hervorhebung,
                           },
                           new CodeTab
                           {
                               Name = "Daten",
                               Sprache = "csharp",
                               Quelltext = DemoQuelltextLeser.LiesEingebetteteDatei(
                                   assembly,
                                   "Areas/Kompetenzen/Models/FrageAntwortBeispielDaten.cs"),
                           },
                           new CodeTab
                           {
                               Name = "Zustand",
                               Sprache = "csharp",
                               Quelltext = DemoQuelltextLeser.LiesEingebetteteDatei(
                                   assembly,
                                   "Areas/Kompetenzen/Models/FrageAntwortZustand.cs"),
                           },
                           new CodeTab
                           {
                               Name = "Controller (Endpunkte)",
                               Sprache = "csharp",
                               Quelltext = DemoQuelltextLeser.LiesEingebetteteDatei(
                                   assembly,
                                   "Areas/Kompetenzen/Controllers/DemoController.cs"),

                               // Der Serializer, die beiden Endpunkte und der gemeinsame Rumpf –
                               // der Rest der Datei ist die Demo-Mechanik.
                               Hervorhebung = "37-48,61-77,500-543",
                           },
                       };

            if (string.IsNullOrEmpty(skriptDatei) == false)
            {
                tabs.Add(ErstelleSkriptTab(skriptDatei, "JavaScript"));
                tabs.Add(ErstelleSkriptTab(skriptDatei.Replace(".js", "-jquery.js"), "jQuery"));
            }

            return new KonfigurationsBeispielViewModel
                   {
                       Id = id,
                       Titel = titel,
                       Beschreibung = beschreibung,
                       PartialName = $"~/Areas/Kompetenzen/Views/Demo/{partialDatei}",
                       Tabs = tabs,
                   };
        }

        /// <summary>
        /// Code-Tab mit einem Skript des Beispiels aus wwwroot/js/demos.
        /// </summary>
        /// <param name="skriptDatei">Der Dateiname des Skripts.</param>
        /// <param name="name">Die Beschriftung des Code-Tabs.</param>
        /// <returns>Der Code-Tab mit dem Quelltext des Skripts.</returns>
        private static CodeTab ErstelleSkriptTab(string skriptDatei, string name)
        {
            return new CodeTab
                   {
                       Name = name,
                       Sprache = "javascript",
                       Quelltext = DemoQuelltextLeser.LiesEingebetteteDatei(
                           typeof(DemoController).Assembly,
                           $"wwwroot/js/demos/{skriptDatei}"),
                   };
        }

        private IActionResult ZeigeBeispiel(KonfigurationsBeispielViewModel beispiel)
        {
            beispiel.AnleitungHtml = AnleitungLeser.LiesAbschnitte("Areas/Kompetenzen/Anleitungen/LikertSkala.md")
                .GetValueOrDefault(beispiel.Id);

            return this.View("Beispiel", beispiel);
        }

        /// <summary>
        /// Setzt die Rolle an jeder Frage, lädt die Anleitung und rendert die Beispiel-Seite.
        /// </summary>
        /// <param name="rolle">Die Rolle, in der die Fragen gezeigt werden.</param>
        /// <param name="beispiel">Das Beispiel, das gerendert wird.</param>
        /// <returns>Die gerenderte Beispiel-Seite.</returns>
        /// <remarks>
        /// Die Rolle gehört an die einzelne Frage – das Template liest sie dort. Sie kommt aus dem
        /// Ablauf und wird bei jedem Aufruf neu gesetzt; im Formular steht sie nur als verstecktes
        /// Feld, damit der Endpunkt weiss, wer geantwortet hat.
        /// </remarks>
        private IActionResult ZeigeFrageAntwort(Rolle rolle, KonfigurationsBeispielViewModel beispiel)
        {
            var beurteilung = FrageAntwortBeispielDaten.Beurteilung();
            beurteilung.FrageAntwortListe = FrageAntwortZustand.Fragen();

            foreach (var frage in beurteilung.FrageAntwortListe)
            {
                frage.AktuelleRolle = rolle;
            }

            beispiel.Model = beurteilung;
            this.ViewBag.Rolle = rolle;
            this.ViewBag.Baustein = "FrageAntwort";

            beispiel.AnleitungHtml = AnleitungLeser.LiesAbschnitte("Areas/Kompetenzen/Anleitungen/FrageAntwort.md")
                .GetValueOrDefault(beispiel.Id);

            return this.View("Beispiel", beispiel);
        }

        /// <summary>
        /// Nimmt eine Antwort entgegen und beantwortet den Ajax-Aufruf des EditorTemplates.
        /// </summary>
        /// <param name="modell">Die beantwortete Frage aus dem Formular.</param>
        /// <param name="rolle">Die Rolle, deren Antwort übernommen wird.</param>
        /// <returns>Das <see cref="AntwortResultat" /> als JSON, im Fehlerfall Status 400.</returns>
        /// <remarks>
        /// Der Ablauf ist die Vorlage für ein Projekt: prüfen, speichern, ein
        /// <see cref="AntwortResultat" /> zurückgeben. Bei Status 400 wertet das Skript der
        /// Komponente die Eigenschaft <c>Nachricht</c> aus und zeigt sie als Fehler-Snackbar.
        /// </remarks>
        private IActionResult Speichere(FrageAntwortViewModel modell, Rolle rolle)
        {
            modell.AktuelleRolle = rolle;

            if (this.ModelState.IsValid == false)
            {
                var fehler = this.ModelState.Values.SelectMany(wert => wert.Errors)
                    .Select(eintrag => eintrag.ErrorMessage)
                    .FirstOrDefault();

                return this.Fehler(fehler ?? "Die Antwort ist unvollständig.");
            }

            var gespeichert = FrageAntwortZustand.Uebernehmen(modell);

            if (gespeichert == null)
            {
                return this.Fehler($"Zur Frage mit der Id {modell.Id} gibt es keinen Eintrag.");
            }

            var resultat = new AntwortResultat
                           {
                               Nachricht = $"Ihre Antwort zu Frage {gespeichert.Reihenfolge} wurde gespeichert.",
                               Status = FrageAntwortZustand.Status(rolle),

                               // Material-Design-Code des Symbols der Snackbar. Ohne Angabe check_circle.
                               IconTyp = IconTypeDictionary.Get(IconType.FactCheck),
                           };

            // Frei belegbar: Alles, was der Browser nach dem Speichern braucht, aber nicht aus dem
            // Formular kennt. Es landet unverändert im detail des Ereignisses FrageAntwortStatus.
            resultat.Metadaten.Add("FrageId", gespeichert.Id);
            resultat.Metadaten.Add("Rolle", rolle.ToString());
            resultat.Metadaten.Add("Uneinig", gespeichert.IstUneinig);

            return this.Json(resultat, JsonPascalCase);
        }

        private IActionResult Fehler(string nachricht)
        {
            this.Response.StatusCode = (int)HttpStatusCode.BadRequest;

            return this.Json(new AntwortResultat { Nachricht = nachricht, IconTyp = "error" }, JsonPascalCase);
        }
    }
}

Darstellung

Rolle: Fachdelegierter (Rolle.FachDelegierter)
Erfasst wird die Antwort des Fachdelegierten; eine bereits vorliegende fachfremde Antwort steht darüber. Abgesendet wird an den Endpunkt AntwortFD.
1. Wurde die geforderte Anzahl Weiterbildungsjahre am Zentrum absolviert?
Reply of the delegate of the foreign subject:
Yes
Belege liegen vollständig vor.
0/4000

2. Sind die Lernziele des Weiterbildungsprogramms nachweislich erreicht?
0/4000

3. Bestehen Vorbehalte gegen die Erteilung des Facharzttitels?
Reply of the delegate of the foreign subject:
No
Die Beurteilung der Klinik weicht in Punkt 4 ab.
0/4000

AktuelleRolle an der Frage entscheidet allein darüber, was gerendert wird, dieselbe Liste sieht in jeder Rolle anders aus:

Rolle Bearbeitet wird Darüber steht Speichern
FachDelegierter die eigene Antwort die fachfremde Antwort, falls vorhanden ja, an AntwortFD
FachFremdDelegierter die fachfremde Antwort die Antwort des Fachdelegierten ja, an AntwortFFD
Abgeschlossen nichts beide Antworten nein

Die Rolle wird je Frage gesetzt, nicht an der Beurteilung:

foreach (var frage in beurteilung.FrageAntwortListe)
{
    frage.AktuelleRolle = rolle;
}

Die Gegenseite wird angezeigt, nicht mitgesendet

Der Vorbefund der anderen Rolle steht als Text über den Eingabefeldern, er ist kein Formularfeld. Was der Endpunkt bekommt, ist ausschliesslich die Antwort der postenden Rolle. Beim Speichern deshalb nie das ganze Objekt übernehmen, sondern nur die Felder der Rolle:

if (gepostet.AktuelleRolle == Rolle.FachFremdDelegierter)
{
    ziel.AntwortFachFremdDelegierter = gepostet.AntwortFachFremdDelegierter;
    ziel.AntwortTextFachFremdDelegierter = gepostet.AntwortTextFachFremdDelegierter;
}

Sonst überschreibt ein Speichern der einen Rolle die Antwort der anderen mit null.

Was zu beachten ist

  • AktuelleRolle geht als verstecktes Feld mit. Der Endpunkt kann sich darauf stützen, sollte die Berechtigung aber trotzdem serverseitig prüfen, ein verstecktes Feld ist kein Nachweis.
  • Dem Fachdelegierten wird die fachfremde Antwort nur gezeigt, wenn es eine gibt (HatFachFremdDelegierterBeantwortet). Umgekehrt steht der Block immer da, auch leer.
  • Rolle.None sperrt nicht. Es zeigt beide Antworten an, rendert aber weiterhin eine Speichern-Schaltfläche. Für die reine Anzeige ist Rolle.Abgeschlossen vorgesehen.
  • IstUneinig sagt, ob beide Rollen geantwortet haben und die Antworten abweichen, die Grundlage für eine Hervorhebung auf der eigenen Seite.