4 Commits

Author SHA1 Message Date
admin.juergen 4fe142422a Warn on leaving the editor with unsaved changes
Ergänzt einen beforeunload-Handler im Frontend-Editor: Beim Verlassen der
Seite (Link, Zurück, Tab schließen) wird gewarnt, sobald mindestens ein
Editor-Formular ungespeicherte Änderungen hat. Der Handler koppelt an die
bestehende Dirty-Erkennung (data-survey-dirty-form) und wird nur
registriert, wenn solche Formulare existieren – also nur im Editor, nicht
in Liste/Reader/Ergebnissen.

Ein bewusstes Absenden (Speichern oder Aktion) setzt über einen globalen
submit-Listener ein Flag, sodass der normale Speicher-Flow nicht warnt.

Bewusst kein Auto-Save (getrennte Formulare mit eigenen CSRF-Tokens,
Struktur-Sperre nach Teilnahmen/Veröffentlichung); Dirty-Hinweis plus
beforeunload sind der risikoarme Kompromiss.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 20:24:09 +02:00
admin.juergen 954a5102ac Refine range results and restore new-tab view links
Zweite Runde Kundenrückmeldung:

- Range-Ergebnisse: Das Diagramm behält die komplette lineare Skala
  (rangeMin..rangeMax), aber die Werte-Liste/Tabelle zeigt nur noch die
  tatsächlich beantworteten Werte. Sonst würde eine große Skala (z. B. Alter
  1-100, Schrittweite 1) die Ergebnisseite mit hunderten Nullzeilen fluten.
  Neues Feld distributionRows für die Liste; distribution bleibt fürs Diagramm.
- Große Skalen: X-Achsenbeschriftung wird ausgedünnt (Chart.js autoSkip/
  maxTicksLimit, SVG jede n-te Beschriftung), Punkt-Marker verkleinert bzw.
  ab >40 Punkten weggelassen. Auch die Y-Ticks im PDF-SVG werden gedeckelt.
- PDF-Range-Tabelle ohne Farb-Swatch-Spalte (nur Wert/Anzahl/Anteil), da die
  Kopplung an ein Vollskalen-Diagramm nicht mehr sinnvoll ist.
- Aktionslinks in der Frontend-Liste: Öffentliche Ansicht, Entwurfs-Ansicht
  und Ergebnisse anzeigen öffnen wieder im neuen Fenster (target=_blank);
  nur Bearbeiten bleibt im selben Tab.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 20:15:05 +02:00
admin.juergen c7439e4822 Fix customer-reported survey results and list issues
Behebt die Fehler aus der Kunden-Testrunde (Praktikantin, Juli 2026):

- Ergebnisreihenfolge: Fragen werden vor dem Finalisieren nach _sorting
  sortiert. finalizeQuestionResult entfernt _sorting, daher fiel die
  bisherige Nachsortierung auf den alphabetischen Fallback zurück. Online,
  PDF und Excel folgen jetzt derselben Frage-Sortierung.
- Excel-Export: Eigene Fragetext-Zeile über den Antwortspalten (je Frage
  über die zugehörigen Spalten zusammengeführt). Zuvor fehlte der Fragetext.
- Range-Ergebnis: Verteilung deckt die komplette Skala rangeMin..rangeMax in
  rangeStep-Schritten ab, damit die X-Achse linear und vollständig ist
  (vorher nur tatsächlich vorkommende Werte in gleichen Abständen).
- Teilnahmen/Rücklauf pro Frage als absolute Zahlen ("X von Y beantwortet",
  "Z übersprungen") statt reiner Prozent-Rücklaufquote.
- Freitextantworten gruppiert: jede Antwort einmal mit exakter Häufigkeit,
  ohne Prozentangaben und ohne Doppel-Auflistung.
- Frontend-Liste: "Link kopieren"-Button (absolute Reader-URL) für
  veröffentlichte Umfragen; Aktiv-Umschalter mit Textlabel statt nur Punkt;
  Aktionslinks öffnen im selben Tab statt in neuen Tabs.
- Duplizieren: Titel-Kollisionsprüfung ohne Ausschluss der Quell-ID, damit
  aus "... - Kopie" korrekt "... - Kopie 2" wird statt eines Dubletten-Titels.

Der internalNote-Fehler beim Anlegen/Duplizieren war ein fehlendes
DB-Schema-Update (Spalte tl_survey.internalNote) und wurde per
contao:migrate auf der Testumgebung nachgezogen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 19:30:34 +02:00
Jürgen Mummert 788f5ccde0 Widen assigned-members checkbox wizard to 6 columns
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 22:36:07 +02:00
13 changed files with 367 additions and 127 deletions
+33 -2
View File
@@ -109,10 +109,16 @@ Aktuelle Button- und Sichtbarkeitslogik pro Umfrage:
- Nach Veröffentlichung disabled
- `Öffentliche Ansicht`
- 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`
- Nur sichtbar, wenn `published = 0`
- `Ergebnisse anzeigen`
- 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`
- Eigene Aktion in der Liste
- 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
- 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
- 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`
- 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):
@@ -161,6 +170,14 @@ Aktueller Aufbau:
- Fragen im Frontend nicht mehr änderbar
- 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:
- Die Header-Badges im Fragen-Akkordeon wurden bewusst entfernt.
@@ -229,9 +246,21 @@ Aktuelle Ergebnislogik:
- Optionsstatistik plus Kreisdiagramm
- `range`
- 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 1100 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`
- 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:
@@ -296,6 +325,8 @@ Wichtige Eigenschaft:
- Die Spalten orientieren sich an den aktuell vorhandenen Fragen im Survey.
- 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
@@ -440,15 +440,36 @@
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 {
aspect-ratio: 1;
height: 42px;
height: 28px;
border-radius: 999px;
background: #a6adb7;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
flex-shrink: 0;
transition: background 0.18s ease;
}
@@ -456,6 +477,10 @@
background: #0a832e;
}
.survey-visibility-label {
white-space: nowrap;
}
.survey-button-row {
display: flex;
flex-wrap: wrap;
@@ -1141,6 +1166,9 @@
});
});
var surveyDirtyChecks = [];
var surveyIsSubmitting = false;
document.querySelectorAll('[data-survey-dirty-form]').forEach(function (form) {
var formId = form.getAttribute('id') || '';
var hint = form.querySelector('[data-survey-dirty-hint]');
@@ -1149,10 +1177,6 @@
hint = document.querySelector('[data-survey-dirty-hint-for="' + formId + '"]');
}
if (!hint) {
return;
}
var serializeForm = function () {
var entries = [];
var formData = new FormData(form);
@@ -1166,25 +1190,61 @@
var initialState = serializeForm();
var isDirty = function () {
return serializeForm() !== initialState;
};
surveyDirtyChecks.push(isDirty);
var updateDirtyState = function () {
hint.hidden = serializeForm() === initialState;
};
var markDirty = function () {
updateDirtyState();
if (hint) {
hint.hidden = !isDirty();
}
};
if (hint) {
hint.hidden = true;
}
form.addEventListener('input', markDirty);
form.addEventListener('change', markDirty);
form.addEventListener('input', updateDirtyState);
form.addEventListener('change', updateDirtyState);
form.addEventListener('reset', function () {
window.setTimeout(function () {
if (hint) {
hint.hidden = true;
}
}, 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') {
document.querySelectorAll('[data-survey-handorgel]').forEach(function (element) {
var accordion = new handorgel(element, {
@@ -72,6 +72,15 @@
{% if survey.publicUrl and survey.published %}
<a class="survey-button secondary" href="{{ survey.publicUrl }}" target="_blank" rel="noreferrer">{{ 'survey.list.public_view'|trans }}</a>
{% 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 %}
<a class="survey-button secondary" href="{{ survey.draftUrl }}" target="_blank" rel="noreferrer">{{ 'survey.list.draft_view'|trans }}</a>
{% endif %}
@@ -118,7 +127,10 @@
<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="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>
</div>
</div>
@@ -41,8 +41,10 @@
<article class="survey-card survey-results-card">
<div class="survey-meta">
<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.response_rate'|trans({'%rate%': question.responseRate}) }}</span>
<span class="survey-badge neutral">{{ 'survey.results.answered'|trans({'%count%': question.totalAnswers, '%total%': completedSubmissionCount}) }}</span>
{% if question.skippedCount > 0 %}
<span class="survey-badge neutral">{{ 'survey.results.skipped'|trans({'%count%': question.skippedCount}) }}</span>
{% endif %}
</div>
{% if question.description|striptags|trim %}
@@ -91,11 +93,11 @@
</div>
<div class="survey-results-list">
{% for option in question.distribution %}
{% for option in question.distributionRows|default([]) %}
<div class="survey-results-row">
<div class="survey-meta">
<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>
</div>
<div class="survey-progress">
@@ -107,25 +109,13 @@
{% endfor %}
</div>
{% else %}
{% if question.responseSummary is defined and question.responseSummary %}
<div class="survey-text-summary-grid">
{% 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 %}
{# Eine Liste statt Doppel-Auflistung: jede Antwort einmal,
mit exakter Anzahl statt Prozentwerten. #}
<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">
<span class="survey-response-card__index">{{ loop.index }}</span>
<blockquote>{{ response|striptags|trim|nl2br }}</blockquote>
<span class="survey-badge neutral">{{ 'survey.results.answer_count'|trans({'%count%': option.count}) }}</span>
<blockquote>{{ option.label|striptags|trim|nl2br }}</blockquote>
</article>
{% else %}
<p>{{ 'survey.results.no_text_responses'|trans }}</p>
+1 -1
View File
@@ -1,5 +1,5 @@
.tl_checkbox_wizard.survey-assigned-members-checkbox-wizard {
column-count: 4;
column-count: 6;
column-gap: 1.5rem;
}
@@ -18,6 +18,7 @@ use Mummert\SurveyBundle\Service\SurveyEditorService;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
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_template_list', category: 'survey', template: 'member_survey_list')]
@@ -69,6 +70,9 @@ final class MemberSurveyListController extends AbstractFrontendModuleController
'participationCount' => (int) ($survey['participationCount'] ?? 0),
'editUrl' => $editPage instanceof PageModel ? $this->generateContentUrl($editPage, ['survey' => $surveyId]) : 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,
'resultsUrl' => $resultsPage instanceof PageModel ? $this->generateContentUrl($resultsPage, ['parameters' => '/'.$alias]) : null,
];
@@ -223,16 +223,15 @@
{% endif %}
<div class="meta-row">
<span class="meta-pill">{{ 'survey.pdf.participations'|trans({'%count%': completedSubmissionCount}) }}</span>
<span class="meta-pill">{{ 'survey.pdf.response_rate'|trans({'%rate%': question.responseRate}) }}</span>
<span class="meta-pill">{{ 'survey.pdf.answered'|trans({'%count%': question.totalAnswers, '%total%': completedSubmissionCount}) }}</span>
{% if question.skippedCount > 0 %}
<span class="meta-pill">{{ 'survey.pdf.skipped'|trans({'%count%': question.skippedCount}) }}</span>
{% endif %}
</div>
{% if question.pdfChartDataUri %}
<div class="chart-box chart-{{ question.pdfChartType ?: 'default' }}">
<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>
{% endif %}
@@ -277,61 +276,51 @@
</div>
</div>
{% if question.distributionRows|default([]) %}
<table class="stats-table">
<thead>
<tr>
<th class="swatch-col">{{ 'survey.pdf.color'|trans }}</th>
<th>{{ 'survey.pdf.value'|trans }}</th>
<th class="numeric">{{ 'survey.pdf.count'|trans }}</th>
<th class="numeric">{{ 'survey.pdf.share'|trans }}</th>
</tr>
</thead>
<tbody>
{% for option in question.pdfChartLegend %}
{% for option in question.distributionRows %}
<tr>
<td><span class="swatch" style="background: {{ option.fillColor }}; border-color: {{ option.borderColor }};"></span></td>
<td>{{ option.label|striptags|trim }}</td>
<td class="numeric">{{ option.count }}</td>
<td class="numeric">{{ option.percentage }}%</td>
</tr>
{% else %}
<tr>
<td colspan="4">{{ 'survey.pdf.no_ratings'|trans }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% 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">
<thead>
<tr>
<th class="swatch-col">{{ 'survey.pdf.color'|trans }}</th>
<th>{{ 'survey.pdf.answer_group'|trans }}</th>
<th class="numeric">{{ 'survey.pdf.count'|trans }}</th>
<th class="numeric">{{ 'survey.pdf.share'|trans }}</th>
</tr>
</thead>
<tbody>
{% for option in question.pdfChartLegend %}
{% for option in question.responseSummary %}
<tr>
<td><span class="swatch" style="background: {{ option.fillColor }}; border-color: {{ option.borderColor }};"></span></td>
<td>{{ option.label|striptags|trim }}</td>
<td class="numeric">{{ option.count }}</td>
<td class="numeric">{{ option.percentage }}%</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
<ol class="response-list">
{% for response in question.responses %}
<li>{{ response|striptags|trim }}</li>
{% else %}
<li>{{ 'survey.pdf.no_text_responses'|trans }}</li>
{% endfor %}
</ol>
<p>{{ 'survey.pdf.no_text_responses'|trans }}</p>
{% endif %}
{% endif %}
</section>
{% else %}
+4 -1
View File
@@ -251,7 +251,10 @@ final class SurveyEditorService
$suffix = 1 === $copyNumber ? ' - Kopie' : sprintf(' - Kopie %d', $copyNumber);
$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;
}
}
+44 -8
View File
@@ -29,9 +29,19 @@ final class SurveyResultsExportService
*/
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 = [
[(string) $survey->title],
$questionRow,
$headers,
];
@@ -66,7 +76,7 @@ final class SurveyResultsExportService
$rows[] = $row;
}
$response = new Response($this->buildSpreadsheetContent($rows));
$response = new Response($this->buildSpreadsheetContent($rows, $questionSpans));
$disposition = $response->headers->makeDisposition(
ResponseHeaderBag::DISPOSITION_ATTACHMENT,
$this->buildExportFilename((string) $survey->title)
@@ -84,16 +94,18 @@ final class SurveyResultsExportService
/**
* @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
{
$questionSpans = [];
$headers = [];
$columns = [];
foreach (array_values($questions) as $index => $question) {
$questionNumber = $index + 1;
$prefix = 'F'.$questionNumber;
$firstColumnIndex = count($headers) + 1;
switch ((string) $question->type) {
case 'yes_no_maybe':
@@ -137,9 +149,17 @@ final class SurveyResultsExportService
];
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<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();
$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('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);
}
+17 -2
View File
@@ -361,7 +361,16 @@ final class SurveyResultsPdfService
$xLabels = [];
$yGrid = [];
for ($tick = 0; $tick <= $maxValue; ++$tick) {
// Große Skalen (z. B. Alter 1100): 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;
$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);
@@ -373,9 +382,15 @@ final class SurveyResultsPdfService
$y = $paddingTop + $plotHeight - (($value / $maxValue) * $plotHeight);
$polylinePoints[] = 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]));
}
}
$areaPoints = sprintf(
'%.2f,%.2f %s %.2f,%.2f',
+113 -31
View File
@@ -61,6 +61,11 @@ final class SurveyResultsViewService
);
$results[$key]['_choiceOptions'] = $this->getChoiceOptions($question);
$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) {
@@ -83,10 +88,9 @@ final class SurveyResultsViewService
$results[$key]['_values'][] = (string) ($answer['value'] ?? '');
}
foreach ($results as $key => $result) {
$results[$key] = $this->finalizeQuestionResult($result, $completedSubmissionCount);
}
// Erst sortieren, dann finalisieren: finalizeQuestionResult entfernt die
// 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 {
$sortingComparison = ((int) ($left['_sorting'] ?? 0)) <=> ((int) ($right['_sorting'] ?? 0));
@@ -97,6 +101,10 @@ final class SurveyResultsViewService
return strcasecmp((string) ($left['question'] ?? ''), (string) ($right['question'] ?? ''));
});
foreach ($results as $key => $result) {
$results[$key] = $this->finalizeQuestionResult($result, $completedSubmissionCount);
}
return array_values($results);
}
@@ -124,6 +132,7 @@ final class SurveyResultsViewService
'_values' => [],
'_choiceOptions' => [],
'_allowMaybe' => false,
'_rangeConfig' => null,
];
}
@@ -139,6 +148,7 @@ final class SurveyResultsViewService
$totalAnswers = count($values);
$result['totalAnswers'] = $totalAnswers;
$result['skippedCount'] = max(0, $completedSubmissionCount - $totalAnswers);
$result['responseRate'] = $completedSubmissionCount > 0 ? (int) round(($totalAnswers / $completedSubmissionCount) * 100) : 0;
switch ((string) ($result['type'] ?? 'text')) {
@@ -186,19 +196,36 @@ final class SurveyResultsViewService
case 'range':
$numericValues = array_values(array_map(static fn (string $value): int => (int) $value, $values));
$distribution = [];
$valueCounts = [];
foreach ($numericValues as $numericValue) {
$label = (string) $numericValue;
$distribution[$label] = ($distribution[$label] ?? 0) + 1;
$valueCounts[$numericValue] = ($valueCounts[$numericValue] ?? 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
// 1100) 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['maximum'] = [] !== $numericValues ? max($numericValues) : 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']);
break;
@@ -210,11 +237,71 @@ final class SurveyResultsViewService
break;
}
unset($result['_sorting'], $result['_values'], $result['_choiceOptions'], $result['_allowMaybe']);
unset($result['_sorting'], $result['_values'], $result['_choiceOptions'], $result['_allowMaybe'], $result['_rangeConfig']);
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>
*/
@@ -326,6 +413,11 @@ final class SurveyResultsViewService
$pointStyles = ['circle', 'rectRounded', 'triangle', 'star', 'rectRot', 'crossRot'];
$palette = $this->buildChartPalette(count($distribution));
$pointCount = count($distribution);
// Bei großen Skalen (z. B. Alter 1100) die Punkt-Marker verkleinern bzw.
// ausblenden, damit sich die Linie nicht in überlappenden Punkten verliert.
$pointRadius = $pointCount > 40 ? 0 : ($pointCount > 20 ? 3 : 7);
return [
'type' => 'line',
@@ -338,10 +430,10 @@ final class SurveyResultsViewService
'backgroundColor' => 'rgba(236, 124, 50, 0.12)',
'pointBackgroundColor' => array_column($palette, 'border'),
'pointBorderColor' => '#ffffff',
'pointBorderWidth' => 3,
'pointBorderWidth' => $pointRadius > 0 ? 3 : 0,
'pointHoverBorderWidth' => 3,
'pointRadius' => 7,
'pointHoverRadius' => 9,
'pointRadius' => $pointRadius,
'pointHoverRadius' => $pointRadius > 0 ? $pointRadius + 2 : 4,
'pointStyle' => array_map(
static fn (int $index): string => $pointStyles[$index % count($pointStyles)],
array_keys($distribution)
@@ -376,6 +468,11 @@ final class SurveyResultsViewService
],
'ticks' => [
'color' => '#555E68',
// Achsenbeschriftung bei großen Skalen ausdünnen, damit
// sich die Zahlen nicht überlappen.
'autoSkip' => true,
'maxTicksLimit' => 12,
'maxRotation' => 0,
'font' => [
'family' => 'PT Sans Narrow',
'weight' => '600',
@@ -424,26 +521,11 @@ final class SurveyResultsViewService
$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);
$summary = [];
$remainingCount = 0;
$index = 0;
foreach ($counts as $label => $count) {
if ($index < 5) {
$summary[$label] = $count;
} else {
$remainingCount += $count;
}
++$index;
}
if ($remainingCount > 0) {
$summary['Weitere Antworten'] = $remainingCount;
}
return $this->buildOptionStats($summary);
return $this->buildOptionStats($counts);
}
/**
+9
View File
@@ -16,6 +16,10 @@ survey:
participations: "%count% Teilnahmen"
edit: "Bearbeiten"
public_view: "Öffentliche Ansicht"
copy_link: "Link kopieren"
copy_link_done: "Link kopiert!"
active_state: "Aktiv"
inactive_state: "Deaktiviert"
draft_view: "Entwurfs-Ansicht"
show_results: "Ergebnisse anzeigen"
publish: "Umfrage veröffentlichen"
@@ -123,6 +127,9 @@ survey:
download_excel: "Ergebnisse als Excel herunterladen"
download_pdf: "Ergebnisse als PDF herunterladen"
answers: "%count% Antworten"
answered: "%count% von %total% beantwortet"
skipped: "%count% übersprungen"
answer_count: "%count%×"
response_rate: "%rate%% Rücklauf"
minimum: "Minimum"
maximum: "Maximum"
@@ -137,6 +144,8 @@ survey:
questions: "%count% Fragen"
export_prefix: "Export %date%"
answers: "%count% Antworten"
answered: "%count% von %total% beantwortet"
skipped: "%count% übersprungen"
response_rate: "%rate%% Rücklauf"
minimum: "Minimum"
maximum: "Maximum"
+9
View File
@@ -16,6 +16,10 @@ survey:
participations: "%count% participations"
edit: "Edit"
public_view: "Public view"
copy_link: "Copy link"
copy_link_done: "Link copied!"
active_state: "Active"
inactive_state: "Deactivated"
draft_view: "Draft view"
show_results: "Show results"
publish: "Publish survey"
@@ -123,6 +127,9 @@ survey:
download_excel: "Download results as Excel"
download_pdf: "Download results as PDF"
answers: "%count% answers"
answered: "%count% of %total% answered"
skipped: "%count% skipped"
answer_count: "%count%×"
response_rate: "%rate%% response rate"
minimum: "Minimum"
maximum: "Maximum"
@@ -137,6 +144,8 @@ survey:
questions: "%count% questions"
export_prefix: "Export %date%"
answers: "%count% answers"
answered: "%count% of %total% answered"
skipped: "%count% skipped"
response_rate: "%rate%% response rate"
minimum: "Minimum"
maximum: "Maximum"