Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4fe142422a | |||
| 954a5102ac | |||
| c7439e4822 | |||
| 788f5ccde0 |
+33
-2
@@ -109,10 +109,16 @@ Aktuelle Button- und Sichtbarkeitslogik pro Umfrage:
|
|||||||
- Nach Veröffentlichung disabled
|
- Nach Veröffentlichung disabled
|
||||||
- `Öffentliche Ansicht`
|
- `Öffentliche Ansicht`
|
||||||
- Nur sichtbar, wenn `published = 1`
|
- Nur sichtbar, wenn `published = 1`
|
||||||
|
- `Link kopieren`
|
||||||
|
- Nur sichtbar, wenn `published = 1`
|
||||||
|
- Kopiert die **absolute** öffentliche Reader-URL (`copyUrl`, erzeugt mit `UrlGeneratorInterface::ABSOLUTE_URL`) über den `data-copy-text`-Handler in `_survey_assets.html.twig`.
|
||||||
- `Entwurfs-Ansicht`
|
- `Entwurfs-Ansicht`
|
||||||
- Nur sichtbar, wenn `published = 0`
|
- Nur sichtbar, wenn `published = 0`
|
||||||
- `Ergebnisse anzeigen`
|
- `Ergebnisse anzeigen`
|
||||||
- Immer verfügbar, sofern Results-Seite konfiguriert ist
|
- Immer verfügbar, sofern Results-Seite konfiguriert ist
|
||||||
|
- Tab-Verhalten (bewusste Produktentscheidung nach Kundenrückmeldung):
|
||||||
|
- `Bearbeiten` öffnet **im selben Tab** (eigentlicher Arbeitsschritt).
|
||||||
|
- `Öffentliche Ansicht`, `Entwurfs-Ansicht` und `Ergebnisse anzeigen` öffnen **im neuen Fenster** (`target="_blank"`), damit die Editor-/Listenansicht erhalten bleibt.
|
||||||
- `Umfrage veröffentlichen`
|
- `Umfrage veröffentlichen`
|
||||||
- Eigene Aktion in der Liste
|
- Eigene Aktion in der Liste
|
||||||
- Nach Veröffentlichung disabled und textlich als veröffentlicht markiert
|
- Nach Veröffentlichung disabled und textlich als veröffentlicht markiert
|
||||||
@@ -120,9 +126,12 @@ Aktuelle Button- und Sichtbarkeitslogik pro Umfrage:
|
|||||||
- Legt eine Kopie der Umfrage für den Redakteur an
|
- Legt eine Kopie der Umfrage für den Redakteur an
|
||||||
- Titelvergabe ist fortlaufend: `Titel - Kopie`, danach `Titel - Kopie 2`, `Titel - Kopie 3` usw.
|
- Titelvergabe ist fortlaufend: `Titel - Kopie`, danach `Titel - Kopie 2`, `Titel - Kopie 3` usw.
|
||||||
- Bestehende `- Kopie`-Suffixe werden fachlich normalisiert, damit keine Titel wie `... - Kopie - Kopie` mehr neu entstehen
|
- Bestehende `- Kopie`-Suffixe werden fachlich normalisiert, damit keine Titel wie `... - Kopie - Kopie` mehr neu entstehen
|
||||||
- Kreis grau/grün
|
- Wichtig: Die Titel-Kollisionsprüfung (`SurveyEditorService::buildDuplicateTitle`) prüft **ohne** Ausschluss der Quell-ID gegen `titleExists`. Sonst würde beim Duplizieren einer bereits `... - Kopie`-Umfrage erneut derselbe Titel vergeben statt `... - Kopie 2` (früherer Bug).
|
||||||
|
- Aktiv-Umschalter (grauer/grüner Punkt **mit Textlabel** „Aktiv"/„Deaktiviert")
|
||||||
- Schaltet `isActive`
|
- Schaltet `isActive`
|
||||||
- Ist bewusst kein Auge mehr
|
- Ist bewusst kein Auge mehr
|
||||||
|
- Das Textlabel wurde ergänzt, weil der reine Punkt ohne Beschriftung für den Kunden unklar war (`survey-visibility-toggle` in `_survey_assets.html.twig`).
|
||||||
|
- Bei `isActive = 0` ist der öffentliche Reader-Link deaktiviert: `SurveyRepository::findByPublicAlias` verlangt `published = 1` UND `isActive = 1`, sonst liefert der Reader die Leer-/Hinweisseite.
|
||||||
|
|
||||||
Wichtiger technischer Punkt (CSRF):
|
Wichtiger technischer Punkt (CSRF):
|
||||||
|
|
||||||
@@ -161,6 +170,14 @@ Aktueller Aufbau:
|
|||||||
- Fragen im Frontend nicht mehr änderbar
|
- Fragen im Frontend nicht mehr änderbar
|
||||||
- Metadaten im Frontend ebenfalls nicht mehr bearbeitbar
|
- Metadaten im Frontend ebenfalls nicht mehr bearbeitbar
|
||||||
|
|
||||||
|
Ungespeicherte Änderungen (bewusst kein Auto-Save):
|
||||||
|
|
||||||
|
- Jedes Editor-Formular (`data-survey-dirty-form`: Metadaten, jede Frage, neue Frage) trackt clientseitig, ob sich der Inhalt gegenüber dem Ladezustand geändert hat, und blendet den roten „Ungespeicherte Änderungen"-Hinweis ein.
|
||||||
|
- Zusätzlich gibt es einen `beforeunload`-Handler (`_survey_assets.html.twig`), der beim Verlassen der Seite (Link, Zurück, Tab schließen) warnt, sobald mindestens ein Formular ungespeicherte Änderungen hat.
|
||||||
|
- Der Handler wird nur registriert, wenn dirty-getrackte Formulare existieren (also nur im Editor, nicht in Liste/Reader/Ergebnissen).
|
||||||
|
- Ein bewusstes Absenden (Speichern oder eine Aktion) setzt ein `surveyIsSubmitting`-Flag über einen globalen `submit`-Listener, damit der normale Speicher-Flow **nicht** warnt.
|
||||||
|
- Auto-Save wurde bewusst nicht umgesetzt (getrennte Formulare mit eigenen CSRF-Tokens, Struktur-Sperre nach Teilnahmen/Veröffentlichung). Der Dirty-Hinweis plus die `beforeunload`-Warnung sind der gewählte, risikoarme Kompromiss.
|
||||||
|
|
||||||
Wichtige Entscheidung:
|
Wichtige Entscheidung:
|
||||||
|
|
||||||
- Die Header-Badges im Fragen-Akkordeon wurden bewusst entfernt.
|
- Die Header-Badges im Fragen-Akkordeon wurden bewusst entfernt.
|
||||||
@@ -229,9 +246,21 @@ Aktuelle Ergebnislogik:
|
|||||||
- Optionsstatistik plus Kreisdiagramm
|
- Optionsstatistik plus Kreisdiagramm
|
||||||
- `range`
|
- `range`
|
||||||
- Minimum, Maximum, Durchschnitt plus Verteilung und Linienchart
|
- Minimum, Maximum, Durchschnitt plus Verteilung und Linienchart
|
||||||
|
- **Diagramm** (`distribution`): deckt die **komplette Skala** von `rangeMin` bis `rangeMax` in `rangeStep`-Schritten ab (inkl. Werte ohne Antworten), damit die X-Achse linear und vollständig ist. Antworten außerhalb der konfigurierten Grenzen erweitern die Skala. Siehe `SurveyResultsViewService::buildRangeScale`.
|
||||||
|
- **Werte-Liste/Tabelle** (`distributionRows`): zeigt bewusst nur die **tatsächlich beantworteten** Werte (aufsteigend). So bläht sich die Ergebnisseite bei großen Skalen (z. B. Alter 1–100 mit Schrittweite 1) nicht mit hunderten Nullzeilen auf. Die vollständige Skala bleibt trotzdem im Diagramm sichtbar. Online-Liste, PDF-Tabelle und die SVG-/Chart.js-Diagramme greifen entsprechend auf `distributionRows` (Liste) bzw. `distribution` (Diagramm) zu.
|
||||||
|
- Bei großen Skalen werden die X-Achsen-Beschriftungen ausgedünnt (Chart.js `autoSkip`/`maxTicksLimit`, SVG jede n-te Beschriftung) und die Punkt-Marker verkleinert bzw. ab >40 Punkten weggelassen. Die PDF-Range-Tabelle hat keine Farb-Swatch-Spalte mehr (nur Wert/Anzahl/Anteil), weil die Kopplung an ein Vollskalen-Diagramm nicht mehr sinnvoll ist.
|
||||||
- `text`
|
- `text`
|
||||||
- Kein Pie-Chart
|
- Kein Pie-Chart
|
||||||
- Stattdessen Response-Summary und Liste der Freitextantworten
|
- Gruppierte Freitextantworten: jede unterschiedliche Antwort **einmal** mit exakter Häufigkeit (`responseSummary`, häufigste zuerst, kein Top-5-Limit). Prozentangaben und die frühere Doppel-Auflistung wurden bewusst entfernt.
|
||||||
|
|
||||||
|
Pro Frage werden statt einer reinen Prozent-Rücklaufquote absolute Zahlen ausgewiesen:
|
||||||
|
|
||||||
|
- `totalAnswers` und `completedSubmissionCount` als „X von Y beantwortet".
|
||||||
|
- `skippedCount` (= `completedSubmissionCount - totalAnswers`) als „Z übersprungen", nur wenn > 0. So sind per Sprungregel übersprungene Fragen sichtbar, statt überall die volle Teilnehmerzahl zu zeigen.
|
||||||
|
|
||||||
|
Reihenfolge:
|
||||||
|
|
||||||
|
- Die Frageergebnisse werden **vor** dem Finalisieren nach `_sorting` sortiert. Wichtig: `finalizeQuestionResult` entfernt die internen `_sorting`-Werte – würde man erst danach sortieren (früherer Bug), fiele die Reihenfolge auf den alphabetischen Fallback zurück. Online-Ansicht, PDF und Excel folgen dadurch derselben Frage-Sortierung.
|
||||||
|
|
||||||
Wichtig:
|
Wichtig:
|
||||||
|
|
||||||
@@ -296,6 +325,8 @@ Wichtige Eigenschaft:
|
|||||||
|
|
||||||
- Die Spalten orientieren sich an den aktuell vorhandenen Fragen im Survey.
|
- Die Spalten orientieren sich an den aktuell vorhandenen Fragen im Survey.
|
||||||
- Die Zeilen enthalten nur abgeschlossene Submissions.
|
- Die Zeilen enthalten nur abgeschlossene Submissions.
|
||||||
|
- Über den Antwortspalten steht eine eigene **Fragetext-Zeile** (`F1: <Fragetext>`, `F2: ...`). Je Frage werden die Zellen dieser Zeile über alle zugehörigen Antwortspalten zusammengeführt (`mergeCells`). Zuvor fehlte der Fragetext im Excel komplett – der Kunde sah nur `F1 ja`, `F1 nein` usw. Reihenfolge: Titel (Zeile 1), Fragetexte (Zeile 2), Spaltenköpfe (Zeile 3), Daten ab Zeile 4; Freeze-Pane auf `A4`.
|
||||||
|
- Diagramme gibt es im Excel bewusst nicht (dafür ist der PDF-Export da).
|
||||||
|
|
||||||
## PDF-Export
|
## PDF-Export
|
||||||
|
|
||||||
|
|||||||
@@ -440,15 +440,36 @@
|
|||||||
transform: translateY(0);
|
transform: translateY(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.survey-visibility-toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.55rem;
|
||||||
|
padding: 0.35rem 0.9rem 0.35rem 0.4rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid rgba(162, 168, 180, 0.5);
|
||||||
|
background: rgba(255, 255, 255, 0.88);
|
||||||
|
color: var(--survey-gray-dark);
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.18s ease, color 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.survey-visibility-toggle.is-on {
|
||||||
|
border-color: rgba(10, 131, 46, 0.45);
|
||||||
|
color: #0a6e28;
|
||||||
|
}
|
||||||
|
|
||||||
.survey-visibility-dot {
|
.survey-visibility-dot {
|
||||||
aspect-ratio: 1;
|
aspect-ratio: 1;
|
||||||
height: 42px;
|
height: 28px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: #a6adb7;
|
background: #a6adb7;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
cursor: pointer;
|
flex-shrink: 0;
|
||||||
transition: background 0.18s ease;
|
transition: background 0.18s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -456,6 +477,10 @@
|
|||||||
background: #0a832e;
|
background: #0a832e;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.survey-visibility-label {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.survey-button-row {
|
.survey-button-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@@ -1141,6 +1166,9 @@
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
var surveyDirtyChecks = [];
|
||||||
|
var surveyIsSubmitting = false;
|
||||||
|
|
||||||
document.querySelectorAll('[data-survey-dirty-form]').forEach(function (form) {
|
document.querySelectorAll('[data-survey-dirty-form]').forEach(function (form) {
|
||||||
var formId = form.getAttribute('id') || '';
|
var formId = form.getAttribute('id') || '';
|
||||||
var hint = form.querySelector('[data-survey-dirty-hint]');
|
var hint = form.querySelector('[data-survey-dirty-hint]');
|
||||||
@@ -1149,10 +1177,6 @@
|
|||||||
hint = document.querySelector('[data-survey-dirty-hint-for="' + formId + '"]');
|
hint = document.querySelector('[data-survey-dirty-hint-for="' + formId + '"]');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!hint) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var serializeForm = function () {
|
var serializeForm = function () {
|
||||||
var entries = [];
|
var entries = [];
|
||||||
var formData = new FormData(form);
|
var formData = new FormData(form);
|
||||||
@@ -1166,25 +1190,61 @@
|
|||||||
|
|
||||||
var initialState = serializeForm();
|
var initialState = serializeForm();
|
||||||
|
|
||||||
|
var isDirty = function () {
|
||||||
|
return serializeForm() !== initialState;
|
||||||
|
};
|
||||||
|
|
||||||
|
surveyDirtyChecks.push(isDirty);
|
||||||
|
|
||||||
var updateDirtyState = function () {
|
var updateDirtyState = function () {
|
||||||
hint.hidden = serializeForm() === initialState;
|
if (hint) {
|
||||||
};
|
hint.hidden = !isDirty();
|
||||||
|
}
|
||||||
var markDirty = function () {
|
|
||||||
updateDirtyState();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (hint) {
|
||||||
hint.hidden = true;
|
hint.hidden = true;
|
||||||
|
}
|
||||||
|
|
||||||
form.addEventListener('input', markDirty);
|
form.addEventListener('input', updateDirtyState);
|
||||||
form.addEventListener('change', markDirty);
|
form.addEventListener('change', updateDirtyState);
|
||||||
form.addEventListener('reset', function () {
|
form.addEventListener('reset', function () {
|
||||||
window.setTimeout(function () {
|
window.setTimeout(function () {
|
||||||
|
if (hint) {
|
||||||
hint.hidden = true;
|
hint.hidden = true;
|
||||||
|
}
|
||||||
}, 0);
|
}, 0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (surveyDirtyChecks.length > 0) {
|
||||||
|
// Ein bewusstes Absenden (Speichern oder eine Aktion) darf nicht warnen.
|
||||||
|
document.addEventListener('submit', function () {
|
||||||
|
surveyIsSubmitting = true;
|
||||||
|
}, true);
|
||||||
|
|
||||||
|
window.addEventListener('beforeunload', function (event) {
|
||||||
|
if (surveyIsSubmitting) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
var hasUnsavedChanges = surveyDirtyChecks.some(function (isDirty) {
|
||||||
|
return isDirty();
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!hasUnsavedChanges) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Moderne Browser zeigen einen generischen Hinweis; der Text wird
|
||||||
|
// ignoriert, muss aber gesetzt werden, damit der Dialog erscheint.
|
||||||
|
event.preventDefault();
|
||||||
|
event.returnValue = '';
|
||||||
|
|
||||||
|
return '';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (typeof handorgel === 'function') {
|
if (typeof handorgel === 'function') {
|
||||||
document.querySelectorAll('[data-survey-handorgel]').forEach(function (element) {
|
document.querySelectorAll('[data-survey-handorgel]').forEach(function (element) {
|
||||||
var accordion = new handorgel(element, {
|
var accordion = new handorgel(element, {
|
||||||
|
|||||||
@@ -72,6 +72,15 @@
|
|||||||
{% if survey.publicUrl and survey.published %}
|
{% if survey.publicUrl and survey.published %}
|
||||||
<a class="survey-button secondary" href="{{ survey.publicUrl }}" target="_blank" rel="noreferrer">{{ 'survey.list.public_view'|trans }}</a>
|
<a class="survey-button secondary" href="{{ survey.publicUrl }}" target="_blank" rel="noreferrer">{{ 'survey.list.public_view'|trans }}</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if survey.copyUrl and survey.published %}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="survey-button secondary"
|
||||||
|
data-copy-text="{{ survey.copyUrl }}"
|
||||||
|
data-copy-label="{{ 'survey.list.copy_link'|trans }}"
|
||||||
|
data-copy-success="{{ 'survey.list.copy_link_done'|trans }}"
|
||||||
|
>{{ 'survey.list.copy_link'|trans }}</button>
|
||||||
|
{% endif %}
|
||||||
{% if survey.draftUrl and not survey.published %}
|
{% if survey.draftUrl and not survey.published %}
|
||||||
<a class="survey-button secondary" href="{{ survey.draftUrl }}" target="_blank" rel="noreferrer">{{ 'survey.list.draft_view'|trans }}</a>
|
<a class="survey-button secondary" href="{{ survey.draftUrl }}" target="_blank" rel="noreferrer">{{ 'survey.list.draft_view'|trans }}</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -118,7 +127,10 @@
|
|||||||
<input type="hidden" name="_token" value="{{ csrf_token('toggle-active-' ~ survey.id) }}">
|
<input type="hidden" name="_token" value="{{ csrf_token('toggle-active-' ~ survey.id) }}">
|
||||||
<input type="hidden" name="_survey_action" value="toggle-active">
|
<input type="hidden" name="_survey_action" value="toggle-active">
|
||||||
<input type="hidden" name="item_id" value="{{ survey.id }}">
|
<input type="hidden" name="item_id" value="{{ survey.id }}">
|
||||||
<button type="submit" class="survey-visibility-dot{{ survey.isActive ? ' is-on' : '' }}" title="{{ survey.isActive ? 'survey.edit.deactivate_survey'|trans : 'survey.edit.activate_survey'|trans }}" aria-label="{{ survey.isActive ? 'survey.edit.deactivate_survey'|trans : 'survey.edit.activate_survey'|trans }}"></button>
|
<button type="submit" class="survey-visibility-toggle{{ survey.isActive ? ' is-on' : '' }}" title="{{ survey.isActive ? 'survey.edit.deactivate_survey'|trans : 'survey.edit.activate_survey'|trans }}" aria-label="{{ survey.isActive ? 'survey.edit.deactivate_survey'|trans : 'survey.edit.activate_survey'|trans }}">
|
||||||
|
<span class="survey-visibility-dot{{ survey.isActive ? ' is-on' : '' }}" aria-hidden="true"></span>
|
||||||
|
<span class="survey-visibility-label">{{ survey.isActive ? 'survey.list.active_state'|trans : 'survey.list.inactive_state'|trans }}</span>
|
||||||
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -41,8 +41,10 @@
|
|||||||
<article class="survey-card survey-results-card">
|
<article class="survey-card survey-results-card">
|
||||||
<div class="survey-meta">
|
<div class="survey-meta">
|
||||||
<h3>{{ question.question|striptags|trim }}</h3>
|
<h3>{{ question.question|striptags|trim }}</h3>
|
||||||
<span class="survey-badge neutral">{{ 'survey.results.participations'|trans({'%count%': completedSubmissionCount}) }}</span>
|
<span class="survey-badge neutral">{{ 'survey.results.answered'|trans({'%count%': question.totalAnswers, '%total%': completedSubmissionCount}) }}</span>
|
||||||
<span class="survey-badge neutral">{{ 'survey.results.response_rate'|trans({'%rate%': question.responseRate}) }}</span>
|
{% if question.skippedCount > 0 %}
|
||||||
|
<span class="survey-badge neutral">{{ 'survey.results.skipped'|trans({'%count%': question.skippedCount}) }}</span>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if question.description|striptags|trim %}
|
{% if question.description|striptags|trim %}
|
||||||
@@ -91,11 +93,11 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="survey-results-list">
|
<div class="survey-results-list">
|
||||||
{% for option in question.distribution %}
|
{% for option in question.distributionRows|default([]) %}
|
||||||
<div class="survey-results-row">
|
<div class="survey-results-row">
|
||||||
<div class="survey-meta">
|
<div class="survey-meta">
|
||||||
<strong>{{ option.label|striptags|trim }}</strong>
|
<strong>{{ option.label|striptags|trim }}</strong>
|
||||||
<span class="survey-badge neutral">{{ option.count }}</span>
|
<span class="survey-badge neutral">{{ 'survey.results.answer_count'|trans({'%count%': option.count}) }}</span>
|
||||||
<span class="survey-badge neutral">{{ option.percentage }}%</span>
|
<span class="survey-badge neutral">{{ option.percentage }}%</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="survey-progress">
|
<div class="survey-progress">
|
||||||
@@ -107,25 +109,13 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
{% if question.responseSummary is defined and question.responseSummary %}
|
{# Eine Liste statt Doppel-Auflistung: jede Antwort einmal,
|
||||||
<div class="survey-text-summary-grid">
|
mit exakter Anzahl statt Prozentwerten. #}
|
||||||
{% for option in question.responseSummary %}
|
|
||||||
<article class="survey-text-summary-card">
|
|
||||||
<strong>{{ option.label|striptags|trim }}</strong>
|
|
||||||
<div class="survey-meta">
|
|
||||||
<span class="survey-badge neutral">{{ option.count }}</span>
|
|
||||||
<span class="survey-badge neutral">{{ option.percentage }}%</span>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<div class="survey-text-response-grid survey-results-responses">
|
<div class="survey-text-response-grid survey-results-responses">
|
||||||
{% for response in question.responses %}
|
{% for option in question.responseSummary|default([]) %}
|
||||||
<article class="survey-card survey-response-card">
|
<article class="survey-card survey-response-card">
|
||||||
<span class="survey-response-card__index">{{ loop.index }}</span>
|
<span class="survey-badge neutral">{{ 'survey.results.answer_count'|trans({'%count%': option.count}) }}</span>
|
||||||
<blockquote>{{ response|striptags|trim|nl2br }}</blockquote>
|
<blockquote>{{ option.label|striptags|trim|nl2br }}</blockquote>
|
||||||
</article>
|
</article>
|
||||||
{% else %}
|
{% else %}
|
||||||
<p>{{ 'survey.results.no_text_responses'|trans }}</p>
|
<p>{{ 'survey.results.no_text_responses'|trans }}</p>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
.tl_checkbox_wizard.survey-assigned-members-checkbox-wizard {
|
.tl_checkbox_wizard.survey-assigned-members-checkbox-wizard {
|
||||||
column-count: 4;
|
column-count: 6;
|
||||||
column-gap: 1.5rem;
|
column-gap: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ use Mummert\SurveyBundle\Service\SurveyEditorService;
|
|||||||
use Symfony\Component\HttpFoundation\RedirectResponse;
|
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||||
use Symfony\Component\HttpFoundation\Request;
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||||
|
|
||||||
#[AsFrontendModule(type: 'member_survey_list', category: 'survey', template: 'member_survey_list')]
|
#[AsFrontendModule(type: 'member_survey_list', category: 'survey', template: 'member_survey_list')]
|
||||||
#[AsFrontendModule(type: 'member_survey_template_list', category: 'survey', template: 'member_survey_list')]
|
#[AsFrontendModule(type: 'member_survey_template_list', category: 'survey', template: 'member_survey_list')]
|
||||||
@@ -69,6 +70,9 @@ final class MemberSurveyListController extends AbstractFrontendModuleController
|
|||||||
'participationCount' => (int) ($survey['participationCount'] ?? 0),
|
'participationCount' => (int) ($survey['participationCount'] ?? 0),
|
||||||
'editUrl' => $editPage instanceof PageModel ? $this->generateContentUrl($editPage, ['survey' => $surveyId]) : null,
|
'editUrl' => $editPage instanceof PageModel ? $this->generateContentUrl($editPage, ['survey' => $surveyId]) : null,
|
||||||
'publicUrl' => $readerPage instanceof PageModel ? $this->generateContentUrl($readerPage, ['parameters' => '/'.$alias]) : null,
|
'publicUrl' => $readerPage instanceof PageModel ? $this->generateContentUrl($readerPage, ['parameters' => '/'.$alias]) : null,
|
||||||
|
// Absolute URL für den "Link kopieren"-Button, damit der geteilte
|
||||||
|
// Link auch außerhalb der Seite funktioniert.
|
||||||
|
'copyUrl' => $readerPage instanceof PageModel ? $this->generateContentUrl($readerPage, ['parameters' => '/'.$alias], UrlGeneratorInterface::ABSOLUTE_URL) : null,
|
||||||
'draftUrl' => $readerPage instanceof PageModel ? $this->generateContentUrl($readerPage, ['parameters' => '/'.$alias, 'preview' => '1']) : null,
|
'draftUrl' => $readerPage instanceof PageModel ? $this->generateContentUrl($readerPage, ['parameters' => '/'.$alias, 'preview' => '1']) : null,
|
||||||
'resultsUrl' => $resultsPage instanceof PageModel ? $this->generateContentUrl($resultsPage, ['parameters' => '/'.$alias]) : null,
|
'resultsUrl' => $resultsPage instanceof PageModel ? $this->generateContentUrl($resultsPage, ['parameters' => '/'.$alias]) : null,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -223,16 +223,15 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div class="meta-row">
|
<div class="meta-row">
|
||||||
<span class="meta-pill">{{ 'survey.pdf.participations'|trans({'%count%': completedSubmissionCount}) }}</span>
|
<span class="meta-pill">{{ 'survey.pdf.answered'|trans({'%count%': question.totalAnswers, '%total%': completedSubmissionCount}) }}</span>
|
||||||
<span class="meta-pill">{{ 'survey.pdf.response_rate'|trans({'%rate%': question.responseRate}) }}</span>
|
{% if question.skippedCount > 0 %}
|
||||||
|
<span class="meta-pill">{{ 'survey.pdf.skipped'|trans({'%count%': question.skippedCount}) }}</span>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if question.pdfChartDataUri %}
|
{% if question.pdfChartDataUri %}
|
||||||
<div class="chart-box chart-{{ question.pdfChartType ?: 'default' }}">
|
<div class="chart-box chart-{{ question.pdfChartType ?: 'default' }}">
|
||||||
<img src="{{ question.pdfChartDataUri }}" alt="Diagramm zu {{ question.question|striptags|trim }}">
|
<img src="{{ question.pdfChartDataUri }}" alt="Diagramm zu {{ question.question|striptags|trim }}">
|
||||||
{% if question.type == 'range' and question.pdfChartLegend %}
|
|
||||||
<div class="chart-hint">{{ 'survey.pdf.chart_hint'|trans }}</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
@@ -277,61 +276,51 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% if question.distributionRows|default([]) %}
|
||||||
<table class="stats-table">
|
<table class="stats-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th class="swatch-col">{{ 'survey.pdf.color'|trans }}</th>
|
|
||||||
<th>{{ 'survey.pdf.value'|trans }}</th>
|
<th>{{ 'survey.pdf.value'|trans }}</th>
|
||||||
<th class="numeric">{{ 'survey.pdf.count'|trans }}</th>
|
<th class="numeric">{{ 'survey.pdf.count'|trans }}</th>
|
||||||
<th class="numeric">{{ 'survey.pdf.share'|trans }}</th>
|
<th class="numeric">{{ 'survey.pdf.share'|trans }}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for option in question.pdfChartLegend %}
|
{% for option in question.distributionRows %}
|
||||||
<tr>
|
<tr>
|
||||||
<td><span class="swatch" style="background: {{ option.fillColor }}; border-color: {{ option.borderColor }};"></span></td>
|
|
||||||
<td>{{ option.label|striptags|trim }}</td>
|
<td>{{ option.label|striptags|trim }}</td>
|
||||||
<td class="numeric">{{ option.count }}</td>
|
<td class="numeric">{{ option.count }}</td>
|
||||||
<td class="numeric">{{ option.percentage }}%</td>
|
<td class="numeric">{{ option.percentage }}%</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% else %}
|
|
||||||
<tr>
|
|
||||||
<td colspan="4">{{ 'survey.pdf.no_ratings'|trans }}</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
{% else %}
|
{% else %}
|
||||||
{% if question.pdfChartLegend %}
|
<p>{{ 'survey.pdf.no_ratings'|trans }}</p>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
{# Eine Tabelle statt Doppel-Auflistung: jede Antwort einmal, mit
|
||||||
|
exakter Anzahl statt Prozentwerten. #}
|
||||||
|
{% if question.responseSummary|default([]) %}
|
||||||
<table class="stats-table">
|
<table class="stats-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th class="swatch-col">{{ 'survey.pdf.color'|trans }}</th>
|
|
||||||
<th>{{ 'survey.pdf.answer_group'|trans }}</th>
|
<th>{{ 'survey.pdf.answer_group'|trans }}</th>
|
||||||
<th class="numeric">{{ 'survey.pdf.count'|trans }}</th>
|
<th class="numeric">{{ 'survey.pdf.count'|trans }}</th>
|
||||||
<th class="numeric">{{ 'survey.pdf.share'|trans }}</th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for option in question.pdfChartLegend %}
|
{% for option in question.responseSummary %}
|
||||||
<tr>
|
<tr>
|
||||||
<td><span class="swatch" style="background: {{ option.fillColor }}; border-color: {{ option.borderColor }};"></span></td>
|
|
||||||
<td>{{ option.label|striptags|trim }}</td>
|
<td>{{ option.label|striptags|trim }}</td>
|
||||||
<td class="numeric">{{ option.count }}</td>
|
<td class="numeric">{{ option.count }}</td>
|
||||||
<td class="numeric">{{ option.percentage }}%</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<ol class="response-list">
|
|
||||||
{% for response in question.responses %}
|
|
||||||
<li>{{ response|striptags|trim }}</li>
|
|
||||||
{% else %}
|
{% else %}
|
||||||
<li>{{ 'survey.pdf.no_text_responses'|trans }}</li>
|
<p>{{ 'survey.pdf.no_text_responses'|trans }}</p>
|
||||||
{% endfor %}
|
{% endif %}
|
||||||
</ol>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</section>
|
</section>
|
||||||
{% else %}
|
{% else %}
|
||||||
|
|||||||
@@ -251,7 +251,10 @@ final class SurveyEditorService
|
|||||||
$suffix = 1 === $copyNumber ? ' - Kopie' : sprintf(' - Kopie %d', $copyNumber);
|
$suffix = 1 === $copyNumber ? ' - Kopie' : sprintf(' - Kopie %d', $copyNumber);
|
||||||
$candidate = $this->buildDuplicateTitleCandidate($baseTitle, $suffix);
|
$candidate = $this->buildDuplicateTitleCandidate($baseTitle, $suffix);
|
||||||
|
|
||||||
if (!$this->surveyRepository->titleExists($candidate, (int) $survey->id)) {
|
// Bewusst ohne Ausschluss der Quelle: Beim Duplizieren einer bereits
|
||||||
|
// "... - Kopie"-Umfrage muss der Quelltitel selbst als vergeben gelten,
|
||||||
|
// sonst entsteht erneut derselbe Titel statt "... - Kopie 2".
|
||||||
|
if (!$this->surveyRepository->titleExists($candidate)) {
|
||||||
return $candidate;
|
return $candidate;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,9 +29,19 @@ final class SurveyResultsExportService
|
|||||||
*/
|
*/
|
||||||
public function createExcelExportResponse(SurveyModel $survey, array $finishedSubmissions): Response
|
public function createExcelExportResponse(SurveyModel $survey, array $finishedSubmissions): Response
|
||||||
{
|
{
|
||||||
[$headers, $columns] = $this->buildExportColumns($this->surveyQuestionRepository->findAllBySurvey((int) $survey->id));
|
[$questionSpans, $headers, $columns] = $this->buildExportColumns($this->surveyQuestionRepository->findAllBySurvey((int) $survey->id));
|
||||||
|
|
||||||
|
// Eigene Zeile mit dem Fragetext oberhalb der Antwortspalten; die Zellen
|
||||||
|
// werden je Frage über alle zugehörigen Spalten zusammengeführt.
|
||||||
|
$questionRow = array_fill(0, max(1, count($headers)), '');
|
||||||
|
|
||||||
|
foreach ($questionSpans as $span) {
|
||||||
|
$questionRow[$span['start'] - 1] = $span['label'];
|
||||||
|
}
|
||||||
|
|
||||||
$rows = [
|
$rows = [
|
||||||
[(string) $survey->title],
|
[(string) $survey->title],
|
||||||
|
$questionRow,
|
||||||
$headers,
|
$headers,
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -66,7 +76,7 @@ final class SurveyResultsExportService
|
|||||||
$rows[] = $row;
|
$rows[] = $row;
|
||||||
}
|
}
|
||||||
|
|
||||||
$response = new Response($this->buildSpreadsheetContent($rows));
|
$response = new Response($this->buildSpreadsheetContent($rows, $questionSpans));
|
||||||
$disposition = $response->headers->makeDisposition(
|
$disposition = $response->headers->makeDisposition(
|
||||||
ResponseHeaderBag::DISPOSITION_ATTACHMENT,
|
ResponseHeaderBag::DISPOSITION_ATTACHMENT,
|
||||||
$this->buildExportFilename((string) $survey->title)
|
$this->buildExportFilename((string) $survey->title)
|
||||||
@@ -84,16 +94,18 @@ final class SurveyResultsExportService
|
|||||||
/**
|
/**
|
||||||
* @param list<SurveyContentModel> $questions
|
* @param list<SurveyContentModel> $questions
|
||||||
*
|
*
|
||||||
* @return array{0:list<string>,1:list<array{questionId:int,kind:string,value:string}>}
|
* @return array{0:list<array{label:string,start:int,end:int}>,1:list<string>,2:list<array{questionId:int,kind:string,value:string}>}
|
||||||
*/
|
*/
|
||||||
private function buildExportColumns(array $questions): array
|
private function buildExportColumns(array $questions): array
|
||||||
{
|
{
|
||||||
|
$questionSpans = [];
|
||||||
$headers = [];
|
$headers = [];
|
||||||
$columns = [];
|
$columns = [];
|
||||||
|
|
||||||
foreach (array_values($questions) as $index => $question) {
|
foreach (array_values($questions) as $index => $question) {
|
||||||
$questionNumber = $index + 1;
|
$questionNumber = $index + 1;
|
||||||
$prefix = 'F'.$questionNumber;
|
$prefix = 'F'.$questionNumber;
|
||||||
|
$firstColumnIndex = count($headers) + 1;
|
||||||
|
|
||||||
switch ((string) $question->type) {
|
switch ((string) $question->type) {
|
||||||
case 'yes_no_maybe':
|
case 'yes_no_maybe':
|
||||||
@@ -137,9 +149,17 @@ final class SurveyResultsExportService
|
|||||||
];
|
];
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$questionLabel = trim((string) $question->question);
|
||||||
|
|
||||||
|
$questionSpans[] = [
|
||||||
|
'label' => sprintf('%s: %s', $prefix, '' !== $questionLabel ? $questionLabel : 'Frage #'.(int) $question->id),
|
||||||
|
'start' => $firstColumnIndex,
|
||||||
|
'end' => count($headers),
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
return [$headers, $columns];
|
return [$questionSpans, $headers, $columns];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -179,8 +199,9 @@ final class SurveyResultsExportService
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @param list<list<int|string|float>> $rows
|
* @param list<list<int|string|float>> $rows
|
||||||
|
* @param list<array{label:string,start:int,end:int}> $questionSpans
|
||||||
*/
|
*/
|
||||||
private function buildSpreadsheetContent(array $rows): string
|
private function buildSpreadsheetContent(array $rows, array $questionSpans): string
|
||||||
{
|
{
|
||||||
$spreadsheet = new Spreadsheet();
|
$spreadsheet = new Spreadsheet();
|
||||||
$worksheet = $spreadsheet->getActiveSheet();
|
$worksheet = $spreadsheet->getActiveSheet();
|
||||||
@@ -200,12 +221,27 @@ final class SurveyResultsExportService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$lastColumn = Coordinate::stringFromColumnIndex(max(1, count($rows[1] ?? $rows[0] ?? [])));
|
// Fragetext-Zellen je Frage über die zugehörigen Antwortspalten zusammenführen.
|
||||||
|
foreach ($questionSpans as $span) {
|
||||||
|
if ($span['end'] > $span['start']) {
|
||||||
|
$worksheet->mergeCells(sprintf(
|
||||||
|
'%s2:%s2',
|
||||||
|
Coordinate::stringFromColumnIndex($span['start']),
|
||||||
|
Coordinate::stringFromColumnIndex($span['end']),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$columnCount = max(1, count($rows[2] ?? $rows[0] ?? []));
|
||||||
|
$lastColumn = Coordinate::stringFromColumnIndex($columnCount);
|
||||||
$worksheet->getStyle('A1')->getFont()->setBold(true)->setSize(14);
|
$worksheet->getStyle('A1')->getFont()->setBold(true)->setSize(14);
|
||||||
$worksheet->getStyle('A2:'.$lastColumn.'2')->getFont()->setBold(true);
|
$worksheet->getStyle('A2:'.$lastColumn.'2')->getFont()->setBold(true);
|
||||||
$worksheet->freezePane('A3');
|
$worksheet->getStyle('A2:'.$lastColumn.'2')->getAlignment()->setWrapText(true)->setVertical(\PhpOffice\PhpSpreadsheet\Style\Alignment::VERTICAL_TOP);
|
||||||
|
$worksheet->getRowDimension(2)->setRowHeight(42);
|
||||||
|
$worksheet->getStyle('A3:'.$lastColumn.'3')->getFont()->setBold(true);
|
||||||
|
$worksheet->freezePane('A4');
|
||||||
|
|
||||||
for ($column = 1; $column <= max(1, count($rows[1] ?? $rows[0] ?? [])); ++$column) {
|
for ($column = 1; $column <= $columnCount; ++$column) {
|
||||||
$worksheet->getColumnDimension(Coordinate::stringFromColumnIndex($column))->setAutoSize(true);
|
$worksheet->getColumnDimension(Coordinate::stringFromColumnIndex($column))->setAutoSize(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -361,7 +361,16 @@ final class SurveyResultsPdfService
|
|||||||
$xLabels = [];
|
$xLabels = [];
|
||||||
$yGrid = [];
|
$yGrid = [];
|
||||||
|
|
||||||
for ($tick = 0; $tick <= $maxValue; ++$tick) {
|
// Große Skalen (z. B. Alter 1–100): X-Beschriftung ausdünnen und die
|
||||||
|
// Punkt-Marker verkleinern bzw. weglassen, damit das Diagramm lesbar bleibt.
|
||||||
|
$pointCount = count($values);
|
||||||
|
$labelStep = max(1, (int) ceil($pointCount / 12));
|
||||||
|
$lastIndex = $pointCount - 1;
|
||||||
|
$pointRadius = $pointCount > 40 ? 0.0 : ($pointCount > 20 ? 3.0 : 5.5);
|
||||||
|
|
||||||
|
$yStep = max(1, (int) ceil($maxValue / 6));
|
||||||
|
|
||||||
|
for ($tick = 0; $tick <= $maxValue; $tick += $yStep) {
|
||||||
$ratio = $maxValue > 0 ? $tick / $maxValue : 0;
|
$ratio = $maxValue > 0 ? $tick / $maxValue : 0;
|
||||||
$y = $paddingTop + $plotHeight - ($ratio * $plotHeight);
|
$y = $paddingTop + $plotHeight - ($ratio * $plotHeight);
|
||||||
$yGrid[] = sprintf('<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="#d7dee8" stroke-width="1" />', $paddingLeft, $y, $width - $paddingRight, $y);
|
$yGrid[] = sprintf('<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="#d7dee8" stroke-width="1" />', $paddingLeft, $y, $width - $paddingRight, $y);
|
||||||
@@ -373,9 +382,15 @@ final class SurveyResultsPdfService
|
|||||||
$y = $paddingTop + $plotHeight - (($value / $maxValue) * $plotHeight);
|
$y = $paddingTop + $plotHeight - (($value / $maxValue) * $plotHeight);
|
||||||
$polylinePoints[] = sprintf('%.2f,%.2f', $x, $y);
|
$polylinePoints[] = sprintf('%.2f,%.2f', $x, $y);
|
||||||
$areaPolylinePoints[] = sprintf('%.2f,%.2f', $x, $y);
|
$areaPolylinePoints[] = sprintf('%.2f,%.2f', $x, $y);
|
||||||
$pointCircles[] = sprintf('<circle cx="%.2f" cy="%.2f" r="5.5" fill="%s" stroke="#ffffff" stroke-width="2" />', $x, $y, $this->escapeXml((string) ($pointColors[$index] ?? '#EC7C32')));
|
|
||||||
|
if ($pointRadius > 0.0) {
|
||||||
|
$pointCircles[] = sprintf('<circle cx="%.2f" cy="%.2f" r="%.1f" fill="%s" stroke="#ffffff" stroke-width="2" />', $x, $y, $pointRadius, $this->escapeXml((string) ($pointColors[$index] ?? '#EC7C32')));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (0 === $index % $labelStep || $index === $lastIndex) {
|
||||||
$xLabels[] = sprintf('<text x="%.2f" y="%.2f" text-anchor="middle" font-size="11" font-family="DejaVu Sans" fill="#555e68">%s</text>', $x, $height - 16, $this->escapeXml($labels[$index]));
|
$xLabels[] = sprintf('<text x="%.2f" y="%.2f" text-anchor="middle" font-size="11" font-family="DejaVu Sans" fill="#555e68">%s</text>', $x, $height - 16, $this->escapeXml($labels[$index]));
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$areaPoints = sprintf(
|
$areaPoints = sprintf(
|
||||||
'%.2f,%.2f %s %.2f,%.2f',
|
'%.2f,%.2f %s %.2f,%.2f',
|
||||||
|
|||||||
@@ -61,6 +61,11 @@ final class SurveyResultsViewService
|
|||||||
);
|
);
|
||||||
$results[$key]['_choiceOptions'] = $this->getChoiceOptions($question);
|
$results[$key]['_choiceOptions'] = $this->getChoiceOptions($question);
|
||||||
$results[$key]['_allowMaybe'] = '1' === (string) $question->allowMaybe;
|
$results[$key]['_allowMaybe'] = '1' === (string) $question->allowMaybe;
|
||||||
|
$results[$key]['_rangeConfig'] = 'range' === (string) $question->type ? [
|
||||||
|
'min' => (int) $question->rangeMin,
|
||||||
|
'max' => (int) $question->rangeMax,
|
||||||
|
'step' => max(1, (int) $question->rangeStep),
|
||||||
|
] : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach ($answers as $answer) {
|
foreach ($answers as $answer) {
|
||||||
@@ -83,10 +88,9 @@ final class SurveyResultsViewService
|
|||||||
$results[$key]['_values'][] = (string) ($answer['value'] ?? '');
|
$results[$key]['_values'][] = (string) ($answer['value'] ?? '');
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach ($results as $key => $result) {
|
// Erst sortieren, dann finalisieren: finalizeQuestionResult entfernt die
|
||||||
$results[$key] = $this->finalizeQuestionResult($result, $completedSubmissionCount);
|
// internen _sorting-Werte – ein späteres Sortieren fiele sonst immer auf
|
||||||
}
|
// den alphabetischen Fallback zurück.
|
||||||
|
|
||||||
uasort($results, static function (array $left, array $right): int {
|
uasort($results, static function (array $left, array $right): int {
|
||||||
$sortingComparison = ((int) ($left['_sorting'] ?? 0)) <=> ((int) ($right['_sorting'] ?? 0));
|
$sortingComparison = ((int) ($left['_sorting'] ?? 0)) <=> ((int) ($right['_sorting'] ?? 0));
|
||||||
|
|
||||||
@@ -97,6 +101,10 @@ final class SurveyResultsViewService
|
|||||||
return strcasecmp((string) ($left['question'] ?? ''), (string) ($right['question'] ?? ''));
|
return strcasecmp((string) ($left['question'] ?? ''), (string) ($right['question'] ?? ''));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
foreach ($results as $key => $result) {
|
||||||
|
$results[$key] = $this->finalizeQuestionResult($result, $completedSubmissionCount);
|
||||||
|
}
|
||||||
|
|
||||||
return array_values($results);
|
return array_values($results);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,6 +132,7 @@ final class SurveyResultsViewService
|
|||||||
'_values' => [],
|
'_values' => [],
|
||||||
'_choiceOptions' => [],
|
'_choiceOptions' => [],
|
||||||
'_allowMaybe' => false,
|
'_allowMaybe' => false,
|
||||||
|
'_rangeConfig' => null,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,6 +148,7 @@ final class SurveyResultsViewService
|
|||||||
$totalAnswers = count($values);
|
$totalAnswers = count($values);
|
||||||
|
|
||||||
$result['totalAnswers'] = $totalAnswers;
|
$result['totalAnswers'] = $totalAnswers;
|
||||||
|
$result['skippedCount'] = max(0, $completedSubmissionCount - $totalAnswers);
|
||||||
$result['responseRate'] = $completedSubmissionCount > 0 ? (int) round(($totalAnswers / $completedSubmissionCount) * 100) : 0;
|
$result['responseRate'] = $completedSubmissionCount > 0 ? (int) round(($totalAnswers / $completedSubmissionCount) * 100) : 0;
|
||||||
|
|
||||||
switch ((string) ($result['type'] ?? 'text')) {
|
switch ((string) ($result['type'] ?? 'text')) {
|
||||||
@@ -186,19 +196,36 @@ final class SurveyResultsViewService
|
|||||||
|
|
||||||
case 'range':
|
case 'range':
|
||||||
$numericValues = array_values(array_map(static fn (string $value): int => (int) $value, $values));
|
$numericValues = array_values(array_map(static fn (string $value): int => (int) $value, $values));
|
||||||
$distribution = [];
|
$valueCounts = [];
|
||||||
|
|
||||||
foreach ($numericValues as $numericValue) {
|
foreach ($numericValues as $numericValue) {
|
||||||
$label = (string) $numericValue;
|
$valueCounts[$numericValue] = ($valueCounts[$numericValue] ?? 0) + 1;
|
||||||
$distribution[$label] = ($distribution[$label] ?? 0) + 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ksort($distribution, SORT_NATURAL);
|
// Diagramm: die komplette Skala abbilden (inklusive Werte ohne
|
||||||
|
// Antworten), damit die X-Achse linear und vollständig ist.
|
||||||
|
$fullScale = [];
|
||||||
|
|
||||||
|
foreach ($this->buildRangeScale($result['_rangeConfig'] ?? null, $valueCounts) as $scalePoint) {
|
||||||
|
$fullScale[(string) $scalePoint] = $valueCounts[$scalePoint] ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Liste/Tabelle: nur tatsächlich beantwortete Werte, aufsteigend.
|
||||||
|
// So bläht sich die Ergebnisseite bei großen Skalen (z. B. Alter
|
||||||
|
// 1–100) nicht mit hunderten Nullzeilen auf. Die vollständige Skala
|
||||||
|
// bleibt weiterhin im Diagramm sichtbar.
|
||||||
|
ksort($valueCounts, SORT_NUMERIC);
|
||||||
|
$answeredRows = [];
|
||||||
|
|
||||||
|
foreach ($valueCounts as $value => $count) {
|
||||||
|
$answeredRows[(string) $value] = $count;
|
||||||
|
}
|
||||||
|
|
||||||
$result['minimum'] = [] !== $numericValues ? min($numericValues) : null;
|
$result['minimum'] = [] !== $numericValues ? min($numericValues) : null;
|
||||||
$result['maximum'] = [] !== $numericValues ? max($numericValues) : null;
|
$result['maximum'] = [] !== $numericValues ? max($numericValues) : null;
|
||||||
$result['average'] = [] !== $numericValues ? round(array_sum($numericValues) / count($numericValues), 2) : null;
|
$result['average'] = [] !== $numericValues ? round(array_sum($numericValues) / count($numericValues), 2) : null;
|
||||||
$result['distribution'] = $this->buildOptionStats($distribution);
|
$result['distribution'] = $this->buildOptionStats($fullScale);
|
||||||
|
$result['distributionRows'] = $this->buildOptionStats($answeredRows);
|
||||||
$result['chart'] = $this->buildRangeChartConfig($result['distribution']);
|
$result['chart'] = $this->buildRangeChartConfig($result['distribution']);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
@@ -210,11 +237,71 @@ final class SurveyResultsViewService
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
unset($result['_sorting'], $result['_values'], $result['_choiceOptions'], $result['_allowMaybe']);
|
unset($result['_sorting'], $result['_values'], $result['_choiceOptions'], $result['_allowMaybe'], $result['_rangeConfig']);
|
||||||
|
|
||||||
return $result;
|
return $result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liefert alle Skalenpunkte einer Bewertungsfrage. Ohne Skalen-Konfiguration
|
||||||
|
* (z. B. bei gelöschten Fragen, die nur noch als Antwort-Snapshot existieren)
|
||||||
|
* wird auf die tatsächlich vorkommenden Werte zurückgegriffen.
|
||||||
|
*
|
||||||
|
* @param array{min:int,max:int,step:int}|null $rangeConfig
|
||||||
|
* @param array<int, int> $valueCounts
|
||||||
|
*
|
||||||
|
* @return list<int>
|
||||||
|
*/
|
||||||
|
private function buildRangeScale(?array $rangeConfig, array $valueCounts): array
|
||||||
|
{
|
||||||
|
$observedValues = array_keys($valueCounts);
|
||||||
|
$min = $rangeConfig['min'] ?? ([] !== $observedValues ? min($observedValues) : null);
|
||||||
|
$max = $rangeConfig['max'] ?? ([] !== $observedValues ? max($observedValues) : null);
|
||||||
|
$step = max(1, (int) ($rangeConfig['step'] ?? 1));
|
||||||
|
|
||||||
|
if (null === $min || null === $max) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Antworten außerhalb der konfigurierten Grenzen (nachträglich geänderte
|
||||||
|
// Skala) nicht verschlucken, sondern die Skala entsprechend erweitern.
|
||||||
|
if ([] !== $observedValues) {
|
||||||
|
$min = min($min, min($observedValues));
|
||||||
|
$max = max($max, max($observedValues));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($max < $min) {
|
||||||
|
[$min, $max] = [$max, $min];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schutz vor unbrauchbar dichten Achsen bei extremen Skalen.
|
||||||
|
if (($max - $min) / $step > 100) {
|
||||||
|
sort($observedValues);
|
||||||
|
|
||||||
|
return array_values($observedValues);
|
||||||
|
}
|
||||||
|
|
||||||
|
$scale = [];
|
||||||
|
|
||||||
|
for ($point = $min; $point <= $max; $point += $step) {
|
||||||
|
$scale[] = $point;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ([] === $scale || $max !== end($scale)) {
|
||||||
|
$scale[] = $max;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($observedValues as $observedValue) {
|
||||||
|
if (!\in_array($observedValue, $scale, true)) {
|
||||||
|
$scale[] = $observedValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sort($scale);
|
||||||
|
|
||||||
|
return array_values($scale);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return list<string>
|
* @return list<string>
|
||||||
*/
|
*/
|
||||||
@@ -326,6 +413,11 @@ final class SurveyResultsViewService
|
|||||||
|
|
||||||
$pointStyles = ['circle', 'rectRounded', 'triangle', 'star', 'rectRot', 'crossRot'];
|
$pointStyles = ['circle', 'rectRounded', 'triangle', 'star', 'rectRot', 'crossRot'];
|
||||||
$palette = $this->buildChartPalette(count($distribution));
|
$palette = $this->buildChartPalette(count($distribution));
|
||||||
|
$pointCount = count($distribution);
|
||||||
|
|
||||||
|
// Bei großen Skalen (z. B. Alter 1–100) die Punkt-Marker verkleinern bzw.
|
||||||
|
// ausblenden, damit sich die Linie nicht in überlappenden Punkten verliert.
|
||||||
|
$pointRadius = $pointCount > 40 ? 0 : ($pointCount > 20 ? 3 : 7);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'type' => 'line',
|
'type' => 'line',
|
||||||
@@ -338,10 +430,10 @@ final class SurveyResultsViewService
|
|||||||
'backgroundColor' => 'rgba(236, 124, 50, 0.12)',
|
'backgroundColor' => 'rgba(236, 124, 50, 0.12)',
|
||||||
'pointBackgroundColor' => array_column($palette, 'border'),
|
'pointBackgroundColor' => array_column($palette, 'border'),
|
||||||
'pointBorderColor' => '#ffffff',
|
'pointBorderColor' => '#ffffff',
|
||||||
'pointBorderWidth' => 3,
|
'pointBorderWidth' => $pointRadius > 0 ? 3 : 0,
|
||||||
'pointHoverBorderWidth' => 3,
|
'pointHoverBorderWidth' => 3,
|
||||||
'pointRadius' => 7,
|
'pointRadius' => $pointRadius,
|
||||||
'pointHoverRadius' => 9,
|
'pointHoverRadius' => $pointRadius > 0 ? $pointRadius + 2 : 4,
|
||||||
'pointStyle' => array_map(
|
'pointStyle' => array_map(
|
||||||
static fn (int $index): string => $pointStyles[$index % count($pointStyles)],
|
static fn (int $index): string => $pointStyles[$index % count($pointStyles)],
|
||||||
array_keys($distribution)
|
array_keys($distribution)
|
||||||
@@ -376,6 +468,11 @@ final class SurveyResultsViewService
|
|||||||
],
|
],
|
||||||
'ticks' => [
|
'ticks' => [
|
||||||
'color' => '#555E68',
|
'color' => '#555E68',
|
||||||
|
// Achsenbeschriftung bei großen Skalen ausdünnen, damit
|
||||||
|
// sich die Zahlen nicht überlappen.
|
||||||
|
'autoSkip' => true,
|
||||||
|
'maxTicksLimit' => 12,
|
||||||
|
'maxRotation' => 0,
|
||||||
'font' => [
|
'font' => [
|
||||||
'family' => 'PT Sans Narrow',
|
'family' => 'PT Sans Narrow',
|
||||||
'weight' => '600',
|
'weight' => '600',
|
||||||
@@ -424,26 +521,11 @@ final class SurveyResultsViewService
|
|||||||
$counts[$label] = ($counts[$label] ?? 0) + 1;
|
$counts[$label] = ($counts[$label] ?? 0) + 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Alle unterschiedlichen Antworten mit exakter Häufigkeit zeigen (häufigste
|
||||||
|
// zuerst) – kein Top-5-Limit mit Sammelposten mehr.
|
||||||
arsort($counts);
|
arsort($counts);
|
||||||
$summary = [];
|
|
||||||
$remainingCount = 0;
|
|
||||||
$index = 0;
|
|
||||||
|
|
||||||
foreach ($counts as $label => $count) {
|
return $this->buildOptionStats($counts);
|
||||||
if ($index < 5) {
|
|
||||||
$summary[$label] = $count;
|
|
||||||
} else {
|
|
||||||
$remainingCount += $count;
|
|
||||||
}
|
|
||||||
|
|
||||||
++$index;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($remainingCount > 0) {
|
|
||||||
$summary['Weitere Antworten'] = $remainingCount;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->buildOptionStats($summary);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ survey:
|
|||||||
participations: "%count% Teilnahmen"
|
participations: "%count% Teilnahmen"
|
||||||
edit: "Bearbeiten"
|
edit: "Bearbeiten"
|
||||||
public_view: "Öffentliche Ansicht"
|
public_view: "Öffentliche Ansicht"
|
||||||
|
copy_link: "Link kopieren"
|
||||||
|
copy_link_done: "Link kopiert!"
|
||||||
|
active_state: "Aktiv"
|
||||||
|
inactive_state: "Deaktiviert"
|
||||||
draft_view: "Entwurfs-Ansicht"
|
draft_view: "Entwurfs-Ansicht"
|
||||||
show_results: "Ergebnisse anzeigen"
|
show_results: "Ergebnisse anzeigen"
|
||||||
publish: "Umfrage veröffentlichen"
|
publish: "Umfrage veröffentlichen"
|
||||||
@@ -123,6 +127,9 @@ survey:
|
|||||||
download_excel: "Ergebnisse als Excel herunterladen"
|
download_excel: "Ergebnisse als Excel herunterladen"
|
||||||
download_pdf: "Ergebnisse als PDF herunterladen"
|
download_pdf: "Ergebnisse als PDF herunterladen"
|
||||||
answers: "%count% Antworten"
|
answers: "%count% Antworten"
|
||||||
|
answered: "%count% von %total% beantwortet"
|
||||||
|
skipped: "%count% übersprungen"
|
||||||
|
answer_count: "%count%×"
|
||||||
response_rate: "%rate%% Rücklauf"
|
response_rate: "%rate%% Rücklauf"
|
||||||
minimum: "Minimum"
|
minimum: "Minimum"
|
||||||
maximum: "Maximum"
|
maximum: "Maximum"
|
||||||
@@ -137,6 +144,8 @@ survey:
|
|||||||
questions: "%count% Fragen"
|
questions: "%count% Fragen"
|
||||||
export_prefix: "Export %date%"
|
export_prefix: "Export %date%"
|
||||||
answers: "%count% Antworten"
|
answers: "%count% Antworten"
|
||||||
|
answered: "%count% von %total% beantwortet"
|
||||||
|
skipped: "%count% übersprungen"
|
||||||
response_rate: "%rate%% Rücklauf"
|
response_rate: "%rate%% Rücklauf"
|
||||||
minimum: "Minimum"
|
minimum: "Minimum"
|
||||||
maximum: "Maximum"
|
maximum: "Maximum"
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ survey:
|
|||||||
participations: "%count% participations"
|
participations: "%count% participations"
|
||||||
edit: "Edit"
|
edit: "Edit"
|
||||||
public_view: "Public view"
|
public_view: "Public view"
|
||||||
|
copy_link: "Copy link"
|
||||||
|
copy_link_done: "Link copied!"
|
||||||
|
active_state: "Active"
|
||||||
|
inactive_state: "Deactivated"
|
||||||
draft_view: "Draft view"
|
draft_view: "Draft view"
|
||||||
show_results: "Show results"
|
show_results: "Show results"
|
||||||
publish: "Publish survey"
|
publish: "Publish survey"
|
||||||
@@ -123,6 +127,9 @@ survey:
|
|||||||
download_excel: "Download results as Excel"
|
download_excel: "Download results as Excel"
|
||||||
download_pdf: "Download results as PDF"
|
download_pdf: "Download results as PDF"
|
||||||
answers: "%count% answers"
|
answers: "%count% answers"
|
||||||
|
answered: "%count% of %total% answered"
|
||||||
|
skipped: "%count% skipped"
|
||||||
|
answer_count: "%count%×"
|
||||||
response_rate: "%rate%% response rate"
|
response_rate: "%rate%% response rate"
|
||||||
minimum: "Minimum"
|
minimum: "Minimum"
|
||||||
maximum: "Maximum"
|
maximum: "Maximum"
|
||||||
@@ -137,6 +144,8 @@ survey:
|
|||||||
questions: "%count% questions"
|
questions: "%count% questions"
|
||||||
export_prefix: "Export %date%"
|
export_prefix: "Export %date%"
|
||||||
answers: "%count% answers"
|
answers: "%count% answers"
|
||||||
|
answered: "%count% of %total% answered"
|
||||||
|
skipped: "%count% skipped"
|
||||||
response_rate: "%rate%% response rate"
|
response_rate: "%rate%% response rate"
|
||||||
minimum: "Minimum"
|
minimum: "Minimum"
|
||||||
maximum: "Maximum"
|
maximum: "Maximum"
|
||||||
|
|||||||
Reference in New Issue
Block a user