Initial survey bundle import

This commit is contained in:
Jürgen Mummert
2026-05-17 17:31:28 +02:00
commit b8475e3a57
84 changed files with 7157 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
.DS_Store
+33
View File
@@ -0,0 +1,33 @@
{
"name": "mummert/survey-bundle",
"description": "Modernes Survey-Bundle fuer Contao 5.7 mit Symfony-Frontend.",
"type": "contao-bundle",
"license": "proprietary",
"require": {
"php": "^8.3",
"contao/core-bundle": "^5.7",
"contao/manager-plugin": "^2.0",
"phpoffice/phpspreadsheet": "^5.7",
"symfony/form": "^7.3",
"symfony/http-client": "^7.3",
"symfony/mime": "^7.3",
"symfony/security-bundle": "^7.3",
"symfony/uid": "^7.3",
"twig/twig": "^3.0"
},
"autoload": {
"psr-4": {
"Mummert\\SurveyBundle\\": "src/"
}
},
"extra": {
"contao-manager-plugin": "Mummert\\SurveyBundle\\Contao\\Manager\\Plugin"
},
"prefer-stable": true,
"config": {
"allow-plugins": {
"contao-components/installer": true,
"contao/manager-plugin": true
}
}
}
+22
View File
@@ -0,0 +1,22 @@
services:
_defaults:
autowire: true
autoconfigure: true
_instanceof:
Mummert\SurveyBundle\QuestionType\QuestionTypeInterface:
tags: ['survey.question_type']
Mummert\SurveyBundle\:
resource: ../src/
exclude:
- ../src/Contao/Manager/
- ../src/DependencyInjection/
- ../src/SurveyBundle.php
Mummert\SurveyBundle\QuestionType\QuestionTypeRegistry:
arguments:
$questionTypes: !tagged_iterator survey.question_type
Mummert\SurveyBundle\EventListener\SurveyDcaListener:
public: true
+21
View File
@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
$GLOBALS['TL_CSS'][] = 'assets/survey-backend.css|static';
$GLOBALS['TL_MODELS']['tl_survey'] = Mummert\SurveyBundle\Model\SurveyModel::class;
$GLOBALS['TL_MODELS']['tl_survey_category'] = Mummert\SurveyBundle\Model\SurveyCategoryModel::class;
$GLOBALS['TL_MODELS']['tl_survey_content'] = Mummert\SurveyBundle\Model\SurveyContentModel::class;
$GLOBALS['TL_MODELS']['tl_survey_condition'] = Mummert\SurveyBundle\Model\SurveyConditionModel::class;
$GLOBALS['TL_MODELS']['tl_survey_submission'] = Mummert\SurveyBundle\Model\SurveySubmissionModel::class;
$GLOBALS['TL_MODELS']['tl_survey_answer'] = Mummert\SurveyBundle\Model\SurveyAnswerModel::class;
$GLOBALS['TL_MODELS']['tl_survey_editor'] = Mummert\SurveyBundle\Model\SurveyEditorModel::class;
$GLOBALS['BE_MOD']['content']['survey_bundle_surveys'] = [
'tables' => ['tl_survey', 'tl_survey_content', 'tl_survey_condition', 'tl_survey_editor', 'tl_survey_submission', 'tl_survey_answer'],
];
$GLOBALS['BE_MOD']['content']['survey_bundle_categories'] = [
'tables' => ['tl_survey_category'],
];
+55
View File
@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
use Contao\CoreBundle\DataContainer\PaletteManipulator;
use Mummert\SurveyBundle\EventListener\SurveyDcaListener;
if (isset($GLOBALS['TL_DCA']['tl_member']['palettes']) && is_array($GLOBALS['TL_DCA']['tl_member']['palettes'])) {
foreach (array_keys($GLOBALS['TL_DCA']['tl_member']['palettes']) as $paletteName) {
if ('__selector__' === $paletteName) {
continue;
}
PaletteManipulator::create()
->addLegend('survey_legend', 'contact_legend', PaletteManipulator::POSITION_AFTER)
->addField('surveyCategories', 'survey_legend', PaletteManipulator::POSITION_APPEND)
->addField('assignedSurveys', 'survey_legend', PaletteManipulator::POSITION_APPEND)
->applyToPalette((string) $paletteName, 'tl_member');
}
}
$GLOBALS['TL_DCA']['tl_member']['config']['onsubmit_callback'][] = [SurveyDcaListener::class, 'refreshMemberSurveyListMetadata'];
$GLOBALS['TL_DCA']['tl_member']['fields']['surveyCategories'] = [
'label' => &$GLOBALS['TL_LANG']['tl_member']['surveyCategories'],
'exclude' => true,
'inputType' => 'select',
'foreignKey' => 'tl_survey_category.title',
'eval' => ['multiple' => true, 'chosen' => true, 'tl_class' => 'clr'],
'relation' => [
'type' => 'hasMany',
'load' => 'lazy',
'table' => 'tl_survey_category',
'field' => 'id',
],
'save_callback' => [
[SurveyDcaListener::class, 'saveMemberSurveyCategories'],
],
'sql' => 'blob NULL',
];
$GLOBALS['TL_DCA']['tl_member']['fields']['assignedSurveys'] = [
'label' => &$GLOBALS['TL_LANG']['tl_member']['assignedSurveys'],
'exclude' => true,
'inputType' => 'select',
'options_callback' => [SurveyDcaListener::class, 'getSurveyOptions'],
'eval' => ['multiple' => true, 'chosen' => true, 'tl_class' => 'clr'],
'load_callback' => [
[SurveyDcaListener::class, 'loadMemberAssignedSurveys'],
],
'save_callback' => [
[SurveyDcaListener::class, 'saveMemberAssignedSurveys'],
],
'sql' => 'blob NULL',
];
+32
View File
@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
$GLOBALS['TL_DCA']['tl_module']['palettes']['member_survey_list'] = '{title_legend},name,headline,type;{survey_navigation_legend},surveyEditPage;{protected_legend:hide},protected;{expert_legend:hide},guests,cssID';
$GLOBALS['TL_DCA']['tl_module']['palettes']['member_survey_edit'] = '{title_legend},name,headline,type;{survey_navigation_legend},surveyListPage;{protected_legend:hide},protected;{expert_legend:hide},guests,cssID';
$GLOBALS['TL_DCA']['tl_module']['palettes']['show_survey'] = '{title_legend},name,headline,type;{expert_legend:hide},guests,cssID';
$GLOBALS['TL_DCA']['tl_module']['palettes']['survey_results'] = '{title_legend},name,headline,type;{expert_legend:hide},guests,cssID';
$GLOBALS['TL_DCA']['tl_module']['fields']['surveyEditPage'] = [
'label' => &$GLOBALS['TL_LANG']['tl_module']['surveyEditPage'],
'exclude' => true,
'inputType' => 'pageTree',
'eval' => ['fieldType' => 'radio', 'mandatory' => true, 'tl_class' => 'w50'],
'sql' => ['type' => 'integer', 'unsigned' => true, 'default' => 0],
];
$GLOBALS['TL_DCA']['tl_module']['fields']['surveyListPage'] = [
'label' => &$GLOBALS['TL_LANG']['tl_module']['surveyListPage'],
'exclude' => true,
'inputType' => 'pageTree',
'eval' => ['fieldType' => 'radio', 'mandatory' => false, 'tl_class' => 'w50'],
'sql' => ['type' => 'integer', 'unsigned' => true, 'default' => 0],
];
$GLOBALS['TL_DCA']['tl_module']['fields']['surveyReaderPage'] = [
'label' => &$GLOBALS['TL_LANG']['tl_module']['surveyReaderPage'],
'exclude' => true,
'inputType' => 'pageTree',
'eval' => ['fieldType' => 'radio', 'mandatory' => false, 'tl_class' => 'w50'],
'sql' => ['type' => 'integer', 'unsigned' => true, 'default' => 0],
];
+30
View File
@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
use Contao\CoreBundle\DataContainer\PaletteManipulator;
PaletteManipulator::create()
->addLegend('survey_settings_legend', null, PaletteManipulator::POSITION_AFTER, true)
->addField('surveyReaderPage', 'survey_settings_legend')
->addField('surveyResultsPage', 'survey_settings_legend')
->addField('surveyGotenbergUrl', 'survey_settings_legend')
->applyToPalette('default', 'tl_settings');
$GLOBALS['TL_DCA']['tl_settings']['fields']['surveyReaderPage'] = [
'label' => &$GLOBALS['TL_LANG']['tl_settings']['surveyReaderPage'],
'inputType' => 'pageTree',
'eval' => ['fieldType' => 'radio', 'tl_class' => 'w50'],
];
$GLOBALS['TL_DCA']['tl_settings']['fields']['surveyResultsPage'] = [
'label' => &$GLOBALS['TL_LANG']['tl_settings']['surveyResultsPage'],
'inputType' => 'pageTree',
'eval' => ['fieldType' => 'radio', 'tl_class' => 'w50'],
];
$GLOBALS['TL_DCA']['tl_settings']['fields']['surveyGotenbergUrl'] = [
'label' => &$GLOBALS['TL_LANG']['tl_settings']['surveyGotenbergUrl'],
'inputType' => 'text',
'eval' => ['rgxp' => 'url', 'decodeEntities' => true, 'maxlength' => 255, 'tl_class' => 'w50 clr'],
];
+212
View File
@@ -0,0 +1,212 @@
<?php
declare(strict_types=1);
use Contao\DC_Table;
use Contao\System;
use Mummert\SurveyBundle\EventListener\SurveyDcaListener;
$GLOBALS['TL_DCA']['tl_survey'] = [
'config' => [
'dataContainer' => DC_Table::class,
'ctable' => ['tl_survey_content', 'tl_survey_condition', 'tl_survey_editor', 'tl_survey_submission'],
'enableVersioning' => true,
'onload_callback' => [
[SurveyDcaListener::class, 'refreshSurveyBackendStateIndex'],
],
'onsubmit_callback' => [
[SurveyDcaListener::class, 'persistSurveyBackendState'],
],
'sql' => [
'keys' => [
'id' => 'primary',
'alias' => 'unique',
'published' => 'index',
],
],
],
'list' => [
'sorting' => [
'mode' => 1,
'fields' => ['title'],
'flag' => 1,
'panelLayout' => 'filter;sort,search,limit',
],
'label' => [
'fields' => ['title', 'categorySummary', 'assignedMembersSummary'],
'showColumns' => true,
],
'global_operations' => [
'all' => [
'href' => 'act=select',
'class' => 'header_edit_all',
'attributes' => 'onclick="Backend.getScrollOffset()" accesskey="e"',
],
],
'operations' => [
'edit' => [
'href' => 'table=tl_survey_content',
'icon' => 'children.svg',
],
'editheader' => [
'href' => 'act=edit',
'icon' => 'header.svg',
],
'delete' => [
'href' => 'act=delete',
'icon' => 'delete.svg',
'attributes' => 'onclick="if(!confirm(\'Eintrag wirklich loeschen?\'))return false;Backend.getScrollOffset()"',
],
'show' => [
'href' => 'act=show',
'icon' => 'show.svg',
],
'copyPublicLink' => [
'label' => &$GLOBALS['TL_LANG']['tl_survey']['copyPublicLink'],
'icon' => 'copy.svg',
'button_callback' => [SurveyDcaListener::class, 'generateCopyPublicLinkButton'],
],
'showResults' => [
'label' => &$GLOBALS['TL_LANG']['tl_survey']['showResults'],
'icon' => 'show.svg',
'button_callback' => [SurveyDcaListener::class, 'generateShowResultsButton'],
],
],
],
'palettes' => [
'default' => '{title_legend},title,alias,category,description;{assignment_legend},assignedMembers;{publishing_legend},published,isLocked;{meta_legend},createdAt,updatedAt',
],
'fields' => [
'id' => [
'sql' => 'int(10) unsigned NOT NULL auto_increment',
],
'tstamp' => [
'sql' => 'int(10) unsigned NOT NULL default 0',
],
'title' => [
'label' => &$GLOBALS['TL_LANG']['tl_survey']['title'],
'inputType' => 'text',
'search' => true,
'eval' => ['mandatory' => true, 'maxlength' => 255, 'tl_class' => 'w50'],
'sql' => "varchar(255) NOT NULL default ''",
],
'alias' => [
'label' => &$GLOBALS['TL_LANG']['tl_survey']['alias'],
'inputType' => 'text',
'eval' => ['maxlength' => 20, 'readonly' => true, 'alwaysSave' => true, 'tl_class' => 'w50'],
'save_callback' => [[SurveyDcaListener::class, 'generateSurveyAlias']],
'sql' => "varchar(20) NOT NULL default ''",
],
'description' => [
'label' => &$GLOBALS['TL_LANG']['tl_survey']['description'],
'inputType' => 'textarea',
'eval' => ['rte' => 'tinyMCE', 'tl_class' => 'clr'],
'sql' => 'text NULL',
],
'category' => [
'label' => &$GLOBALS['TL_LANG']['tl_survey']['category'],
'inputType' => 'select',
'foreignKey' => 'tl_survey_category.title',
'eval' => ['multiple' => true, 'chosen' => true, 'tl_class' => 'clr'],
'relation' => ['type' => 'hasMany', 'load' => 'lazy', 'table' => 'tl_survey_category', 'field' => 'id'],
'sql' => 'blob NULL',
],
'categoryFilter' => [
'label' => &$GLOBALS['TL_LANG']['tl_survey']['categoryFilter'],
'filter' => true,
'inputType' => 'select',
'options_callback' => [SurveyDcaListener::class, 'getCategoryOptions'],
'eval' => ['includeBlankOption' => true, 'chosen' => true, 'findInSet' => true],
'sql' => "varchar(255) NOT NULL default ''",
],
'categorySummary' => [
'label' => &$GLOBALS['TL_LANG']['tl_survey']['categorySummary'],
'sql' => 'text NULL',
],
'published' => [
'label' => &$GLOBALS['TL_LANG']['tl_survey']['published'],
'inputType' => 'checkbox',
'eval' => ['tl_class' => 'w50 m12'],
'sql' => "char(1) NOT NULL default ''",
],
'isLocked' => [
'label' => &$GLOBALS['TL_LANG']['tl_survey']['isLocked'],
'inputType' => 'checkbox',
'eval' => ['tl_class' => 'w50 m12'],
'sql' => "char(1) NOT NULL default ''",
],
'assignedMembers' => [
'label' => &$GLOBALS['TL_LANG']['tl_survey']['assignedMembers'],
'inputType' => 'select',
'options_callback' => [Mummert\SurveyBundle\EventListener\SurveyDcaListener::class, 'getMemberOptions'],
'eval' => ['multiple' => true, 'chosen' => true, 'tl_class' => 'clr'],
'load_callback' => [[Mummert\SurveyBundle\EventListener\SurveyDcaListener::class, 'loadSurveyAssignedMembers']],
'save_callback' => [[Mummert\SurveyBundle\EventListener\SurveyDcaListener::class, 'saveSurveyAssignedMembers']],
'sql' => 'blob NULL',
],
'assignedMembersFilter' => [
'label' => &$GLOBALS['TL_LANG']['tl_survey']['assignedMembersFilter'],
'filter' => true,
'inputType' => 'select',
'options_callback' => [SurveyDcaListener::class, 'getMemberOptions'],
'eval' => ['includeBlankOption' => true, 'chosen' => true, 'findInSet' => true],
'sql' => 'text NULL',
],
'assignedMembersSummary' => [
'label' => &$GLOBALS['TL_LANG']['tl_survey']['assignedMembersSummary'],
'sql' => 'text NULL',
],
'createdBy' => [
'label' => &$GLOBALS['TL_LANG']['tl_survey']['createdBy'],
'inputType' => 'text',
'eval' => ['readonly' => true, 'tl_class' => 'w50'],
'sql' => 'int(10) unsigned NOT NULL default 0',
],
'updatedBy' => [
'label' => &$GLOBALS['TL_LANG']['tl_survey']['updatedBy'],
'inputType' => 'text',
'eval' => ['readonly' => true, 'tl_class' => 'w50'],
'sql' => 'int(10) unsigned NOT NULL default 0',
],
'createdAt' => [
'label' => &$GLOBALS['TL_LANG']['tl_survey']['createdAt'],
'inputType' => 'text',
'eval' => ['rgxp' => 'datim', 'readonly' => true, 'tl_class' => 'w50'],
'sql' => 'int(10) unsigned NOT NULL default 0',
],
'updatedAt' => [
'label' => &$GLOBALS['TL_LANG']['tl_survey']['updatedAt'],
'inputType' => 'text',
'eval' => ['rgxp' => 'datim', 'readonly' => true, 'tl_class' => 'w50'],
'sql' => 'int(10) unsigned NOT NULL default 0',
],
],
];
if ('cli' !== PHP_SAPI) {
$hasListMetadataColumns = false;
try {
$columnNames = array_map(
static fn (string $columnName): string => strtolower($columnName),
System::getContainer()->get('database_connection')->fetchFirstColumn('SHOW COLUMNS FROM tl_survey'),
);
$hasListMetadataColumns = !array_diff(
['categoryfilter', 'categorysummary', 'assignedmembersfilter', 'assignedmemberssummary'],
$columnNames,
);
} catch (\Throwable) {
$hasListMetadataColumns = false;
}
if (!$hasListMetadataColumns) {
$GLOBALS['TL_DCA']['tl_survey']['list']['label']['fields'] = ['title', 'alias'];
unset(
$GLOBALS['TL_DCA']['tl_survey']['fields']['categoryFilter'],
$GLOBALS['TL_DCA']['tl_survey']['fields']['categorySummary'],
$GLOBALS['TL_DCA']['tl_survey']['fields']['assignedMembersFilter'],
$GLOBALS['TL_DCA']['tl_survey']['fields']['assignedMembersSummary'],
);
}
}
+60
View File
@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
use Contao\DC_Table;
$GLOBALS['TL_DCA']['tl_survey_answer'] = [
'config' => [
'dataContainer' => DC_Table::class,
'ptable' => 'tl_survey_submission',
'closed' => true,
'notCreatable' => true,
'notEditable' => true,
'notCopyable' => true,
'sql' => [
'keys' => [
'id' => 'primary',
'pid' => 'index',
'submission' => 'index',
'question' => 'index',
],
],
],
'list' => [
'sorting' => [
'mode' => 4,
'fields' => ['id'],
'headerFields' => ['token', 'startedAt', 'completedAt'],
'panelLayout' => 'sort,search,limit',
],
'label' => [
'fields' => ['question', 'questionType', 'value'],
'showColumns' => true,
],
'operations' => [
'show' => [
'href' => 'act=show',
'icon' => 'show.svg',
],
],
],
'fields' => [
'id' => ['sql' => 'int(10) unsigned NOT NULL auto_increment'],
'tstamp' => ['sql' => 'int(10) unsigned NOT NULL default 0'],
'pid' => ['sql' => 'int(10) unsigned NOT NULL default 0'],
'submission' => ['sql' => 'int(10) unsigned NOT NULL default 0'],
'question' => [
'label' => &$GLOBALS['TL_LANG']['tl_survey_answer']['question'],
'sql' => 'int(10) unsigned NOT NULL default 0',
],
'questionType' => [
'label' => &$GLOBALS['TL_LANG']['tl_survey_answer']['questionType'],
'sql' => "varchar(32) NOT NULL default ''",
],
'value' => [
'label' => &$GLOBALS['TL_LANG']['tl_survey_answer']['value'],
'sql' => 'text NULL',
],
],
];
+70
View File
@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
use Contao\DC_Table;
$GLOBALS['TL_DCA']['tl_survey_category'] = [
'config' => [
'dataContainer' => DC_Table::class,
'enableVersioning' => true,
'sql' => [
'keys' => [
'id' => 'primary',
'alias' => 'index',
'sorting' => 'index',
],
],
],
'list' => [
'sorting' => [
'mode' => 1,
'fields' => ['sorting'],
'panelLayout' => 'sort,search,limit',
],
'label' => [
'fields' => ['title', 'alias'],
'showColumns' => true,
],
'operations' => [
'edit' => [
'href' => 'act=edit',
'icon' => 'edit.svg',
],
'delete' => [
'href' => 'act=delete',
'icon' => 'delete.svg',
'attributes' => 'onclick="if(!confirm(\'Eintrag wirklich loeschen?\'))return false;Backend.getScrollOffset()"',
],
'show' => [
'href' => 'act=show',
'icon' => 'show.svg',
],
],
],
'palettes' => [
'default' => '{title_legend},title,alias,sorting',
],
'fields' => [
'id' => ['sql' => 'int(10) unsigned NOT NULL auto_increment'],
'tstamp' => ['sql' => 'int(10) unsigned NOT NULL default 0'],
'title' => [
'label' => &$GLOBALS['TL_LANG']['tl_survey_category']['title'],
'inputType' => 'text',
'eval' => ['mandatory' => true, 'maxlength' => 255, 'tl_class' => 'w50'],
'sql' => "varchar(255) NOT NULL default ''",
],
'alias' => [
'label' => &$GLOBALS['TL_LANG']['tl_survey_category']['alias'],
'inputType' => 'text',
'eval' => ['maxlength' => 128, 'tl_class' => 'w50'],
'sql' => "varchar(128) NOT NULL default ''",
],
'sorting' => [
'label' => &$GLOBALS['TL_LANG']['tl_survey_category']['sorting'],
'inputType' => 'text',
'eval' => ['rgxp' => 'natural', 'tl_class' => 'w50'],
'sql' => 'int(10) unsigned NOT NULL default 0',
],
],
];
+93
View File
@@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
use Contao\DC_Table;
use Mummert\SurveyBundle\EventListener\SurveyDcaListener;
$guardCallback = [SurveyDcaListener::class, 'guardConditionSave'];
$GLOBALS['TL_DCA']['tl_survey_condition'] = [
'config' => [
'dataContainer' => DC_Table::class,
'ptable' => 'tl_survey',
'enableVersioning' => true,
'onsubmit_callback' => [
[SurveyDcaListener::class, 'touchSurveyConditionParent'],
],
'ondelete_callback' => [
[SurveyDcaListener::class, 'guardConditionDelete'],
[SurveyDcaListener::class, 'touchSurveyConditionParent'],
],
'sql' => [
'keys' => [
'id' => 'primary',
'pid' => 'index',
'sourceQuestion' => 'index',
'targetQuestion' => 'index',
],
],
],
'list' => [
'sorting' => [
'mode' => 4,
'fields' => ['id'],
'headerFields' => ['title', 'alias', 'published', 'isLocked'],
'panelLayout' => 'sort,search,limit',
],
'label' => [
'fields' => ['sourceQuestion', 'answerValue', 'targetQuestion'],
'showColumns' => true,
],
'operations' => [
'edit' => [
'href' => 'act=edit',
'icon' => 'edit.svg',
],
'copy' => [
'href' => 'act=copy',
'icon' => 'copy.svg',
],
'delete' => [
'href' => 'act=delete',
'icon' => 'delete.svg',
'attributes' => 'onclick="if(!confirm(\'Eintrag wirklich loeschen?\'))return false;Backend.getScrollOffset()"',
],
'show' => [
'href' => 'act=show',
'icon' => 'show.svg',
],
],
],
'palettes' => [
'default' => '{logic_legend},sourceQuestion,answerValue,targetQuestion',
],
'fields' => [
'id' => ['sql' => 'int(10) unsigned NOT NULL auto_increment'],
'tstamp' => ['sql' => 'int(10) unsigned NOT NULL default 0'],
'pid' => ['sql' => 'int(10) unsigned NOT NULL default 0'],
'sourceQuestion' => [
'label' => &$GLOBALS['TL_LANG']['tl_survey_condition']['sourceQuestion'],
'inputType' => 'select',
'options_callback' => [SurveyDcaListener::class, 'getQuestionOptions'],
'eval' => ['mandatory' => true, 'includeBlankOption' => true, 'chosen' => true, 'tl_class' => 'w50'],
'save_callback' => [$guardCallback],
'sql' => 'int(10) unsigned NOT NULL default 0',
],
'answerValue' => [
'label' => &$GLOBALS['TL_LANG']['tl_survey_condition']['answerValue'],
'inputType' => 'text',
'eval' => ['mandatory' => true, 'maxlength' => 255, 'tl_class' => 'w50'],
'save_callback' => [$guardCallback],
'sql' => "varchar(255) NOT NULL default ''",
],
'targetQuestion' => [
'label' => &$GLOBALS['TL_LANG']['tl_survey_condition']['targetQuestion'],
'inputType' => 'select',
'options_callback' => [SurveyDcaListener::class, 'getQuestionOptions'],
'eval' => ['mandatory' => true, 'includeBlankOption' => true, 'chosen' => true, 'tl_class' => 'clr'],
'save_callback' => [$guardCallback],
'sql' => 'int(10) unsigned NOT NULL default 0',
],
],
];
+241
View File
@@ -0,0 +1,241 @@
<?php
declare(strict_types=1);
use Contao\DC_Table;
use Mummert\SurveyBundle\EventListener\SurveyDcaListener;
$guardCallback = [SurveyDcaListener::class, 'guardContentSave'];
$GLOBALS['TL_DCA']['tl_survey_content'] = [
'config' => [
'dataContainer' => DC_Table::class,
'ptable' => 'tl_survey',
'enableVersioning' => true,
'onsubmit_callback' => [
[SurveyDcaListener::class, 'touchSurveyContentParent'],
],
'onpalette_callback' => [
[SurveyDcaListener::class, 'resolveSurveyContentPalette'],
],
'ondelete_callback' => [
[SurveyDcaListener::class, 'guardContentDelete'],
[SurveyDcaListener::class, 'touchSurveyContentParent'],
],
'sql' => [
'keys' => [
'id' => 'primary',
'pid' => 'index',
'sorting' => 'index',
'published' => 'index',
],
],
],
'list' => [
'sorting' => [
'mode' => 4,
'fields' => ['sorting'],
'headerFields' => ['title', 'alias', 'published', 'isLocked'],
'child_record_class' => 'survey-question-record',
'panelLayout' => 'sort,search,limit',
],
'label' => [
'fields' => ['question', 'type'],
'showColumns' => true,
],
'operations' => [
'edit' => [
'href' => 'act=edit',
'icon' => 'edit.svg',
],
'copy' => [
'href' => 'act=copy',
'icon' => 'copy.svg',
],
'delete' => [
'href' => 'act=delete',
'icon' => 'delete.svg',
'attributes' => 'onclick="if(!confirm(\'Eintrag wirklich loeschen?\'))return false;Backend.getScrollOffset()"',
],
'show' => [
'href' => 'act=show',
'icon' => 'show.svg',
],
],
],
'palettes' => [
'default' => '{question_legend},type,question,description;{settings_legend},mandatory,published',
'yes_no_maybe' => '{question_legend},type,question,description;{settings_legend},mandatory,published,allowMaybe,jumpOnYes,jumpOnNo',
'text' => '{question_legend},type,question,description;{settings_legend},mandatory,published',
'range' => '{question_legend},type,question,description;{settings_legend},mandatory,published,rangeMin,rangeMax,rangeStep',
'choice' => '{question_legend},type,question,description;{settings_legend},mandatory,published,allowMultiple,answerOption1,answerOption2,answerOption3,answerOption4,answerOption5,answerOption6,answerOption7,answerOption8,answerOption9,answerOption10',
],
'fields' => [
'id' => ['sql' => 'int(10) unsigned NOT NULL auto_increment'],
'tstamp' => ['sql' => 'int(10) unsigned NOT NULL default 0'],
'pid' => ['sql' => 'int(10) unsigned NOT NULL default 0'],
'sorting' => [
'sql' => 'int(10) unsigned NOT NULL default 0',
],
'type' => [
'label' => &$GLOBALS['TL_LANG']['tl_survey_content']['type'],
'inputType' => 'select',
'options' => ['yes_no_maybe', 'text', 'range', 'choice'],
'reference' => &$GLOBALS['TL_LANG']['tl_survey_content']['types'],
'eval' => ['mandatory' => true, 'includeBlankOption' => true, 'submitOnChange' => true, 'chosen' => true, 'tl_class' => 'w50'],
'save_callback' => [$guardCallback],
'sql' => "varchar(32) NOT NULL default ''",
],
'question' => [
'label' => &$GLOBALS['TL_LANG']['tl_survey_content']['question'],
'inputType' => 'text',
'eval' => ['mandatory' => true, 'maxlength' => 255, 'tl_class' => 'clr'],
'save_callback' => [$guardCallback],
'sql' => "varchar(255) NOT NULL default ''",
],
'description' => [
'label' => &$GLOBALS['TL_LANG']['tl_survey_content']['description'],
'inputType' => 'textarea',
'eval' => ['tl_class' => 'clr'],
'save_callback' => [$guardCallback],
'sql' => 'text NULL',
],
'mandatory' => [
'label' => &$GLOBALS['TL_LANG']['tl_survey_content']['mandatory'],
'inputType' => 'checkbox',
'default' => '1',
'eval' => ['tl_class' => 'w50 m12'],
'save_callback' => [$guardCallback],
'sql' => "char(1) NOT NULL default ''",
],
'published' => [
'label' => &$GLOBALS['TL_LANG']['tl_survey_content']['published'],
'inputType' => 'checkbox',
'default' => '1',
'eval' => ['tl_class' => 'w50 m12'],
'save_callback' => [$guardCallback],
'sql' => "char(1) NOT NULL default ''",
],
'allowMaybe' => [
'label' => &$GLOBALS['TL_LANG']['tl_survey_content']['allowMaybe'],
'inputType' => 'checkbox',
'eval' => ['tl_class' => 'w50 m12 clr'],
'save_callback' => [$guardCallback],
'sql' => "char(1) NOT NULL default ''",
],
'jumpOnYes' => [
'label' => &$GLOBALS['TL_LANG']['tl_survey_content']['jumpOnYes'],
'inputType' => 'select',
'options_callback' => [SurveyDcaListener::class, 'getQuestionOptionsForContent'],
'eval' => ['includeBlankOption' => true, 'chosen' => true, 'tl_class' => 'w50 clr'],
'save_callback' => [$guardCallback],
'sql' => 'int(10) unsigned NOT NULL default 0',
],
'jumpOnNo' => [
'label' => &$GLOBALS['TL_LANG']['tl_survey_content']['jumpOnNo'],
'inputType' => 'select',
'options_callback' => [SurveyDcaListener::class, 'getQuestionOptionsForContent'],
'eval' => ['includeBlankOption' => true, 'chosen' => true, 'tl_class' => 'w50'],
'save_callback' => [$guardCallback],
'sql' => 'int(10) unsigned NOT NULL default 0',
],
'allowMultiple' => [
'label' => &$GLOBALS['TL_LANG']['tl_survey_content']['allowMultiple'],
'inputType' => 'checkbox',
'eval' => ['tl_class' => 'w50 m12 clr'],
'save_callback' => [$guardCallback],
'sql' => "char(1) NOT NULL default ''",
],
'answerOption1' => [
'label' => &$GLOBALS['TL_LANG']['tl_survey_content']['answerOption1'],
'inputType' => 'text',
'eval' => ['maxlength' => 255, 'tl_class' => 'w50 clr'],
'save_callback' => [$guardCallback],
'sql' => "varchar(255) NOT NULL default ''",
],
'answerOption2' => [
'label' => &$GLOBALS['TL_LANG']['tl_survey_content']['answerOption2'],
'inputType' => 'text',
'eval' => ['maxlength' => 255, 'tl_class' => 'w50'],
'save_callback' => [$guardCallback],
'sql' => "varchar(255) NOT NULL default ''",
],
'answerOption3' => [
'label' => &$GLOBALS['TL_LANG']['tl_survey_content']['answerOption3'],
'inputType' => 'text',
'eval' => ['maxlength' => 255, 'tl_class' => 'w50 clr'],
'save_callback' => [$guardCallback],
'sql' => "varchar(255) NOT NULL default ''",
],
'answerOption4' => [
'label' => &$GLOBALS['TL_LANG']['tl_survey_content']['answerOption4'],
'inputType' => 'text',
'eval' => ['maxlength' => 255, 'tl_class' => 'w50'],
'save_callback' => [$guardCallback],
'sql' => "varchar(255) NOT NULL default ''",
],
'answerOption5' => [
'label' => &$GLOBALS['TL_LANG']['tl_survey_content']['answerOption5'],
'inputType' => 'text',
'eval' => ['maxlength' => 255, 'tl_class' => 'w50 clr'],
'save_callback' => [$guardCallback],
'sql' => "varchar(255) NOT NULL default ''",
],
'answerOption6' => [
'label' => &$GLOBALS['TL_LANG']['tl_survey_content']['answerOption6'],
'inputType' => 'text',
'eval' => ['maxlength' => 255, 'tl_class' => 'w50'],
'save_callback' => [$guardCallback],
'sql' => "varchar(255) NOT NULL default ''",
],
'answerOption7' => [
'label' => &$GLOBALS['TL_LANG']['tl_survey_content']['answerOption7'],
'inputType' => 'text',
'eval' => ['maxlength' => 255, 'tl_class' => 'w50 clr'],
'save_callback' => [$guardCallback],
'sql' => "varchar(255) NOT NULL default ''",
],
'answerOption8' => [
'label' => &$GLOBALS['TL_LANG']['tl_survey_content']['answerOption8'],
'inputType' => 'text',
'eval' => ['maxlength' => 255, 'tl_class' => 'w50'],
'save_callback' => [$guardCallback],
'sql' => "varchar(255) NOT NULL default ''",
],
'answerOption9' => [
'label' => &$GLOBALS['TL_LANG']['tl_survey_content']['answerOption9'],
'inputType' => 'text',
'eval' => ['maxlength' => 255, 'tl_class' => 'w50 clr'],
'save_callback' => [$guardCallback],
'sql' => "varchar(255) NOT NULL default ''",
],
'answerOption10' => [
'label' => &$GLOBALS['TL_LANG']['tl_survey_content']['answerOption10'],
'inputType' => 'text',
'eval' => ['maxlength' => 255, 'tl_class' => 'w50'],
'save_callback' => [$guardCallback],
'sql' => "varchar(255) NOT NULL default ''",
],
'rangeMin' => [
'label' => &$GLOBALS['TL_LANG']['tl_survey_content']['rangeMin'],
'inputType' => 'text',
'eval' => ['rgxp' => 'digit', 'tl_class' => 'w50'],
'save_callback' => [$guardCallback],
'sql' => 'int(10) NOT NULL default 0',
],
'rangeMax' => [
'label' => &$GLOBALS['TL_LANG']['tl_survey_content']['rangeMax'],
'inputType' => 'text',
'eval' => ['rgxp' => 'digit', 'tl_class' => 'w50'],
'save_callback' => [$guardCallback],
'sql' => 'int(10) NOT NULL default 10',
],
'rangeStep' => [
'label' => &$GLOBALS['TL_LANG']['tl_survey_content']['rangeStep'],
'inputType' => 'text',
'eval' => ['rgxp' => 'digit', 'tl_class' => 'w50'],
'save_callback' => [$guardCallback],
'sql' => 'int(10) unsigned NOT NULL default 1',
],
],
];
+75
View File
@@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
use Contao\DC_Table;
use Mummert\SurveyBundle\EventListener\SurveyDcaListener;
$guardCallback = [SurveyDcaListener::class, 'guardEditorSave'];
$GLOBALS['TL_DCA']['tl_survey_editor'] = [
'config' => [
'dataContainer' => DC_Table::class,
'ptable' => 'tl_survey',
'enableVersioning' => true,
'ondelete_callback' => [
[SurveyDcaListener::class, 'guardEditorDelete'],
],
'sql' => [
'keys' => [
'id' => 'primary',
'pid' => 'index',
'survey' => 'index',
'member' => 'index',
],
],
],
'list' => [
'sorting' => [
'mode' => 4,
'fields' => ['member'],
'headerFields' => ['title', 'alias', 'isLocked'],
'panelLayout' => 'sort,search,limit',
],
'label' => [
'fields' => ['member'],
'showColumns' => true,
],
'operations' => [
'edit' => [
'href' => 'act=edit',
'icon' => 'edit.svg',
],
'copy' => [
'href' => 'act=copy',
'icon' => 'copy.svg',
],
'delete' => [
'href' => 'act=delete',
'icon' => 'delete.svg',
'attributes' => 'onclick="if(!confirm(\'Eintrag wirklich loeschen?\'))return false;Backend.getScrollOffset()"',
],
'show' => [
'href' => 'act=show',
'icon' => 'show.svg',
],
],
],
'palettes' => [
'default' => '{editor_legend},member',
],
'fields' => [
'id' => ['sql' => 'int(10) unsigned NOT NULL auto_increment'],
'tstamp' => ['sql' => 'int(10) unsigned NOT NULL default 0'],
'pid' => ['sql' => 'int(10) unsigned NOT NULL default 0'],
'survey' => ['sql' => 'int(10) unsigned NOT NULL default 0'],
'member' => [
'label' => &$GLOBALS['TL_LANG']['tl_survey_editor']['member'],
'inputType' => 'select',
'options_callback' => [SurveyDcaListener::class, 'getMemberOptions'],
'eval' => ['mandatory' => true, 'includeBlankOption' => true, 'chosen' => true],
'save_callback' => [$guardCallback],
'sql' => 'int(10) unsigned NOT NULL default 0',
],
],
];
+74
View File
@@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
use Contao\DC_Table;
$GLOBALS['TL_DCA']['tl_survey_submission'] = [
'config' => [
'dataContainer' => DC_Table::class,
'ptable' => 'tl_survey',
'ctable' => ['tl_survey_answer'],
'closed' => true,
'notCreatable' => true,
'notEditable' => true,
'notCopyable' => true,
'sql' => [
'keys' => [
'id' => 'primary',
'pid' => 'index',
'survey' => 'index',
'token' => 'index',
'isFinished' => 'index',
],
],
],
'list' => [
'sorting' => [
'mode' => 4,
'fields' => ['startedAt'],
'headerFields' => ['title', 'alias'],
'panelLayout' => 'sort,search,limit',
],
'label' => [
'fields' => ['token', 'startedAt', 'completedAt', 'isFinished'],
'showColumns' => true,
],
'operations' => [
'edit' => [
'href' => 'table=tl_survey_answer',
'icon' => 'children.svg',
],
'show' => [
'href' => 'act=show',
'icon' => 'show.svg',
],
],
],
'fields' => [
'id' => ['sql' => 'int(10) unsigned NOT NULL auto_increment'],
'tstamp' => ['sql' => 'int(10) unsigned NOT NULL default 0'],
'pid' => ['sql' => 'int(10) unsigned NOT NULL default 0'],
'survey' => ['sql' => 'int(10) unsigned NOT NULL default 0'],
'token' => [
'label' => &$GLOBALS['TL_LANG']['tl_survey_submission']['token'],
'sql' => "varchar(26) NOT NULL default ''",
],
'startedAt' => [
'label' => &$GLOBALS['TL_LANG']['tl_survey_submission']['startedAt'],
'sql' => 'int(10) unsigned NOT NULL default 0',
],
'completedAt' => [
'label' => &$GLOBALS['TL_LANG']['tl_survey_submission']['completedAt'],
'sql' => 'int(10) unsigned NOT NULL default 0',
],
'currentQuestion' => [
'label' => &$GLOBALS['TL_LANG']['tl_survey_submission']['currentQuestion'],
'sql' => 'int(10) unsigned NOT NULL default 0',
],
'isFinished' => [
'label' => &$GLOBALS['TL_LANG']['tl_survey_submission']['isFinished'],
'sql' => "char(1) NOT NULL default ''",
],
],
];
+10
View File
@@ -0,0 +1,10 @@
<?php
$GLOBALS['TL_LANG']['MOD']['survey_bundle_surveys'] = ['Umfragen', 'Umfragen, Fragen, Bedingungen und anonyme Antworten verwalten.'];
$GLOBALS['TL_LANG']['MOD']['survey_bundle_categories'] = ['Umfrage-Kategorien', 'Kategorien fuer Umfragen pflegen.'];
$GLOBALS['TL_LANG']['FMD']['survey'] = 'Umfragen';
$GLOBALS['TL_LANG']['FMD']['member_survey_list'] = ['Meine Umfragen', 'Zeigt alle dem Mitglied zugewiesenen Umfragen an.'];
$GLOBALS['TL_LANG']['FMD']['member_survey_edit'] = ['Umfrage bearbeiten', 'Bearbeitungsbereich fuer zugewiesene Umfragen im Frontend.'];
$GLOBALS['TL_LANG']['FMD']['show_survey'] = ['Umfrage ausfuellen', 'Oeffentliche Umfrageansicht ueber den oeffentlichen Link.'];
$GLOBALS['TL_LANG']['FMD']['survey_results'] = ['Ergebnisse', 'Zeigt die Ergebnisse einer Umfrage ueber den konfigurierten Ergebnis-Link an.'];
+5
View File
@@ -0,0 +1,5 @@
<?php
$GLOBALS['TL_LANG']['tl_member']['survey_legend'] = 'Umfrage-Zuordnung';
$GLOBALS['TL_LANG']['tl_member']['surveyCategories'] = ['Umfrage-Kategorien', 'Diese Kategorien werden neuen und bereits zugewiesenen Umfragen dieses Mitglieds automatisch zugeordnet.'];
$GLOBALS['TL_LANG']['tl_member']['assignedSurveys'] = ['Zugewiesene Umfragen', 'Waehlen Sie die Umfragen aus, die dieses Mitglied bearbeiten darf. Das Suchfeld in der Auswahl hilft bei vielen Eintraegen.'];
+6
View File
@@ -0,0 +1,6 @@
<?php
$GLOBALS['TL_LANG']['tl_module']['survey_navigation_legend'] = 'Verknuepfte Seiten';
$GLOBALS['TL_LANG']['tl_module']['surveyEditPage'] = ['Bearbeitungsseite', 'Seite mit dem Frontendmodul zur Bearbeitung von Umfragen.'];
$GLOBALS['TL_LANG']['tl_module']['surveyListPage'] = ['Uebersichtsseite', 'Seite mit der Uebersicht der Umfragen.'];
$GLOBALS['TL_LANG']['tl_module']['surveyReaderPage'] = ['Anzeigeseite', 'Oeffentliche Seite zum Aufrufen einer Umfrage ueber den oeffentlichen Link.'];
+6
View File
@@ -0,0 +1,6 @@
<?php
$GLOBALS['TL_LANG']['tl_settings']['survey_settings_legend'] = 'Umfrage-Einstellungen';
$GLOBALS['TL_LANG']['tl_settings']['surveyReaderPage'] = ['Reader-Seite', 'Diese Seite wird global fuer den oeffentlichen Umfrage-Link verwendet.'];
$GLOBALS['TL_LANG']['tl_settings']['surveyResultsPage'] = ['Ergebnisseite', 'Diese Seite wird global fuer die Anzeige der Umfrage-Ergebnisse verwendet.'];
$GLOBALS['TL_LANG']['tl_settings']['surveyGotenbergUrl'] = ['Gotenberg-URL', 'Basis-URL der Gotenberg-Instanz fuer den serverseitigen PDF-Export, z. B. https://gotenberg.mummert.media'];
+23
View File
@@ -0,0 +1,23 @@
<?php
$GLOBALS['TL_LANG']['tl_survey']['title'] = ['Titel', 'Titel der Umfrage.'];
$GLOBALS['TL_LANG']['tl_survey']['alias'] = ['Oeffentlicher Link-Schluessel', 'Wird automatisch als 20-stelliger Link-Schluessel erzeugt.'];
$GLOBALS['TL_LANG']['tl_survey']['category'] = ['Kategorien', 'Ordnet die Umfrage einer oder mehreren Kategorien zu.'];
$GLOBALS['TL_LANG']['tl_survey']['categoryFilter'] = ['Kategorien', 'Filtert die Liste nach einer Umfrage-Kategorie.'];
$GLOBALS['TL_LANG']['tl_survey']['categorySummary'] = ['Kategorien', 'Zugeordnete Kategorien der Umfrage.'];
$GLOBALS['TL_LANG']['tl_survey']['description'] = ['Beschreibung', 'Einleitung fuer den Editor und das Frontend.'];
$GLOBALS['TL_LANG']['tl_survey']['title_legend'] = 'Grunddaten';
$GLOBALS['TL_LANG']['tl_survey']['assignment_legend'] = 'Bearbeitende Mitglieder';
$GLOBALS['TL_LANG']['tl_survey']['publishing_legend'] = 'Sichtbarkeit';
$GLOBALS['TL_LANG']['tl_survey']['meta_legend'] = 'Verwaltungsdaten';
$GLOBALS['TL_LANG']['tl_survey']['published'] = ['Veroeffentlicht', 'Die Umfrage oeffentlich freischalten.'];
$GLOBALS['TL_LANG']['tl_survey']['isLocked'] = ['Gesperrt', 'Struktur ist wegen vorhandener Antworten gesperrt.'];
$GLOBALS['TL_LANG']['tl_survey']['assignedMembers'] = ['Zugewiesene Mitglieder', 'Waehlen Sie die Mitglieder aus, die diese Umfrage bearbeiten duerfen.'];
$GLOBALS['TL_LANG']['tl_survey']['assignedMembersFilter'] = ['Zugewiesene Benutzer', 'Filtert die Liste nach einem zugewiesenen Benutzer.'];
$GLOBALS['TL_LANG']['tl_survey']['assignedMembersSummary'] = ['Zugewiesene Benutzer', 'Kommagetrennte Liste der zugewiesenen Benutzer.'];
$GLOBALS['TL_LANG']['tl_survey']['copyPublicLink'] = ['Link kopieren', 'Den oeffentlichen Link in die Zwischenablage kopieren'];
$GLOBALS['TL_LANG']['tl_survey']['showResults'] = ['Ergebnisse anzeigen', 'Die Ergebnisse dieser Umfrage im Frontend anzeigen'];
$GLOBALS['TL_LANG']['tl_survey']['createdBy'] = ['Angelegt von', 'ID des erstellenden Mitglieds.'];
$GLOBALS['TL_LANG']['tl_survey']['updatedBy'] = ['Aktualisiert von', 'ID des zuletzt bearbeitenden Mitglieds.'];
$GLOBALS['TL_LANG']['tl_survey']['createdAt'] = ['Angelegt am', 'Zeitpunkt der Erstellung.'];
$GLOBALS['TL_LANG']['tl_survey']['updatedAt'] = ['Aktualisiert am', 'Zeitpunkt der letzten Aktualisierung.'];
+5
View File
@@ -0,0 +1,5 @@
<?php
$GLOBALS['TL_LANG']['tl_survey_answer']['question'] = ['Frage', 'Referenz zur Frage.'];
$GLOBALS['TL_LANG']['tl_survey_answer']['questionType'] = ['Frageart', 'Gespeicherte Art der Frage zum Zeitpunkt der Antwort.'];
$GLOBALS['TL_LANG']['tl_survey_answer']['value'] = ['Antwort', 'Gespeicherte Antwort.'];
@@ -0,0 +1,6 @@
<?php
$GLOBALS['TL_LANG']['tl_survey_category']['title'] = ['Titel', 'Titel der Kategorie.'];
$GLOBALS['TL_LANG']['tl_survey_category']['alias'] = ['Alias', 'Optionaler Alias.'];
$GLOBALS['TL_LANG']['tl_survey_category']['title_legend'] = 'Grunddaten';
$GLOBALS['TL_LANG']['tl_survey_category']['sorting'] = ['Sortierung', 'Sortierwert fuer die Backend-Liste.'];
@@ -0,0 +1,6 @@
<?php
$GLOBALS['TL_LANG']['tl_survey_condition']['logic_legend'] = 'Sprungregel';
$GLOBALS['TL_LANG']['tl_survey_condition']['sourceQuestion'] = ['Ausgangsfrage', 'Frage, deren Antwort ausgewertet wird.'];
$GLOBALS['TL_LANG']['tl_survey_condition']['answerValue'] = ['Antwort', 'Antwortwert, bei dem zu einer anderen Frage gesprungen wird.'];
$GLOBALS['TL_LANG']['tl_survey_condition']['targetQuestion'] = ['Naechste Frage', 'Zu dieser Frage wird bei passender Antwort gewechselt.'];
+32
View File
@@ -0,0 +1,32 @@
<?php
$GLOBALS['TL_LANG']['tl_survey_content']['type'] = ['Fragetyp', 'Typ der Frage.'];
$GLOBALS['TL_LANG']['tl_survey_content']['types'] = [
'yes_no_maybe' => 'Ja-Nein-Frage',
'text' => 'Offene Frage',
'range' => 'Bewertungsfrage',
'choice' => 'Single/Multiple-Choice',
];
$GLOBALS['TL_LANG']['tl_survey_content']['question'] = ['Frage', 'Titel bzw. Wortlaut der Frage.'];
$GLOBALS['TL_LANG']['tl_survey_content']['description'] = ['Beschreibung', 'Optionale Zusatzbeschreibung zur Frage.'];
$GLOBALS['TL_LANG']['tl_survey_content']['question_legend'] = 'Frage';
$GLOBALS['TL_LANG']['tl_survey_content']['settings_legend'] = 'Einstellungen';
$GLOBALS['TL_LANG']['tl_survey_content']['mandatory'] = ['Pflichtfrage', 'Diese Frage muss beantwortet werden.'];
$GLOBALS['TL_LANG']['tl_survey_content']['published'] = ['Aktiv', 'Die Frage wird in der Umfrage angezeigt.'];
$GLOBALS['TL_LANG']['tl_survey_content']['allowMaybe'] = ['Vielleicht anbieten', 'Ergaenzt die Ja-Nein-Frage um die Antwortmoeglichkeit Vielleicht.'];
$GLOBALS['TL_LANG']['tl_survey_content']['jumpOnYes'] = ['Zur Frage springen bei Auswahl ja', 'Springt bei der Antwort Ja direkt zu dieser Frage.'];
$GLOBALS['TL_LANG']['tl_survey_content']['jumpOnNo'] = ['Zur Frage springen bei Auswahl nein', 'Springt bei der Antwort Nein direkt zu dieser Frage.'];
$GLOBALS['TL_LANG']['tl_survey_content']['allowMultiple'] = ['Multiple-Choice aktivieren', 'Erlaubt mehrere Antworten statt einer einzelnen Auswahl.'];
$GLOBALS['TL_LANG']['tl_survey_content']['answerOption1'] = ['Antwort 1', 'Erste moegliche Antwort.'];
$GLOBALS['TL_LANG']['tl_survey_content']['answerOption2'] = ['Antwort 2', 'Zweite moegliche Antwort.'];
$GLOBALS['TL_LANG']['tl_survey_content']['answerOption3'] = ['Antwort 3', 'Dritte moegliche Antwort.'];
$GLOBALS['TL_LANG']['tl_survey_content']['answerOption4'] = ['Antwort 4', 'Vierte moegliche Antwort.'];
$GLOBALS['TL_LANG']['tl_survey_content']['answerOption5'] = ['Antwort 5', 'Fuenfte moegliche Antwort.'];
$GLOBALS['TL_LANG']['tl_survey_content']['answerOption6'] = ['Antwort 6', 'Sechste moegliche Antwort.'];
$GLOBALS['TL_LANG']['tl_survey_content']['answerOption7'] = ['Antwort 7', 'Siebte moegliche Antwort.'];
$GLOBALS['TL_LANG']['tl_survey_content']['answerOption8'] = ['Antwort 8', 'Achte moegliche Antwort.'];
$GLOBALS['TL_LANG']['tl_survey_content']['answerOption9'] = ['Antwort 9', 'Neunte moegliche Antwort.'];
$GLOBALS['TL_LANG']['tl_survey_content']['answerOption10'] = ['Antwort 10', 'Zehnte moegliche Antwort.'];
$GLOBALS['TL_LANG']['tl_survey_content']['rangeMin'] = ['Kleinster Wert', 'Kleinster Wert des Schiebereglers.'];
$GLOBALS['TL_LANG']['tl_survey_content']['rangeMax'] = ['Groesster Wert', 'Groesster Wert des Schiebereglers.'];
$GLOBALS['TL_LANG']['tl_survey_content']['rangeStep'] = ['Schrittweite', 'Abstand zwischen den moeglichen Werten des Schiebereglers.'];
+4
View File
@@ -0,0 +1,4 @@
<?php
$GLOBALS['TL_LANG']['tl_survey_editor']['editor_legend'] = 'Bearbeitungsrechte';
$GLOBALS['TL_LANG']['tl_survey_editor']['member'] = ['Bearbeitendes Mitglied', 'Dieses Frontend-Mitglied darf die Umfrage bearbeiten.'];
@@ -0,0 +1,7 @@
<?php
$GLOBALS['TL_LANG']['tl_survey_submission']['token'] = ['Teilnahme-Code', 'Anonymer Code dieses Umfrage-Durchlaufs.'];
$GLOBALS['TL_LANG']['tl_survey_submission']['startedAt'] = ['Begonnen am', 'Zeitpunkt, an dem die Umfrage begonnen wurde.'];
$GLOBALS['TL_LANG']['tl_survey_submission']['completedAt'] = ['Beendet am', 'Zeitpunkt, an dem die Umfrage abgeschlossen wurde.'];
$GLOBALS['TL_LANG']['tl_survey_submission']['currentQuestion'] = ['Aktuelle Frage', 'Interne Kennung der aktuell offenen Frage.'];
$GLOBALS['TL_LANG']['tl_survey_submission']['isFinished'] = ['Abgeschlossen', 'Kennzeichnet abgeschlossene Umfrage-Durchlaeufe.'];
@@ -0,0 +1,410 @@
<style>
@font-face {
font-family: "PT Sans Narrow";
src: url("{{ asset('bundles/survey/fonts/PTSansNarrow-Regular.woff2') }}") format("woff2");
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "PT Sans Narrow";
src: url("{{ asset('bundles/survey/fonts/PTSansNarrow-Bold.woff2') }}") format("woff2");
font-weight: 700;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "Blogger Sans";
src: url("{{ asset('bundles/survey/fonts/Blogger%20Sans-Medium.woff2') }}") format("woff2");
font-weight: 500;
font-style: normal;
font-display: swap;
}
:root {
--survey-blue: #0064ad;
--survey-orange: #ec7c32;
--survey-purple: #9d5276;
--survey-green: #00ae97;
--survey-rose: #fce4d6;
--survey-black: #222222;
--survey-gray-dark: #555e68;
--survey-gray-mid: #a2a8b4;
--survey-gray-soft: #e9e9eb;
--survey-surface: rgba(255, 255, 255, 0.92);
}
.survey-brand-strip {
display: flex;
align-items: center;
gap: 1.5rem;
justify-content: center;
margin: 0 0 1.75rem;
padding: 1rem 1.25rem 0.5rem;
}
.survey-brand-logo {
display: block;
object-fit: contain;
object-position: center;
}
.survey-brand-logo.primary {
width: auto;
max-width: min(100%, 320px);
height: 75px;
}
.survey-brand-logo.secondary {
width: auto;
max-width: min(100%, 420px);
height: 75px;
}
.survey-shell {
color: var(--survey-black);
font-family: "PT Sans Narrow", sans-serif;
letter-spacing: 0.3px;
background:
radial-gradient(circle at top left, rgba(0, 100, 173, 0.22), transparent 30%),
radial-gradient(circle at bottom right, rgba(0, 174, 151, 0.12), transparent 34%),
linear-gradient(180deg, rgba(0, 100, 173, 0.12) 0%, rgba(255, 255, 255, 0.98) 52%, rgba(0, 100, 173, 0.08) 100%);
border: 1px solid rgba(162, 168, 180, 0.45);
border-radius: 2rem;
box-shadow: 0 28px 80px rgba(34, 34, 34, 0.08);
padding: 1.5rem;
}
.survey-shell h2,
.survey-shell h3,
.survey-shell h4 {
font-family: "Blogger Sans", sans-serif;
font-weight: 500;
color: var(--survey-blue);
letter-spacing: 0.01em;
}
.survey-shell,
.survey-shell * {
font-style: normal;
}
.survey-grid {
display: grid;
gap: 1.5rem;
}
.survey-grid-columns {
display: grid;
gap: 1.5rem;
}
.survey-results-card {
overflow: hidden;
}
.survey-results-layout {
display: grid;
gap: 1.25rem;
margin-top: 1.1rem;
align-items: start;
}
.survey-results-layout.no-chart {
grid-template-columns: 1fr;
}
.survey-chart-panel {
position: relative;
min-height: 320px;
}
.survey-chart-panel::before {
content: "";
position: absolute;
inset: 0;
border-radius: 1.5rem;
background:
radial-gradient(circle at top left, rgba(0, 100, 173, 0.18), transparent 42%),
radial-gradient(circle at bottom right, rgba(236, 124, 50, 0.18), transparent 42%),
linear-gradient(135deg, rgba(0, 100, 173, 0.12) 0%, rgba(255, 255, 255, 0.9) 52%, rgba(236, 124, 50, 0.12) 100%);
border: 1px solid rgba(162, 168, 180, 0.28);
}
.survey-chart-frame {
position: relative;
z-index: 1;
min-height: 320px;
padding: 1rem;
}
.survey-chart-panel.is-line .survey-chart-frame {
min-height: 360px;
padding: 1rem 1rem 0.75rem;
}
.survey-chart-canvas {
width: 100%;
height: 100%;
}
.survey-results-panel {
display: grid;
gap: 1rem;
}
.survey-results-list {
display: grid;
gap: 0.9rem;
}
.survey-results-row {
display: grid;
gap: 0.45rem;
padding: 0.9rem 1rem;
border-radius: 1.15rem;
background: rgba(255, 255, 255, 0.78);
border: 1px solid rgba(162, 168, 180, 0.24);
}
.survey-results-responses {
max-height: 24rem;
overflow: auto;
padding-right: 0.25rem;
}
.survey-response-card {
margin: 0;
font-style: normal;
background: rgba(255, 255, 255, 0.72);
}
.survey-metric-grid {
display: grid;
gap: 0.85rem;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
}
.survey-metric-card {
display: grid;
gap: 0.35rem;
padding: 0.95rem 1rem;
border-radius: 1.2rem;
background: rgba(252, 228, 214, 0.38);
border: 1px solid rgba(236, 124, 50, 0.16);
}
.survey-metric-card strong {
font-family: "Blogger Sans", sans-serif;
font-size: 1.35rem;
color: var(--survey-blue);
}
.survey-metric-label {
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--survey-gray-dark);
font-weight: 700;
}
.survey-card {
background: var(--survey-surface);
border: 1px solid rgba(162, 168, 180, 0.34);
border-radius: 1.5rem;
padding: 1.25rem;
box-shadow: 0 12px 32px rgba(34, 34, 34, 0.06);
}
.survey-meta {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
}
.survey-badge {
display: inline-flex;
align-items: center;
padding: 0.25rem 0.65rem;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
background: rgba(162, 168, 180, 0.16);
}
.survey-badge.success { background: rgba(0, 174, 151, 0.16); color: var(--survey-green); }
.survey-badge.warning { background: rgba(236, 124, 50, 0.16); color: var(--survey-orange); }
.survey-badge.neutral { background: rgba(162, 168, 180, 0.16); color: var(--survey-gray-dark); }
.survey-button-row {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
margin-top: 1rem;
}
.survey-button {
display: inline-flex;
justify-content: center;
align-items: center;
gap: 0.5rem;
padding: 0.8rem 1rem;
border-radius: 999px;
border: 1px solid transparent;
text-decoration: none;
font-weight: 700;
cursor: pointer;
}
.survey-button.primary { background: var(--survey-blue); color: #fff; }
.survey-button.secondary { background: rgba(255,255,255,0.86); color: var(--survey-blue); border-color: rgba(0,100,173,0.18); }
.survey-button.danger { background: rgba(157, 82, 118, 0.12); color: var(--survey-purple); border-color: rgba(157, 82, 118, 0.22); }
.survey-form-grid {
display: grid;
gap: 1rem;
}
.survey-label {
display: grid;
gap: 0.4rem;
font-weight: 600;
}
.survey-label input,
.survey-label textarea,
.survey-label select {
width: 100%;
border-radius: 1rem;
border: 1px solid rgba(162,168,180,0.5);
padding: 0.8rem 0.9rem;
background: rgba(255,255,255,0.98);
color: var(--survey-black);
}
.survey-inline-check {
display: inline-flex;
gap: 0.6rem;
align-items: center;
border: 1px solid rgba(162,168,180,0.35);
border-radius: 1rem;
padding: 0.8rem 1rem;
background: rgba(252,228,214,0.42);
}
.survey-alert {
border-radius: 1.2rem;
padding: 1rem 1.1rem;
margin-bottom: 1rem;
background: rgba(255,255,255,0.78);
border: 1px solid rgba(162,168,180,0.35);
}
.survey-alert.error { border-color: rgba(157, 82, 118, 0.28); color: var(--survey-purple); }
.survey-alert.warning { border-color: rgba(236, 124, 50, 0.28); color: var(--survey-orange); }
.survey-kpis {
display: grid;
gap: 1rem;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
}
.survey-progress {
width: 100%;
height: 0.75rem;
border-radius: 999px;
overflow: hidden;
background: rgba(162,168,180,0.22);
}
.survey-progress > span {
display: block;
height: 100%;
background: linear-gradient(90deg, var(--survey-blue) 0%, var(--survey-orange) 100%);
}
.survey-progress-bar {
display: block;
height: 100%;
background: linear-gradient(90deg, var(--survey-blue) 0%, var(--survey-orange) 100%);
}
.survey-range-output {
font-size: 2rem;
font-weight: 800;
color: var(--survey-green);
}
@media (min-width: 960px) {
.survey-grid-columns {
grid-template-columns: 0.95fr 1.05fr;
}
.survey-results-layout {
grid-template-columns: minmax(320px, 0.95fr) minmax(0, 1.05fr);
}
}
@media (max-width: 767px) {
.survey-brand-strip {
padding: 0.75rem 0.75rem 0.25rem;
}
.survey-brand-logo.secondary {
height: 75px;
max-width: 100%;
}
}
</style>
<script>
document.addEventListener('DOMContentLoaded', function () {
document.querySelectorAll('[data-survey-range]').forEach(function (input) {
var targetId = input.getAttribute('data-survey-range-target');
var output = targetId ? document.getElementById(targetId) : null;
if (!output) {
return;
}
var update = function () {
output.textContent = input.value;
};
input.addEventListener('input', update);
update();
});
document.querySelectorAll('[data-survey-question-type]').forEach(function (select) {
var wrapper = select.closest('[data-survey-question-editor]');
if (!wrapper) {
return;
}
var toggle = function () {
var showRange = select.value === 'range';
var showYesNoMaybe = select.value === 'yes_no_maybe';
var showChoice = select.value === 'choice';
wrapper.querySelectorAll('.range-config-field').forEach(function (element) {
element.style.display = showRange ? '' : 'none';
});
wrapper.querySelectorAll('.yes-no-config-field').forEach(function (element) {
element.style.display = showYesNoMaybe ? '' : 'none';
});
wrapper.querySelectorAll('.choice-config-field').forEach(function (element) {
element.style.display = showChoice ? '' : 'none';
});
};
select.addEventListener('change', toggle);
toggle();
});
});
</script>
@@ -0,0 +1,3 @@
<div class="survey-brand-strip" aria-label="Projektlogos">
<img class="survey-brand-logo primary" src="{{ asset('bundles/survey/images/Logo_Landesverband.svg') }}" alt="Kinderschutzbund Sachsen">
</div>
@@ -0,0 +1,193 @@
{% include '@Survey/frontend/_survey_assets.html.twig' %}
{% include '@Survey/frontend/_survey_branding.html.twig' %}
<section class="survey-shell survey-grid">
{% if headline.text %}
<header>
<h2>{{ headline.text }}</h2>
</header>
{% endif %}
{% if loginRequired %}
<div class="survey-alert warning">Dieses Frontendmodul ist fuer eingeloggte Mitglieder gedacht. Nutzen Sie den normalen Contao-Mitgliederlogin auf einer geschuetzten Seite.</div>
{% elseif createMode %}
<div class="survey-card">
<h3>Neue Umfrage erstellen</h3>
<p>Die Umfrage wird nach dem Anlegen automatisch dem eingeloggten Mitglied zugewiesen.</p>
{{ form_start(surveyForm, {attr: {class: 'survey-form-grid'}}) }}
<label class="survey-label">Titel{{ form_widget(surveyForm.title) }}{{ form_errors(surveyForm.title) }}</label>
<label class="survey-label">Beschreibung{{ form_widget(surveyForm.description) }}</label>
<label class="survey-inline-check">{{ form_widget(surveyForm.published) }}<span>Direkt veroeffentlichen</span></label>
<div class="survey-button-row">
<button class="survey-button primary">Umfrage erstellen</button>
{% if backUrl %}<a class="survey-button secondary" href="{{ backUrl }}">Zur Liste</a>{% endif %}
</div>
{{ form_end(surveyForm) }}
</div>
{% else %}
{% set locked = survey.isLocked == '1' %}
<div class="survey-button-row">
{% if backUrl %}<a class="survey-button secondary" href="{{ backUrl }}">Zur Liste</a>{% endif %}
{% if publicUrl %}<a class="survey-button primary" href="{{ publicUrl }}" target="_blank" rel="noreferrer">Oeffentliche Ansicht oeffnen</a>{% endif %}
</div>
<div class="survey-kpis">
<article class="survey-card"><strong>Oeffentlicher Link</strong><div>{% if publicUrl %}<a href="{{ publicUrl }}" target="_blank" rel="noreferrer">{{ publicUrl }}</a>{% else %}Noch keine oeffentliche Seite hinterlegt.{% endif %}</div></article>
<article class="survey-card"><strong>Status</strong><div class="survey-meta"><span class="survey-badge {{ survey.published ? 'success' : 'neutral' }}">{{ survey.published ? 'veroeffentlicht' : 'entwurf' }}</span>{% if locked %}<span class="survey-badge warning">gesperrt</span>{% endif %}</div></article>
<article class="survey-card"><strong>Bearbeitende</strong><div>{{ editors|length }} Mitglied(er)</div></article>
</div>
{% if locked %}
<div class="survey-alert warning">Die Umfrage ist strukturell gesperrt, weil bereits Antworten vorliegen. Stammdaten koennen weiter gepflegt werden, der Ablauf und die Fragen jedoch nicht.</div>
{% endif %}
<div class="survey-grid-columns">
<div class="survey-grid">
<div class="survey-card">
<h3>Umfrage-Metadaten</h3>
{{ form_start(surveyForm, {attr: {class: 'survey-form-grid'}}) }}
<label class="survey-label">Titel{{ form_widget(surveyForm.title) }}{{ form_errors(surveyForm.title) }}</label>
<label class="survey-label">Beschreibung{{ form_widget(surveyForm.description) }}</label>
<label class="survey-inline-check">{{ form_widget(surveyForm.published) }}<span>Umfrage veroeffentlichen</span></label>
<div class="survey-button-row"><button class="survey-button primary">Speichern</button></div>
{{ form_end(surveyForm) }}
</div>
<div class="survey-card">
<h3>Bearbeitende Mitglieder</h3>
<div class="survey-grid">
{% for editor in editors %}
<div>
<strong>{{ [editor.firstname, editor.lastname]|join(' ')|trim ?: editor.email }}</strong><br>
<span>{{ editor.email }}</span>
</div>
{% else %}
<div>Keine Editoren hinterlegt.</div>
{% endfor %}
</div>
</div>
<div class="survey-card">
<h3>Antworten und Ergebnisse</h3>
<div class="survey-grid">
{% for submission in submissions %}
<article>
<div class="survey-meta">
<span class="survey-badge neutral">{{ submission.answerCount }} Antworten</span>
<span class="survey-badge {{ submission.isFinished ? 'success' : 'warning' }}">{{ submission.isFinished ? 'abgeschlossen' : 'offen' }}</span>
</div>
<div>Begonnen: {{ submission.startedAt ? submission.startedAt|date('d.m.Y H:i') : 'unbekannt' }}</div>
<div>Beendet: {{ submission.completedAt ? submission.completedAt|date('d.m.Y H:i') : 'unbekannt' }}</div>
<div>Teilnahme-Code: {{ submission.token }}</div>
</article>
{% else %}
<div>Es liegen noch keine Teilnahmen vor.</div>
{% endfor %}
</div>
</div>
</div>
<div class="survey-grid">
<div class="survey-card" data-survey-question-editor>
<div class="survey-meta"><h3>Fragen bearbeiten</h3>{% if activeQuestionId %}<span class="survey-badge neutral">Bearbeitungsmodus</span>{% endif %}</div>
{% if not locked %}
{% set questionType = questionForm.type.vars.value ?: 'yes_no_maybe' %}
{{ form_start(questionForm, {attr: {class: 'survey-form-grid'}}) }}
<label class="survey-label">Fragetyp{{ form_widget(questionForm.type, {attr: {'data-survey-question-type': '1'}}) }}</label>
<label class="survey-label">Frage{{ form_widget(questionForm.question) }}{{ form_errors(questionForm.question) }}</label>
<label class="survey-label">Beschreibung{{ form_widget(questionForm.description) }}</label>
<label class="survey-inline-check">{{ form_widget(questionForm.mandatory) }}<span>Pflichtfrage</span></label>
<label class="survey-inline-check">{{ form_widget(questionForm.published) }}<span>Aktiv</span></label>
<label class="survey-inline-check yes-no-config-field"{% if questionType != 'yes_no_maybe' %} style="display:none"{% endif %}>{{ form_widget(questionForm.allowMaybe) }}<span>Vielleicht anbieten</span></label>
<div class="yes-no-config-field survey-grid-columns"{% if questionType != 'yes_no_maybe' %} style="display:none;grid-template-columns: repeat(2, minmax(0, 1fr));"{% else %} style="grid-template-columns: repeat(2, minmax(0, 1fr));"{% endif %}>
<label class="survey-label">Zur Frage springen bei Auswahl ja{{ form_widget(questionForm.jumpOnYes) }}</label>
<label class="survey-label">Zur Frage springen bei Auswahl nein{{ form_widget(questionForm.jumpOnNo) }}</label>
</div>
<label class="survey-inline-check choice-config-field"{% if questionType != 'choice' %} style="display:none"{% endif %}>{{ form_widget(questionForm.allowMultiple) }}<span>Multiple-Choice aktivieren</span></label>
<div class="choice-config-field survey-grid-columns"{% if questionType != 'choice' %} style="display:none;grid-template-columns: repeat(2, minmax(0, 1fr));"{% else %} style="grid-template-columns: repeat(2, minmax(0, 1fr));"{% endif %}>
<label class="survey-label">Antwort 1{{ form_widget(questionForm.answerOption1) }}</label>
<label class="survey-label">Antwort 2{{ form_widget(questionForm.answerOption2) }}</label>
<label class="survey-label">Antwort 3{{ form_widget(questionForm.answerOption3) }}</label>
<label class="survey-label">Antwort 4{{ form_widget(questionForm.answerOption4) }}</label>
<label class="survey-label">Antwort 5{{ form_widget(questionForm.answerOption5) }}</label>
<label class="survey-label">Antwort 6{{ form_widget(questionForm.answerOption6) }}</label>
<label class="survey-label">Antwort 7{{ form_widget(questionForm.answerOption7) }}</label>
<label class="survey-label">Antwort 8{{ form_widget(questionForm.answerOption8) }}</label>
<label class="survey-label">Antwort 9{{ form_widget(questionForm.answerOption9) }}</label>
<label class="survey-label">Antwort 10{{ form_widget(questionForm.answerOption10) }}</label>
</div>
<div class="range-config-field survey-grid-columns"{% if questionType != 'range' %} style="display:none;grid-template-columns: repeat(3, minmax(0, 1fr));"{% else %} style="grid-template-columns: repeat(3, minmax(0, 1fr));"{% endif %}>
<label class="survey-label">Kleinster Wert{{ form_widget(questionForm.rangeMin) }}</label>
<label class="survey-label">Groesster Wert{{ form_widget(questionForm.rangeMax) }}</label>
<label class="survey-label">Schrittweite{{ form_widget(questionForm.rangeStep) }}</label>
</div>
<div class="survey-button-row"><button class="survey-button primary">{{ activeQuestionId ? 'Frage aktualisieren' : 'Frage speichern' }}</button></div>
{{ form_end(questionForm) }}
{% endif %}
<div class="survey-grid">
{% for question in questions %}
<article class="survey-card">
<div class="survey-meta">
<span class="survey-badge neutral">{{ question.type }}</span>
{% if question.mandatory %}<span class="survey-badge warning">Pflicht</span>{% endif %}
{% if loop.first %}<span class="survey-badge success">Start</span>{% endif %}
</div>
<h4>{{ question.question }}</h4>
<p>{{ question.description ?: 'Keine Zusatzbeschreibung.' }}</p>
{% if not locked %}
<div class="survey-button-row">
<a class="survey-button secondary" href="?survey={{ survey.id }}&question={{ question.id }}">Bearbeiten</a>
<form method="post">
<input type="hidden" name="_survey_action" value="delete-question">
<input type="hidden" name="item_id" value="{{ question.id }}">
<input type="hidden" name="_token" value="{{ csrf_token('delete-question-' ~ question.id) }}">
<button class="survey-button danger" onclick="return confirm('Frage wirklich loeschen?');">Loeschen</button>
</form>
</div>
{% endif %}
</article>
{% else %}
<div>Noch keine Fragen angelegt.</div>
{% endfor %}
</div>
</div>
<div class="survey-card">
<div class="survey-meta"><h3>Sprungregeln</h3>{% if activeConditionId %}<span class="survey-badge neutral">Bearbeitungsmodus</span>{% endif %}</div>
{% if not locked %}
{{ form_start(conditionForm, {attr: {class: 'survey-form-grid'}}) }}
<label class="survey-label">Ausgangsfrage{{ form_widget(conditionForm.sourceQuestion) }}</label>
<label class="survey-label">Antwort{{ form_widget(conditionForm.answerValue) }}</label>
<label class="survey-label">Naechste Frage{{ form_widget(conditionForm.targetQuestion) }}</label>
<div class="survey-button-row"><button class="survey-button primary">{{ activeConditionId ? 'Bedingung aktualisieren' : 'Bedingung speichern' }}</button></div>
{{ form_end(conditionForm) }}
{% endif %}
<div class="survey-grid">
{% for condition in conditions %}
<article class="survey-card">
<div><strong>Wenn Antwort = {{ condition.answerValue }}</strong></div>
<div>{{ condition.sourceQuestionLabel ?: ('Frage #' ~ condition.sourceQuestion) }}</div>
<div>weiter zu {{ condition.targetQuestionLabel ?: ('Frage #' ~ condition.targetQuestion) }}</div>
{% if not locked %}
<div class="survey-button-row">
<a class="survey-button secondary" href="?survey={{ survey.id }}&condition={{ condition.id }}">Bearbeiten</a>
<form method="post">
<input type="hidden" name="_survey_action" value="delete-condition">
<input type="hidden" name="item_id" value="{{ condition.id }}">
<input type="hidden" name="_token" value="{{ csrf_token('delete-condition-' ~ condition.id) }}">
<button class="survey-button danger" onclick="return confirm('Bedingung wirklich loeschen?');">Loeschen</button>
</form>
</div>
{% endif %}
</article>
{% else %}
<div>Noch keine Bedingungen definiert.</div>
{% endfor %}
</div>
</div>
</div>
</div>
{% endif %}
</section>
@@ -0,0 +1,52 @@
{% include '@Survey/frontend/_survey_assets.html.twig' %}
{% include '@Survey/frontend/_survey_branding.html.twig' %}
<section class="survey-shell survey-grid">
{% if headline.text %}
<header>
<h2>{{ headline.text }}</h2>
</header>
{% endif %}
{% if loginRequired %}
<div class="survey-alert warning">Dieser Bereich verwendet den normalen Contao-Mitgliederlogin. Bitte binden Sie das Modul auf einer geschuetzten Seite oder mit einer separaten Login-Seite ein.</div>
{% else %}
<div class="survey-button-row">
{% if createUrl %}
<a class="survey-button primary" href="{{ createUrl }}">Neue Umfrage anlegen</a>
{% endif %}
</div>
<div class="survey-grid">
{% for survey in surveys %}
<article class="survey-card">
<div class="survey-meta">
<h3>{{ survey.title }}</h3>
<span class="survey-badge {{ survey.published ? 'success' : 'neutral' }}">{{ survey.published ? 'veroeffentlicht' : 'Entwurf' }}</span>
{% if survey.isLocked %}
<span class="survey-badge warning">gesperrt</span>
{% endif %}
</div>
<p>{{ survey.description ?: 'Keine Beschreibung hinterlegt.' }}</p>
<div class="survey-meta">
<span class="survey-badge neutral">{{ survey.questionCount }} Fragen</span>
<span class="survey-badge neutral">{{ survey.answerCount }} Antworten</span>
</div>
<div class="survey-button-row">
{% if survey.editUrl %}
<a class="survey-button primary" href="{{ survey.editUrl }}">Bearbeiten</a>
{% endif %}
{% if survey.publicUrl %}
<a class="survey-button secondary" href="{{ survey.publicUrl }}" target="_blank" rel="noreferrer">Oeffentliche Ansicht</a>
{% endif %}
{% if survey.resultsUrl %}
<a class="survey-button secondary" href="{{ survey.resultsUrl }}" target="_blank" rel="noreferrer">Ergebnisse anzeigen</a>
{% endif %}
</div>
</article>
{% else %}
<div class="survey-card">Dem eingeloggten Mitglied ist aktuell keine Umfrage zugewiesen.</div>
{% endfor %}
</div>
{% endif %}
</section>
@@ -0,0 +1,84 @@
{% include '@Survey/frontend/_survey_assets.html.twig' %}
{% include '@Survey/frontend/_survey_branding.html.twig' %}
<section class="survey-shell survey-grid">
{% if headline.text %}
<header><h2>{{ headline.text }}</h2></header>
{% endif %}
{% if errorMessage %}
<div class="survey-alert error">{{ errorMessage }}</div>
{% elseif survey %}
<article class="survey-card">
<div class="survey-meta">
<h3>{{ survey.title }}</h3>
{% if progress.total > 0 %}<span class="survey-badge neutral">{{ progress.current }}/{{ progress.total }}</span>{% endif %}
</div>
<p>{{ survey.description ?: 'Bitte beantworten Sie die folgenden Fragen. Jede Frage wird einzeln angezeigt.' }}</p>
{% if progress.total > 0 %}
<div class="survey-progress"><span style="width: {{ progress.percentage }}%"></span></div>
{% endif %}
</article>
{% if isComplete %}
<article class="survey-card">
<h3>Vielen Dank fuers Ausfuellen.</h3>
<p>Ihre Antworten wurden anonym gespeichert. Es wurde keine Teilnehmeridentitaet mit diesem Umfrage-Durchlauf verknuepft.</p>
{% if answers %}
<div class="survey-grid">
{% for answer in answers %}
<article class="survey-card">
<div class="survey-badge neutral">{{ answer.questionType }}</div>
<h4>{{ answer.question }}</h4>
<p>{{ answer.value }}</p>
</article>
{% endfor %}
</div>
{% endif %}
</article>
{% elseif question %}
<article class="survey-card">
<div class="survey-meta"><span class="survey-badge neutral">Frage {{ progress.current }}</span></div>
<h3>{{ question.question }}</h3>
{% if question.description %}<p>{{ question.description }}</p>{% endif %}
{{ form_start(surveyForm, {attr: {class: 'survey-form-grid'}}) }}
<input type="hidden" name="REQUEST_TOKEN" value="{{ contao.request_token }}">
{% if question.type == 'yes_no_maybe' %}
<div class="survey-grid" style="grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));">
{% for child in surveyForm.answer %}
<label class="survey-card" style="cursor: pointer;">
<div class="survey-meta"><strong>{{ child.vars.label }}</strong>{{ form_widget(child) }}</div>
<div>Diese Antwort wird direkt fuer den weiteren Ablauf verwendet.</div>
</label>
{% endfor %}
</div>
{% elseif question.type == 'choice' %}
<div class="survey-grid" style="grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));">
{% for child in surveyForm.answer %}
<label class="survey-card" style="cursor: pointer;">
<div class="survey-meta"><strong>{{ child.vars.label }}</strong>{{ form_widget(child) }}</div>
</label>
{% endfor %}
</div>
{% elseif question.type == 'range' %}
<div class="survey-grid">
<div class="survey-card">
<div>Aktueller Wert</div>
<div id="survey-range-output-{{ question.id }}" class="survey-range-output">{{ question.rangeMin }}</div>
</div>
{{ form_widget(surveyForm.answer, {attr: {'data-survey-range': '1', 'data-survey-range-target': 'survey-range-output-' ~ question.id}}) }}
<div class="survey-meta"><span>{{ question.rangeMin }}</span><span>{{ question.rangeMax }}</span></div>
</div>
{% else %}
{{ form_widget(surveyForm.answer) }}
{% endif %}
{{ form_errors(surveyForm.answer) }}
<div class="survey-button-row"><button class="survey-button primary">Weiter</button></div>
{{ form_end(surveyForm) }}
</article>
{% endif %}
{% endif %}
</section>
@@ -0,0 +1,162 @@
{% include '@Survey/frontend/_survey_assets.html.twig' %}
{% include '@Survey/frontend/_survey_branding.html.twig' %}
<section class="survey-shell survey-grid">
{% if headline.text %}
<header>
<h2>{{ headline.text }}</h2>
</header>
{% endif %}
{% if errorMessage %}
<div class="survey-alert warning">{{ errorMessage }}</div>
{% elseif survey %}
<article class="survey-card">
<div class="survey-meta">
<h2>{{ survey.title }}</h2>
<span class="survey-badge neutral">{{ completedSubmissionCount }} abgeschlossene Teilnahmen</span>
<span class="survey-badge neutral">{{ questionResults|length }} Fragen</span>
</div>
<p>{{ survey.description ?: 'Keine Beschreibung hinterlegt.' }}</p>
{% if downloadUrl or pdfDownloadUrl %}
<div class="survey-button-row">
{% if downloadUrl %}
<a class="survey-button primary" href="{{ downloadUrl }}">Ergebnisse als Excel herunterladen</a>
{% endif %}
{% if pdfDownloadUrl %}
<a class="survey-button secondary" href="{{ pdfDownloadUrl }}">Ergebnisse als PDF herunterladen</a>
{% endif %}
</div>
{% endif %}
</article>
<div class="survey-grid">
{% for question in questionResults %}
<article class="survey-card survey-results-card">
<div class="survey-meta">
<h3>{{ question.question }}</h3>
<span class="survey-badge neutral">{{ question.totalAnswers }} Antworten</span>
<span class="survey-badge neutral">{{ question.responseRate }}% Ruecklauf</span>
</div>
{% if question.description %}
<p>{{ question.description }}</p>
{% endif %}
<div class="survey-results-layout{% if not question.chart %} no-chart{% endif %}">
{% if question.chart %}
<div class="survey-chart-panel{% if question.chart.type == 'line' %} is-line{% endif %}">
<div class="survey-chart-frame">
<canvas class="survey-chart-canvas" data-survey-chart="{{ question.chart|json_encode|e('html_attr') }}"></canvas>
</div>
</div>
{% endif %}
<div class="survey-results-panel">
{% if question.type in ['yes_no_maybe', 'choice'] %}
<div class="survey-results-list">
{% for option in question.options %}
<div class="survey-results-row">
<div class="survey-meta">
<strong>{{ option.label }}</strong>
<span class="survey-badge neutral">{{ option.count }}</span>
<span class="survey-badge neutral">{{ option.percentage }}%</span>
</div>
<div class="survey-progress">
<span class="survey-progress-bar" style="width: {{ option.percentage }}%"></span>
</div>
</div>
{% endfor %}
</div>
{% elseif question.type == 'range' %}
<div class="survey-metric-grid">
<div class="survey-metric-card">
<span class="survey-metric-label">Minimum</span>
<strong>{{ question.minimum is not null ? question.minimum : '-' }}</strong>
</div>
<div class="survey-metric-card">
<span class="survey-metric-label">Maximum</span>
<strong>{{ question.maximum is not null ? question.maximum : '-' }}</strong>
</div>
<div class="survey-metric-card">
<span class="survey-metric-label">Durchschnitt</span>
<strong>{{ question.average is not null ? question.average : '-' }}</strong>
</div>
</div>
<div class="survey-results-list">
{% for option in question.distribution %}
<div class="survey-results-row">
<div class="survey-meta">
<strong>{{ option.label }}</strong>
<span class="survey-badge neutral">{{ option.count }}</span>
<span class="survey-badge neutral">{{ option.percentage }}%</span>
</div>
<div class="survey-progress">
<span class="survey-progress-bar" style="width: {{ option.percentage }}%"></span>
</div>
</div>
{% else %}
<p>Noch keine Bewertungen vorhanden.</p>
{% endfor %}
</div>
{% else %}
{% if question.responseSummary is defined and question.responseSummary %}
<div class="survey-results-list">
{% for option in question.responseSummary %}
<div class="survey-results-row">
<div class="survey-meta">
<strong>{{ option.label }}</strong>
<span class="survey-badge neutral">{{ option.count }}</span>
<span class="survey-badge neutral">{{ option.percentage }}%</span>
</div>
<div class="survey-progress">
<span class="survey-progress-bar" style="width: {{ option.percentage }}%"></span>
</div>
</div>
{% endfor %}
</div>
{% endif %}
<div class="survey-results-list survey-results-responses">
{% for response in question.responses %}
<blockquote class="survey-card survey-response-card">{{ response|nl2br }}</blockquote>
{% else %}
<p>Noch keine Freitextantworten vorhanden.</p>
{% endfor %}
</div>
{% endif %}
</div>
</div>
</article>
{% else %}
<article class="survey-card">Fuer diese Umfrage sind noch keine Fragen angelegt.</article>
{% endfor %}
</div>
{% endif %}
</section>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.5.1/dist/chart.umd.min.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function () {
if (typeof Chart === 'undefined') {
return;
}
document.querySelectorAll('[data-survey-chart]').forEach(function (canvas) {
try {
var chartConfig = JSON.parse(canvas.getAttribute('data-survey-chart') || '{}');
if (!chartConfig.type || !chartConfig.data) {
return;
}
new Chart(canvas, chartConfig);
} catch (error) {
console.error('Survey chart could not be initialized.', error);
}
});
});
</script>
Binary file not shown.
Binary file not shown.
Binary file not shown.
+59
View File
@@ -0,0 +1,59 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Ebene_2" data-name="Ebene 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 839.81 235.96">
<defs>
<style>
.cls-1 {
fill: #0063af;
}
</style>
</defs>
<g id="Ebene_1-2" data-name="Ebene 1">
<g>
<path class="cls-1" d="M307.26,170.67h-15.79c-1.34,0-2.52-1.12-2.52-2.52v-35.28c0-1.18,1.01-2.24,2.24-2.24s2.24,1.06,2.24,2.24v33.49h13.83c1.12,0,2.13.95,2.13,2.13s-.95,2.18-2.13,2.18Z"/>
<path class="cls-1" d="M329.54,151.74c0-4.09-1.96-5.99-5.99-5.99-1.96,0-3.02.5-4.26,1.18-.17.06-.84.45-1.46.45-1.06,0-1.51-1.01-1.57-1.85-.22-2.58,3.98-3.64,7.84-3.64,7,0,9.8,3.92,9.8,11.31v12.32c0,1.12.34,1.68,1.01,1.68.17,0,.22-.06.45-.06,1.06,0,1.62.95,1.62,1.9,0,1.4-1.29,1.9-2.52,1.9-1.85,0-3.36-1.06-4.2-2.8-2.13,1.96-4.65,2.97-7.67,2.97-5.77,0-8.57-3.42-8.57-8.46v-.22c0-5.49,3.25-8.29,9.86-8.29,2.02,0,3.92.28,5.66.78v-3.19ZM318.34,162.49v.22c0,2.63,1.18,4.48,4.93,4.48,2.41,0,4.31-.78,6.27-2.46v-6.05c-1.85-.56-4.03-.84-5.71-.84-4.59,0-5.49,2.13-5.49,4.65Z"/>
<path class="cls-1" d="M348.36,168.93c0,1.18-.73,2.13-2.18,2.13s-2.18-.95-2.18-2.13v-19.04c0-3.08-.5-3.08-1.01-3.92-.11-.22-.39-.56-.39-1.12,0-1.12.9-1.96,2.07-1.96,1.01,0,1.74.67,2.35,1.62,2.46-1.74,5.32-2.52,8.01-2.52,5.94,0,8.9,4.26,8.9,10.58v16.35c0,1.18-.78,2.13-2.18,2.13s-2.18-.95-2.18-2.13v-16.35c0-4.26-1.79-6.66-5.15-6.66-2.18,0-4.31.9-6.22,2.35.11.62.17,1.18.17,1.62v19.04Z"/>
<path class="cls-1" d="M387.45,143.17v-10.98c0-1.18.78-2.13,2.18-2.13s2.18.95,2.18,2.13v32.09c0,1.57.34,2.52,1.46,3.36.22.34.39.62.39,1.12,0,1.18-1.01,1.96-1.96,1.96-1.29,0-2.3-.73-2.97-1.79-2.46,1.23-5.15,2.18-7.95,2.18-5.88,0-8.9-4.31-8.9-10.58v-7.11c0-6.66,2.24-11.31,9.35-11.31,1.85,0,4.2.39,6.22,1.06ZM387.45,147.09c-1.85-.78-4.31-1.18-5.66-1.18-4.09,0-5.54,2.3-5.54,7.5v7.11c0,4.37,1.74,6.66,5.43,6.66,1.79,0,3.98-.62,5.88-1.62-.11-.45-.11-.9-.11-1.29v-17.19Z"/>
<path class="cls-1" d="M416.68,165.68c.11-.06.39-.17.78-.17,1.12,0,1.96.95,1.96,1.96,0,2.74-6.05,3.64-9.07,3.64-6.33,0-9.97-4.03-9.97-9.3v-10.47c0-5.38,3.58-9.46,10.25-9.46,6.22,0,9.3,3.58,9.3,9.13,0,5.15-2.8,8.29-9.46,8.29-1.68,0-3.75-.17-5.71-.5v3.3c0,3.14,2.07,5.15,6.1,5.15,2.35,0,3.86-.67,5.82-1.57ZM410.52,145.86c-3.64,0-5.77,1.96-5.77,5.1v4.26c2.07.28,4.76.39,5.88.39,4.26,0,4.98-2.3,4.98-4.59,0-3.64-1.85-5.15-5.1-5.15Z"/>
<path class="cls-1" d="M435.33,141.88c2.91,0,7.45.78,7.45,3.14,0,1.06-.84,2.02-2.02,2.02-.39,0-.67-.11-1.01-.28-1.18-.56-2.24-1.01-4.26-1.01-3.42,0-4.98,1.34-4.98,3.92,0,1.74.73,2.63,2.63,3.58,3.86,1.9,10.42,2.41,10.42,9.69,0,5.04-3.19,8.18-9.41,8.18-2.86,0-8.23-.67-8.23-3.25,0-.95.73-2.02,2.07-2.02.34,0,.39.06.56.11,1.57.62,3.36,1.29,5.6,1.29,3.08,0,5.15-1.23,5.15-4.14,0-2.02-.95-2.86-2.07-3.47-3.53-1.9-10.98-2.3-10.98-9.69,0-4.82,3.14-8.06,9.07-8.06Z"/>
<path class="cls-1" d="M462.43,168.48c-.62,1.85-1.4,2.63-2.86,2.63s-2.24-.78-2.86-2.58l-7.78-23.91c-.45-1.34.84-2.58,2.13-2.58.84,0,1.62.5,1.96,1.46l6.55,21.11,6.44-21.11c.28-.84,1.12-1.46,1.96-1.46,1.34,0,2.46,1.29,2.07,2.74l-7.62,23.69Z"/>
<path class="cls-1" d="M491.27,165.68c.11-.06.39-.17.78-.17,1.12,0,1.96.95,1.96,1.96,0,2.74-6.05,3.64-9.07,3.64-6.33,0-9.97-4.03-9.97-9.3v-10.47c0-5.38,3.58-9.46,10.25-9.46,6.22,0,9.3,3.58,9.3,9.13,0,5.15-2.8,8.29-9.46,8.29-1.68,0-3.75-.17-5.71-.5v3.3c0,3.14,2.07,5.15,6.1,5.15,2.35,0,3.86-.67,5.82-1.57ZM485.11,145.86c-3.64,0-5.77,1.96-5.77,5.1v4.26c2.07.28,4.76.39,5.88.39,4.26,0,4.98-2.3,4.98-4.59,0-3.64-1.85-5.15-5.1-5.15Z"/>
<path class="cls-1" d="M506.67,168.93c0,1.23-.95,2.18-2.18,2.18s-2.18-.95-2.18-2.18v-18.93c0-1.85-.22-2.91-.9-3.92-.22-.34-.39-.62-.39-1.12,0-1.18.95-1.96,2.02-1.96.9,0,1.68.62,2.24,1.51,2.41-1.51,5.54-2.41,7.67-2.41,2.24,0,2.91.95,2.91,2.13,0,1.4-1.01,2.3-2.18,2.24-2.3-.06-4.31-.06-7.11,2.02.06.56.11,1.06.11,1.51v18.93Z"/>
<path class="cls-1" d="M522.18,132.2c0-1.12.78-2.13,2.18-2.13s2.18,1.01,2.18,2.13v11.98c2.07-1.46,4.42-2.3,7.45-2.3,6.38,0,8.68,3.81,8.68,8.74v10.53c0,5.6-3.42,9.97-10.25,9.97s-10.25-3.92-10.25-8.85v-30.07ZM526.55,162.27c0,3.02,2.24,4.82,5.88,4.82,4.03,0,5.88-2.24,5.88-5.94v-10.53c0-3.08-1.62-4.93-5.38-4.93-2.8,0-4.82,1.06-6.38,2.8v13.78Z"/>
<path class="cls-1" d="M564.4,151.74c0-4.09-1.96-5.99-5.99-5.99-1.96,0-3.02.5-4.26,1.18-.17.06-.84.45-1.46.45-1.06,0-1.51-1.01-1.57-1.85-.22-2.58,3.98-3.64,7.84-3.64,7,0,9.8,3.92,9.8,11.31v12.32c0,1.12.34,1.68,1.01,1.68.17,0,.22-.06.45-.06,1.06,0,1.62.95,1.62,1.9,0,1.4-1.29,1.9-2.52,1.9-1.85,0-3.36-1.06-4.2-2.8-2.13,1.96-4.65,2.97-7.67,2.97-5.77,0-8.57-3.42-8.57-8.46v-.22c0-5.49,3.25-8.29,9.86-8.29,2.02,0,3.92.28,5.66.78v-3.19ZM553.2,162.49v.22c0,2.63,1.18,4.48,4.93,4.48,2.41,0,4.31-.78,6.27-2.46v-6.05c-1.85-.56-4.03-.84-5.71-.84-4.59,0-5.49,2.13-5.49,4.65Z"/>
<path class="cls-1" d="M583.22,168.93c0,1.18-.73,2.13-2.18,2.13s-2.18-.95-2.18-2.13v-19.04c0-3.08-.5-3.08-1.01-3.92-.11-.22-.39-.56-.39-1.12,0-1.12.9-1.96,2.07-1.96,1.01,0,1.74.67,2.35,1.62,2.46-1.74,5.32-2.52,8.01-2.52,5.94,0,8.9,4.26,8.9,10.58v16.35c0,1.18-.78,2.13-2.18,2.13s-2.18-.95-2.18-2.13v-16.35c0-4.26-1.79-6.66-5.15-6.66-2.18,0-4.31.9-6.22,2.35.11.62.17,1.18.17,1.62v19.04Z"/>
<path class="cls-1" d="M622.31,143.17v-10.98c0-1.18.78-2.13,2.18-2.13s2.18.95,2.18,2.13v32.09c0,1.57.34,2.52,1.46,3.36.22.34.39.62.39,1.12,0,1.18-1.01,1.96-1.96,1.96-1.29,0-2.3-.73-2.97-1.79-2.46,1.23-5.15,2.18-7.95,2.18-5.88,0-8.9-4.31-8.9-10.58v-7.11c0-6.66,2.24-11.31,9.35-11.31,1.85,0,4.2.39,6.22,1.06ZM622.31,147.09c-1.85-.78-4.31-1.18-5.66-1.18-4.09,0-5.54,2.3-5.54,7.5v7.11c0,4.37,1.74,6.66,5.43,6.66,1.79,0,3.98-.62,5.88-1.62-.11-.45-.11-.9-.11-1.29v-17.19Z"/>
<path class="cls-1" d="M667.66,154.32c-5.32-2.8-14.62-2.86-14.62-12.77,0-6.78,4.37-10.92,11.31-10.92,2.8,0,9.58.67,9.58,3.81,0,1.12-.84,2.13-2.24,2.13-1.74,0-3.19-1.57-7-1.57-3.58,0-7,1.4-7,6.27,0,3.7,1.96,4.93,3.25,5.6,5.38,2.8,14.62,2.91,14.62,12.71,0,7.39-5.21,11.65-12.26,11.65-3.58,0-10.3-1.12-10.3-4.14,0-1.12.9-2.18,2.02-2.18,1.57,0,3.98,1.96,8.34,1.96,4.98,0,7.5-2.52,7.5-6.89,0-3.64-1.79-4.93-3.19-5.66Z"/>
<path class="cls-1" d="M697.12,151.74c0-4.09-1.96-5.99-5.99-5.99-1.96,0-3.02.5-4.26,1.18-.17.06-.84.45-1.46.45-1.06,0-1.51-1.01-1.57-1.85-.22-2.58,3.98-3.64,7.84-3.64,7,0,9.8,3.92,9.8,11.31v12.32c0,1.12.34,1.68,1.01,1.68.17,0,.22-.06.45-.06,1.06,0,1.62.95,1.62,1.9,0,1.4-1.29,1.9-2.52,1.9-1.85,0-3.36-1.06-4.2-2.8-2.13,1.96-4.65,2.97-7.67,2.97-5.77,0-8.57-3.42-8.57-8.46v-.22c0-5.49,3.25-8.29,9.86-8.29,2.02,0,3.92.28,5.66.78v-3.19ZM685.92,162.49v.22c0,2.63,1.18,4.48,4.93,4.48,2.41,0,4.31-.78,6.27-2.46v-6.05c-1.85-.56-4.03-.84-5.71-.84-4.59,0-5.49,2.13-5.49,4.65Z"/>
<path class="cls-1" d="M709.78,152.36c0-5.77,3.02-10.3,10.02-10.3,2.63,0,7.78.78,7.78,3.42,0,1.01-.84,1.9-1.9,1.9-.28,0-.56-.06-.9-.17-1.34-.67-2.58-1.29-4.54-1.29-4.37,0-6.1,2.35-6.1,6.44v8.46c0,4.09,1.74,6.44,6.1,6.44,1.96,0,3.19-.62,4.54-1.29.34-.11.62-.17.9-.17,1.06,0,1.9.9,1.9,1.9,0,2.63-5.15,3.42-7.78,3.42-7,0-10.02-4.54-10.02-10.3v-8.46Z"/>
<path class="cls-1" d="M736.32,171.12c-1.23,0-2.18-.95-2.18-2.18v-37.02c0-1.12.9-2.18,2.13-2.18s2.18.95,2.18,2.18v12.32c1.9-1.46,4.31-2.35,7-2.35,5.99,0,8.74,3.86,8.74,9.3v17.75c0,1.23-1.01,2.18-2.18,2.18s-2.18-.95-2.18-2.18v-17.7c0-3.42-1.4-5.26-4.87-5.26-2.3,0-4.87,1.01-6.5,3.19v19.77c0,1.23-.9,2.18-2.13,2.18Z"/>
<path class="cls-1" d="M771.6,141.88c2.91,0,7.45.78,7.45,3.14,0,1.06-.84,2.02-2.02,2.02-.39,0-.67-.11-1.01-.28-1.18-.56-2.24-1.01-4.26-1.01-3.42,0-4.98,1.34-4.98,3.92,0,1.74.73,2.63,2.63,3.58,3.86,1.9,10.42,2.41,10.42,9.69,0,5.04-3.19,8.18-9.41,8.18-2.86,0-8.23-.67-8.23-3.25,0-.95.73-2.02,2.07-2.02.34,0,.39.06.56.11,1.57.62,3.36,1.29,5.6,1.29,3.08,0,5.15-1.23,5.15-4.14,0-2.02-.95-2.86-2.07-3.47-3.53-1.9-10.98-2.3-10.98-9.69,0-4.82,3.14-8.06,9.07-8.06Z"/>
<path class="cls-1" d="M802.29,165.68c.11-.06.39-.17.78-.17,1.12,0,1.96.95,1.96,1.96,0,2.74-6.05,3.64-9.07,3.64-6.33,0-9.97-4.03-9.97-9.3v-10.47c0-5.38,3.58-9.46,10.25-9.46,6.22,0,9.3,3.58,9.3,9.13,0,5.15-2.8,8.29-9.46,8.29-1.68,0-3.75-.17-5.71-.5v3.3c0,3.14,2.07,5.15,6.1,5.15,2.35,0,3.86-.67,5.82-1.57ZM796.13,145.86c-3.64,0-5.77,1.96-5.77,5.1v4.26c2.07.28,4.76.39,5.88.39,4.26,0,4.98-2.3,4.98-4.59,0-3.64-1.85-5.15-5.1-5.15Z"/>
<path class="cls-1" d="M817.69,168.93c0,1.18-.73,2.13-2.18,2.13s-2.18-.95-2.18-2.13v-19.04c0-3.08-.5-3.08-1.01-3.92-.11-.22-.39-.56-.39-1.12,0-1.12.9-1.96,2.07-1.96,1.01,0,1.74.67,2.35,1.62,2.46-1.74,5.32-2.52,8.01-2.52,5.94,0,8.9,4.26,8.9,10.58v16.35c0,1.18-.78,2.13-2.18,2.13s-2.18-.95-2.18-2.13v-16.35c0-4.26-1.79-6.66-5.15-6.66-2.18,0-4.31.9-6.22,2.35.11.62.17,1.18.17,1.62v19.04Z"/>
</g>
<g>
<path class="cls-1" d="M298.97,105.13h-6.33c-2.35,0-4.37-1.85-4.37-4.03v-31.47c0-2.18,2.02-4.03,4.37-4.03h6.33c12.66,0,17.02,6.94,17.02,14.73v9.63c0,7.9-4.31,15.18-17.02,15.18ZM308.15,80.32c0-4.26-2.18-7.62-8.23-7.62h-3.81v25.31h3.81c6.16,0,8.23-3.64,8.23-8.06v-9.63Z"/>
<path class="cls-1" d="M329.71,94.1v1.85c0,1.9,1.46,3.14,4.48,3.14,1.79,0,3.25-.45,5.04-1.23.22-.11.73-.34,1.68-.34,2.69,0,3.47,1.79,3.47,3.08,0,3.92-7.17,4.93-10.75,4.93-7.39,0-11.65-4.26-11.65-9.8v-9.35c0-5.66,4.14-9.97,11.93-9.97,7.34,0,10.98,3.86,10.98,9.52s-3.3,8.51-10.86,8.51c-1.46,0-2.69-.11-4.31-.34ZM333.85,82.39c-2.69,0-4.14,1.4-4.14,3.64v2.41c1.79.17,3.47.22,4.03.22,2.13,0,3.47-.73,3.47-3.14,0-2.24-1.18-3.14-3.36-3.14Z"/>
<path class="cls-1" d="M359.17,84.07c.06.39.11.73.11,1.06v16.91c0,1.96-1.68,3.47-3.86,3.47s-3.86-1.51-3.86-3.47v-16.91c0-1.29-.17-1.96-.62-2.63-.45-.56-.78-1.01-.78-1.85,0-2.02,1.79-3.36,3.81-3.36,1.18,0,2.18.73,2.97,1.74,2.58-1.51,5.15-2.41,6.94-2.41,3.53,0,4.59,1.46,4.59,3.36,0,2.18-1.79,3.64-3.92,3.53-2.41-.11-3.42-.22-5.38.56Z"/>
<path class="cls-1" d="M405.48,83.74l12.26,16.24c.56.73.62,1.34.62,1.9,0,1.62-1.4,3.75-3.81,3.75-1.29,0-2.41-.5-3.25-1.62l-10.86-14.56-1.01,1.12v11.42c0,1.96-1.79,3.64-3.92,3.64s-3.92-1.68-3.92-3.64v-33.26c0-1.9,1.74-3.58,3.92-3.58s3.92,1.68,3.92,3.58v10.64l11.09-12.6c.67-.84,1.96-1.62,3.19-1.62,1.68,0,3.86,1.62,3.86,3.58,0,.84-.06,1.4-.95,2.41l-11.14,12.6Z"/>
<path class="cls-1" d="M428.33,72.99c-2.46,0-4.37-1.79-4.37-3.92s1.9-3.92,4.37-3.92,4.37,1.79,4.37,3.92-1.9,3.92-4.37,3.92ZM432.19,102.05c0,1.96-1.68,3.47-3.86,3.47s-3.86-1.51-3.86-3.47v-22.06c0-1.85,1.68-3.42,3.86-3.42s3.86,1.57,3.86,3.42v22.06Z"/>
<path class="cls-1" d="M448.99,102.05c0,1.9-1.29,3.42-3.86,3.42s-3.86-1.51-3.86-3.42v-16.97c0-1.85-.22-2.02-.45-2.35-.78-1.06-.95-1.46-.95-2.18,0-1.85,1.62-3.25,3.75-3.25,1.18,0,2.18.62,2.97,1.62,2.52-1.68,5.15-2.41,7.39-2.41,7.06,0,10.58,4.37,10.58,10.92v14.62c0,1.9-1.4,3.42-3.86,3.42s-3.86-1.51-3.86-3.42v-14.62c0-2.8-1.4-4.54-3.64-4.54-1.18,0-2.58.39-4.31,1.18.06.39.11.73.11,1.06v16.91Z"/>
<path class="cls-1" d="M487.52,77.69v-9.58c0-1.9,1.4-3.42,3.86-3.42s3.86,1.57,3.86,3.42v29.79c0,.84.34,1.23,1.06,1.96.56.56.78,1.12.78,1.9,0,2.24-1.9,3.42-3.64,3.42-1.4,0-2.52-.9-3.36-2.13-2.74,1.51-5.04,2.46-7.9,2.46-6.5,0-10.25-4.42-10.25-10.98v-6.33c0-6.66,2.63-11.59,10.64-11.59,1.74,0,2.91.22,4.93,1.06ZM487.52,83.74c-1.74-.5-3.14-.73-4.09-.73-2.74,0-3.75,1.74-3.75,5.21v6.33c0,2.91,1.23,4.48,3.64,4.48,1.18,0,2.58-.39,4.31-1.18-.06-.39-.11-.73-.11-1.06v-13.05Z"/>
<path class="cls-1" d="M510.98,94.1v1.85c0,1.9,1.46,3.14,4.48,3.14,1.79,0,3.25-.45,5.04-1.23.22-.11.73-.34,1.68-.34,2.69,0,3.47,1.79,3.47,3.08,0,3.92-7.17,4.93-10.75,4.93-7.39,0-11.65-4.26-11.65-9.8v-9.35c0-5.66,4.14-9.97,11.93-9.97,7.34,0,10.98,3.86,10.98,9.52s-3.3,8.51-10.86,8.51c-1.46,0-2.69-.11-4.31-.34ZM515.12,82.39c-2.69,0-4.14,1.4-4.14,3.64v2.41c1.79.17,3.47.22,4.03.22,2.13,0,3.47-.73,3.47-3.14,0-2.24-1.18-3.14-3.36-3.14Z"/>
<path class="cls-1" d="M540.44,84.07c.06.39.11.73.11,1.06v16.91c0,1.96-1.68,3.47-3.86,3.47s-3.86-1.51-3.86-3.47v-16.91c0-1.29-.17-1.96-.62-2.63-.45-.56-.78-1.01-.78-1.85,0-2.02,1.79-3.36,3.81-3.36,1.18,0,2.18.73,2.97,1.74,2.58-1.51,5.15-2.41,6.94-2.41,3.53,0,4.59,1.46,4.59,3.36,0,2.18-1.79,3.64-3.92,3.53-2.41-.11-3.42-.22-5.38.56Z"/>
<path class="cls-1" d="M565.3,76.4c3.64,0,9.07.9,9.07,4.76,0,2.41-2.13,2.86-3.3,2.86-1.96,0-2.97-1.18-5.71-1.18-2.13,0-3.25.73-3.25,2.02,0,.78.45,1.34,1.68,1.85,4.03,1.74,11.37,2.52,11.37,9.97,0,5.43-3.86,8.85-10.92,8.85-3.58,0-10.08-.9-10.08-4.37,0-1.62,1.46-3.3,3.64-3.3,1.85,0,3.58,1.23,6.33,1.23,2.02,0,3.42-.67,3.42-2.24,0-1.01-.56-1.46-1.18-1.79-3.64-2.02-11.87-2.18-11.87-9.97,0-5.15,3.64-8.68,10.81-8.68Z"/>
<path class="cls-1" d="M580.75,87.27c0-5.99,3.58-10.7,11.59-10.7,3.3,0,9.58.78,9.58,4.87,0,1.34-.67,2.86-3.47,2.86-.56,0-1.06-.06-1.85-.34-1.18-.45-2.07-.95-3.81-.95-3.08,0-4.31,1.62-4.31,4.26v7.56c0,2.63,1.23,4.26,4.31,4.26,1.74,0,2.69-.5,3.81-.95.56-.22.95-.34,1.74-.34,2.91,0,3.58,1.57,3.58,2.86,0,4.03-6.27,4.87-9.63,4.87-7.9,0-11.54-4.7-11.54-10.7v-7.56Z"/>
<path class="cls-1" d="M615.59,102.05c0,1.96-1.62,3.47-3.81,3.47s-3.86-1.51-3.86-3.47v-34.16c0-1.79,1.62-3.47,3.81-3.47s3.86,1.51,3.86,3.47v10.02c1.57-.95,3.42-1.51,5.32-1.51,7.17,0,10.42,4.03,10.42,9.8v15.85c0,1.96-1.79,3.47-3.86,3.47-2.18,0-3.86-1.51-3.86-3.47v-16.35c0-2.13-.9-3.19-3.19-3.19-1.57,0-3.53.62-4.82,1.85v17.7Z"/>
<path class="cls-1" d="M650.14,105.52c-6.78,0-10.42-4.42-10.42-10.98v-14.62c0-1.9,1.4-3.36,3.86-3.36s3.86,1.46,3.86,3.36v14.62c0,2.86,1.18,4.48,3.64,4.48,1.18,0,2.58-.39,4.31-1.18-.06-.39-.11-.73-.11-1.06v-16.86c0-1.9,1.29-3.36,3.86-3.36s3.86,1.46,3.86,3.36v17.98c0,.84.34,1.23,1.06,1.96.56.56.78,1.12.78,1.9,0,2.24-1.9,3.42-3.58,3.42-1.46,0-2.58-.9-3.42-2.13-2.63,1.85-5.21,2.46-7.73,2.46Z"/>
<path class="cls-1" d="M671.64,71.75c0-1.9,1.4-3.42,3.86-3.42s3.86,1.57,3.86,3.42v6.22h4.14c2.07,0,3.7,1.46,3.7,3.36s-1.62,3.3-3.7,3.3h-4.14v8.46c0,4.14.34,5.6,3.02,5.6.5,0,.84-.06.95-.06,3.53,0,4.59.95,4.59,3.02,0,3.25-2.58,3.7-5.38,3.7-8.68,0-10.92-4.26-10.92-11.87v-21.73Z"/>
<path class="cls-1" d="M714.54,82.62l-12.1,15.9h9.24c2.13,0,3.75,1.46,3.75,3.3s-1.62,3.3-3.75,3.3h-15.51c-2.07,0-3.92-1.57-3.92-3.58,0-.67.11-1.34.67-2.07l12.15-16.02h-8.57c-2.07,0-3.7-1.46-3.7-3.3s1.62-3.3,3.7-3.3h14.84c2.13,0,3.98,1.29,3.98,3.47,0,.73-.22,1.57-.78,2.3Z"/>
<path class="cls-1" d="M720.75,68.12c0-1.79,1.4-3.42,3.86-3.42s3.86,1.62,3.86,3.42v9.86c1.79-1.01,3.47-1.57,6.1-1.57,7.11,0,10.02,4.03,10.02,9.3v9.41c0,5.88-3.98,10.42-11.93,10.42s-11.93-4.14-11.93-9.41v-28ZM728.48,96.11c0,2.07,1.62,3.36,4.26,3.36,2.8,0,4.14-1.46,4.14-3.81v-10.25c0-2.02-1.12-3.19-3.86-3.19-1.79,0-3.36.5-4.54,1.57v12.32Z"/>
<path class="cls-1" d="M762.3,105.52c-6.78,0-10.42-4.42-10.42-10.98v-14.62c0-1.9,1.4-3.36,3.86-3.36s3.86,1.46,3.86,3.36v14.62c0,2.86,1.18,4.48,3.64,4.48,1.18,0,2.58-.39,4.31-1.18-.06-.39-.11-.73-.11-1.06v-16.86c0-1.9,1.29-3.36,3.86-3.36s3.86,1.46,3.86,3.36v17.98c0,.84.34,1.23,1.06,1.96.56.56.78,1.12.78,1.9,0,2.24-1.9,3.42-3.58,3.42-1.46,0-2.58-.9-3.42-2.13-2.63,1.85-5.21,2.46-7.73,2.46Z"/>
<path class="cls-1" d="M791.7,102.05c0,1.9-1.29,3.42-3.86,3.42s-3.86-1.51-3.86-3.42v-16.97c0-1.85-.22-2.02-.45-2.35-.78-1.06-.95-1.46-.95-2.18,0-1.85,1.62-3.25,3.75-3.25,1.18,0,2.18.62,2.97,1.62,2.52-1.68,5.15-2.41,7.39-2.41,7.06,0,10.58,4.37,10.58,10.92v14.62c0,1.9-1.4,3.42-3.86,3.42s-3.86-1.51-3.86-3.42v-14.62c0-2.8-1.4-4.54-3.64-4.54-1.18,0-2.58.39-4.31,1.18.06.39.11.73.11,1.06v16.91Z"/>
<path class="cls-1" d="M830.23,77.69v-9.58c0-1.9,1.4-3.42,3.86-3.42s3.86,1.57,3.86,3.42v29.79c0,.84.34,1.23,1.06,1.96.56.56.78,1.12.78,1.9,0,2.24-1.9,3.42-3.64,3.42-1.4,0-2.52-.9-3.36-2.13-2.74,1.51-5.04,2.46-7.9,2.46-6.5,0-10.25-4.42-10.25-10.98v-6.33c0-6.66,2.63-11.59,10.64-11.59,1.74,0,2.91.22,4.93,1.06ZM830.23,83.74c-1.74-.5-3.14-.73-4.09-.73-2.74,0-3.75,1.74-3.75,5.21v6.33c0,2.91,1.23,4.48,3.64,4.48,1.18,0,2.58-.39,4.31-1.18-.06-.39-.11-.73-.11-1.06v-13.05Z"/>
</g>
<path class="cls-1" d="M117.98,8.22c60.52,0,109.77,49.23,109.77,109.75s-49.25,109.75-109.77,109.75S8.23,178.5,8.23,117.98,57.46,8.22,117.98,8.22M117.98,0C52.82,0,0,52.82,0,117.98s52.82,117.98,117.98,117.98,117.99-52.81,117.99-117.98S183.14,0,117.98,0"/>
<path class="cls-1" d="M178.58,85.49c-.31-.18-29.67-18.54-52.35-43-2.2-2.38-5.26-3.45-8.26-3.3-3-.15-6.03.91-8.24,3.3-22.69,24.46-52.06,42.82-52.35,43-4.92,3.06-6.43,9.52-3.37,14.45,1.98,3.21,5.41,4.97,8.91,4.97,1.89,0,3.8-.51,5.51-1.58,1.18-.73,26.45-15.2,49.53-37.92,23.09,22.72,48.36,37.19,49.53,37.92,1.72,1.07,3.63,1.58,5.52,1.58,3.51,0,6.94-1.76,8.93-4.97,3.05-4.93,1.54-11.39-3.37-14.45"/>
<path class="cls-1" d="M102.59,102.6c0-8.49,6.87-15.35,15.37-15.35s15.35,6.84,15.35,15.35-6.88,15.35-15.35,15.35-15.37-6.87-15.37-15.35"/>
<path class="cls-1" d="M154.84,117.58c-3.79-4.38-10.41-4.88-14.8-1.09-6.12,5.28-13.94,8.19-22.04,8.2-8.09-.02-15.91-2.92-22.03-8.2-4.38-3.78-11-3.29-14.8,1.09-3.78,4.39-3.29,11.01,1.09,14.8,6.56,5.67,14.36,9.61,22.67,11.66l-13.56,38.67c-1.91,5.47.97,11.45,6.44,13.37,1.14.4,2.32.59,3.47.59,4.33,0,8.39-2.7,9.9-7.02l6.82-19.45,6.82,19.45c1.52,4.32,5.58,7.02,9.91,7.02,1.15,0,2.32-.19,3.47-.59,5.47-1.92,8.35-7.9,6.43-13.37l-13.55-38.67c8.31-2.05,16.1-5.99,22.67-11.66,4.39-3.79,4.88-10.41,1.09-14.8"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 17 KiB

+67
View File
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 31 KiB

+34
View File
@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace Mummert\SurveyBundle\Contao\Manager;
use Contao\CoreBundle\ContaoCoreBundle;
use Contao\ManagerPlugin\Bundle\BundlePluginInterface;
use Contao\ManagerPlugin\Bundle\Config\BundleConfig;
use Contao\ManagerPlugin\Bundle\Parser\ParserInterface;
use Contao\ManagerPlugin\Routing\RoutingPluginInterface;
use Mummert\SurveyBundle\SurveyBundle;
use Symfony\Component\Config\Loader\LoaderResolverInterface;
use Symfony\Component\HttpKernel\KernelInterface;
use Symfony\Component\Routing\RouteCollection;
class Plugin implements BundlePluginInterface, RoutingPluginInterface
{
public function getBundles(ParserInterface $parser): iterable
{
return [
BundleConfig::create(SurveyBundle::class)
->setLoadAfter([ContaoCoreBundle::class]),
];
}
public function getRouteCollection(LoaderResolverInterface $resolver, KernelInterface $kernel): ?RouteCollection
{
return $resolver
->resolve(__DIR__.'/../../Resources/config/routes.yaml')
?->load(__DIR__.'/../../Resources/config/routes.yaml')
;
}
}
@@ -0,0 +1,273 @@
<?php
declare(strict_types=1);
namespace Mummert\SurveyBundle\Controller\FrontendModule;
use Contao\Config;
use Contao\CoreBundle\Controller\FrontendModule\AbstractFrontendModuleController;
use Contao\CoreBundle\DependencyInjection\Attribute\AsFrontendModule;
use Contao\CoreBundle\Twig\FragmentTemplate;
use Contao\FrontendUser;
use Contao\ModuleModel;
use Contao\PageModel;
use Mummert\SurveyBundle\Form\Model\SurveyConditionData;
use Mummert\SurveyBundle\Form\Model\SurveyEditorData;
use Mummert\SurveyBundle\Form\Model\SurveyQuestionData;
use Mummert\SurveyBundle\Form\SurveyConditionEditorType;
use Mummert\SurveyBundle\Form\SurveyEditorType;
use Mummert\SurveyBundle\Form\SurveyQuestionEditorType;
use Mummert\SurveyBundle\Model\SurveyModel;
use Mummert\SurveyBundle\Repository\SurveyConditionRepository;
use Mummert\SurveyBundle\Repository\SurveyEditorRepository;
use Mummert\SurveyBundle\Repository\SurveyQuestionRepository;
use Mummert\SurveyBundle\Repository\SurveyRepository;
use Mummert\SurveyBundle\Security\SurveyEditorVoter;
use Mummert\SurveyBundle\Service\SurveyEditorService;
use Mummert\SurveyBundle\Service\SurveySubmissionService;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
#[AsFrontendModule(type: 'member_survey_edit', category: 'survey', template: 'frontend/member_survey_edit')]
final class MemberSurveyEditController extends AbstractFrontendModuleController
{
public function __construct(
private readonly SurveyRepository $surveyRepository,
private readonly SurveyQuestionRepository $surveyQuestionRepository,
private readonly SurveyConditionRepository $surveyConditionRepository,
private readonly SurveyEditorRepository $surveyEditorRepository,
private readonly SurveyEditorService $surveyEditorService,
private readonly SurveySubmissionService $surveySubmissionService,
) {
}
protected function getResponse(FragmentTemplate $template, ModuleModel $model, Request $request): Response
{
$user = $this->getUser();
if (!$user instanceof FrontendUser) {
$template->set('loginRequired', true);
$template->set('backUrl', $this->resolveListUrl($model));
return $template->getResponse();
}
$survey = $this->resolveSurvey($request, $user);
if ($survey instanceof SurveyModel && $request->isMethod('POST') && $request->request->has('_survey_action')) {
return $this->handleAction($survey, $request, $model);
}
$questionChoices = $survey instanceof SurveyModel ? $this->buildQuestionChoices((int) $survey->id) : [];
$surveyForm = $this->createNamed('survey_meta', SurveyEditorType::class, $survey instanceof SurveyModel ? SurveyEditorData::fromModel($survey) : new SurveyEditorData());
$surveyForm->handleRequest($request);
if ($surveyForm->isSubmitted() && $surveyForm->isValid()) {
try {
if ($survey instanceof SurveyModel) {
$this->surveyEditorService->updateSurvey($survey, (int) $user->id, $surveyForm->getData());
$this->addFlash('success', 'Die Umfrage wurde gespeichert.');
return new RedirectResponse($this->buildEditUrl($model, (int) $survey->id));
}
$createdSurvey = $this->surveyEditorService->createSurvey((int) $user->id, $surveyForm->getData());
$this->addFlash('success', 'Die Umfrage wurde angelegt.');
return new RedirectResponse($this->buildEditUrl($model, (int) $createdSurvey->id));
} catch (\Throwable $exception) {
$this->addFlash('error', $exception->getMessage());
}
}
if (!$survey instanceof SurveyModel) {
$template->set('loginRequired', false);
$template->set('createMode', true);
$template->set('surveyForm', $surveyForm->createView());
$template->set('backUrl', $this->resolveListUrl($model));
$template->set('publicUrl', null);
$template->set('survey', null);
$template->set('questionForm', null);
$template->set('conditionForm', null);
$template->set('questions', []);
$template->set('conditions', []);
$template->set('editors', []);
$template->set('submissions', []);
$template->set('activeQuestionId', 0);
$template->set('activeConditionId', 0);
return $template->getResponse();
}
$activeQuestion = $this->resolveActiveQuestion($request, (int) $survey->id);
$activeCondition = $this->resolveActiveCondition($request, (int) $survey->id);
$questionForm = $this->createNamed('survey_question', SurveyQuestionEditorType::class, $activeQuestion ? SurveyQuestionData::fromModel($activeQuestion) : new SurveyQuestionData(), [
'question_choices' => $this->buildQuestionChoices((int) $survey->id),
]);
$conditionForm = $this->createNamed('survey_condition', SurveyConditionEditorType::class, $activeCondition ? SurveyConditionData::fromModel($activeCondition) : new SurveyConditionData(), [
'question_choices' => $questionChoices,
]);
$questionForm->handleRequest($request);
$conditionForm->handleRequest($request);
if ($questionForm->isSubmitted() && $questionForm->isValid()) {
try {
$this->surveyEditorService->saveQuestion($survey, $questionForm->getData(), $activeQuestion ? (int) $activeQuestion->id : 0);
$this->addFlash('success', $activeQuestion ? 'Die Frage wurde aktualisiert.' : 'Die Frage wurde angelegt.');
return new RedirectResponse($this->buildEditUrl($model, (int) $survey->id));
} catch (\Throwable $exception) {
$this->addFlash('error', $exception->getMessage());
}
}
if ($conditionForm->isSubmitted() && $conditionForm->isValid()) {
try {
$this->surveyEditorService->saveCondition($survey, $conditionForm->getData(), $activeCondition ? (int) $activeCondition->id : 0);
$this->addFlash('success', $activeCondition ? 'Die Bedingung wurde aktualisiert.' : 'Die Bedingung wurde angelegt.');
return new RedirectResponse($this->buildEditUrl($model, (int) $survey->id));
} catch (\Throwable $exception) {
$this->addFlash('error', $exception->getMessage());
}
}
$template->set('loginRequired', false);
$template->set('createMode', false);
$template->set('survey', $survey);
$template->set('surveyForm', $surveyForm->createView());
$template->set('questionForm', $questionForm->createView());
$template->set('conditionForm', $conditionForm->createView());
$template->set('questions', $this->surveyQuestionRepository->findAllBySurvey((int) $survey->id));
$template->set('conditions', $this->surveyConditionRepository->findOverviewBySurvey((int) $survey->id));
$template->set('editors', $this->surveyEditorRepository->findEditorsBySurvey((int) $survey->id));
$template->set('submissions', $this->surveySubmissionService->getSubmissionOverview($survey));
$template->set('activeQuestionId', $activeQuestion ? (int) $activeQuestion->id : 0);
$template->set('activeConditionId', $activeCondition ? (int) $activeCondition->id : 0);
$template->set('backUrl', $this->resolveListUrl($model));
$template->set('publicUrl', $this->resolveReaderUrl($model, (string) $survey->alias));
return $template->getResponse();
}
private function resolveSurvey(Request $request, FrontendUser $user): ?SurveyModel
{
$surveyId = (int) $request->query->get('survey', 0);
if ($surveyId <= 0) {
return null;
}
$survey = $this->surveyRepository->findById($surveyId);
if (!$survey instanceof SurveyModel) {
throw new \RuntimeException('Die Umfrage wurde nicht gefunden.');
}
$this->denyAccessUnlessGranted(SurveyEditorVoter::EDIT, $survey);
return $survey;
}
private function handleAction(SurveyModel $survey, Request $request, ModuleModel $model): Response
{
$action = (string) $request->request->get('_survey_action');
$itemId = (int) $request->request->get('item_id', 0);
$token = (string) $request->request->get('_token');
if (!$this->isCsrfTokenValid($action.'-'.$itemId, $token)) {
throw new \RuntimeException('Ungueltiger CSRF-Token.');
}
try {
if ('delete-question' === $action) {
$this->surveyEditorService->deleteQuestion($survey, $itemId);
$this->addFlash('success', 'Die Frage wurde geloescht.');
}
if ('delete-condition' === $action) {
$this->surveyEditorService->deleteCondition($survey, $itemId);
$this->addFlash('success', 'Die Bedingung wurde geloescht.');
}
} catch (\Throwable $exception) {
$this->addFlash('error', $exception->getMessage());
}
return new RedirectResponse($this->buildEditUrl($model, (int) $survey->id));
}
private function resolveActiveQuestion(Request $request, int $surveyId): mixed
{
$questionId = (int) $request->query->get('question', 0);
return $questionId > 0 ? $this->surveyQuestionRepository->findByIdForSurvey($surveyId, $questionId) : null;
}
private function resolveActiveCondition(Request $request, int $surveyId): mixed
{
$conditionId = (int) $request->query->get('condition', 0);
$condition = $conditionId > 0 ? $this->surveyConditionRepository->findById($conditionId) : null;
if (null === $condition || (int) $condition->pid !== $surveyId) {
return null;
}
return $condition;
}
/**
* @return array<int, string>
*/
private function buildQuestionChoices(int $surveyId, int $excludeQuestionId = 0): array
{
$choices = [];
foreach ($this->surveyQuestionRepository->findAllBySurvey($surveyId) as $question) {
if ($excludeQuestionId > 0 && (int) $question->id === $excludeQuestionId) {
continue;
}
$choices[(int) $question->id] = sprintf('#%d %s', (int) $question->id, (string) $question->question);
}
return $choices;
}
private function resolveListUrl(ModuleModel $model): string
{
$page = $this->resolvePage((int) ($model->surveyListPage ?? 0)) ?? $this->getPageModel();
return $page instanceof PageModel ? $this->generateContentUrl($page) : '/';
}
private function resolveReaderUrl(ModuleModel $model, string $publicAlias): ?string
{
$page = $this->resolvePage((int) $this->getContaoAdapter(Config::class)->get('surveyReaderPage'));
if (!$page instanceof PageModel) {
return null;
}
return $this->generateContentUrl($page, ['auto_item' => $publicAlias]);
}
private function buildEditUrl(ModuleModel $model, int $surveyId): string
{
$page = $this->getPageModel();
return $page instanceof PageModel ? $this->generateContentUrl($page, ['survey' => $surveyId]) : $this->resolveListUrl($model);
}
private function resolvePage(int $pageId): ?PageModel
{
if ($pageId <= 0) {
return null;
}
$page = $this->getContaoAdapter(PageModel::class)->findById($pageId);
return $page instanceof PageModel ? $page : null;
}
}
@@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
namespace Mummert\SurveyBundle\Controller\FrontendModule;
use Contao\Config;
use Contao\CoreBundle\Controller\FrontendModule\AbstractFrontendModuleController;
use Contao\CoreBundle\DependencyInjection\Attribute\AsFrontendModule;
use Contao\CoreBundle\Twig\FragmentTemplate;
use Contao\FrontendUser;
use Contao\ModuleModel;
use Contao\PageModel;
use Mummert\SurveyBundle\Repository\SurveyRepository;
use Mummert\SurveyBundle\Service\SurveySubmissionService;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
#[AsFrontendModule(type: 'member_survey_list', category: 'survey', template: 'frontend/member_survey_list')]
final class MemberSurveyListController extends AbstractFrontendModuleController
{
public function __construct(
private readonly SurveyRepository $surveyRepository,
private readonly SurveySubmissionService $surveySubmissionService,
) {
}
protected function getResponse(FragmentTemplate $template, ModuleModel $model, Request $request): Response
{
$user = $this->getUser();
if (!$user instanceof FrontendUser) {
$template->set('loginRequired', true);
$template->set('surveys', []);
$template->set('createUrl', null);
return $template->getResponse();
}
$editPage = $this->resolvePage((int) ($model->surveyEditPage ?? 0));
$readerPage = $this->resolvePage((int) $this->getContaoAdapter(Config::class)->get('surveyReaderPage'));
$resultsPage = $this->resolvePage((int) $this->getContaoAdapter(Config::class)->get('surveyResultsPage'));
$items = [];
foreach ($this->surveyRepository->findEditableByMember((int) $user->id) as $survey) {
$surveyId = (int) $survey['id'];
$isCompletedInCurrentSession = $this->surveySubmissionService->hasFinishedSubmissionForSurvey($surveyId, $request->getSession());
$items[] = [
'id' => $surveyId,
'title' => (string) $survey['title'],
'description' => (string) ($survey['description'] ?? ''),
'isLocked' => !empty($survey['isLocked']),
'published' => !empty($survey['published']),
'questionCount' => (int) ($survey['questionCount'] ?? 0),
'answerCount' => (int) ($survey['answerCount'] ?? 0),
'editUrl' => $editPage instanceof PageModel ? $this->generateContentUrl($editPage, ['survey' => $surveyId]) : null,
'publicUrl' => $readerPage instanceof PageModel ? $this->generateContentUrl($readerPage, ['auto_item' => (string) $survey['alias']]) : null,
'resultsUrl' => !$isCompletedInCurrentSession && $resultsPage instanceof PageModel ? $this->generateContentUrl($resultsPage, ['auto_item' => (string) $survey['alias']]) : null,
];
}
$template->set('loginRequired', false);
$template->set('surveys', $items);
$template->set('createUrl', $editPage instanceof PageModel ? $this->generateContentUrl($editPage) : null);
return $template->getResponse();
}
private function resolvePage(int $pageId): ?PageModel
{
if ($pageId <= 0) {
return null;
}
$page = $this->getContaoAdapter(PageModel::class)->findById($pageId);
return $page instanceof PageModel ? $page : null;
}
}
@@ -0,0 +1,126 @@
<?php
declare(strict_types=1);
namespace Mummert\SurveyBundle\Controller\FrontendModule;
use Contao\CoreBundle\Controller\FrontendModule\AbstractFrontendModuleController;
use Contao\CoreBundle\DependencyInjection\Attribute\AsFrontendModule;
use Contao\CoreBundle\Twig\FragmentTemplate;
use Contao\ModuleModel;
use Mummert\SurveyBundle\Form\Model\SurveyAnswerData;
use Mummert\SurveyBundle\Form\SurveyQuestionAnswerType;
use Mummert\SurveyBundle\Repository\SurveyRepository;
use Mummert\SurveyBundle\Service\SurveyFlowService;
use Mummert\SurveyBundle\Service\SurveySubmissionService;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
#[AsFrontendModule(type: 'show_survey', category: 'survey', template: 'frontend/show_survey')]
final class ShowSurveyController extends AbstractFrontendModuleController
{
public function __construct(
private readonly SurveyRepository $surveyRepository,
private readonly SurveyFlowService $surveyFlowService,
private readonly SurveySubmissionService $surveySubmissionService,
) {
}
protected function getResponse(FragmentTemplate $template, ModuleModel $model, Request $request): Response
{
$publicAlias = $this->resolvePublicAlias($request);
if ('' === $publicAlias) {
$template->set('errorMessage', 'Es wurde kein oeffentlicher Link auf der Anzeigeseite gefunden.');
$template->set('question', null);
$template->set('surveyForm', null);
$template->set('isComplete', false);
$template->set('answers', []);
$template->set('survey', null);
$template->set('progress', ['current' => 0, 'total' => 0, 'percentage' => 0]);
return $template->getResponse();
}
$survey = $this->surveyRepository->findByPublicAlias($publicAlias, true);
if (null === $survey) {
$template->set('errorMessage', 'Die Umfrage wurde nicht gefunden oder ist nicht veroeffentlicht.');
$template->set('question', null);
$template->set('surveyForm', null);
$template->set('isComplete', false);
$template->set('answers', []);
$template->set('survey', null);
$template->set('progress', ['current' => 0, 'total' => 0, 'percentage' => 0]);
return $template->getResponse();
}
if ($htmlHeadBag = $this->getHtmlHeadBag()) {
$htmlHeadBag->setTitle((string) $survey->title);
}
$submission = $this->surveySubmissionService->resolveActiveSubmission($survey, $request->getSession(), null);
$question = $this->surveyFlowService->resolveCurrentQuestion($survey, $submission);
if (null === $question && '1' !== (string) $submission->isFinished) {
$template->set('errorMessage', 'Diese Umfrage ist noch nicht vollstaendig eingerichtet. Es fehlt eine gueltige erste Frage oder eine verknuepfte Folgefrage ist nicht mehr verfuegbar.');
$template->set('question', null);
$template->set('surveyForm', null);
$template->set('isComplete', false);
$template->set('answers', []);
$template->set('survey', $survey);
$template->set('progress', ['current' => 0, 'total' => 0, 'percentage' => 0]);
return $template->getResponse();
}
if (null === $question) {
$template->set('errorMessage', null);
$template->set('survey', $survey);
$template->set('progress', $this->surveyFlowService->getProgress($survey, null));
$template->set('question', null);
$template->set('surveyForm', null);
$template->set('isComplete', true);
$template->set('answers', $this->surveySubmissionService->getAnswersForSubmission($submission));
return $template->getResponse();
}
$answerData = new SurveyAnswerData();
$form = $this->createForm(SurveyQuestionAnswerType::class, $answerData, ['question' => $question]);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$normalizedAnswer = $this->surveySubmissionService->storeAnswer($survey, $submission, $question, $answerData->answer);
$nextQuestion = $this->surveyFlowService->determineNextQuestion($survey, $question, $normalizedAnswer);
$this->surveySubmissionService->advance($submission, $nextQuestion);
return new RedirectResponse($request->getUri());
}
$template->set('errorMessage', null);
$template->set('survey', $survey);
$template->set('progress', $this->surveyFlowService->getProgress($survey, $question));
$template->set('question', $question);
$template->set('surveyForm', $form->createView());
$template->set('isComplete', false);
$template->set('answers', []);
return $template->getResponse();
}
private function resolvePublicAlias(Request $request): string
{
$candidate = $request->attributes->get('auto_item');
if (\is_string($candidate) && '' !== trim($candidate)) {
return trim($candidate);
}
$queryValue = (string) $request->query->get('auto_item', '');
return trim($queryValue);
}
}
@@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
namespace Mummert\SurveyBundle\Controller\FrontendModule;
use Contao\Config;
use Contao\CoreBundle\Controller\FrontendModule\AbstractFrontendModuleController;
use Contao\CoreBundle\DependencyInjection\Attribute\AsFrontendModule;
use Contao\CoreBundle\Twig\FragmentTemplate;
use Contao\ModuleModel;
use Mummert\SurveyBundle\Repository\SurveyRepository;
use Mummert\SurveyBundle\Service\SurveyResultsViewService;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
#[AsFrontendModule(type: 'survey_results', category: 'survey', template: 'frontend/show_survey_results')]
final class ShowSurveyResultsController extends AbstractFrontendModuleController
{
public function __construct(
private readonly SurveyRepository $surveyRepository,
private readonly SurveyResultsViewService $surveyResultsViewService,
private readonly UrlGeneratorInterface $urlGenerator,
) {
}
protected function getResponse(FragmentTemplate $template, ModuleModel $model, Request $request): Response
{
$publicAlias = $this->resolvePublicAlias($request);
if ('' === $publicAlias) {
$template->set('errorMessage', 'Es wurde kein Umfrage-Link auf der Ergebnisseite gefunden.');
$template->set('survey', null);
$template->set('questionResults', []);
$template->set('completedSubmissionCount', 0);
return $template->getResponse();
}
$survey = $this->surveyRepository->findByPublicAlias($publicAlias, false);
if (null === $survey) {
$template->set('errorMessage', 'Die Umfrage wurde nicht gefunden.');
$template->set('survey', null);
$template->set('questionResults', []);
$template->set('completedSubmissionCount', 0);
return $template->getResponse();
}
if ($htmlHeadBag = $this->getHtmlHeadBag()) {
$htmlHeadBag->setTitle((string) $survey->title.' - Ergebnisse');
}
$resultsData = $this->surveyResultsViewService->buildForSurvey($survey);
$template->set('errorMessage', null);
$template->set('survey', $survey);
$template->set('completedSubmissionCount', $resultsData['completedSubmissionCount']);
$template->set('downloadUrl', $this->buildDownloadUrl((string) $survey->alias));
$template->set('pdfDownloadUrl', $this->hasGotenbergConfiguration() ? $this->buildPdfDownloadUrl((string) $survey->alias) : null);
$template->set('questionResults', $resultsData['questionResults']);
return $template->getResponse();
}
private function buildDownloadUrl(string $alias): string
{
return $this->urlGenerator->generate('mummert_survey_results_export', ['alias' => $alias]);
}
private function buildPdfDownloadUrl(string $alias): string
{
return $this->urlGenerator->generate('mummert_survey_results_pdf_export', ['alias' => $alias]);
}
private function hasGotenbergConfiguration(): bool
{
return '' !== trim((string) Config::get('surveyGotenbergUrl'));
}
private function resolvePublicAlias(Request $request): string
{
$candidate = $request->attributes->get('auto_item');
if (\is_string($candidate) && '' !== trim($candidate)) {
return trim($candidate);
}
return trim((string) $request->query->get('auto_item', ''));
}
}
@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace Mummert\SurveyBundle\Controller;
use Mummert\SurveyBundle\Repository\SurveyRepository;
use Mummert\SurveyBundle\Service\SurveyResultsExportService;
use Mummert\SurveyBundle\Service\SurveyResultsPdfService;
use Mummert\SurveyBundle\Service\SurveyResultsViewService;
use Mummert\SurveyBundle\Service\SurveySubmissionService;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Routing\Attribute\Route;
final class SurveyResultsExportController
{
public function __construct(
private readonly SurveyRepository $surveyRepository,
private readonly SurveyResultsViewService $surveyResultsViewService,
private readonly SurveySubmissionService $surveySubmissionService,
private readonly SurveyResultsExportService $surveyResultsExportService,
private readonly SurveyResultsPdfService $surveyResultsPdfService,
) {
}
#[Route('/umfrage-ergebnisse-export/{alias}', name: 'mummert_survey_results_export', defaults: ['_scope' => 'frontend'], requirements: ['alias' => '[^/]+'], methods: ['GET'])]
public function __invoke(string $alias): Response
{
$survey = $this->surveyRepository->findByPublicAlias($alias, false);
if (null === $survey) {
throw new NotFoundHttpException('Die Umfrage wurde nicht gefunden.');
}
$finishedSubmissions = array_values(array_filter(
$this->surveySubmissionService->getSubmissionOverview($survey),
static fn (array $submission): bool => '1' === (string) ($submission['isFinished'] ?? '')
));
return $this->surveyResultsExportService->createExcelExportResponse($survey, $finishedSubmissions);
}
#[Route('/umfrage-ergebnisse-pdf/{alias}', name: 'mummert_survey_results_pdf_export', defaults: ['_scope' => 'frontend'], requirements: ['alias' => '[^/]+'], methods: ['GET'])]
public function pdf(string $alias): Response
{
$survey = $this->surveyRepository->findByPublicAlias($alias, false);
if (null === $survey) {
throw new NotFoundHttpException('Die Umfrage wurde nicht gefunden.');
}
$resultsData = $this->surveyResultsViewService->buildForSurvey($survey);
return $this->surveyResultsPdfService->createPdfExportResponse(
$survey,
$resultsData['completedSubmissionCount'],
$resultsData['questionResults'],
);
}
}
@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace Mummert\SurveyBundle\DependencyInjection;
use Symfony\Component\Config\FileLocator;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Extension\Extension;
use Symfony\Component\DependencyInjection\Extension\PrependExtensionInterface;
use Symfony\Component\DependencyInjection\Loader\YamlFileLoader;
class SurveyExtension extends Extension implements PrependExtensionInterface
{
public function prepend(ContainerBuilder $container): void
{
if (!$container->hasExtension('twig')) {
return;
}
$container->prependExtensionConfig('twig', [
'paths' => [
dirname(__DIR__, 2).'/contao/templates' => 'Survey',
],
]);
}
public function load(array $configs, ContainerBuilder $container): void
{
$loader = new YamlFileLoader($container, new FileLocator(dirname(__DIR__, 2).'/config'));
$loader->load('services.yaml');
}
}
+477
View File
@@ -0,0 +1,477 @@
<?php
declare(strict_types=1);
namespace Mummert\SurveyBundle\EventListener;
use Contao\Config;
use Contao\CoreBundle\Framework\ContaoFramework;
use Contao\DataContainer;
use Contao\Environment;
use Contao\Image;
use Contao\Input;
use Contao\PageModel;
use Doctrine\DBAL\Connection;
use Mummert\SurveyBundle\Repository\SurveyEditorRepository;
use Mummert\SurveyBundle\Repository\SurveyQuestionRepository;
use Mummert\SurveyBundle\Repository\SurveyRepository;
use Mummert\SurveyBundle\Service\SurveyCategoryAssignmentService;
final class SurveyDcaListener
{
public function __construct(
private readonly SurveyRepository $surveyRepository,
private readonly SurveyQuestionRepository $surveyQuestionRepository,
private readonly SurveyEditorRepository $surveyEditorRepository,
private readonly SurveyCategoryAssignmentService $surveyCategoryAssignmentService,
private readonly ContaoFramework $framework,
private readonly Connection $connection,
) {
}
public function generateSurveyAlias(mixed $value, DataContainer $dataContainer): string
{
return $this->surveyRepository->ensurePublicAlias((string) $value, (int) ($dataContainer->id ?? 0));
}
public function generateCopyPublicLinkButton(array $row, ?string $href, string $label, string $title, ?string $icon, string $attributes): string
{
$publicUrl = $this->buildSurveyPageUrl((string) ($row['alias'] ?? ''), 'surveyReaderPage');
$label = 'Link kopieren';
$title = 'Den oeffentlichen Link in die Zwischenablage kopieren';
if (null === $publicUrl) {
return sprintf(
'<span title="%s">%s</span>',
htmlspecialchars('In den Contao-Einstellungen ist noch keine Reader-Seite hinterlegt oder der Link-Schluessel ist ungueltig.', ENT_QUOTES),
Image::getHtml($icon ?? 'copy.svg', $label)
);
}
$copyScript = sprintf(
'navigator.clipboard.writeText(%s).then(function(){Contao.flashMessage(%s, %s, "confirmation");});return false;',
json_encode($publicUrl, JSON_THROW_ON_ERROR),
json_encode('Oeffentlicher Link wurde in die Zwischenablage kopiert.', JSON_THROW_ON_ERROR),
json_encode('Bestaetigung', JSON_THROW_ON_ERROR),
);
return sprintf(
'<a href="#" title="%s" onclick="%s"%s>%s</a>',
htmlspecialchars($title, ENT_QUOTES),
htmlspecialchars($copyScript, ENT_QUOTES),
$attributes,
Image::getHtml($icon ?? 'copy.svg', $label).' '.$label
);
}
public function generateShowResultsButton(array $row, ?string $href, string $label, string $title, ?string $icon, string $attributes): string
{
$resultsUrl = $this->buildSurveyPageUrl((string) ($row['alias'] ?? ''), 'surveyResultsPage');
$label = 'Ergebnisse anzeigen';
$title = 'Die Ergebnisse dieser Umfrage im Frontend anzeigen';
if (null === $resultsUrl) {
return sprintf(
'<span title="%s">%s</span>',
htmlspecialchars('In den Contao-Einstellungen ist noch keine Ergebnisseite hinterlegt oder der Link-Schluessel ist ungueltig.', ENT_QUOTES),
Image::getHtml($icon ?? 'show.svg', $label)
);
}
return sprintf(
'<a href="%s" title="%s" target="_blank" rel="noreferrer"%s>%s</a>',
htmlspecialchars($resultsUrl, ENT_QUOTES),
htmlspecialchars($title, ENT_QUOTES),
$attributes,
Image::getHtml($icon ?? 'show.svg', $label).' '.$label
);
}
/**
* @return array<int, string>
*/
public function getQuestionOptions(?DataContainer $dataContainer = null): array
{
$surveyId = $this->resolveSurveyId('tl_survey_condition', $dataContainer);
return $this->buildQuestionOptions($surveyId);
}
/**
* @return array<int, string>
*/
public function getQuestionOptionsForContent(?DataContainer $dataContainer = null): array
{
$surveyId = $this->resolveSurveyId('tl_survey_content', $dataContainer);
return $this->buildQuestionOptions($surveyId);
}
/**
* @return array<int, string>
*/
private function buildQuestionOptions(int $surveyId): array
{
$choices = [];
foreach ($this->surveyQuestionRepository->findAllBySurvey($surveyId) as $question) {
$choices[(int) $question->id] = sprintf('#%d %s', (int) $question->id, (string) $question->question);
}
return $choices;
}
/**
* @return array<int, string>
*/
public function getMemberOptions(): array
{
return $this->surveyEditorRepository->findMemberChoices();
}
/**
* @return array<int, string>
*/
public function getCategoryOptions(): array
{
$rows = $this->connection->fetchAllAssociative('SELECT id, title FROM tl_survey_category ORDER BY sorting ASC, title ASC');
$options = [];
foreach ($rows as $row) {
$options[(int) $row['id']] = (string) $row['title'];
}
return $options;
}
/**
* @return array<int, string>
*/
public function getSurveyOptions(): array
{
return $this->surveyEditorRepository->findSurveyChoices();
}
/**
* @return list<int>
*/
public function loadMemberAssignedSurveys(mixed $value, DataContainer $dataContainer): array
{
$memberId = (int) ($dataContainer->id ?? 0);
return $memberId > 0 ? $this->surveyEditorRepository->findSurveyIdsByMember($memberId) : [];
}
/**
* @return list<int>
*/
public function loadSurveyAssignedMembers(mixed $value, DataContainer $dataContainer): array
{
$surveyId = (int) ($dataContainer->id ?? 0);
return $surveyId > 0 ? $this->surveyEditorRepository->findMemberIdsBySurvey($surveyId) : [];
}
public function guardContentSave(mixed $value, DataContainer $dataContainer): mixed
{
$this->assertSurveyUnlocked($this->resolveSurveyId('tl_survey_content', $dataContainer));
return $value;
}
public function guardConditionSave(mixed $value, DataContainer $dataContainer): mixed
{
$this->assertSurveyUnlocked($this->resolveSurveyId('tl_survey_condition', $dataContainer));
return $value;
}
public function guardEditorSave(mixed $value, DataContainer $dataContainer): mixed
{
$surveyId = $this->resolveSurveyId('tl_survey_editor', $dataContainer);
$this->assertSurveyUnlocked($surveyId);
$this->surveyCategoryAssignmentService->applyMemberCategoriesToSurvey((int) $value, $surveyId);
return $value;
}
/**
* @return list<int>
*/
public function saveMemberSurveyCategories(mixed $value, DataContainer $dataContainer): array
{
$memberId = (int) ($dataContainer->id ?? 0);
$categoryIds = $this->surveyCategoryAssignmentService->normalizeCategoryIds($value);
if ($memberId > 0) {
$this->surveyCategoryAssignmentService->syncAssignedSurveys($memberId, $categoryIds);
}
return $categoryIds;
}
public function refreshMemberSurveyListMetadata(DataContainer $dataContainer): void
{
$memberId = (int) ($dataContainer->id ?? 0);
if ($memberId <= 0) {
return;
}
foreach ($this->surveyEditorRepository->findSurveyIdsByMember($memberId) as $surveyId) {
$this->surveyRepository->refreshListMetadata($surveyId);
}
}
/**
* @return list<int>
*/
public function saveMemberAssignedSurveys(mixed $value, DataContainer $dataContainer): array
{
$memberId = (int) ($dataContainer->id ?? 0);
$surveyIds = $this->surveyCategoryAssignmentService->normalizeCategoryIds($value);
if ($memberId <= 0) {
return $surveyIds;
}
$previousSurveyIds = $this->surveyEditorRepository->findSurveyIdsByMember($memberId);
if ($previousSurveyIds !== $surveyIds) {
foreach (array_unique(array_merge($previousSurveyIds, $surveyIds)) as $surveyId) {
$this->assertSurveyUnlocked((int) $surveyId);
}
$this->surveyEditorRepository->syncSurveysForMember($memberId, $surveyIds);
}
$postedCategories = Input::post('surveyCategories');
$memberCategoryIds = null !== $postedCategories
? $this->surveyCategoryAssignmentService->normalizeCategoryIds($postedCategories)
: $this->surveyCategoryAssignmentService->getMemberCategoryIds($memberId);
foreach (array_unique(array_merge($previousSurveyIds, $surveyIds)) as $surveyId) {
$this->surveyCategoryAssignmentService->syncSurvey((int) $surveyId, [$memberId => $memberCategoryIds]);
}
return $surveyIds;
}
/**
* @return list<int>
*/
public function saveSurveyAssignedMembers(mixed $value, DataContainer $dataContainer): array
{
$surveyId = (int) ($dataContainer->id ?? 0);
$memberIds = $this->surveyCategoryAssignmentService->normalizeCategoryIds($value);
if ($surveyId <= 0) {
return $memberIds;
}
$previousMemberIds = $this->surveyEditorRepository->findMemberIdsBySurvey($surveyId);
if ($previousMemberIds !== $memberIds) {
$this->assertSurveyUnlocked($surveyId);
$this->surveyEditorRepository->syncMembersForSurvey($surveyId, $memberIds);
$this->surveyCategoryAssignmentService->syncSurvey($surveyId);
}
$this->surveyRepository->refreshListMetadata($surveyId);
return $memberIds;
}
public function refreshSurveyBackendStateIndex(): void
{
foreach ($this->connection->fetchFirstColumn('SELECT id FROM tl_survey') as $surveyId) {
$this->normalizeSurveyBackendState((int) $surveyId);
}
}
public function persistSurveyBackendState(DataContainer $dataContainer): void
{
$surveyId = (int) ($dataContainer->id ?? 0);
if ($surveyId <= 0) {
return;
}
$this->normalizeSurveyBackendState($surveyId);
}
private function normalizeSurveyBackendState(int $surveyId): void
{
if ($surveyId <= 0) {
return;
}
$survey = $this->surveyRepository->findById($surveyId);
if (null === $survey) {
return;
}
$now = time();
$createdAt = (int) $survey->createdAt;
$updatedAt = (int) $survey->updatedAt;
$tstamp = (int) $survey->tstamp;
$this->connection->update('tl_survey', [
'createdAt' => $createdAt > 0 ? $createdAt : ($updatedAt > 0 ? $updatedAt : ($tstamp > 0 ? $tstamp : $now)),
'updatedAt' => $updatedAt > 0 ? $updatedAt : $now,
], [
'id' => $surveyId,
]);
$this->surveyRepository->refreshListMetadata($surveyId);
}
public function guardContentDelete(DataContainer $dataContainer): void
{
$this->assertSurveyUnlocked($this->resolveSurveyId('tl_survey_content', $dataContainer));
}
public function touchSurveyContentParent(DataContainer $dataContainer): void
{
$surveyId = $this->resolveSurveyId('tl_survey_content', $dataContainer);
if ($surveyId > 0) {
$this->surveyRepository->touch($surveyId);
}
}
public function guardConditionDelete(DataContainer $dataContainer): void
{
$this->assertSurveyUnlocked($this->resolveSurveyId('tl_survey_condition', $dataContainer));
}
public function touchSurveyConditionParent(DataContainer $dataContainer): void
{
$surveyId = $this->resolveSurveyId('tl_survey_condition', $dataContainer);
if ($surveyId > 0) {
$this->surveyRepository->touch($surveyId);
}
}
public function guardEditorDelete(DataContainer $dataContainer): void
{
$surveyId = $this->resolveSurveyId('tl_survey_editor', $dataContainer);
$this->assertSurveyUnlocked($surveyId);
if ($dataContainer->activeRecord?->member) {
$this->surveyCategoryAssignmentService->syncSurveyWithoutMember($surveyId, (int) $dataContainer->activeRecord->member);
}
}
public function guardSurveyStructureSave(mixed $value, DataContainer $dataContainer): mixed
{
$surveyId = (int) ($dataContainer->id ?? 0);
$this->assertSurveyUnlocked($surveyId);
return $value;
}
public function resolveSurveyContentPalette(string $palette, DataContainer $dataContainer): string
{
$type = (string) ($dataContainer->activeRecord->type ?? '');
if ('' === $type) {
$type = (string) Input::post('type');
}
return match ($type) {
'range' => (string) ($GLOBALS['TL_DCA']['tl_survey_content']['palettes']['range'] ?? $palette),
'text' => (string) ($GLOBALS['TL_DCA']['tl_survey_content']['palettes']['text'] ?? $palette),
'choice' => (string) ($GLOBALS['TL_DCA']['tl_survey_content']['palettes']['choice'] ?? $palette),
'yes_no_maybe' => (string) ($GLOBALS['TL_DCA']['tl_survey_content']['palettes']['yes_no_maybe'] ?? $palette),
default => $palette,
};
}
private function assertSurveyUnlocked(int $surveyId): void
{
$survey = $this->surveyRepository->findById($surveyId);
if (null === $survey) {
return;
}
if ('1' === (string) $survey->isLocked) {
throw new \RuntimeException('Die Umfrage ist gesperrt, weil bereits Antworten existieren.');
}
}
private function resolveSurveyId(string $table, ?DataContainer $dataContainer = null): int
{
if ($dataContainer?->activeRecord) {
if (isset($dataContainer->activeRecord->pid)) {
return (int) $dataContainer->activeRecord->pid;
}
if (isset($dataContainer->activeRecord->survey)) {
return (int) $dataContainer->activeRecord->survey;
}
}
$pid = (int) Input::get('pid');
if ($pid > 0) {
return $pid;
}
$recordId = (int) ($dataContainer?->id ?? 0);
if ($recordId <= 0) {
return 0;
}
if ('survey' === $table) {
$value = $this->connection->fetchOne(sprintf('SELECT %s FROM %s WHERE id = ?', 'id', $table), [$recordId]);
return false === $value ? 0 : (int) $value;
}
if ('tl_survey_editor' === $table) {
$value = $this->connection->fetchOne(
'SELECT COALESCE(NULLIF(survey, 0), pid) FROM tl_survey_editor WHERE id = ?',
[$recordId],
);
return false === $value ? 0 : (int) $value;
}
$value = $this->connection->fetchOne(sprintf('SELECT %s FROM %s WHERE id = ?', 'pid', $table), [$recordId]);
return false === $value ? 0 : (int) $value;
}
private function buildSurveyPageUrl(string $alias, string $settingKey): ?string
{
$pageId = $this->resolvePageId($settingKey);
if ($pageId <= 0 || '' === $alias) {
return null;
}
$page = $this->framework->getAdapter(PageModel::class)->findById($pageId);
if (!$page instanceof PageModel) {
return null;
}
$baseUrl = rtrim((string) $this->framework->getAdapter(Environment::class)->get('url'), '/');
$pagePath = trim((string) $page->getFrontendUrl(), '/');
if ('' === $pagePath) {
return sprintf('%s/%s', $baseUrl, $alias);
}
return sprintf('%s/%s/%s', $baseUrl, $pagePath, $alias);
}
private function resolvePageId(string $settingKey): int
{
return (int) $this->framework->getAdapter(Config::class)->get($settingKey);
}
}
+10
View File
@@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace Mummert\SurveyBundle\Form\Model;
final class SurveyAnswerData
{
public mixed $answer = null;
}
+36
View File
@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace Mummert\SurveyBundle\Form\Model;
use Mummert\SurveyBundle\Model\SurveyConditionModel;
final class SurveyConditionData
{
public int $sourceQuestion = 0;
public string $answerValue = '';
public int $targetQuestion = 0;
public static function fromModel(SurveyConditionModel $condition): self
{
$data = new self();
$data->sourceQuestion = (int) $condition->sourceQuestion;
$data->answerValue = (string) $condition->answerValue;
$data->targetQuestion = (int) $condition->targetQuestion;
return $data;
}
/**
* @return array<string, mixed>
*/
public function toArray(): array
{
return [
'sourceQuestion' => $this->sourceQuestion,
'answerValue' => $this->answerValue,
'targetQuestion' => $this->targetQuestion,
];
}
}
+36
View File
@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace Mummert\SurveyBundle\Form\Model;
use Mummert\SurveyBundle\Model\SurveyModel;
final class SurveyEditorData
{
public string $title = '';
public string $description = '';
public bool $published = false;
public static function fromModel(SurveyModel $survey): self
{
$data = new self();
$data->title = (string) $survey->title;
$data->description = (string) $survey->description;
$data->published = '1' === (string) $survey->published;
return $data;
}
/**
* @return array<string, mixed>
*/
public function toArray(): array
{
return [
'title' => $this->title,
'description' => $this->description,
'published' => $this->published,
];
}
}
+86
View File
@@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
namespace Mummert\SurveyBundle\Form\Model;
use Mummert\SurveyBundle\Model\SurveyContentModel;
final class SurveyQuestionData
{
public string $type = 'yes_no_maybe';
public string $question = '';
public string $description = '';
public bool $mandatory = true;
public bool $published = true;
public bool $allowMaybe = false;
public bool $allowMultiple = false;
public int $jumpOnYes = 0;
public int $jumpOnNo = 0;
public string $answerOption1 = '';
public string $answerOption2 = '';
public string $answerOption3 = '';
public string $answerOption4 = '';
public string $answerOption5 = '';
public string $answerOption6 = '';
public string $answerOption7 = '';
public string $answerOption8 = '';
public string $answerOption9 = '';
public string $answerOption10 = '';
public int $rangeMin = 0;
public int $rangeMax = 10;
public int $rangeStep = 1;
public static function fromModel(SurveyContentModel $question): self
{
$data = new self();
$data->type = (string) $question->type;
$data->question = (string) $question->question;
$data->description = (string) $question->description;
$data->mandatory = '1' === (string) $question->mandatory;
$data->published = '1' === (string) $question->published;
$data->allowMaybe = '1' === (string) $question->allowMaybe;
$data->allowMultiple = '1' === (string) $question->allowMultiple;
$data->jumpOnYes = (int) $question->jumpOnYes;
$data->jumpOnNo = (int) $question->jumpOnNo;
for ($index = 1; $index <= 10; ++$index) {
$property = 'answerOption'.$index;
$data->{$property} = (string) $question->{$property};
}
$data->rangeMin = (int) $question->rangeMin;
$data->rangeMax = (int) $question->rangeMax;
$data->rangeStep = max(1, (int) $question->rangeStep);
return $data;
}
/**
* @return array<string, mixed>
*/
public function toArray(): array
{
$payload = [
'type' => $this->type,
'question' => $this->question,
'description' => $this->description,
'mandatory' => $this->mandatory,
'published' => $this->published,
'allowMaybe' => $this->allowMaybe,
'allowMultiple' => $this->allowMultiple,
'jumpOnYes' => $this->jumpOnYes,
'jumpOnNo' => $this->jumpOnNo,
'rangeMin' => $this->rangeMin,
'rangeMax' => $this->rangeMax,
'rangeStep' => $this->rangeStep,
];
for ($index = 1; $index <= 10; ++$index) {
$property = 'answerOption'.$index;
$payload[$property] = $this->{$property};
}
return $payload;
}
}
+48
View File
@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace Mummert\SurveyBundle\Form;
use Mummert\SurveyBundle\Form\Model\SurveyConditionData;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\Length;
use Symfony\Component\Validator\Constraints\NotBlank;
final class SurveyConditionEditorType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$questionChoices = $options['question_choices'];
$builder
->add('sourceQuestion', ChoiceType::class, [
'label' => 'Ausgangsfrage',
'choices' => array_flip($questionChoices),
'constraints' => [new NotBlank()],
])
->add('answerValue', TextType::class, [
'label' => 'Antwort',
'help' => 'Bei Ja-Nein-Fragen bitte genau ja oder nein eintragen, bei aktivierter Vielleicht-Option auch vielleicht. Bei Choice-Fragen den exakten Antworttext verwenden, bei Multiple-Choice mehrere Werte mit | trennen.',
'constraints' => [new NotBlank(), new Length(['max' => 255])],
])
->add('targetQuestion', ChoiceType::class, [
'label' => 'Naechste Frage',
'choices' => array_flip($questionChoices),
'constraints' => [new NotBlank()],
])
;
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => SurveyConditionData::class,
'question_choices' => [],
]);
}
}
+44
View File
@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace Mummert\SurveyBundle\Form;
use Mummert\SurveyBundle\Form\Model\SurveyEditorData;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\Length;
use Symfony\Component\Validator\Constraints\NotBlank;
final class SurveyEditorType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('title', TextType::class, [
'label' => 'Titel',
'constraints' => [new NotBlank(), new Length(['max' => 255])],
])
->add('description', TextareaType::class, [
'label' => 'Beschreibung',
'required' => false,
'attr' => ['rows' => 5],
])
->add('published', CheckboxType::class, [
'label' => 'Veroeffentlicht',
'required' => false,
])
;
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => SurveyEditorData::class,
]);
}
}
+40
View File
@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace Mummert\SurveyBundle\Form;
use Mummert\SurveyBundle\Form\Model\SurveyAnswerData;
use Mummert\SurveyBundle\Model\SurveyContentModel;
use Mummert\SurveyBundle\QuestionType\QuestionTypeRegistry;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
final class SurveyQuestionAnswerType extends AbstractType
{
public function __construct(private readonly QuestionTypeRegistry $questionTypeRegistry)
{
}
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$question = $options['question'];
if (!$question instanceof SurveyContentModel) {
throw new \InvalidArgumentException('Die Frage fehlt fuer das Antwortformular.');
}
$this->questionTypeRegistry->get((string) $question->type)->buildField($builder, $question);
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => SurveyAnswerData::class,
'question' => null,
]);
$resolver->setAllowedTypes('question', [SurveyContentModel::class]);
}
}
+155
View File
@@ -0,0 +1,155 @@
<?php
declare(strict_types=1);
namespace Mummert\SurveyBundle\Form;
use Mummert\SurveyBundle\Form\Model\SurveyQuestionData;
use Mummert\SurveyBundle\QuestionType\QuestionTypeRegistry;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\IntegerType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\GreaterThanOrEqual;
use Symfony\Component\Validator\Constraints\Length;
use Symfony\Component\Validator\Constraints\NotBlank;
final class SurveyQuestionEditorType extends AbstractType
{
public function __construct(private readonly QuestionTypeRegistry $questionTypeRegistry)
{
}
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('type', ChoiceType::class, [
'label' => 'Fragetyp',
'choices' => $this->questionTypeRegistry->getChoices(),
'attr' => [
'data-controller' => 'question-editor',
'data-action' => 'change->question-editor#toggle',
'data-question-editor-target' => 'type',
],
])
->add('question', TextType::class, [
'label' => 'Frage',
'constraints' => [new NotBlank(), new Length(['max' => 255])],
])
->add('description', TextareaType::class, [
'label' => 'Beschreibung',
'required' => false,
'attr' => ['rows' => 4],
])
->add('mandatory', CheckboxType::class, [
'label' => 'Pflichtfrage',
'required' => false,
])
->add('published', CheckboxType::class, [
'label' => 'Aktiv',
'required' => false,
])
->add('allowMaybe', CheckboxType::class, [
'label' => 'Vielleicht anbieten',
'required' => false,
'attr' => ['data-question-editor-target' => 'yesNoMaybeField'],
])
->add('jumpOnYes', ChoiceType::class, [
'label' => 'Zur Frage springen bei Auswahl ja',
'required' => false,
'choices' => array_flip($options['question_choices']),
'placeholder' => 'keine',
'attr' => ['data-question-editor-target' => 'yesNoMaybeField'],
])
->add('jumpOnNo', ChoiceType::class, [
'label' => 'Zur Frage springen bei Auswahl nein',
'required' => false,
'choices' => array_flip($options['question_choices']),
'placeholder' => 'keine',
'attr' => ['data-question-editor-target' => 'yesNoMaybeField'],
])
->add('allowMultiple', CheckboxType::class, [
'label' => 'Multiple-Choice aktivieren',
'required' => false,
'attr' => ['data-question-editor-target' => 'choiceField'],
])
->add('answerOption1', TextType::class, [
'label' => 'Antwort 1',
'required' => false,
'attr' => ['data-question-editor-target' => 'choiceField'],
])
->add('answerOption2', TextType::class, [
'label' => 'Antwort 2',
'required' => false,
'attr' => ['data-question-editor-target' => 'choiceField'],
])
->add('answerOption3', TextType::class, [
'label' => 'Antwort 3',
'required' => false,
'attr' => ['data-question-editor-target' => 'choiceField'],
])
->add('answerOption4', TextType::class, [
'label' => 'Antwort 4',
'required' => false,
'attr' => ['data-question-editor-target' => 'choiceField'],
])
->add('answerOption5', TextType::class, [
'label' => 'Antwort 5',
'required' => false,
'attr' => ['data-question-editor-target' => 'choiceField'],
])
->add('answerOption6', TextType::class, [
'label' => 'Antwort 6',
'required' => false,
'attr' => ['data-question-editor-target' => 'choiceField'],
])
->add('answerOption7', TextType::class, [
'label' => 'Antwort 7',
'required' => false,
'attr' => ['data-question-editor-target' => 'choiceField'],
])
->add('answerOption8', TextType::class, [
'label' => 'Antwort 8',
'required' => false,
'attr' => ['data-question-editor-target' => 'choiceField'],
])
->add('answerOption9', TextType::class, [
'label' => 'Antwort 9',
'required' => false,
'attr' => ['data-question-editor-target' => 'choiceField'],
])
->add('answerOption10', TextType::class, [
'label' => 'Antwort 10',
'required' => false,
'attr' => ['data-question-editor-target' => 'choiceField'],
])
->add('rangeMin', IntegerType::class, [
'label' => 'Kleinster Wert',
'constraints' => [new GreaterThanOrEqual(['value' => -100000])],
'attr' => ['data-question-editor-target' => 'rangeField'],
])
->add('rangeMax', IntegerType::class, [
'label' => 'Groesster Wert',
'constraints' => [new GreaterThanOrEqual(['value' => -100000])],
'attr' => ['data-question-editor-target' => 'rangeField'],
])
->add('rangeStep', IntegerType::class, [
'label' => 'Schrittweite',
'constraints' => [new GreaterThanOrEqual(['value' => 1])],
'attr' => ['data-question-editor-target' => 'rangeField'],
])
;
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => SurveyQuestionData::class,
'question_choices' => [],
]);
}
}
+12
View File
@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace Mummert\SurveyBundle\Model;
use Contao\Model;
final class SurveyAnswerModel extends Model
{
protected static $strTable = 'tl_survey_answer';
}
+12
View File
@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace Mummert\SurveyBundle\Model;
use Contao\Model;
final class SurveyCategoryModel extends Model
{
protected static $strTable = 'tl_survey_category';
}
+12
View File
@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace Mummert\SurveyBundle\Model;
use Contao\Model;
final class SurveyConditionModel extends Model
{
protected static $strTable = 'tl_survey_condition';
}
+12
View File
@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace Mummert\SurveyBundle\Model;
use Contao\Model;
final class SurveyContentModel extends Model
{
protected static $strTable = 'tl_survey_content';
}
+12
View File
@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace Mummert\SurveyBundle\Model;
use Contao\Model;
final class SurveyEditorModel extends Model
{
protected static $strTable = 'tl_survey_editor';
}
+12
View File
@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace Mummert\SurveyBundle\Model;
use Contao\Model;
final class SurveyModel extends Model
{
protected static $strTable = 'tl_survey';
}
+12
View File
@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace Mummert\SurveyBundle\Model;
use Contao\Model;
final class SurveySubmissionModel extends Model
{
protected static $strTable = 'tl_survey_submission';
}
+79
View File
@@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace Mummert\SurveyBundle\QuestionType;
use Mummert\SurveyBundle\Model\SurveyContentModel;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Validator\Constraints\Choice;
use Symfony\Component\Validator\Constraints\Count;
use Symfony\Component\Validator\Constraints\NotBlank;
final class ChoiceQuestionType implements QuestionTypeInterface
{
public function getName(): string
{
return 'choice';
}
public function getConstraints(SurveyContentModel $question): array
{
$choices = $this->getAnswerOptions($question);
$multiple = '1' === (string) $question->allowMultiple;
$constraints = [new Choice(['choices' => $choices, 'multiple' => $multiple])];
if ('1' === (string) $question->mandatory) {
$constraints[] = $multiple ? new Count(['min' => 1]) : new NotBlank();
}
return $constraints;
}
public function buildField(FormBuilderInterface $builder, SurveyContentModel $question, string $fieldName = 'answer'): void
{
$choices = [];
foreach ($this->getAnswerOptions($question) as $answerOption) {
$choices[$answerOption] = $answerOption;
}
$builder->add($fieldName, ChoiceType::class, [
'label' => false,
'expanded' => true,
'multiple' => '1' === (string) $question->allowMultiple,
'choices' => $choices,
'constraints' => $this->getConstraints($question),
]);
}
public function normalizeAnswer(mixed $value, SurveyContentModel $question): string
{
if (!\is_array($value)) {
return trim((string) $value);
}
$answers = array_values(array_filter(array_map(static fn (mixed $entry): string => trim((string) $entry), $value), static fn (string $entry): bool => '' !== $entry));
return implode(' | ', $answers);
}
/**
* @return list<string>
*/
private function getAnswerOptions(SurveyContentModel $question): array
{
$options = [];
for ($index = 1; $index <= 10; ++$index) {
$value = trim((string) $question->{'answerOption'.$index});
if ('' !== $value) {
$options[] = $value;
}
}
return $options;
}
}
@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace Mummert\SurveyBundle\QuestionType;
use Mummert\SurveyBundle\Model\SurveyContentModel;
use Symfony\Component\Form\FormBuilderInterface;
interface QuestionTypeInterface
{
public function getName(): string;
/**
* @return list<object>
*/
public function getConstraints(SurveyContentModel $question): array;
public function buildField(FormBuilderInterface $builder, SurveyContentModel $question, string $fieldName = 'answer'): void;
public function normalizeAnswer(mixed $value, SurveyContentModel $question): string;
}
+46
View File
@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace Mummert\SurveyBundle\QuestionType;
final class QuestionTypeRegistry
{
/**
* @var array<string, QuestionTypeInterface>
*/
private array $questionTypes = [];
public function __construct(iterable $questionTypes)
{
foreach ($questionTypes as $questionType) {
if (!$questionType instanceof QuestionTypeInterface) {
continue;
}
$this->questionTypes[$questionType->getName()] = $questionType;
}
}
public function get(string $type): QuestionTypeInterface
{
if (!isset($this->questionTypes[$type])) {
throw new \InvalidArgumentException(sprintf('Unbekannter Fragetyp "%s".', $type));
}
return $this->questionTypes[$type];
}
/**
* @return array<string, string>
*/
public function getChoices(): array
{
return [
'Ja-Nein-Frage' => 'yes_no_maybe',
'Offene Frage' => 'text',
'Bewertungsfrage' => 'range',
'Single/Multiple-Choice' => 'choice',
];
}
}
+56
View File
@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace Mummert\SurveyBundle\QuestionType;
use Mummert\SurveyBundle\Model\SurveyContentModel;
use Symfony\Component\Form\Extension\Core\Type\RangeType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Validator\Constraints\NotBlank;
use Symfony\Component\Validator\Constraints\Range;
final class RangeQuestionType implements QuestionTypeInterface
{
public function getName(): string
{
return 'range';
}
public function getConstraints(SurveyContentModel $question): array
{
$constraints = [
new Range([
'min' => (int) $question->rangeMin,
'max' => (int) $question->rangeMax,
]),
];
if ('1' === (string) $question->mandatory) {
$constraints[] = new NotBlank();
}
return $constraints;
}
public function buildField(FormBuilderInterface $builder, SurveyContentModel $question, string $fieldName = 'answer'): void
{
$builder->add($fieldName, RangeType::class, [
'label' => false,
'constraints' => $this->getConstraints($question),
'attr' => [
'min' => (int) $question->rangeMin,
'max' => (int) $question->rangeMax,
'step' => max(1, (int) $question->rangeStep),
'class' => 'range range-primary w-full',
'data-controller' => 'range-preview',
'data-action' => 'input->range-preview#update',
],
]);
}
public function normalizeAnswer(mixed $value, SurveyContentModel $question): string
{
return (string) (int) $value;
}
}
+48
View File
@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace Mummert\SurveyBundle\QuestionType;
use Mummert\SurveyBundle\Model\SurveyContentModel;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Validator\Constraints\Length;
use Symfony\Component\Validator\Constraints\NotBlank;
final class TextQuestionType implements QuestionTypeInterface
{
public function getName(): string
{
return 'text';
}
public function getConstraints(SurveyContentModel $question): array
{
$constraints = [new Length(['max' => 5000])];
if ('1' === (string) $question->mandatory) {
$constraints[] = new NotBlank();
}
return $constraints;
}
public function buildField(FormBuilderInterface $builder, SurveyContentModel $question, string $fieldName = 'answer'): void
{
$builder->add($fieldName, TextareaType::class, [
'label' => false,
'constraints' => $this->getConstraints($question),
'attr' => [
'rows' => 6,
'placeholder' => 'Ihre Antwort',
'class' => 'textarea textarea-bordered w-full min-h-40',
],
]);
}
public function normalizeAnswer(mixed $value, SurveyContentModel $question): string
{
return trim((string) $value);
}
}
@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace Mummert\SurveyBundle\QuestionType;
use Mummert\SurveyBundle\Model\SurveyContentModel;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Validator\Constraints\Choice;
use Symfony\Component\Validator\Constraints\NotBlank;
final class YesNoMaybeQuestionType implements QuestionTypeInterface
{
public function getName(): string
{
return 'yes_no_maybe';
}
public function getConstraints(SurveyContentModel $question): array
{
$choices = ['yes', 'no'];
if ('1' === (string) $question->allowMaybe) {
$choices[] = 'maybe';
}
$constraints = [new Choice(['choices' => $choices])];
if ('1' === (string) $question->mandatory) {
$constraints[] = new NotBlank();
}
return $constraints;
}
public function buildField(FormBuilderInterface $builder, SurveyContentModel $question, string $fieldName = 'answer'): void
{
$choices = [
'Ja' => 'yes',
'Nein' => 'no',
];
if ('1' === (string) $question->allowMaybe) {
$choices['Vielleicht'] = 'maybe';
}
$builder->add($fieldName, ChoiceType::class, [
'label' => false,
'expanded' => true,
'multiple' => false,
'choices' => $choices,
'constraints' => $this->getConstraints($question),
]);
}
public function normalizeAnswer(mixed $value, SurveyContentModel $question): string
{
return mb_strtolower(trim((string) $value));
}
}
+104
View File
@@ -0,0 +1,104 @@
<?php
declare(strict_types=1);
namespace Mummert\SurveyBundle\Repository;
use Doctrine\DBAL\Connection;
final class SurveyAnswerRepository
{
public function __construct(private readonly Connection $connection)
{
}
public function saveAnswer(int $submissionId, int $questionId, string $questionType, string $value): void
{
$existingId = $this->connection->fetchOne(
'SELECT id FROM tl_survey_answer WHERE submission = ? AND question = ? LIMIT 1',
[$submissionId, $questionId],
);
if (false !== $existingId) {
$this->connection->update('tl_survey_answer', [
'questionType' => $questionType,
'value' => $value,
], [
'id' => (int) $existingId,
]);
return;
}
$this->connection->insert('tl_survey_answer', [
'pid' => $submissionId,
'submission' => $submissionId,
'question' => $questionId,
'questionType' => $questionType,
'value' => $value,
]);
}
public function hasAnswersForSurvey(int $surveyId): bool
{
return false !== $this->connection->fetchOne(
<<<'SQL'
SELECT ans.id
FROM tl_survey_answer ans
INNER JOIN tl_survey_submission sub ON sub.id = ans.submission
WHERE sub.survey = ?
LIMIT 1
SQL,
[$surveyId],
);
}
/**
* @return list<array{questionId:int,value:string}>
*/
public function findFinishedAnswersBySurvey(int $surveyId): array
{
$rows = $this->connection->fetchAllAssociative(
<<<'SQL'
SELECT
a.question AS questionId,
a.value
FROM tl_survey_answer a
INNER JOIN tl_survey_submission sub ON sub.id = a.submission
WHERE sub.survey = :survey AND sub.isFinished = 1
ORDER BY a.question ASC, a.id ASC
SQL,
['survey' => $surveyId],
);
return array_map(
static fn (array $row): array => [
'questionId' => (int) $row['questionId'],
'value' => (string) $row['value'],
],
$rows,
);
}
/**
* @return list<array<string, mixed>>
*/
public function findAnswersBySubmission(int $submissionId): array
{
return $this->connection->fetchAllAssociative(
<<<'SQL'
SELECT
a.id,
a.question AS questionId,
a.value,
a.questionType,
q.question
FROM tl_survey_answer a
INNER JOIN tl_survey_content q ON q.id = a.question
WHERE a.submission = :submission
ORDER BY q.sorting ASC
SQL,
['submission' => $submissionId],
);
}
}
@@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
namespace Mummert\SurveyBundle\Repository;
use Contao\CoreBundle\Framework\ContaoFramework;
use Doctrine\DBAL\Connection;
use Mummert\SurveyBundle\Model\SurveyConditionModel;
final class SurveyConditionRepository
{
public function __construct(
private readonly Connection $connection,
private readonly ContaoFramework $framework,
) {
}
public function findById(int $id): ?SurveyConditionModel
{
$adapter = $this->framework->getAdapter(SurveyConditionModel::class);
$model = $adapter->findByPk($id);
return $model instanceof SurveyConditionModel ? $model : null;
}
public function findMatchingCondition(int $surveyId, int $sourceQuestionId, string $answerValue): ?SurveyConditionModel
{
$id = $this->connection->fetchOne(
'SELECT id FROM tl_survey_condition WHERE pid = ? AND sourceQuestion = ? AND LOWER(TRIM(answerValue)) = LOWER(TRIM(?)) ORDER BY id ASC LIMIT 1',
[$surveyId, $sourceQuestionId, $answerValue],
);
return false === $id ? null : $this->findById((int) $id);
}
/**
* @return list<array<string, mixed>>
*/
public function findOverviewBySurvey(int $surveyId): array
{
return $this->connection->fetchAllAssociative(
<<<'SQL'
SELECT
c.id,
c.answerValue,
c.sourceQuestion,
c.targetQuestion,
source.question AS sourceQuestionLabel,
target.question AS targetQuestionLabel
FROM tl_survey_condition c
LEFT JOIN tl_survey_content source ON source.id = c.sourceQuestion
LEFT JOIN tl_survey_content target ON target.id = c.targetQuestion
WHERE c.pid = :survey
ORDER BY source.sorting ASC, c.id ASC
SQL,
['survey' => $surveyId],
);
}
public function create(int $surveyId, array $data): SurveyConditionModel
{
$this->connection->insert('tl_survey_condition', [
'pid' => $surveyId,
'sourceQuestion' => (int) ($data['sourceQuestion'] ?? 0),
'answerValue' => mb_strtolower(trim((string) ($data['answerValue'] ?? ''))),
'targetQuestion' => (int) ($data['targetQuestion'] ?? 0),
]);
$id = (int) $this->connection->lastInsertId();
return $this->findById($id) ?? throw new \RuntimeException('Bedingung konnte nicht angelegt werden.');
}
public function update(SurveyConditionModel $condition, array $data): void
{
$this->connection->update('tl_survey_condition', [
'sourceQuestion' => (int) ($data['sourceQuestion'] ?? $condition->sourceQuestion),
'answerValue' => mb_strtolower(trim((string) ($data['answerValue'] ?? $condition->answerValue))),
'targetQuestion' => (int) ($data['targetQuestion'] ?? $condition->targetQuestion),
], [
'id' => (int) $condition->id,
]);
}
public function delete(int $surveyId, int $conditionId): void
{
$this->connection->delete('tl_survey_condition', [
'id' => $conditionId,
'pid' => $surveyId,
]);
}
}
+177
View File
@@ -0,0 +1,177 @@
<?php
declare(strict_types=1);
namespace Mummert\SurveyBundle\Repository;
use Doctrine\DBAL\Connection;
final class SurveyEditorRepository
{
public function __construct(private readonly Connection $connection)
{
}
public function isEditor(int $surveyId, int $memberId): bool
{
return false !== $this->connection->fetchOne(
'SELECT id FROM tl_survey_editor WHERE COALESCE(NULLIF(survey, 0), pid) = ? AND member = ? LIMIT 1',
[$surveyId, $memberId],
);
}
public function assignEditor(int $surveyId, int $memberId): void
{
if ($this->isEditor($surveyId, $memberId)) {
return;
}
$this->connection->insert('tl_survey_editor', [
'pid' => $surveyId,
'survey' => $surveyId,
'member' => $memberId,
]);
}
public function removeEditor(int $surveyId, int $memberId): void
{
$this->connection->delete('tl_survey_editor', [
'survey' => $surveyId,
'member' => $memberId,
]);
$this->connection->delete('tl_survey_editor', [
'pid' => $surveyId,
'member' => $memberId,
'survey' => 0,
]);
}
/**
* @return list<int>
*/
public function findSurveyIdsByMember(int $memberId): array
{
$surveyIds = $this->connection->fetchFirstColumn(
'SELECT DISTINCT COALESCE(NULLIF(survey, 0), pid) FROM tl_survey_editor WHERE member = ? ORDER BY COALESCE(NULLIF(survey, 0), pid)',
[$memberId],
);
return array_values(array_map('intval', $surveyIds));
}
/**
* @return list<int>
*/
public function findMemberIdsBySurvey(int $surveyId): array
{
$memberIds = $this->connection->fetchFirstColumn(
'SELECT DISTINCT member FROM tl_survey_editor WHERE COALESCE(NULLIF(survey, 0), pid) = ? AND member > 0 ORDER BY member',
[$surveyId],
);
return array_values(array_map('intval', $memberIds));
}
/**
* @return array<int, string>
*/
public function findSurveyChoices(): array
{
$rows = $this->connection->fetchAllAssociative(
'SELECT id, title FROM tl_survey ORDER BY title ASC, id ASC'
);
$choices = [];
foreach ($rows as $row) {
$title = trim((string) ($row['title'] ?? ''));
$choices[(int) $row['id']] = '' !== $title ? $title : 'Ohne Titel';
}
return $choices;
}
/**
* @param list<int> $memberIds
*/
public function syncMembersForSurvey(int $surveyId, array $memberIds): void
{
$memberIds = $this->normalizeIds($memberIds);
$existingMemberIds = $this->findMemberIdsBySurvey($surveyId);
foreach (array_diff($existingMemberIds, $memberIds) as $memberId) {
$this->removeEditor($surveyId, (int) $memberId);
}
foreach (array_diff($memberIds, $existingMemberIds) as $memberId) {
$this->assignEditor($surveyId, (int) $memberId);
}
}
/**
* @param list<int> $surveyIds
*/
public function syncSurveysForMember(int $memberId, array $surveyIds): void
{
$surveyIds = $this->normalizeIds($surveyIds);
$existingSurveyIds = $this->findSurveyIdsByMember($memberId);
foreach (array_diff($existingSurveyIds, $surveyIds) as $surveyId) {
$this->removeEditor((int) $surveyId, $memberId);
}
foreach (array_diff($surveyIds, $existingSurveyIds) as $surveyId) {
$this->assignEditor((int) $surveyId, $memberId);
}
}
/**
* @return list<array<string, mixed>>
*/
public function findEditorsBySurvey(int $surveyId): array
{
return $this->connection->fetchAllAssociative(
<<<'SQL'
SELECT
m.id,
m.firstname,
m.lastname,
m.email
FROM tl_survey_editor e
INNER JOIN tl_member m ON m.id = e.member
WHERE COALESCE(NULLIF(e.survey, 0), e.pid) = :survey
ORDER BY m.lastname ASC, m.firstname ASC
SQL,
['survey' => $surveyId],
);
}
/**
* @return array<int, string>
*/
public function findMemberChoices(): array
{
$rows = $this->connection->fetchAllAssociative('SELECT id, firstname, lastname, email FROM tl_member WHERE disable != 1 ORDER BY lastname ASC, firstname ASC');
$choices = [];
foreach ($rows as $row) {
$name = trim(sprintf('%s %s', (string) $row['firstname'], (string) $row['lastname']));
$choices[(int) $row['id']] = '' !== $name ? sprintf('%s <%s>', $name, (string) $row['email']) : (string) $row['email'];
}
return $choices;
}
/**
* @param list<int> $ids
*
* @return list<int>
*/
private function normalizeIds(array $ids): array
{
$ids = array_values(array_unique(array_filter(array_map('intval', $ids), static fn (int $id): bool => $id > 0)));
sort($ids);
return $ids;
}
}
+163
View File
@@ -0,0 +1,163 @@
<?php
declare(strict_types=1);
namespace Mummert\SurveyBundle\Repository;
use Contao\CoreBundle\Framework\ContaoFramework;
use Doctrine\DBAL\Connection;
use Mummert\SurveyBundle\Model\SurveyContentModel;
final class SurveyQuestionRepository
{
public function __construct(
private readonly Connection $connection,
private readonly ContaoFramework $framework,
) {
}
public function findById(int $id): ?SurveyContentModel
{
$adapter = $this->framework->getAdapter(SurveyContentModel::class);
$model = $adapter->findByPk($id);
return $model instanceof SurveyContentModel ? $model : null;
}
public function findByIdForSurvey(int $surveyId, int $questionId, bool $publishedOnly = false): ?SurveyContentModel
{
$row = $this->connection->fetchAssociative(
'SELECT id FROM tl_survey_content WHERE id = ? AND pid = ?'.($publishedOnly ? ' AND published = 1' : '').' LIMIT 1',
[$questionId, $surveyId],
);
if (!$row) {
return null;
}
return $this->findById((int) $row['id']);
}
/**
* @return list<SurveyContentModel>
*/
public function findAllBySurvey(int $surveyId, bool $publishedOnly = false): array
{
$sql = 'SELECT id FROM tl_survey_content WHERE pid = ?';
if ($publishedOnly) {
$sql .= ' AND published = 1';
}
$sql .= ' ORDER BY sorting ASC';
$ids = $this->connection->fetchFirstColumn($sql, [$surveyId]);
return $this->hydrate($ids);
}
public function create(int $surveyId, array $data): SurveyContentModel
{
$sorting = (int) $this->connection->fetchOne('SELECT COALESCE(MAX(sorting), 0) FROM tl_survey_content WHERE pid = ?', [$surveyId]) + 128;
$this->connection->insert('tl_survey_content', [
'pid' => $surveyId,
'sorting' => $sorting,
'type' => (string) ($data['type'] ?? 'yes_no_maybe'),
'question' => (string) ($data['question'] ?? ''),
'description' => (string) ($data['description'] ?? ''),
'mandatory' => !empty($data['mandatory']) ? '1' : '',
'published' => !empty($data['published']) ? '1' : '',
'allowMaybe' => 'yes_no_maybe' === (string) ($data['type'] ?? 'yes_no_maybe') && !empty($data['allowMaybe']) ? '1' : '',
'jumpOnYes' => 'yes_no_maybe' === (string) ($data['type'] ?? 'yes_no_maybe') ? max(0, (int) ($data['jumpOnYes'] ?? 0)) : 0,
'jumpOnNo' => 'yes_no_maybe' === (string) ($data['type'] ?? 'yes_no_maybe') ? max(0, (int) ($data['jumpOnNo'] ?? 0)) : 0,
'allowMultiple' => 'choice' === (string) ($data['type'] ?? 'yes_no_maybe') && !empty($data['allowMultiple']) ? '1' : '',
'answerOption1' => 'choice' === (string) ($data['type'] ?? 'yes_no_maybe') ? trim((string) ($data['answerOption1'] ?? '')) : '',
'answerOption2' => 'choice' === (string) ($data['type'] ?? 'yes_no_maybe') ? trim((string) ($data['answerOption2'] ?? '')) : '',
'answerOption3' => 'choice' === (string) ($data['type'] ?? 'yes_no_maybe') ? trim((string) ($data['answerOption3'] ?? '')) : '',
'answerOption4' => 'choice' === (string) ($data['type'] ?? 'yes_no_maybe') ? trim((string) ($data['answerOption4'] ?? '')) : '',
'answerOption5' => 'choice' === (string) ($data['type'] ?? 'yes_no_maybe') ? trim((string) ($data['answerOption5'] ?? '')) : '',
'answerOption6' => 'choice' === (string) ($data['type'] ?? 'yes_no_maybe') ? trim((string) ($data['answerOption6'] ?? '')) : '',
'answerOption7' => 'choice' === (string) ($data['type'] ?? 'yes_no_maybe') ? trim((string) ($data['answerOption7'] ?? '')) : '',
'answerOption8' => 'choice' === (string) ($data['type'] ?? 'yes_no_maybe') ? trim((string) ($data['answerOption8'] ?? '')) : '',
'answerOption9' => 'choice' === (string) ($data['type'] ?? 'yes_no_maybe') ? trim((string) ($data['answerOption9'] ?? '')) : '',
'answerOption10' => 'choice' === (string) ($data['type'] ?? 'yes_no_maybe') ? trim((string) ($data['answerOption10'] ?? '')) : '',
'rangeMin' => (int) ($data['rangeMin'] ?? 0),
'rangeMax' => (int) ($data['rangeMax'] ?? 10),
'rangeStep' => max(1, (int) ($data['rangeStep'] ?? 1)),
]);
$id = (int) $this->connection->lastInsertId();
return $this->findById($id) ?? throw new \RuntimeException('Frage konnte nicht angelegt werden.');
}
public function update(SurveyContentModel $question, array $data): void
{
$this->connection->update('tl_survey_content', [
'type' => (string) ($data['type'] ?? $question->type),
'question' => (string) ($data['question'] ?? $question->question),
'description' => (string) ($data['description'] ?? $question->description),
'mandatory' => !empty($data['mandatory']) ? '1' : '',
'published' => !empty($data['published']) ? '1' : '',
'allowMaybe' => 'yes_no_maybe' === (string) ($data['type'] ?? $question->type) && !empty($data['allowMaybe']) ? '1' : '',
'jumpOnYes' => 'yes_no_maybe' === (string) ($data['type'] ?? $question->type) ? max(0, (int) ($data['jumpOnYes'] ?? $question->jumpOnYes)) : 0,
'jumpOnNo' => 'yes_no_maybe' === (string) ($data['type'] ?? $question->type) ? max(0, (int) ($data['jumpOnNo'] ?? $question->jumpOnNo)) : 0,
'allowMultiple' => 'choice' === (string) ($data['type'] ?? $question->type) && !empty($data['allowMultiple']) ? '1' : '',
'answerOption1' => 'choice' === (string) ($data['type'] ?? $question->type) ? trim((string) ($data['answerOption1'] ?? $question->answerOption1)) : '',
'answerOption2' => 'choice' === (string) ($data['type'] ?? $question->type) ? trim((string) ($data['answerOption2'] ?? $question->answerOption2)) : '',
'answerOption3' => 'choice' === (string) ($data['type'] ?? $question->type) ? trim((string) ($data['answerOption3'] ?? $question->answerOption3)) : '',
'answerOption4' => 'choice' === (string) ($data['type'] ?? $question->type) ? trim((string) ($data['answerOption4'] ?? $question->answerOption4)) : '',
'answerOption5' => 'choice' === (string) ($data['type'] ?? $question->type) ? trim((string) ($data['answerOption5'] ?? $question->answerOption5)) : '',
'answerOption6' => 'choice' === (string) ($data['type'] ?? $question->type) ? trim((string) ($data['answerOption6'] ?? $question->answerOption6)) : '',
'answerOption7' => 'choice' === (string) ($data['type'] ?? $question->type) ? trim((string) ($data['answerOption7'] ?? $question->answerOption7)) : '',
'answerOption8' => 'choice' === (string) ($data['type'] ?? $question->type) ? trim((string) ($data['answerOption8'] ?? $question->answerOption8)) : '',
'answerOption9' => 'choice' === (string) ($data['type'] ?? $question->type) ? trim((string) ($data['answerOption9'] ?? $question->answerOption9)) : '',
'answerOption10' => 'choice' === (string) ($data['type'] ?? $question->type) ? trim((string) ($data['answerOption10'] ?? $question->answerOption10)) : '',
'rangeMin' => (int) ($data['rangeMin'] ?? $question->rangeMin),
'rangeMax' => (int) ($data['rangeMax'] ?? $question->rangeMax),
'rangeStep' => max(1, (int) ($data['rangeStep'] ?? $question->rangeStep)),
], [
'id' => (int) $question->id,
]);
}
public function delete(int $surveyId, int $questionId): void
{
$this->connection->delete('tl_survey_content', [
'id' => $questionId,
'pid' => $surveyId,
]);
$this->connection->delete('tl_survey_condition', [
'sourceQuestion' => $questionId,
]);
$this->connection->delete('tl_survey_condition', [
'targetQuestion' => $questionId,
]);
}
public function countPublishedBySurvey(int $surveyId): int
{
return (int) $this->connection->fetchOne(
'SELECT COUNT(*) FROM tl_survey_content WHERE pid = ? AND published = 1',
[$surveyId],
);
}
private function hydrate(array $ids): array
{
$models = [];
foreach ($ids as $id) {
$model = $this->findById((int) $id);
if ($model instanceof SurveyContentModel) {
$models[] = $model;
}
}
return $models;
}
}
+346
View File
@@ -0,0 +1,346 @@
<?php
declare(strict_types=1);
namespace Mummert\SurveyBundle\Repository;
use Contao\CoreBundle\Framework\ContaoFramework;
use Contao\StringUtil;
use Doctrine\DBAL\Connection;
use Mummert\SurveyBundle\Model\SurveyModel;
final class SurveyRepository
{
private ?bool $hasListMetadataColumns = null;
public function __construct(
private readonly Connection $connection,
private readonly ContaoFramework $framework,
) {
}
public function findById(int $id): ?SurveyModel
{
$adapter = $this->framework->getAdapter(SurveyModel::class);
$model = $adapter->findByPk($id);
return $model instanceof SurveyModel ? $model : null;
}
public function findByPublicAlias(string $alias, bool $publishedOnly = true): ?SurveyModel
{
$qb = $this->connection->createQueryBuilder();
$qb
->select('id')
->from('tl_survey')
->where('alias = :alias')
->setParameter('alias', $alias)
->setMaxResults(1)
;
if ($publishedOnly) {
$qb->andWhere('published = 1');
}
$id = $qb->executeQuery()->fetchOne();
if (false === $id) {
return null;
}
return $this->findById((int) $id);
}
/**
* @return list<array<string, mixed>>
*/
public function findEditableByMember(int $memberId): array
{
return $this->connection->fetchAllAssociative(
<<<'SQL'
SELECT
s.id,
s.title,
s.alias,
s.description,
s.published,
s.isLocked,
s.updatedAt,
COUNT(DISTINCT q.id) AS questionCount,
COUNT(DISTINCT a.id) AS answerCount
FROM tl_survey s
INNER JOIN tl_survey_editor e ON COALESCE(NULLIF(e.survey, 0), e.pid) = s.id AND e.member = :member
LEFT JOIN tl_survey_content q ON q.pid = s.id
LEFT JOIN tl_survey_submission sub ON sub.survey = s.id
LEFT JOIN tl_survey_answer a ON a.submission = sub.id
GROUP BY s.id
ORDER BY s.updatedAt DESC, s.title ASC
SQL,
['member' => $memberId],
);
}
public function create(int $memberId, array $data): SurveyModel
{
$now = time();
$title = trim((string) ($data['title'] ?? 'Neue Umfrage'));
$this->connection->insert('tl_survey', [
'tstamp' => $now,
'title' => $title,
'alias' => $this->ensurePublicAlias(null),
'category' => serialize($this->normalizeCategoryIds($data['category'] ?? [])),
'description' => (string) ($data['description'] ?? ''),
'published' => !empty($data['published']) ? '1' : '',
'isLocked' => '',
'createdBy' => $memberId,
'updatedBy' => $memberId,
'createdAt' => $now,
'updatedAt' => $now,
]);
$id = (int) $this->connection->lastInsertId();
$this->refreshListMetadata($id);
return $this->findById($id) ?? throw new \RuntimeException('Umfrage konnte nicht angelegt werden.');
}
public function update(SurveyModel $survey, int $memberId, array $data): void
{
$title = trim((string) ($data['title'] ?? $survey->title));
$this->connection->update('tl_survey', [
'tstamp' => time(),
'title' => $title,
'alias' => $this->ensurePublicAlias((string) $survey->alias, (int) $survey->id),
'category' => serialize($this->normalizeCategoryIds($data['category'] ?? StringUtil::deserialize($survey->category, true))),
'description' => (string) ($data['description'] ?? $survey->description),
'published' => !empty($data['published']) ? '1' : '',
'updatedBy' => $memberId,
'updatedAt' => time(),
], [
'id' => (int) $survey->id,
]);
$this->refreshListMetadata((int) $survey->id);
}
public function ensurePublicAlias(?string $alias, int $excludeId = 0): string
{
$candidate = strtolower(trim((string) $alias));
if ($this->isValidPublicAlias($candidate) && !$this->aliasExists($candidate, $excludeId)) {
return $candidate;
}
do {
$candidate = $this->generateRandomPublicAlias();
} while ($this->aliasExists($candidate, $excludeId));
return $candidate;
}
/**
* @param list<int> $categoryIds
*/
public function updateCategories(int $surveyId, array $categoryIds): void
{
$this->connection->update('tl_survey', [
'category' => serialize($this->normalizeCategoryIds($categoryIds)),
'tstamp' => time(),
'updatedAt' => time(),
], [
'id' => $surveyId,
]);
$this->refreshListMetadata($surveyId);
}
public function refreshListMetadata(int $surveyId): void
{
if (!$this->hasListMetadataColumns()) {
return;
}
$survey = $this->findById($surveyId);
if (!$survey instanceof SurveyModel) {
return;
}
$categoryIds = $this->normalizeCategoryIds($survey->category);
$categoryTitles = $this->fetchCategoryTitles($categoryIds);
$assignedMembers = $this->fetchAssignedMembers($surveyId);
$memberIds = [];
$memberNames = [];
foreach ($assignedMembers as $assignedMember) {
$memberIds[] = (int) $assignedMember['id'];
$memberNames[] = (string) $assignedMember['name'];
}
$this->connection->update('tl_survey', [
'categoryFilter' => implode(',', $categoryIds),
'categorySummary' => implode(', ', $categoryTitles),
'assignedMembersFilter' => implode(',', $memberIds),
'assignedMembersSummary' => implode(', ', $memberNames),
], [
'id' => $surveyId,
]);
}
public function touch(int $surveyId): void
{
$now = time();
$this->connection->update('tl_survey', [
'tstamp' => $now,
'updatedAt' => $now,
], [
'id' => $surveyId,
]);
}
public function lock(int $surveyId): void
{
$this->connection->update('tl_survey', [
'isLocked' => '1',
'tstamp' => time(),
'updatedAt' => time(),
], [
'id' => $surveyId,
]);
}
private function generateRandomPublicAlias(): string
{
$characters = 'abcdefghijklmnopqrstuvwxyz0123456789';
$alias = '';
for ($index = 0; $index < 20; ++$index) {
$alias .= $characters[random_int(0, strlen($characters) - 1)];
}
return $alias;
}
private function aliasExists(string $alias, int $excludeId = 0): bool
{
$qb = $this->connection->createQueryBuilder();
$qb
->select('id')
->from('tl_survey')
->where('alias = :alias')
->setParameter('alias', $alias)
->setMaxResults(1)
;
if ($excludeId > 0) {
$qb
->andWhere('id != :excludeId')
->setParameter('excludeId', $excludeId)
;
}
return false !== $qb->executeQuery()->fetchOne();
}
/**
* @param list<int> $categoryIds
*
* @return list<string>
*/
private function fetchCategoryTitles(array $categoryIds): array
{
if ([] === $categoryIds) {
return [];
}
$placeholders = implode(',', array_fill(0, count($categoryIds), '?'));
$rows = $this->connection->fetchAllAssociative(
sprintf('SELECT id, title FROM tl_survey_category WHERE id IN (%s)', $placeholders),
$categoryIds,
);
$titlesById = [];
foreach ($rows as $row) {
$titlesById[(int) $row['id']] = (string) $row['title'];
}
$titles = [];
foreach ($categoryIds as $categoryId) {
if (isset($titlesById[$categoryId])) {
$titles[] = $titlesById[$categoryId];
}
}
return $titles;
}
/**
* @return list<array{id:int,name:string}>
*/
private function fetchAssignedMembers(int $surveyId): array
{
$rows = $this->connection->fetchAllAssociative(
<<<'SQL'
SELECT DISTINCT
m.id,
m.firstname,
m.lastname,
m.email
FROM tl_survey_editor e
INNER JOIN tl_member m ON m.id = e.member
WHERE COALESCE(NULLIF(e.survey, 0), e.pid) = ?
ORDER BY m.lastname ASC, m.firstname ASC, m.email ASC
SQL,
[$surveyId],
);
$members = [];
foreach ($rows as $row) {
$name = trim(sprintf('%s %s', (string) $row['firstname'], (string) $row['lastname']));
$members[] = [
'id' => (int) $row['id'],
'name' => '' !== $name ? $name : (string) $row['email'],
];
}
return $members;
}
private function hasListMetadataColumns(): bool
{
if (null !== $this->hasListMetadataColumns) {
return $this->hasListMetadataColumns;
}
$columnNames = array_map(
static fn (string $columnName): string => strtolower($columnName),
array_keys($this->connection->createSchemaManager()->listTableColumns('tl_survey')),
);
return $this->hasListMetadataColumns = !array_diff(
['categoryfilter', 'categorysummary', 'assignedmembersfilter', 'assignedmemberssummary'],
$columnNames,
);
}
private function isValidPublicAlias(string $alias): bool
{
return 1 === preg_match('/^[a-z0-9]{20}$/', $alias);
}
/**
* @return list<int>
*/
private function normalizeCategoryIds(mixed $value): array
{
$categoryIds = is_array($value) ? $value : StringUtil::deserialize($value, true);
$categoryIds = array_values(array_unique(array_filter(array_map('intval', $categoryIds), static fn (int $id): bool => $id > 0)));
sort($categoryIds);
return $categoryIds;
}
}
@@ -0,0 +1,117 @@
<?php
declare(strict_types=1);
namespace Mummert\SurveyBundle\Repository;
use Contao\CoreBundle\Framework\ContaoFramework;
use Doctrine\DBAL\Connection;
use Mummert\SurveyBundle\Model\SurveySubmissionModel;
final class SurveySubmissionRepository
{
public function __construct(
private readonly Connection $connection,
private readonly ContaoFramework $framework,
) {
}
public function findById(int $id): ?SurveySubmissionModel
{
$adapter = $this->framework->getAdapter(SurveySubmissionModel::class);
$model = $adapter->findByPk($id);
return $model instanceof SurveySubmissionModel ? $model : null;
}
public function findOpenBySurveyAndToken(int $surveyId, string $token): ?SurveySubmissionModel
{
$id = $this->connection->fetchOne(
'SELECT id FROM tl_survey_submission WHERE survey = ? AND token = ? AND isFinished != 1 ORDER BY id DESC LIMIT 1',
[$surveyId, $token],
);
return false === $id ? null : $this->findById((int) $id);
}
public function findBySurveyAndToken(int $surveyId, string $token): ?SurveySubmissionModel
{
$id = $this->connection->fetchOne(
'SELECT id FROM tl_survey_submission WHERE survey = ? AND token = ? ORDER BY id DESC LIMIT 1',
[$surveyId, $token],
);
return false === $id ? null : $this->findById((int) $id);
}
public function hasFinishedBySurveyAndToken(int $surveyId, string $token): bool
{
return false !== $this->connection->fetchOne(
'SELECT id FROM tl_survey_submission WHERE survey = ? AND token = ? AND isFinished = 1 ORDER BY id DESC LIMIT 1',
[$surveyId, $token],
);
}
public function create(int $surveyId, string $token, ?int $currentQuestionId): SurveySubmissionModel
{
$now = time();
$this->connection->insert('tl_survey_submission', [
'pid' => $surveyId,
'survey' => $surveyId,
'token' => $token,
'startedAt' => $now,
'completedAt' => 0,
'currentQuestion' => $currentQuestionId ?? 0,
'isFinished' => '',
]);
$id = (int) $this->connection->lastInsertId();
return $this->findById($id) ?? throw new \RuntimeException('Der Umfrage-Durchlauf konnte nicht angelegt werden.');
}
public function updateCurrentQuestion(int $submissionId, ?int $questionId): void
{
$this->connection->update('tl_survey_submission', [
'currentQuestion' => $questionId ?? 0,
], [
'id' => $submissionId,
]);
}
public function markFinished(int $submissionId): void
{
$this->connection->update('tl_survey_submission', [
'isFinished' => '1',
'completedAt' => time(),
'currentQuestion' => 0,
], [
'id' => $submissionId,
]);
}
/**
* @return list<array<string, mixed>>
*/
public function findOverviewBySurvey(int $surveyId): array
{
return $this->connection->fetchAllAssociative(
<<<'SQL'
SELECT
sub.id,
sub.token,
sub.startedAt,
sub.completedAt,
sub.isFinished,
COUNT(ans.id) AS answerCount
FROM tl_survey_submission sub
LEFT JOIN tl_survey_answer ans ON ans.submission = sub.id
WHERE sub.survey = :survey
GROUP BY sub.id
ORDER BY sub.startedAt DESC
SQL,
['survey' => $surveyId],
);
}
}
+3
View File
@@ -0,0 +1,3 @@
mummert_survey_bundle_controllers:
resource: ../../Controller/
type: attribute
@@ -0,0 +1,338 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<style>
@page {
margin: 20mm 14mm 18mm;
}
body {
margin: 0;
color: #222222;
font-family: DejaVu Sans, sans-serif;
font-size: 11px;
line-height: 1.45;
background: #ffffff;
}
h1,
h2,
h3,
p {
margin: 0;
}
.page-header {
margin-bottom: 18px;
padding: 16px 18px;
border: 1px solid #d9e1ea;
border-radius: 14px;
background: #f7fafc;
}
.page-header h1 {
font-size: 22px;
color: #0f4f88;
margin-bottom: 8px;
}
.meta-row {
margin-top: 12px;
}
.meta-pill {
display: inline-block;
margin-right: 8px;
margin-bottom: 6px;
padding: 4px 9px;
border-radius: 999px;
background: #eaf2fa;
color: #0f4f88;
font-size: 10px;
font-weight: bold;
}
.question-card {
margin-bottom: 14px;
padding: 14px 16px;
border: 1px solid #dde4ec;
border-radius: 14px;
page-break-inside: avoid;
break-inside: avoid;
}
.question-card h2 {
font-size: 16px;
color: #0f4f88;
margin-bottom: 6px;
}
.question-description {
margin-bottom: 8px;
color: #555e68;
}
.chart-box {
margin: 12px 0 14px;
padding: 10px;
border: 1px solid #e2e7ee;
border-radius: 12px;
background: #fbfdff;
text-align: center;
}
.chart-box img {
display: inline-block;
width: auto;
max-width: 100%;
height: auto;
}
.chart-box.chart-pie img {
width: 170px;
}
.chart-box.chart-line img {
width: 360px;
}
.chart-hint {
margin-top: 8px;
font-size: 9px;
color: #7b8794;
}
.stats-table {
width: 100%;
border-collapse: collapse;
margin-top: 10px;
}
.stats-table th,
.stats-table td {
padding: 7px 8px;
border-bottom: 1px solid #ebeff4;
text-align: left;
vertical-align: top;
}
.stats-table th {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.04em;
color: #667280;
}
.swatch-col {
width: 26px;
}
.swatch {
display: inline-block;
width: 12px;
height: 12px;
border-radius: 999px;
border: 1px solid #d3d9e1;
vertical-align: middle;
}
.numeric {
text-align: right;
white-space: nowrap;
}
.metric-row {
margin: 12px 0 2px;
}
.metric-box {
display: inline-block;
width: 29%;
margin-right: 3%;
padding: 9px 10px;
border: 1px solid #eadfcf;
border-radius: 10px;
background: #fff7f1;
vertical-align: top;
box-sizing: border-box;
}
.metric-box.last {
margin-right: 0;
}
.metric-label {
display: block;
margin-bottom: 4px;
font-size: 9px;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #7b8794;
}
.metric-value {
font-size: 18px;
font-weight: bold;
color: #0f4f88;
}
.response-list {
margin-top: 12px;
padding-left: 18px;
}
.response-list li {
margin-bottom: 6px;
}
.footer-note {
margin-top: 20px;
font-size: 9px;
color: #7b8794;
text-align: right;
}
</style>
</head>
<body>
<header class="page-header">
<h1>{{ survey.title }}</h1>
<p>{{ survey.description ?: 'Keine Beschreibung hinterlegt.' }}</p>
<div class="meta-row">
<span class="meta-pill">{{ completedSubmissionCount }} abgeschlossene Teilnahmen</span>
<span class="meta-pill">{{ questionResults|length }} Fragen</span>
<span class="meta-pill">Export {{ exportedAt }}</span>
</div>
</header>
{% for question in questionResults %}
<section class="question-card">
<h2>{{ question.question }}</h2>
{% if question.description %}
<p class="question-description">{{ question.description }}</p>
{% endif %}
<div class="meta-row">
<span class="meta-pill">{{ question.totalAnswers }} Antworten</span>
<span class="meta-pill">{{ question.responseRate }}% Ruecklauf</span>
</div>
{% if question.pdfChartDataUri %}
<div class="chart-box chart-{{ question.pdfChartType ?: 'default' }}">
<img src="{{ question.pdfChartDataUri }}" alt="Diagramm zu {{ question.question }}">
{% if question.type == 'range' and question.pdfChartLegend %}
<div class="chart-hint">Die Punkte sind farblich den Werten in der Tabelle zugeordnet.</div>
{% endif %}
</div>
{% endif %}
{% if question.type in ['yes_no_maybe', 'choice'] %}
<table class="stats-table">
<thead>
<tr>
<th class="swatch-col">Farbe</th>
<th>Option</th>
<th class="numeric">Anzahl</th>
<th class="numeric">Anteil</th>
</tr>
</thead>
<tbody>
{% for option in question.pdfChartLegend %}
<tr>
<td><span class="swatch" style="background: {{ option.fillColor }}; border-color: {{ option.borderColor }};"></span></td>
<td>{{ option.label }}</td>
<td class="numeric">{{ option.count }}</td>
<td class="numeric">{{ option.percentage }}%</td>
</tr>
{% else %}
<tr>
<td colspan="4">Noch keine Daten vorhanden.</td>
</tr>
{% endfor %}
</tbody>
</table>
{% elseif question.type == 'range' %}
<div class="metric-row">
<div class="metric-box">
<span class="metric-label">Minimum</span>
<span class="metric-value">{{ question.minimum is not null ? question.minimum : '-' }}</span>
</div>
<div class="metric-box">
<span class="metric-label">Maximum</span>
<span class="metric-value">{{ question.maximum is not null ? question.maximum : '-' }}</span>
</div>
<div class="metric-box last">
<span class="metric-label">Durchschnitt</span>
<span class="metric-value">{{ question.average is not null ? question.average : '-' }}</span>
</div>
</div>
<table class="stats-table">
<thead>
<tr>
<th class="swatch-col">Farbe</th>
<th>Wert</th>
<th class="numeric">Anzahl</th>
<th class="numeric">Anteil</th>
</tr>
</thead>
<tbody>
{% for option in question.pdfChartLegend %}
<tr>
<td><span class="swatch" style="background: {{ option.fillColor }}; border-color: {{ option.borderColor }};"></span></td>
<td>{{ option.label }}</td>
<td class="numeric">{{ option.count }}</td>
<td class="numeric">{{ option.percentage }}%</td>
</tr>
{% else %}
<tr>
<td colspan="4">Noch keine Bewertungen vorhanden.</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
{% if question.pdfChartLegend %}
<table class="stats-table">
<thead>
<tr>
<th class="swatch-col">Farbe</th>
<th>Antwortgruppe</th>
<th class="numeric">Anzahl</th>
<th class="numeric">Anteil</th>
</tr>
</thead>
<tbody>
{% for option in question.pdfChartLegend %}
<tr>
<td><span class="swatch" style="background: {{ option.fillColor }}; border-color: {{ option.borderColor }};"></span></td>
<td>{{ option.label }}</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 }}</li>
{% else %}
<li>Noch keine Freitextantworten vorhanden.</li>
{% endfor %}
</ol>
{% endif %}
</section>
{% else %}
<section class="question-card">
<h2>Keine Fragen vorhanden</h2>
<p>Fuer diese Umfrage sind noch keine Fragen angelegt.</p>
</section>
{% endfor %}
<p class="footer-note">PDF-Export des Survey-Bundles</p>
</body>
</html>
+36
View File
@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace Mummert\SurveyBundle\Security;
use Contao\FrontendUser;
use Mummert\SurveyBundle\Model\SurveyModel;
use Mummert\SurveyBundle\Service\SurveyPermissionService;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
final class SurveyEditorVoter extends Voter
{
public const string EDIT = 'SURVEY_EDIT';
public function __construct(private readonly SurveyPermissionService $surveyPermissionService)
{
}
protected function supports(string $attribute, mixed $subject): bool
{
return self::EDIT === $attribute && $subject instanceof SurveyModel;
}
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
{
$user = $token->getUser();
if (!$user instanceof FrontendUser || !$subject instanceof SurveyModel) {
return false;
}
return $this->surveyPermissionService->canEditSurvey($subject, $user);
}
}
@@ -0,0 +1,111 @@
<?php
declare(strict_types=1);
namespace Mummert\SurveyBundle\Service;
use Contao\StringUtil;
use Doctrine\DBAL\Connection;
use Mummert\SurveyBundle\Repository\SurveyEditorRepository;
use Mummert\SurveyBundle\Repository\SurveyRepository;
final class SurveyCategoryAssignmentService
{
public function __construct(
private readonly Connection $connection,
private readonly SurveyRepository $surveyRepository,
private readonly SurveyEditorRepository $surveyEditorRepository,
) {
}
/**
* @return list<int>
*/
public function getMemberCategoryIds(int $memberId): array
{
$value = $this->connection->fetchOne('SELECT surveyCategories FROM tl_member WHERE id = ?', [$memberId]);
return $this->normalizeCategoryIds(false === $value ? [] : $value);
}
public function applyMemberCategoriesToSurvey(int $memberId, int $surveyId): void
{
if ($memberId <= 0 || $surveyId <= 0) {
return;
}
$this->syncSurvey($surveyId, [
$memberId => $this->getMemberCategoryIds($memberId),
]);
}
/**
* @param list<int>|null $categoryIds
*/
public function syncAssignedSurveys(int $memberId, ?array $categoryIds = null): void
{
if ($memberId <= 0) {
return;
}
$normalizedCategoryIds = $this->normalizeCategoryIds($categoryIds ?? $this->getMemberCategoryIds($memberId));
foreach ($this->surveyEditorRepository->findSurveyIdsByMember($memberId) as $surveyId) {
$this->syncSurvey($surveyId, [
$memberId => $normalizedCategoryIds,
]);
}
}
public function syncSurvey(int $surveyId, array $memberCategoryOverrides = []): void
{
if ($surveyId <= 0) {
return;
}
$categoryIds = [];
foreach ($this->surveyEditorRepository->findMemberIdsBySurvey($surveyId) as $memberId) {
$memberCategoryIds = $memberCategoryOverrides[$memberId] ?? $this->getMemberCategoryIds($memberId);
foreach ($this->normalizeCategoryIds($memberCategoryIds) as $categoryId) {
$categoryIds[] = $categoryId;
}
}
$this->surveyRepository->updateCategories($surveyId, $this->normalizeCategoryIds($categoryIds));
}
public function syncSurveyWithoutMember(int $surveyId, int $memberId): void
{
if ($surveyId <= 0) {
return;
}
$categoryIds = [];
foreach ($this->surveyEditorRepository->findMemberIdsBySurvey($surveyId) as $assignedMemberId) {
if ($assignedMemberId === $memberId) {
continue;
}
foreach ($this->getMemberCategoryIds($assignedMemberId) as $categoryId) {
$categoryIds[] = $categoryId;
}
}
$this->surveyRepository->updateCategories($surveyId, $this->normalizeCategoryIds($categoryIds));
}
/**
* @return list<int>
*/
public function normalizeCategoryIds(mixed $value): array
{
$categoryIds = is_array($value) ? $value : StringUtil::deserialize($value, true);
$categoryIds = array_values(array_unique(array_filter(array_map('intval', $categoryIds), static fn (int $id): bool => $id > 0)));
sort($categoryIds);
return $categoryIds;
}
}
+107
View File
@@ -0,0 +1,107 @@
<?php
declare(strict_types=1);
namespace Mummert\SurveyBundle\Service;
use Mummert\SurveyBundle\Form\Model\SurveyConditionData;
use Mummert\SurveyBundle\Form\Model\SurveyEditorData;
use Mummert\SurveyBundle\Form\Model\SurveyQuestionData;
use Mummert\SurveyBundle\Model\SurveyModel;
use Mummert\SurveyBundle\Repository\SurveyConditionRepository;
use Mummert\SurveyBundle\Repository\SurveyEditorRepository;
use Mummert\SurveyBundle\Repository\SurveyQuestionRepository;
use Mummert\SurveyBundle\Repository\SurveyRepository;
final class SurveyEditorService
{
public function __construct(
private readonly SurveyRepository $surveyRepository,
private readonly SurveyQuestionRepository $surveyQuestionRepository,
private readonly SurveyConditionRepository $surveyConditionRepository,
private readonly SurveyEditorRepository $surveyEditorRepository,
private readonly SurveyCategoryAssignmentService $surveyCategoryAssignmentService,
) {
}
public function createSurvey(int $memberId, SurveyEditorData $data): SurveyModel
{
$payload = $data->toArray();
$payload['category'] = $this->surveyCategoryAssignmentService->getMemberCategoryIds($memberId);
$survey = $this->surveyRepository->create($memberId, $payload);
$this->surveyEditorRepository->assignEditor((int) $survey->id, $memberId);
$this->surveyRepository->refreshListMetadata((int) $survey->id);
return $survey;
}
public function updateSurvey(SurveyModel $survey, int $memberId, SurveyEditorData $data): void
{
$this->surveyRepository->update($survey, $memberId, $data->toArray());
}
public function saveQuestion(SurveyModel $survey, SurveyQuestionData $data, int $questionId = 0): void
{
$this->assertUnlocked($survey);
if ($questionId > 0) {
$question = $this->surveyQuestionRepository->findByIdForSurvey((int) $survey->id, $questionId);
if (null === $question) {
throw new \RuntimeException('Die Frage wurde nicht gefunden.');
}
$this->surveyQuestionRepository->update($question, $data->toArray());
$this->surveyRepository->touch((int) $survey->id);
return;
}
$this->surveyQuestionRepository->create((int) $survey->id, $data->toArray());
$this->surveyRepository->touch((int) $survey->id);
}
public function deleteQuestion(SurveyModel $survey, int $questionId): void
{
$this->assertUnlocked($survey);
$this->surveyQuestionRepository->delete((int) $survey->id, $questionId);
$this->surveyRepository->touch((int) $survey->id);
}
public function saveCondition(SurveyModel $survey, SurveyConditionData $data, int $conditionId = 0): void
{
$this->assertUnlocked($survey);
if ($conditionId > 0) {
$condition = $this->surveyConditionRepository->findById($conditionId);
if (null === $condition || (int) $condition->pid !== (int) $survey->id) {
throw new \RuntimeException('Die Bedingung wurde nicht gefunden.');
}
$this->surveyConditionRepository->update($condition, $data->toArray());
$this->surveyRepository->touch((int) $survey->id);
return;
}
$this->surveyConditionRepository->create((int) $survey->id, $data->toArray());
$this->surveyRepository->touch((int) $survey->id);
}
public function deleteCondition(SurveyModel $survey, int $conditionId): void
{
$this->assertUnlocked($survey);
$this->surveyConditionRepository->delete((int) $survey->id, $conditionId);
$this->surveyRepository->touch((int) $survey->id);
}
private function assertUnlocked(SurveyModel $survey): void
{
if ('1' === (string) $survey->isLocked) {
throw new \RuntimeException('Die Umfrage ist gesperrt, weil bereits Antworten existieren.');
}
}
}
+38
View File
@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace Mummert\SurveyBundle\Service;
use Mummert\SurveyBundle\Model\SurveyContentModel;
use Mummert\SurveyBundle\Model\SurveyModel;
use Mummert\SurveyBundle\Repository\SurveyConditionRepository;
use Mummert\SurveyBundle\Repository\SurveyQuestionRepository;
final class SurveyEngine
{
public function __construct(
private readonly SurveyConditionRepository $surveyConditionRepository,
private readonly SurveyQuestionRepository $surveyQuestionRepository,
) {
}
public function resolveConditionalTarget(SurveyModel $survey, SurveyContentModel $sourceQuestion, string $normalizedAnswer): ?SurveyContentModel
{
$condition = $this->surveyConditionRepository->findMatchingCondition(
(int) $survey->id,
(int) $sourceQuestion->id,
mb_strtolower(trim($normalizedAnswer)),
);
if (null === $condition) {
return null;
}
return $this->surveyQuestionRepository->findByIdForSurvey(
(int) $survey->id,
(int) $condition->targetQuestion,
false,
);
}
}
+134
View File
@@ -0,0 +1,134 @@
<?php
declare(strict_types=1);
namespace Mummert\SurveyBundle\Service;
use Mummert\SurveyBundle\Model\SurveyContentModel;
use Mummert\SurveyBundle\Model\SurveyModel;
use Mummert\SurveyBundle\Model\SurveySubmissionModel;
use Mummert\SurveyBundle\Repository\SurveyRepository;
use Mummert\SurveyBundle\Repository\SurveyQuestionRepository;
final class SurveyFlowService
{
public function __construct(
private readonly SurveyEngine $surveyEngine,
private readonly SurveyRepository $surveyRepository,
private readonly SurveyQuestionRepository $surveyQuestionRepository,
) {
}
public function resolveCurrentQuestion(SurveyModel $survey, SurveySubmissionModel $submission): ?SurveyContentModel
{
if ('1' === (string) $submission->isFinished) {
return null;
}
$questions = $this->getOrderedQuestions($survey);
$currentQuestionId = (int) $submission->currentQuestion;
if ($currentQuestionId > 0) {
foreach ($questions as $question) {
if ((int) $question->id === $currentQuestionId) {
return $question;
}
}
}
return $questions[0] ?? null;
}
public function determineNextQuestion(SurveyModel $survey, SurveyContentModel $currentQuestion, string $normalizedAnswer): ?SurveyContentModel
{
$questions = $this->getOrderedQuestions($survey);
$configuredJumpTarget = $this->resolveYesNoJumpTarget($currentQuestion, $questions, $normalizedAnswer);
if ($configuredJumpTarget instanceof SurveyContentModel) {
return $configuredJumpTarget;
}
$conditionalTarget = $this->surveyEngine->resolveConditionalTarget($survey, $currentQuestion, $normalizedAnswer);
if ($conditionalTarget instanceof SurveyContentModel) {
return $conditionalTarget;
}
foreach ($questions as $index => $question) {
if ((int) $question->id === (int) $currentQuestion->id) {
return $questions[$index + 1] ?? null;
}
}
return null;
}
/**
* @return array{current:int,total:int,percentage:int}
*/
public function getProgress(SurveyModel $survey, ?SurveyContentModel $currentQuestion): array
{
$questions = $this->getOrderedQuestions($survey);
$total = max(1, count($questions));
$current = 1;
if ($currentQuestion instanceof SurveyContentModel) {
foreach ($questions as $index => $question) {
if ((int) $question->id === (int) $currentQuestion->id) {
$current = $index + 1;
break;
}
}
} else {
$current = $total;
}
return [
'current' => $current,
'total' => $total,
'percentage' => (int) round(($current / $total) * 100),
];
}
/**
* @return list<SurveyContentModel>
*/
private function getOrderedQuestions(SurveyModel $survey): array
{
$surveyId = (int) $survey->id;
if ($this->surveyQuestionRepository->countPublishedBySurvey($surveyId) > 0) {
return $this->surveyQuestionRepository->findAllBySurvey($surveyId, true);
}
return $this->surveyQuestionRepository->findAllBySurvey($surveyId);
}
/**
* @param list<SurveyContentModel> $questions
*/
private function resolveYesNoJumpTarget(SurveyContentModel $currentQuestion, array $questions, string $normalizedAnswer): ?SurveyContentModel
{
if ('yes_no_maybe' !== (string) $currentQuestion->type) {
return null;
}
$targetId = match ($normalizedAnswer) {
'yes' => (int) ($currentQuestion->jumpOnYes ?? 0),
'no' => (int) ($currentQuestion->jumpOnNo ?? 0),
default => 0,
};
if ($targetId <= 0) {
return null;
}
foreach ($questions as $question) {
if ((int) $question->id === $targetId) {
return $question;
}
}
return null;
}
}
+29
View File
@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace Mummert\SurveyBundle\Service;
use Contao\FrontendUser;
use Mummert\SurveyBundle\Model\SurveyModel;
use Mummert\SurveyBundle\Repository\SurveyEditorRepository;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
final class SurveyPermissionService
{
public function __construct(private readonly SurveyEditorRepository $surveyEditorRepository)
{
}
public function canEditSurvey(SurveyModel $survey, FrontendUser $member): bool
{
return $this->surveyEditorRepository->isEditor((int) $survey->id, (int) $member->id);
}
public function assertEditorAccess(SurveyModel $survey, FrontendUser $member): void
{
if (!$this->canEditSurvey($survey, $member)) {
throw new AccessDeniedHttpException('Sie duerfen diese Umfrage nicht bearbeiten.');
}
}
}
+242
View File
@@ -0,0 +1,242 @@
<?php
declare(strict_types=1);
namespace Mummert\SurveyBundle\Service;
use Contao\StringUtil;
use Mummert\SurveyBundle\Model\SurveyContentModel;
use Mummert\SurveyBundle\Model\SurveyModel;
use PhpOffice\PhpSpreadsheet\Cell\DataType;
use Mummert\SurveyBundle\Repository\SurveyAnswerRepository;
use Mummert\SurveyBundle\Repository\SurveyQuestionRepository;
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
final class SurveyResultsExportService
{
public function __construct(
private readonly SurveyQuestionRepository $surveyQuestionRepository,
private readonly SurveyAnswerRepository $surveyAnswerRepository,
) {
}
/**
* @param list<array<string, mixed>> $finishedSubmissions
*/
public function createExcelExportResponse(SurveyModel $survey, array $finishedSubmissions): Response
{
[$headers, $columns] = $this->buildExportColumns($this->surveyQuestionRepository->findAllBySurvey((int) $survey->id));
$rows = [
[(string) $survey->title],
$headers,
];
foreach ($finishedSubmissions as $submission) {
$answers = $this->surveyAnswerRepository->findAnswersBySubmission((int) $submission['id']);
$answersByQuestion = [];
foreach ($answers as $answer) {
$answersByQuestion[(int) ($answer['questionId'] ?? 0)] = (string) ($answer['value'] ?? '');
}
$row = [];
foreach ($columns as $column) {
$questionId = (int) $column['questionId'];
$value = $answersByQuestion[$questionId] ?? '';
switch ((string) $column['kind']) {
case 'option':
$selectedValues = array_filter(array_map('trim', explode(' | ', $value)), static fn (string $entry): bool => '' !== $entry);
$row[] = in_array((string) $column['value'], $selectedValues, true) ? 1 : '';
break;
case 'range':
case 'text':
default:
$row[] = $value;
break;
}
}
$rows[] = $row;
}
$response = new Response($this->buildSpreadsheetContent($rows));
$disposition = $response->headers->makeDisposition(
ResponseHeaderBag::DISPOSITION_ATTACHMENT,
$this->buildExportFilename((string) $survey->title)
);
$response->headers->set('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
$response->headers->set('Content-Disposition', $disposition);
$response->headers->set('Cache-Control', 'private, no-store, no-cache, must-revalidate');
$response->headers->set('Pragma', 'no-cache');
$response->headers->set('X-Content-Type-Options', 'nosniff');
return $response;
}
/**
* @param list<SurveyContentModel> $questions
*
* @return array{0:list<string>,1:list<array{questionId:int,kind:string,value:string}>}
*/
private function buildExportColumns(array $questions): array
{
$headers = [];
$columns = [];
foreach (array_values($questions) as $index => $question) {
$questionNumber = $index + 1;
$prefix = 'F'.$questionNumber;
switch ((string) $question->type) {
case 'yes_no_maybe':
foreach ($this->getYesNoMaybeLabels($question) as $label => $value) {
$headers[] = $prefix.' '.$label;
$columns[] = [
'questionId' => (int) $question->id,
'kind' => 'option',
'value' => $value,
];
}
break;
case 'choice':
foreach ($this->getChoiceLabels($question) as $label) {
$headers[] = $prefix.' '.$label;
$columns[] = [
'questionId' => (int) $question->id,
'kind' => 'option',
'value' => $label,
];
}
break;
case 'range':
$headers[] = $prefix.' Wert';
$columns[] = [
'questionId' => (int) $question->id,
'kind' => 'range',
'value' => '',
];
break;
case 'text':
default:
$headers[] = $prefix.' Text';
$columns[] = [
'questionId' => (int) $question->id,
'kind' => 'text',
'value' => '',
];
break;
}
}
return [$headers, $columns];
}
/**
* @return array<string, string>
*/
private function getYesNoMaybeLabels(SurveyContentModel $question): array
{
$labels = [
'ja' => 'yes',
'nein' => 'no',
];
if ('1' === (string) $question->allowMaybe) {
$labels['vielleicht'] = 'maybe';
}
return $labels;
}
/**
* @return list<string>
*/
private function getChoiceLabels(SurveyContentModel $question): array
{
$labels = [];
for ($index = 1; $index <= 10; ++$index) {
$option = trim((string) $question->{'answerOption'.$index});
if ('' !== $option) {
$labels[] = $option;
}
}
return $labels;
}
/**
* @param list<list<int|string|float>> $rows
*/
private function buildSpreadsheetContent(array $rows): string
{
$spreadsheet = new Spreadsheet();
$worksheet = $spreadsheet->getActiveSheet();
$worksheet->setTitle('Ergebnisse');
foreach ($rows as $rowIndex => $row) {
foreach ($row as $columnIndex => $cellValue) {
$coordinate = Coordinate::stringFromColumnIndex($columnIndex + 1).($rowIndex + 1);
if (is_int($cellValue) || is_float($cellValue)) {
$worksheet->setCellValue($coordinate, $cellValue);
continue;
}
$worksheet->setCellValueExplicit($coordinate, (string) $cellValue, DataType::TYPE_STRING);
}
}
$lastColumn = Coordinate::stringFromColumnIndex(max(1, count($rows[1] ?? $rows[0] ?? [])));
$worksheet->getStyle('A1')->getFont()->setBold(true)->setSize(14);
$worksheet->getStyle('A2:'.$lastColumn.'2')->getFont()->setBold(true);
$worksheet->freezePane('A3');
for ($column = 1; $column <= max(1, count($rows[1] ?? $rows[0] ?? [])); ++$column) {
$worksheet->getColumnDimension(Coordinate::stringFromColumnIndex($column))->setAutoSize(true);
}
$writer = new Xlsx($spreadsheet);
ob_start();
try {
$writer->save('php://output');
$content = ob_get_clean();
} catch (\Throwable $throwable) {
ob_end_clean();
$spreadsheet->disconnectWorksheets();
throw $throwable;
}
$spreadsheet->disconnectWorksheets();
return false === $content ? '' : $content;
}
private function buildExportFilename(string $title): string
{
$baseName = StringUtil::stripInsertTags($title);
$baseName = preg_replace('/[^A-Za-z0-9]+/', '-', $baseName ?? '') ?? 'umfrage-ergebnisse';
$baseName = trim($baseName, '-');
if ('' === $baseName) {
$baseName = 'umfrage-ergebnisse';
}
return $baseName.'.xlsx';
}
}
+409
View File
@@ -0,0 +1,409 @@
<?php
declare(strict_types=1);
namespace Mummert\SurveyBundle\Service;
use Contao\Config;
use Mummert\SurveyBundle\Model\SurveyModel;
use Symfony\Component\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\BadGatewayHttpException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\Mime\Part\DataPart;
use Symfony\Component\Mime\Part\Multipart\FormDataPart;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Twig\Environment;
final class SurveyResultsPdfService
{
public function __construct(
private readonly Environment $twig,
private readonly HttpClientInterface $httpClient,
) {
}
public function isConfigured(): bool
{
return null !== $this->getGotenbergBaseUrl();
}
/**
* @param list<array<string, mixed>> $questionResults
*/
public function createPdfExportResponse(SurveyModel $survey, int $completedSubmissionCount, array $questionResults): Response
{
$gotenbergBaseUrl = $this->getGotenbergBaseUrl();
if (null === $gotenbergBaseUrl) {
throw new BadRequestHttpException('Es wurde keine Gotenberg-URL in den Einstellungen hinterlegt.');
}
$html = $this->renderTemplate(
$survey,
$completedSubmissionCount,
$this->prepareQuestionResults($questionResults),
);
$filename = $this->buildFilename((string) $survey->title);
$pdfContent = $this->requestPdfFromGotenberg($gotenbergBaseUrl, $html, $filename);
return new Response(
$pdfContent,
Response::HTTP_OK,
[
'Content-Type' => 'application/pdf',
'Content-Disposition' => sprintf('attachment; filename="%s"', $filename),
],
);
}
private function getGotenbergBaseUrl(): ?string
{
$url = trim((string) Config::get('surveyGotenbergUrl'));
return '' !== $url ? rtrim($url, '/') : null;
}
private function requestPdfFromGotenberg(string $gotenbergBaseUrl, string $html, string $filename): string
{
$formData = new FormDataPart([
'files' => new DataPart($html, 'index.html', 'text/html'),
'paperWidth' => '8.27',
'paperHeight' => '11.7',
'marginTop' => '0.5',
'marginBottom' => '0.5',
'marginLeft' => '0.5',
'marginRight' => '0.5',
'emulatedMediaType' => 'screen',
'printBackground' => 'true',
'failOnConsoleExceptions' => 'true',
'failOnResourceLoadingFailed' => 'true',
]);
$headers = $formData->getPreparedHeaders()->toArray();
$headers['Gotenberg-Output-Filename'] = pathinfo($filename, PATHINFO_FILENAME);
try {
$response = $this->httpClient->request('POST', $gotenbergBaseUrl.'/forms/chromium/convert/html', [
'headers' => $headers,
'body' => $formData->bodyToIterable(),
'timeout' => 60,
]);
} catch (TransportExceptionInterface $exception) {
throw new BadGatewayHttpException('Die Gotenberg-Instanz konnte nicht erreicht werden.', $exception);
}
$statusCode = $response->getStatusCode();
$content = $response->getContent(false);
if (Response::HTTP_OK !== $statusCode) {
$message = trim(strip_tags(substr($content, 0, 500)));
throw new BadGatewayHttpException(sprintf(
'Die Gotenberg-Instanz hat den PDF-Export mit HTTP %d abgelehnt%s',
$statusCode,
'' !== $message ? ': '.$message : '.'
));
}
return $content;
}
/**
* @param list<array<string, mixed>> $questionResults
*
* @return list<array<string, mixed>>
*/
private function prepareQuestionResults(array $questionResults): array
{
$preparedResults = [];
foreach ($questionResults as $questionResult) {
$questionResult['pdfChartType'] = (string) (($questionResult['chart']['type'] ?? '') ?: '');
$questionResult['pdfChartDataUri'] = $this->buildChartDataUri($questionResult);
$questionResult['pdfChartLegend'] = $this->buildChartLegend($questionResult);
$preparedResults[] = $questionResult;
}
return $preparedResults;
}
/**
* @param array<string, mixed> $questionResult
*
* @return list<array{label:string,fillColor:string,borderColor:string,count:int,percentage:int}>
*/
private function buildChartLegend(array $questionResult): array
{
$chart = $questionResult['chart'] ?? null;
if (!is_array($chart)) {
return [];
}
$chartType = (string) ($chart['type'] ?? '');
$sourceRows = match ($chartType) {
'pie' => $questionResult['options'] ?? $questionResult['responseSummary'] ?? [],
'line' => $questionResult['distribution'] ?? [],
default => [],
};
if (!is_array($sourceRows) || [] === $sourceRows) {
return [];
}
$dataset = $chart['data']['datasets'][0] ?? [];
$fillColors = match ($chartType) {
'pie' => is_array($dataset['backgroundColor'] ?? null) ? $dataset['backgroundColor'] : [],
'line' => is_array($dataset['pointBackgroundColor'] ?? null) ? $dataset['pointBackgroundColor'] : [],
default => [],
};
$borderColors = match ($chartType) {
'pie' => is_array($dataset['borderColor'] ?? null) ? $dataset['borderColor'] : [],
'line' => is_array($dataset['pointBackgroundColor'] ?? null) ? $dataset['pointBackgroundColor'] : [],
default => [],
};
$legend = [];
foreach (array_values($sourceRows) as $index => $row) {
if (!is_array($row)) {
continue;
}
$legend[] = [
'label' => (string) ($row['label'] ?? ''),
'fillColor' => (string) ($fillColors[$index] ?? '#D3E4F1'),
'borderColor' => (string) ($borderColors[$index] ?? '#0064AD'),
'count' => (int) ($row['count'] ?? 0),
'percentage' => (int) ($row['percentage'] ?? 0),
];
}
return $legend;
}
private function renderTemplate(SurveyModel $survey, int $completedSubmissionCount, array $questionResults): string
{
$templatePath = __DIR__.'/../Resources/views/pdf/survey_results.html.twig';
$templateContent = file_get_contents($templatePath);
if (false === $templateContent) {
throw new \RuntimeException('Die PDF-Template-Datei konnte nicht gelesen werden: '.$templatePath);
}
return $this->twig->createTemplate($templateContent)->render([
'survey' => $survey,
'completedSubmissionCount' => $completedSubmissionCount,
'questionResults' => $questionResults,
'exportedAt' => (new \DateTimeImmutable())->format('d.m.Y H:i'),
]);
}
/**
* @param array<string, mixed> $questionResult
*/
private function buildChartDataUri(array $questionResult): ?string
{
$chart = $questionResult['chart'] ?? null;
if (!is_array($chart)) {
return null;
}
$svg = match ((string) ($chart['type'] ?? '')) {
'pie' => $this->buildPieChartSvg($questionResult, $chart),
'line' => $this->buildLineChartSvg($questionResult, $chart),
default => null,
};
if (null === $svg) {
return null;
}
return 'data:image/svg+xml;base64,'.base64_encode($svg);
}
/**
* @param array<string, mixed> $questionResult
* @param array<string, mixed> $chart
*/
private function buildPieChartSvg(array $questionResult, array $chart): ?string
{
$options = $questionResult['options'] ?? $questionResult['responseSummary'] ?? [];
if (!is_array($options) || [] === $options) {
return $this->buildEmptyChartSvg('Noch keine Daten');
}
$dataset = $chart['data']['datasets'][0] ?? null;
$fillColors = is_array($dataset['backgroundColor'] ?? null) ? $dataset['backgroundColor'] : [];
$borderColors = is_array($dataset['borderColor'] ?? null) ? $dataset['borderColor'] : [];
$counts = array_map(static fn (array $option): int => (int) ($option['count'] ?? 0), $options);
$total = array_sum($counts);
if ($total <= 0) {
return $this->buildEmptyChartSvg('Noch keine Daten');
}
$centerX = 140.0;
$centerY = 140.0;
$radius = 88.0;
$startAngle = -90.0;
$segments = [];
foreach ($options as $index => $option) {
$count = (int) ($option['count'] ?? 0);
if ($count <= 0) {
continue;
}
$sweepAngle = ($count / $total) * 360.0;
$endAngle = $startAngle + $sweepAngle;
$largeArcFlag = $sweepAngle > 180.0 ? 1 : 0;
$startPoint = $this->polarToCartesian($centerX, $centerY, $radius, $startAngle);
$endPoint = $this->polarToCartesian($centerX, $centerY, $radius, $endAngle);
$fill = (string) ($fillColors[$index] ?? '#D3E4F1');
$stroke = (string) ($borderColors[$index] ?? '#0064AD');
$segments[] = sprintf(
'<path d="M %.2f %.2f L %.2f %.2f A %.2f %.2f 0 %d 1 %.2f %.2f Z" fill="%s" stroke="%s" stroke-width="2" />',
$centerX,
$centerY,
$startPoint['x'],
$startPoint['y'],
$radius,
$radius,
$largeArcFlag,
$endPoint['x'],
$endPoint['y'],
$this->escapeXml($fill),
$this->escapeXml($stroke),
);
$startAngle = $endAngle;
}
return sprintf(
'<svg xmlns="http://www.w3.org/2000/svg" width="280" height="280" viewBox="0 0 280 280" role="img" aria-label="Kreisdiagramm"><rect width="280" height="280" rx="24" fill="#ffffff"/><circle cx="140" cy="140" r="108" fill="#f6f8fb"/><g>%s</g><circle cx="140" cy="140" r="26" fill="#ffffff"/><text x="140" y="146" text-anchor="middle" font-size="18" font-family="DejaVu Sans" fill="#222222">%d</text></svg>',
implode('', $segments),
$total,
);
}
/**
* @param array<string, mixed> $questionResult
* @param array<string, mixed> $chart
*/
private function buildLineChartSvg(array $questionResult, array $chart): ?string
{
$distribution = $questionResult['distribution'] ?? [];
if (!is_array($distribution) || [] === $distribution) {
return $this->buildEmptyChartSvg('Noch keine Bewertungen');
}
$labels = array_map(static fn (array $row): string => (string) ($row['label'] ?? ''), $distribution);
$values = array_map(static fn (array $row): int => (int) ($row['count'] ?? 0), $distribution);
$maxValue = max(1, max($values));
$width = 560.0;
$height = 280.0;
$paddingLeft = 58.0;
$paddingRight = 22.0;
$paddingTop = 24.0;
$paddingBottom = 44.0;
$plotWidth = $width - $paddingLeft - $paddingRight;
$plotHeight = $height - $paddingTop - $paddingBottom;
$steps = max(1, count($values) - 1);
$dataset = is_array($chart['data']['datasets'][0] ?? null) ? $chart['data']['datasets'][0] : [];
$lineColor = (string) ($dataset['borderColor'] ?? '#0064AD');
$areaFill = (string) ($dataset['backgroundColor'] ?? 'rgba(236, 124, 50, 0.12)');
$pointColors = is_array($chart['data']['datasets'][0]['pointBackgroundColor'] ?? null) ? $chart['data']['datasets'][0]['pointBackgroundColor'] : [];
$polylinePoints = [];
$areaPolylinePoints = [];
$pointCircles = [];
$xLabels = [];
$yGrid = [];
for ($tick = 0; $tick <= $maxValue; ++$tick) {
$ratio = $maxValue > 0 ? $tick / $maxValue : 0;
$y = $paddingTop + $plotHeight - ($ratio * $plotHeight);
$yGrid[] = sprintf('<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="#d7dee8" stroke-width="1" />', $paddingLeft, $y, $width - $paddingRight, $y);
$yGrid[] = sprintf('<text x="%.2f" y="%.2f" text-anchor="end" font-size="11" font-family="DejaVu Sans" fill="#555e68">%d</text>', $paddingLeft - 8, $y + 4, $tick);
}
foreach ($values as $index => $value) {
$x = $paddingLeft + ($plotWidth * ($index / $steps));
$y = $paddingTop + $plotHeight - (($value / $maxValue) * $plotHeight);
$polylinePoints[] = sprintf('%.2f,%.2f', $x, $y);
$areaPolylinePoints[] = sprintf('%.2f,%.2f', $x, $y);
$pointCircles[] = sprintf('<circle cx="%.2f" cy="%.2f" r="5.5" fill="%s" stroke="#ffffff" stroke-width="2" />', $x, $y, $this->escapeXml((string) ($pointColors[$index] ?? '#EC7C32')));
$xLabels[] = sprintf('<text x="%.2f" y="%.2f" text-anchor="middle" font-size="11" font-family="DejaVu Sans" fill="#555e68">%s</text>', $x, $height - 16, $this->escapeXml($labels[$index]));
}
$areaPoints = sprintf(
'%.2f,%.2f %s %.2f,%.2f',
$paddingLeft,
$paddingTop + $plotHeight,
implode(' ', $areaPolylinePoints),
$width - $paddingRight,
$paddingTop + $plotHeight,
);
return sprintf(
'<svg xmlns="http://www.w3.org/2000/svg" width="560" height="280" viewBox="0 0 560 280" role="img" aria-label="Liniendiagramm"><rect width="560" height="280" rx="22" fill="#ffffff"/><rect x="1" y="1" width="558" height="278" rx="22" fill="none" stroke="#e3e7ee"/><g>%s</g><line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="#8a95a3" stroke-width="1.2"/><line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="#8a95a3" stroke-width="1.2"/><polygon fill="%s" stroke="none" points="%s"/><polyline fill="none" stroke="%s" stroke-width="3.5" stroke-linejoin="round" stroke-linecap="round" points="%s"/><g>%s</g><g>%s</g></svg>',
implode('', $yGrid),
$paddingLeft,
$paddingTop + $plotHeight,
$width - $paddingRight,
$paddingTop + $plotHeight,
$paddingLeft,
$paddingTop,
$paddingLeft,
$paddingTop + $plotHeight,
$this->escapeXml($areaFill),
$areaPoints,
$this->escapeXml($lineColor),
implode(' ', $polylinePoints),
implode('', $pointCircles),
implode('', $xLabels),
);
}
private function buildEmptyChartSvg(string $label): string
{
return sprintf(
'<svg xmlns="http://www.w3.org/2000/svg" width="560" height="220" viewBox="0 0 560 220" role="img" aria-label="Leeres Diagramm"><rect width="560" height="220" rx="22" fill="#f6f8fb"/><rect x="1" y="1" width="558" height="218" rx="22" fill="none" stroke="#e3e7ee"/><text x="280" y="114" text-anchor="middle" font-size="18" font-family="DejaVu Sans" fill="#555e68">%s</text></svg>',
$this->escapeXml($label),
);
}
/**
* @return array{x:float,y:float}
*/
private function polarToCartesian(float $centerX, float $centerY, float $radius, float $angleInDegrees): array
{
$angleInRadians = deg2rad($angleInDegrees);
return [
'x' => $centerX + ($radius * cos($angleInRadians)),
'y' => $centerY + ($radius * sin($angleInRadians)),
];
}
private function escapeXml(string $value): string
{
return htmlspecialchars($value, ENT_QUOTES | ENT_XML1, 'UTF-8');
}
private function buildFilename(string $title): string
{
$normalizedTitle = preg_replace('/\s+/', '_', trim($title)) ?: 'Umfrage';
$sanitizedTitle = preg_replace('/[^\pL\pN_-]+/u', '', $normalizedTitle) ?: 'Umfrage';
return sprintf('%s_Ergebnisse.pdf', trim($sanitizedTitle, '_-'));
}
}
+395
View File
@@ -0,0 +1,395 @@
<?php
declare(strict_types=1);
namespace Mummert\SurveyBundle\Service;
use Mummert\SurveyBundle\Model\SurveyContentModel;
use Mummert\SurveyBundle\Model\SurveyModel;
use Mummert\SurveyBundle\Repository\SurveyAnswerRepository;
use Mummert\SurveyBundle\Repository\SurveyQuestionRepository;
final class SurveyResultsViewService
{
public function __construct(
private readonly SurveyQuestionRepository $surveyQuestionRepository,
private readonly SurveyAnswerRepository $surveyAnswerRepository,
private readonly SurveySubmissionService $surveySubmissionService,
) {
}
/**
* @return array{completedSubmissionCount:int,questionResults:list<array<string, mixed>>}
*/
public function buildForSurvey(SurveyModel $survey): array
{
$overview = $this->surveySubmissionService->getSubmissionOverview($survey);
$finishedSubmissions = array_values(array_filter(
$overview,
static fn (array $submission): bool => '1' === (string) ($submission['isFinished'] ?? '')
));
$completedSubmissionCount = count($finishedSubmissions);
$questions = $this->surveyQuestionRepository->findAllBySurvey((int) $survey->id);
$answers = $this->surveyAnswerRepository->findFinishedAnswersBySurvey((int) $survey->id);
return [
'completedSubmissionCount' => $completedSubmissionCount,
'questionResults' => $this->buildQuestionResults($questions, $answers, $completedSubmissionCount),
];
}
/**
* @param list<SurveyContentModel> $questions
* @param list<array{questionId:int,value:string}> $answers
*
* @return list<array<string, mixed>>
*/
private function buildQuestionResults(array $questions, array $answers, int $completedSubmissionCount): array
{
$answersByQuestion = [];
foreach ($answers as $answer) {
$answersByQuestion[$answer['questionId']][] = $answer['value'];
}
$results = [];
foreach ($questions as $question) {
$questionId = (int) $question->id;
$values = $answersByQuestion[$questionId] ?? [];
$totalAnswers = count($values);
$responseRate = $completedSubmissionCount > 0 ? (int) round(($totalAnswers / $completedSubmissionCount) * 100) : 0;
$result = [
'id' => $questionId,
'question' => (string) $question->question,
'description' => (string) ($question->description ?? ''),
'type' => (string) $question->type,
'totalAnswers' => $totalAnswers,
'responseRate' => $responseRate,
'chart' => null,
];
switch ((string) $question->type) {
case 'yes_no_maybe':
$optionCounts = [
'Ja' => 0,
'Nein' => 0,
];
if ('1' === (string) $question->allowMaybe) {
$optionCounts['Vielleicht'] = 0;
}
foreach ($values as $value) {
$normalizedValue = mb_strtolower(trim($value));
if ('yes' === $normalizedValue) {
++$optionCounts['Ja'];
} elseif ('no' === $normalizedValue) {
++$optionCounts['Nein'];
} elseif ('maybe' === $normalizedValue && isset($optionCounts['Vielleicht'])) {
++$optionCounts['Vielleicht'];
}
}
$result['options'] = $this->buildOptionStats($optionCounts);
$result['chart'] = $this->buildPieChartConfig($result['options']);
break;
case 'choice':
$optionCounts = [];
for ($index = 1; $index <= 10; ++$index) {
$option = trim((string) $question->{'answerOption'.$index});
if ('' !== $option) {
$optionCounts[$option] = 0;
}
}
foreach ($values as $value) {
foreach (array_filter(array_map('trim', explode(' | ', $value)), static fn (string $entry): bool => '' !== $entry) as $selectedOption) {
if (!array_key_exists($selectedOption, $optionCounts)) {
$optionCounts[$selectedOption] = 0;
}
++$optionCounts[$selectedOption];
}
}
$result['options'] = $this->buildOptionStats($optionCounts);
$result['chart'] = $this->buildPieChartConfig($result['options']);
break;
case 'range':
$numericValues = array_values(array_map(static fn (string $value): int => (int) $value, $values));
$distribution = [];
foreach ($numericValues as $numericValue) {
$label = (string) $numericValue;
$distribution[$label] = ($distribution[$label] ?? 0) + 1;
}
ksort($distribution, SORT_NATURAL);
$result['minimum'] = [] !== $numericValues ? min($numericValues) : null;
$result['maximum'] = [] !== $numericValues ? max($numericValues) : null;
$result['average'] = [] !== $numericValues ? round(array_sum($numericValues) / count($numericValues), 2) : null;
$result['distribution'] = $this->buildOptionStats($distribution);
$result['chart'] = $this->buildRangeChartConfig($result['distribution']);
break;
case 'text':
default:
$result['responses'] = array_values(array_filter($values, static fn (string $value): bool => '' !== trim($value)));
$result['responseSummary'] = $this->buildTextResponseSummary($result['responses']);
$result['chart'] = $this->buildPieChartConfig($result['responseSummary']);
break;
}
$results[] = $result;
}
return $results;
}
/**
* @param array<string, int> $optionCounts
*
* @return list<array{label:string,count:int,percentage:int}>
*/
private function buildOptionStats(array $optionCounts): array
{
$total = array_sum($optionCounts);
$options = [];
foreach ($optionCounts as $label => $count) {
$options[] = [
'label' => $label,
'count' => $count,
'percentage' => $total > 0 ? (int) round(($count / $total) * 100) : 0,
];
}
return $options;
}
/**
* @param list<array{label:string,count:int,percentage:int}> $options
*
* @return array<string, mixed>|null
*/
private function buildPieChartConfig(array $options): ?array
{
if ([] === $options) {
return null;
}
$palette = $this->buildChartPalette(count($options));
return [
'type' => 'pie',
'data' => [
'labels' => array_column($options, 'label'),
'datasets' => [[
'data' => array_column($options, 'count'),
'backgroundColor' => array_column($palette, 'background'),
'borderColor' => array_column($palette, 'border'),
'borderWidth' => 2,
'hoverOffset' => 18,
]],
],
'options' => [
'responsive' => true,
'maintainAspectRatio' => false,
'plugins' => [
'legend' => [
'position' => 'bottom',
'labels' => [
'usePointStyle' => true,
'boxWidth' => 12,
'padding' => 18,
'color' => '#555E68',
'font' => [
'family' => 'PT Sans Narrow',
'size' => 12,
'weight' => '600',
],
],
],
'tooltip' => [
'backgroundColor' => 'rgba(34, 34, 34, 0.92)',
'titleFont' => [
'family' => 'Blogger Sans',
'size' => 14,
],
'bodyFont' => [
'family' => 'PT Sans Narrow',
'size' => 12,
],
],
],
],
];
}
/**
* @param list<array{label:string,count:int,percentage:int}> $distribution
*
* @return array<string, mixed>|null
*/
private function buildRangeChartConfig(array $distribution): ?array
{
if ([] === $distribution) {
return null;
}
$pointStyles = ['circle', 'rectRounded', 'triangle', 'star', 'rectRot', 'crossRot'];
$palette = $this->buildChartPalette(count($distribution));
return [
'type' => 'line',
'data' => [
'labels' => array_column($distribution, 'label'),
'datasets' => [[
'label' => 'Antworten',
'data' => array_column($distribution, 'count'),
'borderColor' => '#0064AD',
'backgroundColor' => 'rgba(236, 124, 50, 0.12)',
'pointBackgroundColor' => array_column($palette, 'border'),
'pointBorderColor' => '#ffffff',
'pointBorderWidth' => 3,
'pointHoverBorderWidth' => 3,
'pointRadius' => 7,
'pointHoverRadius' => 9,
'pointStyle' => array_map(
static fn (int $index): string => $pointStyles[$index % count($pointStyles)],
array_keys($distribution)
),
'fill' => true,
'tension' => 0.32,
]],
],
'options' => [
'responsive' => true,
'maintainAspectRatio' => false,
'plugins' => [
'legend' => [
'display' => false,
],
'tooltip' => [
'backgroundColor' => 'rgba(34, 34, 34, 0.92)',
'titleFont' => [
'family' => 'Blogger Sans',
'size' => 14,
],
'bodyFont' => [
'family' => 'PT Sans Narrow',
'size' => 12,
],
],
],
'scales' => [
'x' => [
'grid' => [
'display' => false,
],
'ticks' => [
'color' => '#555E68',
'font' => [
'family' => 'PT Sans Narrow',
'weight' => '600',
],
],
],
'y' => [
'beginAtZero' => true,
'ticks' => [
'precision' => 0,
'color' => '#555E68',
'font' => [
'family' => 'PT Sans Narrow',
'weight' => '600',
],
],
'grid' => [
'color' => 'rgba(162, 168, 180, 0.22)',
],
],
],
],
];
}
/**
* @param list<string> $responses
*
* @return list<array{label:string,count:int,percentage:int}>
*/
private function buildTextResponseSummary(array $responses): array
{
if ([] === $responses) {
return [];
}
$counts = [];
foreach ($responses as $response) {
$label = trim($response);
if ('' === $label) {
continue;
}
$counts[$label] = ($counts[$label] ?? 0) + 1;
}
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 list<array{background:string,border:string}>
*/
private function buildChartPalette(int $count): array
{
$palette = [
['background' => 'rgba(0, 100, 173, 0.16)', 'border' => '#0064AD'],
['background' => 'rgba(236, 124, 50, 0.24)', 'border' => '#EC7C32'],
['background' => 'rgba(157, 82, 118, 0.18)', 'border' => '#9D5276'],
['background' => 'rgba(0, 174, 151, 0.22)', 'border' => '#00AE97'],
['background' => 'rgba(252, 228, 214, 0.9)', 'border' => '#EC7C32'],
['background' => 'rgba(162, 168, 180, 0.28)', 'border' => '#555E68'],
['background' => 'rgba(233, 233, 235, 0.9)', 'border' => '#A2A8B4'],
['background' => 'rgba(34, 34, 34, 0.12)', 'border' => '#222222'],
];
$colors = [];
for ($index = 0; $index < max(1, $count); ++$index) {
$colors[] = $palette[$index % count($palette)];
}
return $colors;
}
}
+113
View File
@@ -0,0 +1,113 @@
<?php
declare(strict_types=1);
namespace Mummert\SurveyBundle\Service;
use Mummert\SurveyBundle\Model\SurveyContentModel;
use Mummert\SurveyBundle\Model\SurveyModel;
use Mummert\SurveyBundle\Model\SurveySubmissionModel;
use Mummert\SurveyBundle\QuestionType\QuestionTypeRegistry;
use Mummert\SurveyBundle\Repository\SurveyAnswerRepository;
use Mummert\SurveyBundle\Repository\SurveyRepository;
use Mummert\SurveyBundle\Repository\SurveySubmissionRepository;
use Symfony\Component\HttpFoundation\Session\SessionInterface;
use Symfony\Component\Uid\Ulid;
final class SurveySubmissionService
{
public function __construct(
private readonly SurveySubmissionRepository $surveySubmissionRepository,
private readonly SurveyAnswerRepository $surveyAnswerRepository,
private readonly SurveyRepository $surveyRepository,
private readonly QuestionTypeRegistry $questionTypeRegistry,
) {
}
public function resolveActiveSubmission(SurveyModel $survey, SessionInterface $session, ?SurveyContentModel $firstQuestion): SurveySubmissionModel
{
$sessionKey = $this->buildSessionKey((int) $survey->id);
$token = (string) $session->get($sessionKey, '');
if ('' !== $token) {
$existing = $this->surveySubmissionRepository->findBySurveyAndToken((int) $survey->id, $token);
if ($existing instanceof SurveySubmissionModel) {
return $existing;
}
}
$token = (string) new Ulid();
$submission = $this->surveySubmissionRepository->create((int) $survey->id, $token, $firstQuestion?->id ? (int) $firstQuestion->id : null);
$session->set($sessionKey, $token);
return $submission;
}
public function storeAnswer(SurveyModel $survey, SurveySubmissionModel $submission, SurveyContentModel $question, mixed $rawAnswer): string
{
$questionType = $this->questionTypeRegistry->get((string) $question->type);
$normalizedAnswer = $questionType->normalizeAnswer($rawAnswer, $question);
$surveyHadAnswers = $this->surveyAnswerRepository->hasAnswersForSurvey((int) $survey->id);
$this->surveyAnswerRepository->saveAnswer(
(int) $submission->id,
(int) $question->id,
(string) $question->type,
$normalizedAnswer,
);
if (!$surveyHadAnswers && '1' !== (string) $survey->isLocked) {
$this->surveyRepository->lock((int) $survey->id);
}
return $normalizedAnswer;
}
public function advance(SurveySubmissionModel $submission, ?SurveyContentModel $nextQuestion): void
{
if ($nextQuestion instanceof SurveyContentModel) {
$this->surveySubmissionRepository->updateCurrentQuestion((int) $submission->id, (int) $nextQuestion->id);
return;
}
$this->surveySubmissionRepository->markFinished((int) $submission->id);
}
/**
* @return list<array<string, mixed>>
*/
public function getAnswersForSubmission(SurveySubmissionModel $submission): array
{
return $this->surveyAnswerRepository->findAnswersBySubmission((int) $submission->id);
}
/**
* @return list<array<string, mixed>>
*/
public function getSubmissionOverview(SurveyModel $survey): array
{
return $this->surveySubmissionRepository->findOverviewBySurvey((int) $survey->id);
}
public function hasFinishedSubmissionForSurvey(int $surveyId, SessionInterface $session): bool
{
if ($surveyId <= 0) {
return false;
}
$token = (string) $session->get($this->buildSessionKey($surveyId), '');
if ('' === $token) {
return false;
}
return $this->surveySubmissionRepository->hasFinishedBySurveyAndToken($surveyId, $token);
}
private function buildSessionKey(int $surveyId): string
{
return 'mummert_survey_submission_'.$surveyId;
}
}
+15
View File
@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace Mummert\SurveyBundle;
use Symfony\Component\HttpKernel\Bundle\Bundle;
class SurveyBundle extends Bundle
{
public function getPath(): string
{
return dirname(__DIR__);
}
}