commit b8475e3a57a0e74bed58c1d66f01070e9a0e577b Author: Jürgen Mummert Date: Sun May 17 17:31:28 2026 +0200 Initial survey bundle import diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..496ee2c --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.DS_Store \ No newline at end of file diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..699c356 --- /dev/null +++ b/composer.json @@ -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 + } + } +} \ No newline at end of file diff --git a/config/services.yaml b/config/services.yaml new file mode 100644 index 0000000..7086991 --- /dev/null +++ b/config/services.yaml @@ -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 \ No newline at end of file diff --git a/contao/config/config.php b/contao/config/config.php new file mode 100644 index 0000000..71e7883 --- /dev/null +++ b/contao/config/config.php @@ -0,0 +1,21 @@ + ['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'], +]; \ No newline at end of file diff --git a/contao/dca/tl_member.php b/contao/dca/tl_member.php new file mode 100644 index 0000000..e99f50f --- /dev/null +++ b/contao/dca/tl_member.php @@ -0,0 +1,55 @@ +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', +]; \ No newline at end of file diff --git a/contao/dca/tl_module.php b/contao/dca/tl_module.php new file mode 100644 index 0000000..07d82c5 --- /dev/null +++ b/contao/dca/tl_module.php @@ -0,0 +1,32 @@ + &$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], +]; \ No newline at end of file diff --git a/contao/dca/tl_settings.php b/contao/dca/tl_settings.php new file mode 100644 index 0000000..1cbc921 --- /dev/null +++ b/contao/dca/tl_settings.php @@ -0,0 +1,30 @@ +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'], +]; \ No newline at end of file diff --git a/contao/dca/tl_survey.php b/contao/dca/tl_survey.php new file mode 100644 index 0000000..0a975c6 --- /dev/null +++ b/contao/dca/tl_survey.php @@ -0,0 +1,212 @@ + [ + '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'], + ); + } +} \ No newline at end of file diff --git a/contao/dca/tl_survey_answer.php b/contao/dca/tl_survey_answer.php new file mode 100644 index 0000000..01bd1b9 --- /dev/null +++ b/contao/dca/tl_survey_answer.php @@ -0,0 +1,60 @@ + [ + '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', + ], + ], +]; \ No newline at end of file diff --git a/contao/dca/tl_survey_category.php b/contao/dca/tl_survey_category.php new file mode 100644 index 0000000..0445ab3 --- /dev/null +++ b/contao/dca/tl_survey_category.php @@ -0,0 +1,70 @@ + [ + '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', + ], + ], +]; \ No newline at end of file diff --git a/contao/dca/tl_survey_condition.php b/contao/dca/tl_survey_condition.php new file mode 100644 index 0000000..cfc7fd0 --- /dev/null +++ b/contao/dca/tl_survey_condition.php @@ -0,0 +1,93 @@ + [ + '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', + ], + ], +]; \ No newline at end of file diff --git a/contao/dca/tl_survey_content.php b/contao/dca/tl_survey_content.php new file mode 100644 index 0000000..7f07827 --- /dev/null +++ b/contao/dca/tl_survey_content.php @@ -0,0 +1,241 @@ + [ + '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', + ], + ], +]; \ No newline at end of file diff --git a/contao/dca/tl_survey_editor.php b/contao/dca/tl_survey_editor.php new file mode 100644 index 0000000..65d4847 --- /dev/null +++ b/contao/dca/tl_survey_editor.php @@ -0,0 +1,75 @@ + [ + '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', + ], + ], +]; \ No newline at end of file diff --git a/contao/dca/tl_survey_submission.php b/contao/dca/tl_survey_submission.php new file mode 100644 index 0000000..28c857b --- /dev/null +++ b/contao/dca/tl_survey_submission.php @@ -0,0 +1,74 @@ + [ + '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 ''", + ], + ], +]; \ No newline at end of file diff --git a/contao/languages/de/modules.php b/contao/languages/de/modules.php new file mode 100644 index 0000000..5a4f9ca --- /dev/null +++ b/contao/languages/de/modules.php @@ -0,0 +1,10 @@ + '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.']; \ No newline at end of file diff --git a/contao/languages/de/tl_survey_editor.php b/contao/languages/de/tl_survey_editor.php new file mode 100644 index 0000000..860b205 --- /dev/null +++ b/contao/languages/de/tl_survey_editor.php @@ -0,0 +1,4 @@ + + @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%; + } + } + + \ No newline at end of file diff --git a/contao/templates/frontend/_survey_branding.html.twig b/contao/templates/frontend/_survey_branding.html.twig new file mode 100644 index 0000000..38669a0 --- /dev/null +++ b/contao/templates/frontend/_survey_branding.html.twig @@ -0,0 +1,3 @@ +
+ +
\ No newline at end of file diff --git a/contao/templates/frontend/member_survey_edit.html.twig b/contao/templates/frontend/member_survey_edit.html.twig new file mode 100644 index 0000000..349e4d4 --- /dev/null +++ b/contao/templates/frontend/member_survey_edit.html.twig @@ -0,0 +1,193 @@ +{% include '@Survey/frontend/_survey_assets.html.twig' %} +{% include '@Survey/frontend/_survey_branding.html.twig' %} + +
+ {% if headline.text %} +
+

{{ headline.text }}

+
+ {% endif %} + + {% if loginRequired %} +
Dieses Frontendmodul ist fuer eingeloggte Mitglieder gedacht. Nutzen Sie den normalen Contao-Mitgliederlogin auf einer geschuetzten Seite.
+ {% elseif createMode %} +
+

Neue Umfrage erstellen

+

Die Umfrage wird nach dem Anlegen automatisch dem eingeloggten Mitglied zugewiesen.

+ {{ form_start(surveyForm, {attr: {class: 'survey-form-grid'}}) }} + + + +
+ + {% if backUrl %}Zur Liste{% endif %} +
+ {{ form_end(surveyForm) }} +
+ {% else %} + {% set locked = survey.isLocked == '1' %} +
+ {% if backUrl %}Zur Liste{% endif %} + {% if publicUrl %}Oeffentliche Ansicht oeffnen{% endif %} +
+ +
+
Oeffentlicher Link
{% if publicUrl %}{{ publicUrl }}{% else %}Noch keine oeffentliche Seite hinterlegt.{% endif %}
+
Status
{{ survey.published ? 'veroeffentlicht' : 'entwurf' }}{% if locked %}gesperrt{% endif %}
+
Bearbeitende
{{ editors|length }} Mitglied(er)
+
+ + {% if locked %} +
Die Umfrage ist strukturell gesperrt, weil bereits Antworten vorliegen. Stammdaten koennen weiter gepflegt werden, der Ablauf und die Fragen jedoch nicht.
+ {% endif %} + +
+
+
+

Umfrage-Metadaten

+ {{ form_start(surveyForm, {attr: {class: 'survey-form-grid'}}) }} + + + +
+ {{ form_end(surveyForm) }} +
+ +
+

Bearbeitende Mitglieder

+
+ {% for editor in editors %} +
+ {{ [editor.firstname, editor.lastname]|join(' ')|trim ?: editor.email }}
+ {{ editor.email }} +
+ {% else %} +
Keine Editoren hinterlegt.
+ {% endfor %} +
+
+ +
+

Antworten und Ergebnisse

+
+ {% for submission in submissions %} +
+
+ {{ submission.answerCount }} Antworten + {{ submission.isFinished ? 'abgeschlossen' : 'offen' }} +
+
Begonnen: {{ submission.startedAt ? submission.startedAt|date('d.m.Y H:i') : 'unbekannt' }}
+
Beendet: {{ submission.completedAt ? submission.completedAt|date('d.m.Y H:i') : 'unbekannt' }}
+
Teilnahme-Code: {{ submission.token }}
+
+ {% else %} +
Es liegen noch keine Teilnahmen vor.
+ {% endfor %} +
+
+
+ +
+
+

Fragen bearbeiten

{% if activeQuestionId %}Bearbeitungsmodus{% endif %}
+ {% if not locked %} + {% set questionType = questionForm.type.vars.value ?: 'yes_no_maybe' %} + {{ form_start(questionForm, {attr: {class: 'survey-form-grid'}}) }} + + + + + + + + + + +
+ {{ form_end(questionForm) }} + {% endif %} + +
+ {% for question in questions %} +
+
+ {{ question.type }} + {% if question.mandatory %}Pflicht{% endif %} + {% if loop.first %}Start{% endif %} +
+

{{ question.question }}

+

{{ question.description ?: 'Keine Zusatzbeschreibung.' }}

+ {% if not locked %} +
+ Bearbeiten +
+ + + + +
+
+ {% endif %} +
+ {% else %} +
Noch keine Fragen angelegt.
+ {% endfor %} +
+
+ +
+

Sprungregeln

{% if activeConditionId %}Bearbeitungsmodus{% endif %}
+ {% if not locked %} + {{ form_start(conditionForm, {attr: {class: 'survey-form-grid'}}) }} + + + +
+ {{ form_end(conditionForm) }} + {% endif %} + +
+ {% for condition in conditions %} +
+
Wenn Antwort = {{ condition.answerValue }}
+
{{ condition.sourceQuestionLabel ?: ('Frage #' ~ condition.sourceQuestion) }}
+
weiter zu {{ condition.targetQuestionLabel ?: ('Frage #' ~ condition.targetQuestion) }}
+ {% if not locked %} +
+ Bearbeiten +
+ + + + +
+
+ {% endif %} +
+ {% else %} +
Noch keine Bedingungen definiert.
+ {% endfor %} +
+
+
+
+ {% endif %} +
\ No newline at end of file diff --git a/contao/templates/frontend/member_survey_list.html.twig b/contao/templates/frontend/member_survey_list.html.twig new file mode 100644 index 0000000..c1adc0e --- /dev/null +++ b/contao/templates/frontend/member_survey_list.html.twig @@ -0,0 +1,52 @@ +{% include '@Survey/frontend/_survey_assets.html.twig' %} +{% include '@Survey/frontend/_survey_branding.html.twig' %} + +
+ {% if headline.text %} +
+

{{ headline.text }}

+
+ {% endif %} + + {% if loginRequired %} +
Dieser Bereich verwendet den normalen Contao-Mitgliederlogin. Bitte binden Sie das Modul auf einer geschuetzten Seite oder mit einer separaten Login-Seite ein.
+ {% else %} +
+ {% if createUrl %} + Neue Umfrage anlegen + {% endif %} +
+ +
+ {% for survey in surveys %} +
+
+

{{ survey.title }}

+ {{ survey.published ? 'veroeffentlicht' : 'Entwurf' }} + {% if survey.isLocked %} + gesperrt + {% endif %} +
+

{{ survey.description ?: 'Keine Beschreibung hinterlegt.' }}

+
+ {{ survey.questionCount }} Fragen + {{ survey.answerCount }} Antworten +
+
+ {% if survey.editUrl %} + Bearbeiten + {% endif %} + {% if survey.publicUrl %} + Oeffentliche Ansicht + {% endif %} + {% if survey.resultsUrl %} + Ergebnisse anzeigen + {% endif %} +
+
+ {% else %} +
Dem eingeloggten Mitglied ist aktuell keine Umfrage zugewiesen.
+ {% endfor %} +
+ {% endif %} +
\ No newline at end of file diff --git a/contao/templates/frontend/show_survey.html.twig b/contao/templates/frontend/show_survey.html.twig new file mode 100644 index 0000000..b17e7b7 --- /dev/null +++ b/contao/templates/frontend/show_survey.html.twig @@ -0,0 +1,84 @@ +{% include '@Survey/frontend/_survey_assets.html.twig' %} +{% include '@Survey/frontend/_survey_branding.html.twig' %} + +
+ {% if headline.text %} +

{{ headline.text }}

+ {% endif %} + + {% if errorMessage %} +
{{ errorMessage }}
+ {% elseif survey %} +
+
+

{{ survey.title }}

+ {% if progress.total > 0 %}{{ progress.current }}/{{ progress.total }}{% endif %} +
+

{{ survey.description ?: 'Bitte beantworten Sie die folgenden Fragen. Jede Frage wird einzeln angezeigt.' }}

+ + {% if progress.total > 0 %} +
+ {% endif %} +
+ + {% if isComplete %} +
+

Vielen Dank fuers Ausfuellen.

+

Ihre Antworten wurden anonym gespeichert. Es wurde keine Teilnehmeridentitaet mit diesem Umfrage-Durchlauf verknuepft.

+ {% if answers %} +
+ {% for answer in answers %} +
+
{{ answer.questionType }}
+

{{ answer.question }}

+

{{ answer.value }}

+
+ {% endfor %} +
+ {% endif %} +
+ {% elseif question %} +
+
Frage {{ progress.current }}
+

{{ question.question }}

+ {% if question.description %}

{{ question.description }}

{% endif %} + + {{ form_start(surveyForm, {attr: {class: 'survey-form-grid'}}) }} + + {% if question.type == 'yes_no_maybe' %} +
+ {% for child in surveyForm.answer %} + + {% endfor %} +
+ {% elseif question.type == 'choice' %} +
+ {% for child in surveyForm.answer %} + + {% endfor %} +
+ {% elseif question.type == 'range' %} +
+
+
Aktueller Wert
+
{{ question.rangeMin }}
+
+ {{ form_widget(surveyForm.answer, {attr: {'data-survey-range': '1', 'data-survey-range-target': 'survey-range-output-' ~ question.id}}) }} +
{{ question.rangeMin }}{{ question.rangeMax }}
+
+ {% else %} + {{ form_widget(surveyForm.answer) }} + {% endif %} + + {{ form_errors(surveyForm.answer) }} +
+ {{ form_end(surveyForm) }} +
+ {% endif %} + {% endif %} +
\ No newline at end of file diff --git a/contao/templates/frontend/show_survey_results.html.twig b/contao/templates/frontend/show_survey_results.html.twig new file mode 100644 index 0000000..85886d8 --- /dev/null +++ b/contao/templates/frontend/show_survey_results.html.twig @@ -0,0 +1,162 @@ +{% include '@Survey/frontend/_survey_assets.html.twig' %} +{% include '@Survey/frontend/_survey_branding.html.twig' %} + +
+ {% if headline.text %} +
+

{{ headline.text }}

+
+ {% endif %} + + {% if errorMessage %} +
{{ errorMessage }}
+ {% elseif survey %} +
+
+

{{ survey.title }}

+ {{ completedSubmissionCount }} abgeschlossene Teilnahmen + {{ questionResults|length }} Fragen +
+ +

{{ survey.description ?: 'Keine Beschreibung hinterlegt.' }}

+ + {% if downloadUrl or pdfDownloadUrl %} +
+ {% if downloadUrl %} + Ergebnisse als Excel herunterladen + {% endif %} + {% if pdfDownloadUrl %} + Ergebnisse als PDF herunterladen + {% endif %} +
+ {% endif %} +
+ +
+ {% for question in questionResults %} +
+
+

{{ question.question }}

+ {{ question.totalAnswers }} Antworten + {{ question.responseRate }}% Ruecklauf +
+ + {% if question.description %} +

{{ question.description }}

+ {% endif %} + +
+ {% if question.chart %} +
+
+ +
+
+ {% endif %} + +
+ {% if question.type in ['yes_no_maybe', 'choice'] %} +
+ {% for option in question.options %} +
+
+ {{ option.label }} + {{ option.count }} + {{ option.percentage }}% +
+
+ +
+
+ {% endfor %} +
+ {% elseif question.type == 'range' %} +
+
+ Minimum + {{ question.minimum is not null ? question.minimum : '-' }} +
+
+ Maximum + {{ question.maximum is not null ? question.maximum : '-' }} +
+
+ Durchschnitt + {{ question.average is not null ? question.average : '-' }} +
+
+ +
+ {% for option in question.distribution %} +
+
+ {{ option.label }} + {{ option.count }} + {{ option.percentage }}% +
+
+ +
+
+ {% else %} +

Noch keine Bewertungen vorhanden.

+ {% endfor %} +
+ {% else %} + {% if question.responseSummary is defined and question.responseSummary %} +
+ {% for option in question.responseSummary %} +
+
+ {{ option.label }} + {{ option.count }} + {{ option.percentage }}% +
+
+ +
+
+ {% endfor %} +
+ {% endif %} + +
+ {% for response in question.responses %} +
{{ response|nl2br }}
+ {% else %} +

Noch keine Freitextantworten vorhanden.

+ {% endfor %} +
+ {% endif %} +
+
+
+ {% else %} +
Fuer diese Umfrage sind noch keine Fragen angelegt.
+ {% endfor %} +
+ {% endif %} +
+ + + \ No newline at end of file diff --git a/public/fonts/Blogger Sans-Medium.woff2 b/public/fonts/Blogger Sans-Medium.woff2 new file mode 100644 index 0000000..f385396 Binary files /dev/null and b/public/fonts/Blogger Sans-Medium.woff2 differ diff --git a/public/fonts/PTSansNarrow-Bold.woff2 b/public/fonts/PTSansNarrow-Bold.woff2 new file mode 100644 index 0000000..2040bb0 Binary files /dev/null and b/public/fonts/PTSansNarrow-Bold.woff2 differ diff --git a/public/fonts/PTSansNarrow-Regular.woff2 b/public/fonts/PTSansNarrow-Regular.woff2 new file mode 100644 index 0000000..af5a3c6 Binary files /dev/null and b/public/fonts/PTSansNarrow-Regular.woff2 differ diff --git a/public/images/Logo_Landesverband.svg b/public/images/Logo_Landesverband.svg new file mode 100644 index 0000000..4ec7ece --- /dev/null +++ b/public/images/Logo_Landesverband.svg @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/images/kigu.svg b/public/images/kigu.svg new file mode 100755 index 0000000..4d96dbd --- /dev/null +++ b/public/images/kigu.svg @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + + diff --git a/src/Contao/Manager/Plugin.php b/src/Contao/Manager/Plugin.php new file mode 100644 index 0000000..b86f412 --- /dev/null +++ b/src/Contao/Manager/Plugin.php @@ -0,0 +1,34 @@ +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') + ; + } +} \ No newline at end of file diff --git a/src/Controller/FrontendModule/MemberSurveyEditController.php b/src/Controller/FrontendModule/MemberSurveyEditController.php new file mode 100644 index 0000000..12ac2f7 --- /dev/null +++ b/src/Controller/FrontendModule/MemberSurveyEditController.php @@ -0,0 +1,273 @@ +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 + */ + 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; + } +} \ No newline at end of file diff --git a/src/Controller/FrontendModule/MemberSurveyListController.php b/src/Controller/FrontendModule/MemberSurveyListController.php new file mode 100644 index 0000000..a8aa428 --- /dev/null +++ b/src/Controller/FrontendModule/MemberSurveyListController.php @@ -0,0 +1,80 @@ +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; + } +} \ No newline at end of file diff --git a/src/Controller/FrontendModule/ShowSurveyController.php b/src/Controller/FrontendModule/ShowSurveyController.php new file mode 100644 index 0000000..f2bf974 --- /dev/null +++ b/src/Controller/FrontendModule/ShowSurveyController.php @@ -0,0 +1,126 @@ +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); + } +} \ No newline at end of file diff --git a/src/Controller/FrontendModule/ShowSurveyResultsController.php b/src/Controller/FrontendModule/ShowSurveyResultsController.php new file mode 100644 index 0000000..01c8744 --- /dev/null +++ b/src/Controller/FrontendModule/ShowSurveyResultsController.php @@ -0,0 +1,93 @@ +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', '')); + } +} \ No newline at end of file diff --git a/src/Controller/SurveyResultsExportController.php b/src/Controller/SurveyResultsExportController.php new file mode 100644 index 0000000..df41ae9 --- /dev/null +++ b/src/Controller/SurveyResultsExportController.php @@ -0,0 +1,61 @@ + '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'], + ); + } +} \ No newline at end of file diff --git a/src/DependencyInjection/SurveyExtension.php b/src/DependencyInjection/SurveyExtension.php new file mode 100644 index 0000000..b372962 --- /dev/null +++ b/src/DependencyInjection/SurveyExtension.php @@ -0,0 +1,33 @@ +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'); + } +} \ No newline at end of file diff --git a/src/EventListener/SurveyDcaListener.php b/src/EventListener/SurveyDcaListener.php new file mode 100644 index 0000000..fe23866 --- /dev/null +++ b/src/EventListener/SurveyDcaListener.php @@ -0,0 +1,477 @@ +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( + '%s', + 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( + '%s', + 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( + '%s', + 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( + '%s', + htmlspecialchars($resultsUrl, ENT_QUOTES), + htmlspecialchars($title, ENT_QUOTES), + $attributes, + Image::getHtml($icon ?? 'show.svg', $label).' '.$label + ); + } + + /** + * @return array + */ + public function getQuestionOptions(?DataContainer $dataContainer = null): array + { + $surveyId = $this->resolveSurveyId('tl_survey_condition', $dataContainer); + return $this->buildQuestionOptions($surveyId); + } + + /** + * @return array + */ + public function getQuestionOptionsForContent(?DataContainer $dataContainer = null): array + { + $surveyId = $this->resolveSurveyId('tl_survey_content', $dataContainer); + + return $this->buildQuestionOptions($surveyId); + } + + /** + * @return array + */ + 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 + */ + public function getMemberOptions(): array + { + return $this->surveyEditorRepository->findMemberChoices(); + } + + /** + * @return array + */ + 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 + */ + public function getSurveyOptions(): array + { + return $this->surveyEditorRepository->findSurveyChoices(); + } + + /** + * @return list + */ + public function loadMemberAssignedSurveys(mixed $value, DataContainer $dataContainer): array + { + $memberId = (int) ($dataContainer->id ?? 0); + + return $memberId > 0 ? $this->surveyEditorRepository->findSurveyIdsByMember($memberId) : []; + } + + /** + * @return list + */ + 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 + */ + 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 + */ + 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 + */ + 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); + } +} \ No newline at end of file diff --git a/src/Form/Model/SurveyAnswerData.php b/src/Form/Model/SurveyAnswerData.php new file mode 100644 index 0000000..16f5455 --- /dev/null +++ b/src/Form/Model/SurveyAnswerData.php @@ -0,0 +1,10 @@ +sourceQuestion = (int) $condition->sourceQuestion; + $data->answerValue = (string) $condition->answerValue; + $data->targetQuestion = (int) $condition->targetQuestion; + + return $data; + } + + /** + * @return array + */ + public function toArray(): array + { + return [ + 'sourceQuestion' => $this->sourceQuestion, + 'answerValue' => $this->answerValue, + 'targetQuestion' => $this->targetQuestion, + ]; + } +} \ No newline at end of file diff --git a/src/Form/Model/SurveyEditorData.php b/src/Form/Model/SurveyEditorData.php new file mode 100644 index 0000000..e2af18c --- /dev/null +++ b/src/Form/Model/SurveyEditorData.php @@ -0,0 +1,36 @@ +title = (string) $survey->title; + $data->description = (string) $survey->description; + $data->published = '1' === (string) $survey->published; + + return $data; + } + + /** + * @return array + */ + public function toArray(): array + { + return [ + 'title' => $this->title, + 'description' => $this->description, + 'published' => $this->published, + ]; + } +} \ No newline at end of file diff --git a/src/Form/Model/SurveyQuestionData.php b/src/Form/Model/SurveyQuestionData.php new file mode 100644 index 0000000..b671b34 --- /dev/null +++ b/src/Form/Model/SurveyQuestionData.php @@ -0,0 +1,86 @@ +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 + */ + 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; + } +} \ No newline at end of file diff --git a/src/Form/SurveyConditionEditorType.php b/src/Form/SurveyConditionEditorType.php new file mode 100644 index 0000000..54c5153 --- /dev/null +++ b/src/Form/SurveyConditionEditorType.php @@ -0,0 +1,48 @@ +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' => [], + ]); + } +} \ No newline at end of file diff --git a/src/Form/SurveyEditorType.php b/src/Form/SurveyEditorType.php new file mode 100644 index 0000000..1e11795 --- /dev/null +++ b/src/Form/SurveyEditorType.php @@ -0,0 +1,44 @@ +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, + ]); + } +} \ No newline at end of file diff --git a/src/Form/SurveyQuestionAnswerType.php b/src/Form/SurveyQuestionAnswerType.php new file mode 100644 index 0000000..690b918 --- /dev/null +++ b/src/Form/SurveyQuestionAnswerType.php @@ -0,0 +1,40 @@ +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]); + } +} \ No newline at end of file diff --git a/src/Form/SurveyQuestionEditorType.php b/src/Form/SurveyQuestionEditorType.php new file mode 100644 index 0000000..19f25c8 --- /dev/null +++ b/src/Form/SurveyQuestionEditorType.php @@ -0,0 +1,155 @@ +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' => [], + ]); + } +} \ No newline at end of file diff --git a/src/Model/SurveyAnswerModel.php b/src/Model/SurveyAnswerModel.php new file mode 100644 index 0000000..20efccb --- /dev/null +++ b/src/Model/SurveyAnswerModel.php @@ -0,0 +1,12 @@ +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 + */ + 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; + } +} \ No newline at end of file diff --git a/src/QuestionType/QuestionTypeInterface.php b/src/QuestionType/QuestionTypeInterface.php new file mode 100644 index 0000000..dc0306f --- /dev/null +++ b/src/QuestionType/QuestionTypeInterface.php @@ -0,0 +1,22 @@ + + */ + 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; +} \ No newline at end of file diff --git a/src/QuestionType/QuestionTypeRegistry.php b/src/QuestionType/QuestionTypeRegistry.php new file mode 100644 index 0000000..65545da --- /dev/null +++ b/src/QuestionType/QuestionTypeRegistry.php @@ -0,0 +1,46 @@ + + */ + 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 + */ + public function getChoices(): array + { + return [ + 'Ja-Nein-Frage' => 'yes_no_maybe', + 'Offene Frage' => 'text', + 'Bewertungsfrage' => 'range', + 'Single/Multiple-Choice' => 'choice', + ]; + } +} \ No newline at end of file diff --git a/src/QuestionType/RangeQuestionType.php b/src/QuestionType/RangeQuestionType.php new file mode 100644 index 0000000..03893da --- /dev/null +++ b/src/QuestionType/RangeQuestionType.php @@ -0,0 +1,56 @@ + (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; + } +} \ No newline at end of file diff --git a/src/QuestionType/TextQuestionType.php b/src/QuestionType/TextQuestionType.php new file mode 100644 index 0000000..85f77b6 --- /dev/null +++ b/src/QuestionType/TextQuestionType.php @@ -0,0 +1,48 @@ + 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); + } +} \ No newline at end of file diff --git a/src/QuestionType/YesNoMaybeQuestionType.php b/src/QuestionType/YesNoMaybeQuestionType.php new file mode 100644 index 0000000..862fa60 --- /dev/null +++ b/src/QuestionType/YesNoMaybeQuestionType.php @@ -0,0 +1,61 @@ +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)); + } +} \ No newline at end of file diff --git a/src/Repository/SurveyAnswerRepository.php b/src/Repository/SurveyAnswerRepository.php new file mode 100644 index 0000000..62825ff --- /dev/null +++ b/src/Repository/SurveyAnswerRepository.php @@ -0,0 +1,104 @@ +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 + */ + 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> + */ + 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], + ); + } +} \ No newline at end of file diff --git a/src/Repository/SurveyConditionRepository.php b/src/Repository/SurveyConditionRepository.php new file mode 100644 index 0000000..8dfa675 --- /dev/null +++ b/src/Repository/SurveyConditionRepository.php @@ -0,0 +1,93 @@ +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> + */ + 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, + ]); + } +} \ No newline at end of file diff --git a/src/Repository/SurveyEditorRepository.php b/src/Repository/SurveyEditorRepository.php new file mode 100644 index 0000000..840bcfd --- /dev/null +++ b/src/Repository/SurveyEditorRepository.php @@ -0,0 +1,177 @@ +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 + */ + 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 + */ + 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 + */ + 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 $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 $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> + */ + 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 + */ + 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 $ids + * + * @return list + */ + 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; + } +} \ No newline at end of file diff --git a/src/Repository/SurveyQuestionRepository.php b/src/Repository/SurveyQuestionRepository.php new file mode 100644 index 0000000..dbc6fbf --- /dev/null +++ b/src/Repository/SurveyQuestionRepository.php @@ -0,0 +1,163 @@ +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 + */ + 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; + } +} \ No newline at end of file diff --git a/src/Repository/SurveyRepository.php b/src/Repository/SurveyRepository.php new file mode 100644 index 0000000..559e7c5 --- /dev/null +++ b/src/Repository/SurveyRepository.php @@ -0,0 +1,346 @@ +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> + */ + 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 $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 $categoryIds + * + * @return list + */ + 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 + */ + 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 + */ + 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; + } +} \ No newline at end of file diff --git a/src/Repository/SurveySubmissionRepository.php b/src/Repository/SurveySubmissionRepository.php new file mode 100644 index 0000000..6bac2f4 --- /dev/null +++ b/src/Repository/SurveySubmissionRepository.php @@ -0,0 +1,117 @@ +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> + */ + 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], + ); + } +} \ No newline at end of file diff --git a/src/Resources/config/routes.yaml b/src/Resources/config/routes.yaml new file mode 100644 index 0000000..00c2a0b --- /dev/null +++ b/src/Resources/config/routes.yaml @@ -0,0 +1,3 @@ +mummert_survey_bundle_controllers: + resource: ../../Controller/ + type: attribute \ No newline at end of file diff --git a/src/Resources/views/pdf/survey_results.html.twig b/src/Resources/views/pdf/survey_results.html.twig new file mode 100644 index 0000000..e82388b --- /dev/null +++ b/src/Resources/views/pdf/survey_results.html.twig @@ -0,0 +1,338 @@ + + + + + + + + + + {% for question in questionResults %} +
+

{{ question.question }}

+ + {% if question.description %} +

{{ question.description }}

+ {% endif %} + +
+ {{ question.totalAnswers }} Antworten + {{ question.responseRate }}% Ruecklauf +
+ + {% if question.pdfChartDataUri %} +
+ Diagramm zu {{ question.question }} + {% if question.type == 'range' and question.pdfChartLegend %} +
Die Punkte sind farblich den Werten in der Tabelle zugeordnet.
+ {% endif %} +
+ {% endif %} + + {% if question.type in ['yes_no_maybe', 'choice'] %} + + + + + + + + + + + {% for option in question.pdfChartLegend %} + + + + + + + {% else %} + + + + {% endfor %} + +
FarbeOptionAnzahlAnteil
{{ option.label }}{{ option.count }}{{ option.percentage }}%
Noch keine Daten vorhanden.
+ {% elseif question.type == 'range' %} +
+
+ Minimum + {{ question.minimum is not null ? question.minimum : '-' }} +
+
+ Maximum + {{ question.maximum is not null ? question.maximum : '-' }} +
+
+ Durchschnitt + {{ question.average is not null ? question.average : '-' }} +
+
+ + + + + + + + + + + + {% for option in question.pdfChartLegend %} + + + + + + + {% else %} + + + + {% endfor %} + +
FarbeWertAnzahlAnteil
{{ option.label }}{{ option.count }}{{ option.percentage }}%
Noch keine Bewertungen vorhanden.
+ {% else %} + {% if question.pdfChartLegend %} + + + + + + + + + + + {% for option in question.pdfChartLegend %} + + + + + + + {% endfor %} + +
FarbeAntwortgruppeAnzahlAnteil
{{ option.label }}{{ option.count }}{{ option.percentage }}%
+ {% endif %} + +
    + {% for response in question.responses %} +
  1. {{ response }}
  2. + {% else %} +
  3. Noch keine Freitextantworten vorhanden.
  4. + {% endfor %} +
+ {% endif %} +
+ {% else %} +
+

Keine Fragen vorhanden

+

Fuer diese Umfrage sind noch keine Fragen angelegt.

+
+ {% endfor %} + + + + \ No newline at end of file diff --git a/src/Security/SurveyEditorVoter.php b/src/Security/SurveyEditorVoter.php new file mode 100644 index 0000000..1b5d91e --- /dev/null +++ b/src/Security/SurveyEditorVoter.php @@ -0,0 +1,36 @@ +getUser(); + + if (!$user instanceof FrontendUser || !$subject instanceof SurveyModel) { + return false; + } + + return $this->surveyPermissionService->canEditSurvey($subject, $user); + } +} \ No newline at end of file diff --git a/src/Service/SurveyCategoryAssignmentService.php b/src/Service/SurveyCategoryAssignmentService.php new file mode 100644 index 0000000..bc8b2a2 --- /dev/null +++ b/src/Service/SurveyCategoryAssignmentService.php @@ -0,0 +1,111 @@ + + */ + 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|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 + */ + 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; + } +} \ No newline at end of file diff --git a/src/Service/SurveyEditorService.php b/src/Service/SurveyEditorService.php new file mode 100644 index 0000000..bad0032 --- /dev/null +++ b/src/Service/SurveyEditorService.php @@ -0,0 +1,107 @@ +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.'); + } + } +} \ No newline at end of file diff --git a/src/Service/SurveyEngine.php b/src/Service/SurveyEngine.php new file mode 100644 index 0000000..9962c08 --- /dev/null +++ b/src/Service/SurveyEngine.php @@ -0,0 +1,38 @@ +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, + ); + } +} \ No newline at end of file diff --git a/src/Service/SurveyFlowService.php b/src/Service/SurveyFlowService.php new file mode 100644 index 0000000..4ca62da --- /dev/null +++ b/src/Service/SurveyFlowService.php @@ -0,0 +1,134 @@ +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 + */ + 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 $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; + } +} \ No newline at end of file diff --git a/src/Service/SurveyPermissionService.php b/src/Service/SurveyPermissionService.php new file mode 100644 index 0000000..15f1714 --- /dev/null +++ b/src/Service/SurveyPermissionService.php @@ -0,0 +1,29 @@ +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.'); + } + } +} \ No newline at end of file diff --git a/src/Service/SurveyResultsExportService.php b/src/Service/SurveyResultsExportService.php new file mode 100644 index 0000000..d5d9d00 --- /dev/null +++ b/src/Service/SurveyResultsExportService.php @@ -0,0 +1,242 @@ +> $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 $questions + * + * @return array{0:list,1:list} + */ + 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 + */ + private function getYesNoMaybeLabels(SurveyContentModel $question): array + { + $labels = [ + 'ja' => 'yes', + 'nein' => 'no', + ]; + + if ('1' === (string) $question->allowMaybe) { + $labels['vielleicht'] = 'maybe'; + } + + return $labels; + } + + /** + * @return list + */ + 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> $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'; + } +} \ No newline at end of file diff --git a/src/Service/SurveyResultsPdfService.php b/src/Service/SurveyResultsPdfService.php new file mode 100644 index 0000000..dfdfbf5 --- /dev/null +++ b/src/Service/SurveyResultsPdfService.php @@ -0,0 +1,409 @@ +getGotenbergBaseUrl(); + } + + /** + * @param list> $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> $questionResults + * + * @return list> + */ + 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 $questionResult + * + * @return list + */ + 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 $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 $questionResult + * @param array $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( + '', + $centerX, + $centerY, + $startPoint['x'], + $startPoint['y'], + $radius, + $radius, + $largeArcFlag, + $endPoint['x'], + $endPoint['y'], + $this->escapeXml($fill), + $this->escapeXml($stroke), + ); + + $startAngle = $endAngle; + } + + return sprintf( + '%s%d', + implode('', $segments), + $total, + ); + } + + /** + * @param array $questionResult + * @param array $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('', $paddingLeft, $y, $width - $paddingRight, $y); + $yGrid[] = sprintf('%d', $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('', $x, $y, $this->escapeXml((string) ($pointColors[$index] ?? '#EC7C32'))); + $xLabels[] = sprintf('%s', $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( + '%s%s%s', + 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( + '%s', + $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, '_-')); + } +} \ No newline at end of file diff --git a/src/Service/SurveyResultsViewService.php b/src/Service/SurveyResultsViewService.php new file mode 100644 index 0000000..bdd26d7 --- /dev/null +++ b/src/Service/SurveyResultsViewService.php @@ -0,0 +1,395 @@ +>} + */ + 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 $questions + * @param list $answers + * + * @return list> + */ + 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 $optionCounts + * + * @return list + */ + 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 $options + * + * @return array|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 $distribution + * + * @return array|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 $responses + * + * @return list + */ + 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 + */ + 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; + } +} \ No newline at end of file diff --git a/src/Service/SurveySubmissionService.php b/src/Service/SurveySubmissionService.php new file mode 100644 index 0000000..3eacc4d --- /dev/null +++ b/src/Service/SurveySubmissionService.php @@ -0,0 +1,113 @@ +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> + */ + public function getAnswersForSubmission(SurveySubmissionModel $submission): array + { + return $this->surveyAnswerRepository->findAnswersBySubmission((int) $submission->id); + } + + /** + * @return list> + */ + 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; + } +} \ No newline at end of file diff --git a/src/SurveyBundle.php b/src/SurveyBundle.php new file mode 100644 index 0000000..820dec9 --- /dev/null +++ b/src/SurveyBundle.php @@ -0,0 +1,15 @@ +