Initial survey bundle import
This commit is contained in:
@@ -0,0 +1 @@
|
||||
.DS_Store
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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'],
|
||||
];
|
||||
@@ -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',
|
||||
];
|
||||
@@ -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],
|
||||
];
|
||||
@@ -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'],
|
||||
];
|
||||
@@ -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'],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
],
|
||||
],
|
||||
];
|
||||
@@ -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',
|
||||
],
|
||||
],
|
||||
];
|
||||
@@ -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',
|
||||
],
|
||||
],
|
||||
];
|
||||
@@ -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',
|
||||
],
|
||||
],
|
||||
];
|
||||
@@ -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',
|
||||
],
|
||||
],
|
||||
];
|
||||
@@ -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 ''",
|
||||
],
|
||||
],
|
||||
];
|
||||
@@ -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.'];
|
||||
@@ -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.'];
|
||||
@@ -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.'];
|
||||
@@ -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'];
|
||||
@@ -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.'];
|
||||
@@ -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.'];
|
||||
@@ -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.'];
|
||||
@@ -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.
@@ -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 |
Executable
+67
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 31 KiB |
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mummert\SurveyBundle\Form\Model;
|
||||
|
||||
final class SurveyAnswerData
|
||||
{
|
||||
public mixed $answer = null;
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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' => [],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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' => [],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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, '_-'));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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__);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user