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:
+21
-2
@@ -109,10 +109,14 @@ 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
|
||||
- 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`
|
||||
- Eigene Aktion in der Liste
|
||||
- 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
|
||||
- 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):
|
||||
|
||||
@@ -229,9 +236,19 @@ Aktuelle Ergebnislogik:
|
||||
- Optionsstatistik plus Kreisdiagramm
|
||||
- `range`
|
||||
- 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`
|
||||
- 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 +313,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;
|
||||
|
||||
@@ -70,13 +70,22 @@
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% 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 %}
|
||||
{% 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 %}
|
||||
{% 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 %}
|
||||
<form method="post" class="survey-question-action-form">
|
||||
<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="_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 %}
|
||||
@@ -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>
|
||||
|
||||
@@ -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,8 +223,10 @@
|
||||
{% 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 %}
|
||||
@@ -302,36 +304,28 @@
|
||||
</tbody>
|
||||
</table>
|
||||
{% 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">
|
||||
<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 %}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,14 +196,20 @@ 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);
|
||||
// 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['maximum'] = [] !== $numericValues ? max($numericValues) : null;
|
||||
@@ -210,11 +226,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>
|
||||
*/
|
||||
@@ -424,26 +500,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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user