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>
This commit is contained in:
2026-07-05 19:30:34 +02:00
parent 788f5ccde0
commit c7439e4822
11 changed files with 242 additions and 80 deletions
+21 -2
View File
@@ -109,10 +109,14 @@ 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
- Aktionslinks (`Bearbeiten`, `Öffentliche Ansicht`, `Ergebnisse anzeigen`, `Entwurfs-Ansicht`) öffnen bewusst **im selben Tab** (kein `target="_blank"` mehr), weil der Kunde die vielen neuen Tabs als störend meldete.
- `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 +124,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):
@@ -229,9 +236,19 @@ Aktuelle Ergebnislogik:
- Optionsstatistik plus Kreisdiagramm - Optionsstatistik plus Kreisdiagramm
- `range` - `range`
- Minimum, Maximum, Durchschnitt plus Verteilung und Linienchart - Minimum, Maximum, Durchschnitt plus Verteilung und Linienchart
- Die Verteilung 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`.
- `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 +313,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;
@@ -70,13 +70,22 @@
{% endif %} {% endif %}
{% endif %} {% endif %}
{% 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 }}">{{ '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 %} {% 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 }}">{{ 'survey.list.draft_view'|trans }}</a>
{% endif %} {% endif %}
{% if survey.resultsUrl %} {% if survey.resultsUrl %}
<a class="survey-button secondary" href="{{ survey.resultsUrl }}" target="_blank" rel="noreferrer">{{ 'survey.list.show_results'|trans }}</a> <a class="survey-button secondary" href="{{ survey.resultsUrl }}">{{ 'survey.list.show_results'|trans }}</a>
{% endif %} {% endif %}
<form method="post" class="survey-question-action-form"> <form method="post" class="survey-question-action-form">
<input type="hidden" name="REQUEST_TOKEN" value="{{ contao.request_token }}"> <input type="hidden" name="REQUEST_TOKEN" value="{{ contao.request_token }}">
@@ -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 %}
@@ -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>
@@ -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,8 +223,10 @@
{% 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 %}
@@ -302,36 +304,28 @@
</tbody> </tbody>
</table> </table>
{% else %} {% else %}
{% if question.pdfChartLegend %} {# 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 %}
+4 -1
View File
@@ -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;
} }
} }
+44 -8
View File
@@ -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);
} }
+88 -27
View File
@@ -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,14 +196,20 @@ 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); // Die komplette Skala abbilden (inklusive Werte ohne Antworten), damit
// die X-Achse linear und vollständig ist nicht nur die Werte, die
// tatsächlich geantwortet wurden.
$distribution = [];
foreach ($this->buildRangeScale($result['_rangeConfig'] ?? null, $valueCounts) as $scalePoint) {
$distribution[(string) $scalePoint] = $valueCounts[$scalePoint] ?? 0;
}
$result['minimum'] = [] !== $numericValues ? min($numericValues) : null; $result['minimum'] = [] !== $numericValues ? min($numericValues) : null;
$result['maximum'] = [] !== $numericValues ? max($numericValues) : null; $result['maximum'] = [] !== $numericValues ? max($numericValues) : null;
@@ -210,11 +226,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>
*/ */
@@ -424,26 +500,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);
} }
/** /**
+9
View File
@@ -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"
+9
View File
@@ -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"