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
|
- 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 %}
|
||||||
|
|||||||
@@ -251,7 +251,10 @@ final class SurveyEditorService
|
|||||||
$suffix = 1 === $copyNumber ? ' - Kopie' : sprintf(' - Kopie %d', $copyNumber);
|
$suffix = 1 === $copyNumber ? ' - Kopie' : sprintf(' - Kopie %d', $copyNumber);
|
||||||
$candidate = $this->buildDuplicateTitleCandidate($baseTitle, $suffix);
|
$candidate = $this->buildDuplicateTitleCandidate($baseTitle, $suffix);
|
||||||
|
|
||||||
if (!$this->surveyRepository->titleExists($candidate, (int) $survey->id)) {
|
// Bewusst ohne Ausschluss der Quelle: Beim Duplizieren einer bereits
|
||||||
|
// "... - Kopie"-Umfrage muss der Quelltitel selbst als vergeben gelten,
|
||||||
|
// sonst entsteht erneut derselbe Titel statt "... - Kopie 2".
|
||||||
|
if (!$this->surveyRepository->titleExists($candidate)) {
|
||||||
return $candidate;
|
return $candidate;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,9 +29,19 @@ final class SurveyResultsExportService
|
|||||||
*/
|
*/
|
||||||
public function createExcelExportResponse(SurveyModel $survey, array $finishedSubmissions): Response
|
public function createExcelExportResponse(SurveyModel $survey, array $finishedSubmissions): Response
|
||||||
{
|
{
|
||||||
[$headers, $columns] = $this->buildExportColumns($this->surveyQuestionRepository->findAllBySurvey((int) $survey->id));
|
[$questionSpans, $headers, $columns] = $this->buildExportColumns($this->surveyQuestionRepository->findAllBySurvey((int) $survey->id));
|
||||||
|
|
||||||
|
// Eigene Zeile mit dem Fragetext oberhalb der Antwortspalten; die Zellen
|
||||||
|
// werden je Frage über alle zugehörigen Spalten zusammengeführt.
|
||||||
|
$questionRow = array_fill(0, max(1, count($headers)), '');
|
||||||
|
|
||||||
|
foreach ($questionSpans as $span) {
|
||||||
|
$questionRow[$span['start'] - 1] = $span['label'];
|
||||||
|
}
|
||||||
|
|
||||||
$rows = [
|
$rows = [
|
||||||
[(string) $survey->title],
|
[(string) $survey->title],
|
||||||
|
$questionRow,
|
||||||
$headers,
|
$headers,
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -66,7 +76,7 @@ final class SurveyResultsExportService
|
|||||||
$rows[] = $row;
|
$rows[] = $row;
|
||||||
}
|
}
|
||||||
|
|
||||||
$response = new Response($this->buildSpreadsheetContent($rows));
|
$response = new Response($this->buildSpreadsheetContent($rows, $questionSpans));
|
||||||
$disposition = $response->headers->makeDisposition(
|
$disposition = $response->headers->makeDisposition(
|
||||||
ResponseHeaderBag::DISPOSITION_ATTACHMENT,
|
ResponseHeaderBag::DISPOSITION_ATTACHMENT,
|
||||||
$this->buildExportFilename((string) $survey->title)
|
$this->buildExportFilename((string) $survey->title)
|
||||||
@@ -84,16 +94,18 @@ final class SurveyResultsExportService
|
|||||||
/**
|
/**
|
||||||
* @param list<SurveyContentModel> $questions
|
* @param list<SurveyContentModel> $questions
|
||||||
*
|
*
|
||||||
* @return array{0:list<string>,1:list<array{questionId:int,kind:string,value:string}>}
|
* @return array{0:list<array{label:string,start:int,end:int}>,1:list<string>,2:list<array{questionId:int,kind:string,value:string}>}
|
||||||
*/
|
*/
|
||||||
private function buildExportColumns(array $questions): array
|
private function buildExportColumns(array $questions): array
|
||||||
{
|
{
|
||||||
|
$questionSpans = [];
|
||||||
$headers = [];
|
$headers = [];
|
||||||
$columns = [];
|
$columns = [];
|
||||||
|
|
||||||
foreach (array_values($questions) as $index => $question) {
|
foreach (array_values($questions) as $index => $question) {
|
||||||
$questionNumber = $index + 1;
|
$questionNumber = $index + 1;
|
||||||
$prefix = 'F'.$questionNumber;
|
$prefix = 'F'.$questionNumber;
|
||||||
|
$firstColumnIndex = count($headers) + 1;
|
||||||
|
|
||||||
switch ((string) $question->type) {
|
switch ((string) $question->type) {
|
||||||
case 'yes_no_maybe':
|
case 'yes_no_maybe':
|
||||||
@@ -137,9 +149,17 @@ final class SurveyResultsExportService
|
|||||||
];
|
];
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$questionLabel = trim((string) $question->question);
|
||||||
|
|
||||||
|
$questionSpans[] = [
|
||||||
|
'label' => sprintf('%s: %s', $prefix, '' !== $questionLabel ? $questionLabel : 'Frage #'.(int) $question->id),
|
||||||
|
'start' => $firstColumnIndex,
|
||||||
|
'end' => count($headers),
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
return [$headers, $columns];
|
return [$questionSpans, $headers, $columns];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -179,8 +199,9 @@ final class SurveyResultsExportService
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @param list<list<int|string|float>> $rows
|
* @param list<list<int|string|float>> $rows
|
||||||
|
* @param list<array{label:string,start:int,end:int}> $questionSpans
|
||||||
*/
|
*/
|
||||||
private function buildSpreadsheetContent(array $rows): string
|
private function buildSpreadsheetContent(array $rows, array $questionSpans): string
|
||||||
{
|
{
|
||||||
$spreadsheet = new Spreadsheet();
|
$spreadsheet = new Spreadsheet();
|
||||||
$worksheet = $spreadsheet->getActiveSheet();
|
$worksheet = $spreadsheet->getActiveSheet();
|
||||||
@@ -200,12 +221,27 @@ final class SurveyResultsExportService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$lastColumn = Coordinate::stringFromColumnIndex(max(1, count($rows[1] ?? $rows[0] ?? [])));
|
// Fragetext-Zellen je Frage über die zugehörigen Antwortspalten zusammenführen.
|
||||||
|
foreach ($questionSpans as $span) {
|
||||||
|
if ($span['end'] > $span['start']) {
|
||||||
|
$worksheet->mergeCells(sprintf(
|
||||||
|
'%s2:%s2',
|
||||||
|
Coordinate::stringFromColumnIndex($span['start']),
|
||||||
|
Coordinate::stringFromColumnIndex($span['end']),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$columnCount = max(1, count($rows[2] ?? $rows[0] ?? []));
|
||||||
|
$lastColumn = Coordinate::stringFromColumnIndex($columnCount);
|
||||||
$worksheet->getStyle('A1')->getFont()->setBold(true)->setSize(14);
|
$worksheet->getStyle('A1')->getFont()->setBold(true)->setSize(14);
|
||||||
$worksheet->getStyle('A2:'.$lastColumn.'2')->getFont()->setBold(true);
|
$worksheet->getStyle('A2:'.$lastColumn.'2')->getFont()->setBold(true);
|
||||||
$worksheet->freezePane('A3');
|
$worksheet->getStyle('A2:'.$lastColumn.'2')->getAlignment()->setWrapText(true)->setVertical(\PhpOffice\PhpSpreadsheet\Style\Alignment::VERTICAL_TOP);
|
||||||
|
$worksheet->getRowDimension(2)->setRowHeight(42);
|
||||||
|
$worksheet->getStyle('A3:'.$lastColumn.'3')->getFont()->setBold(true);
|
||||||
|
$worksheet->freezePane('A4');
|
||||||
|
|
||||||
for ($column = 1; $column <= max(1, count($rows[1] ?? $rows[0] ?? [])); ++$column) {
|
for ($column = 1; $column <= $columnCount; ++$column) {
|
||||||
$worksheet->getColumnDimension(Coordinate::stringFromColumnIndex($column))->setAutoSize(true);
|
$worksheet->getColumnDimension(Coordinate::stringFromColumnIndex($column))->setAutoSize(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ survey:
|
|||||||
participations: "%count% Teilnahmen"
|
participations: "%count% Teilnahmen"
|
||||||
edit: "Bearbeiten"
|
edit: "Bearbeiten"
|
||||||
public_view: "Öffentliche Ansicht"
|
public_view: "Öffentliche Ansicht"
|
||||||
|
copy_link: "Link kopieren"
|
||||||
|
copy_link_done: "Link kopiert!"
|
||||||
|
active_state: "Aktiv"
|
||||||
|
inactive_state: "Deaktiviert"
|
||||||
draft_view: "Entwurfs-Ansicht"
|
draft_view: "Entwurfs-Ansicht"
|
||||||
show_results: "Ergebnisse anzeigen"
|
show_results: "Ergebnisse anzeigen"
|
||||||
publish: "Umfrage veröffentlichen"
|
publish: "Umfrage veröffentlichen"
|
||||||
@@ -123,6 +127,9 @@ survey:
|
|||||||
download_excel: "Ergebnisse als Excel herunterladen"
|
download_excel: "Ergebnisse als Excel herunterladen"
|
||||||
download_pdf: "Ergebnisse als PDF herunterladen"
|
download_pdf: "Ergebnisse als PDF herunterladen"
|
||||||
answers: "%count% Antworten"
|
answers: "%count% Antworten"
|
||||||
|
answered: "%count% von %total% beantwortet"
|
||||||
|
skipped: "%count% übersprungen"
|
||||||
|
answer_count: "%count%×"
|
||||||
response_rate: "%rate%% Rücklauf"
|
response_rate: "%rate%% Rücklauf"
|
||||||
minimum: "Minimum"
|
minimum: "Minimum"
|
||||||
maximum: "Maximum"
|
maximum: "Maximum"
|
||||||
@@ -137,6 +144,8 @@ survey:
|
|||||||
questions: "%count% Fragen"
|
questions: "%count% Fragen"
|
||||||
export_prefix: "Export %date%"
|
export_prefix: "Export %date%"
|
||||||
answers: "%count% Antworten"
|
answers: "%count% Antworten"
|
||||||
|
answered: "%count% von %total% beantwortet"
|
||||||
|
skipped: "%count% übersprungen"
|
||||||
response_rate: "%rate%% Rücklauf"
|
response_rate: "%rate%% Rücklauf"
|
||||||
minimum: "Minimum"
|
minimum: "Minimum"
|
||||||
maximum: "Maximum"
|
maximum: "Maximum"
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ survey:
|
|||||||
participations: "%count% participations"
|
participations: "%count% participations"
|
||||||
edit: "Edit"
|
edit: "Edit"
|
||||||
public_view: "Public view"
|
public_view: "Public view"
|
||||||
|
copy_link: "Copy link"
|
||||||
|
copy_link_done: "Link copied!"
|
||||||
|
active_state: "Active"
|
||||||
|
inactive_state: "Deactivated"
|
||||||
draft_view: "Draft view"
|
draft_view: "Draft view"
|
||||||
show_results: "Show results"
|
show_results: "Show results"
|
||||||
publish: "Publish survey"
|
publish: "Publish survey"
|
||||||
@@ -123,6 +127,9 @@ survey:
|
|||||||
download_excel: "Download results as Excel"
|
download_excel: "Download results as Excel"
|
||||||
download_pdf: "Download results as PDF"
|
download_pdf: "Download results as PDF"
|
||||||
answers: "%count% answers"
|
answers: "%count% answers"
|
||||||
|
answered: "%count% of %total% answered"
|
||||||
|
skipped: "%count% skipped"
|
||||||
|
answer_count: "%count%×"
|
||||||
response_rate: "%rate%% response rate"
|
response_rate: "%rate%% response rate"
|
||||||
minimum: "Minimum"
|
minimum: "Minimum"
|
||||||
maximum: "Maximum"
|
maximum: "Maximum"
|
||||||
@@ -137,6 +144,8 @@ survey:
|
|||||||
questions: "%count% questions"
|
questions: "%count% questions"
|
||||||
export_prefix: "Export %date%"
|
export_prefix: "Export %date%"
|
||||||
answers: "%count% answers"
|
answers: "%count% answers"
|
||||||
|
answered: "%count% of %total% answered"
|
||||||
|
skipped: "%count% skipped"
|
||||||
response_rate: "%rate%% response rate"
|
response_rate: "%rate%% response rate"
|
||||||
minimum: "Minimum"
|
minimum: "Minimum"
|
||||||
maximum: "Maximum"
|
maximum: "Maximum"
|
||||||
|
|||||||
Reference in New Issue
Block a user