├── pages ├── index.php ├── sample.php └── dashboard.php ├── update.php ├── assets ├── rex-poll.js ├── rex-poll.css ├── poll.css ├── poll-dashboard.css └── poll-dashboard.js ├── lib ├── Vote │ └── Answer.php ├── Vote.php ├── transition │ ├── Poll.php │ ├── User.php │ ├── Vote.php │ ├── Question.php │ ├── Answer.php │ └── Choice.php ├── User.php ├── Question │ └── Choice.php ├── Question.php ├── yform │ └── action │ │ └── poll_executevote.php └── poll.php ├── module ├── module_output.inc └── module_input.inc ├── package.yml ├── .github └── workflows │ └── publish-to-redaxo-org.yml ├── LICENSE.md ├── boot.php ├── CHANGELOG.md ├── install.php ├── lang ├── sv_se.lang ├── es_es.lang ├── en_gb.lang └── de_de.lang ├── install ├── sample_poll.json └── tablesets │ └── poll_tables.json ├── fragments └── addons │ └── poll │ └── poll.php └── README.md /pages/index.php: -------------------------------------------------------------------------------- 1 | text; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /lib/Vote.php: -------------------------------------------------------------------------------- 1 | 0 && $poll = Poll::get($pollId)) 11 | { 12 | $fragment = new rex_fragment(); 13 | $fragment->setVar('poll', $poll); 14 | echo $fragment->parse('addons/poll/poll.php'); 15 | } 16 | -------------------------------------------------------------------------------- /lib/transition/Poll.php: -------------------------------------------------------------------------------- 1 | setName('REX_INPUT_VALUE[1]'); 10 | 11 | foreach ($polls as $poll) { 12 | $select->addOption($poll->getTitle(), $poll->getId()); 13 | } 14 | 15 | $select->setSelected('REX_VALUE[1]'); 16 | 17 | echo $select->get(); 18 | -------------------------------------------------------------------------------- /lib/transition/Question.php: -------------------------------------------------------------------------------- 1 | =5.0.0-beta1' 19 | redaxo: '^5.16.1' 20 | php: 21 | version: '>=8.1, <9' 22 | -------------------------------------------------------------------------------- /.github/workflows/publish-to-redaxo-org.yml: -------------------------------------------------------------------------------- 1 | # Instructions: https://github.com/FriendsOfREDAXO/installer-action/ 2 | 3 | name: Publish to REDAXO.org 4 | on: 5 | release: 6 | types: 7 | - published 8 | 9 | jobs: 10 | redaxo_publish: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v3 14 | - uses: FriendsOfREDAXO/installer-action@v1 15 | with: 16 | myredaxo-username: ${{ secrets.MYREDAXO_USERNAME }} 17 | myredaxo-api-key: ${{ secrets.MYREDAXO_API_KEY }} 18 | description: ${{ github.event.release.body }} 19 | version: ${{ github.event.release.tag_name }} 20 | 21 | -------------------------------------------------------------------------------- /lib/User.php: -------------------------------------------------------------------------------- 1 | where('poll_id', $poll->getId()) 14 | ->where('user_hash', $hash) 15 | ->findOne(); 16 | } 17 | 18 | public static function hasVoted(Poll $poll, $hash): bool 19 | { 20 | return self::getVote($poll, $hash) ? true : false; 21 | } 22 | 23 | public static function getHash($salt = ''): string 24 | { 25 | if ('' != $salt) { 26 | return sha1($salt); 27 | } 28 | return sha1( 29 | rex_request::server('HTTP_USER_AGENT', 'string', '') . 30 | rex_request::server('REMOTE_ADDR', 'string', ''), 31 | ); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /lib/Question/Choice.php: -------------------------------------------------------------------------------- 1 | getRelatedDataset('question_id'); 19 | } 20 | 21 | public function getTitle(): string 22 | { 23 | return $this->title; 24 | } 25 | 26 | public function getHits(): int 27 | { 28 | $hits = Answer::query() 29 | ->alias('a') 30 | ->joinRelation('vote_id', 'v') 31 | ->where('a.question_id', $this->getQuestion()->getId()) 32 | ->where('a.question_choice_id', $this->getId()) 33 | ->where('v.status', 1) 34 | ->find(); 35 | 36 | return count($hits); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # MIT License 2 | 3 | Copyright (c) 2017 Friends Of REDAXO 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /boot.php: -------------------------------------------------------------------------------- 1 | getProperty('pages'); 15 | $ycom_tables = ['rex_poll', 'rex_poll_question', 'rex_poll_question_choice', 'rex_poll_vote', 'rex_poll_vote_answer']; 16 | 17 | if (isset($pages) && is_array($pages)) { 18 | foreach ($pages as $page) { 19 | if (in_array($page->getKey(), $ycom_tables)) { 20 | $page->setBlock('poll'); 21 | } 22 | } 23 | } 24 | } 25 | }); 26 | } 27 | 28 | rex_yform_manager_dataset::setModelClass('rex_poll', Poll::class); 29 | rex_yform_manager_dataset::setModelClass('rex_poll_question', Question::class); 30 | rex_yform_manager_dataset::setModelClass('rex_poll_question_choice', Choice::class); 31 | rex_yform_manager_dataset::setModelClass('rex_poll_vote', Vote::class); 32 | rex_yform_manager_dataset::setModelClass('rex_poll_vote_answer', Answer::class); 33 | 34 | if (rex::isBackend() && rex::getUser()) { 35 | rex_view::addCssFile($this->getAssetsUrl('rex-poll.css')); 36 | rex_view::addJsFile($this->getAssetsUrl('rex-poll.js')); 37 | rex_view::addCssFile($this->getAssetsUrl('poll-dashboard.css')); 38 | rex_view::addJsFile($this->getAssetsUrl('poll-dashboard.js')); 39 | } 40 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## Version 2.1.0 (01.08.2025) 4 | 5 | - Umstellung vom Namespace `Poll` auf den Namespace `FriendsOfRedaxo\Poll` 6 | 7 | ## Version 2.0.0 (29.04.2025 - 22.06.2025) 8 | - Pro Umfrage können mehrere Fragen gestellt werden 9 | - Fragen können auch per Texteingabe vom Benutzer beantwortet werden (keine Auswahloptionen erstellen) 10 | - Die Statistik wurde durch ein Dashboard ersetzt 11 | - Optimierungen für PHP >= 8.1 12 | - Readme aktualisiert 13 | - Demodaten, siehe Dashboard 14 | 15 | ## Version 1.4.3 (29.03.2022) 16 | * Fix for YForm 4 17 | 18 | ## Version 1.4.2 (13.11.2021) 19 | * Installation korrigiert 20 | * debug entfernt 21 | 22 | ## Version 1.4 (26.05.2020) 23 | 24 | * Vor einem Update darauf achten, dass sich Sprachschlüssel, Fragmente und Abhängigkeiten verändert haben. 25 | 26 | * Optionen als Inline Relations 27 | * Versionsabgängigkeit nun YForm 3 28 | * Umbau auf Fragmente. 29 | * Sprachkeys fürs Frontend gesetzt 30 | 31 | ## Version 1.3.5 (14.08.2019) 32 | 33 | * Fix für YForm 3 34 | 35 | ## Version 1.3.4 (03.09.2018) 36 | 37 | * Release zur Veröffentlichung im Installer 38 | 39 | ## Version 1.3.3 (18.05.2018) 40 | 41 | * Ausgabe Options-Details im Modul-Output 42 | 43 | ## Version 1.3.2 (18.05.2018) 44 | 45 | * Bugfix 46 | 47 | ## Version 1.3.1 (17.05.2018) 48 | 49 | * Statistikanzeige verbessert, Datenschutzcheckbox 50 | 51 | ## Version 1.3 (16.05.2018) 52 | 53 | * Statistik im Backend 54 | 55 | ## Version 1.2 (14.05.2018) 56 | 57 | * Restrukturierung der Ausgabe 58 | 59 | ## Version 1.1 (08.05.2018) 60 | 61 | * Umfrage mit Emailbestätigung 62 | * YForm basierte Formulare 63 | 64 | ## Version 1.0 (10.05.2017) 65 | 66 | * Erste Version mit reinen Basisfunktionen 67 | -------------------------------------------------------------------------------- /assets/rex-poll.css: -------------------------------------------------------------------------------- 1 | .rex-poll .polls { 2 | display: grid; 3 | grid-template-columns: 1fr 1fr 1fr; 4 | } 5 | 6 | .rex-poll .poll { 7 | padding: 20px; 8 | } 9 | 10 | .rex-poll progress { 11 | height: 6px; 12 | border-radius: 6px; 13 | overflow: hidden; 14 | -webkit-appearance: none; 15 | } 16 | .rex-poll ::-webkit-progress-bar { 17 | background-color: #dfe3e9; 18 | } 19 | .rex-poll ::-moz-progress-bar { 20 | background-color: #4b9ad9; 21 | } 22 | .rex-poll ::-webkit-progress-value { 23 | background-color: #4b9ad9; 24 | } 25 | 26 | .rex-poll .poll:nth-child(even) { 27 | background-color: #f9fcff; 28 | } 29 | .rex-poll .poll-heading { 30 | font-size: 140%; 31 | } 32 | .rex-poll .poll-info-list { 33 | display: flex; 34 | flex-wrap: wrap; 35 | } 36 | .rex-poll .poll-info-list dt { 37 | flex-shrink: 1; 38 | flex-basis: 100px; 39 | } 40 | .rex-poll .poll-info-list dd { 41 | flex-basis: calc(100% - 100px); 42 | } 43 | .rex-poll .poll-result-list { 44 | margin: 0; 45 | padding-left: 0; 46 | list-style: none; 47 | } 48 | .rex-poll .poll-result-list > li { 49 | margin-bottom: 2em; 50 | } 51 | .rex-poll .poll-title { 52 | font-size: 115%; 53 | font-weight: 600; 54 | } 55 | .rex-poll .poll-list-title { 56 | font-size: 100%; 57 | font-weight: 400; 58 | } 59 | .rex-poll .poll-progress { 60 | display: flex; 61 | flex-wrap: wrap; 62 | align-items: center; 63 | margin-top: .75em; 64 | } 65 | .rex-poll .poll-progress-label { 66 | flex-basis: 70%; 67 | } 68 | .rex-poll .poll-progress-bar { 69 | flex-basis: 100%; 70 | order: 3; 71 | } 72 | .rex-poll .poll-progress-value { 73 | order: 2; 74 | flex-basis: 30%; 75 | text-align: right; 76 | } 77 | .rex-poll .poll-progress-value > span { 78 | padding-left: 10px; 79 | } 80 | .rex-poll .poll-answer-list { 81 | max-height: 500px; 82 | overflow-y: scroll; 83 | margin: 0; 84 | padding-left: 0; 85 | list-style: none; 86 | } 87 | .rex-poll .poll-answer-list blockquote { 88 | font-size: 90%; 89 | } 90 | -------------------------------------------------------------------------------- /lib/Question.php: -------------------------------------------------------------------------------- 1 | populateRelation('choices'); 20 | } 21 | 22 | /** 23 | * @return rex_yform_manager_collection|array 24 | */ 25 | public function getChoices() 26 | { 27 | return $this->getRelatedCollection('choices'); 28 | } 29 | 30 | /** 31 | * @return rex_yform_manager_dataset|Poll 32 | */ 33 | public function getPoll() 34 | { 35 | return $this->getRelatedDataset('poll_id'); 36 | } 37 | 38 | public function getDescription(): string 39 | { 40 | return $this->description; 41 | } 42 | 43 | public function getTitle(): string 44 | { 45 | return $this->title; 46 | } 47 | 48 | public function getMedia(): string 49 | { 50 | return $this->media; 51 | } 52 | 53 | public function getUrl(): string 54 | { 55 | return $this->url; 56 | } 57 | 58 | public function getHits(): int 59 | { 60 | $hits = Answer::query() 61 | ->alias('a') 62 | ->joinRelation('vote_id', 'v') 63 | ->where('a.question_id', $this->getId()) 64 | ->where('v.status', 1) 65 | ->groupBy('a.vote_id') 66 | ->find(); 67 | 68 | return count($hits); 69 | } 70 | 71 | public function getAnswers(): rex_yform_manager_collection 72 | { 73 | return Answer::query() 74 | ->alias('a') 75 | ->joinRelation('vote_id', 'v') 76 | ->where('a.question_id', $this->getId()) 77 | ->where('a.question_choice_id', 0) 78 | ->where('a.text', '', '!=') 79 | ->where('v.status', 1) 80 | ->find(); 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /install.php: -------------------------------------------------------------------------------- 1 | setDebug(); 10 | $gm->setQuery('select * from rex_module where output LIKE ?', ['%' . $searchtext . '%']); 11 | $modules = $gm->getArray(); 12 | 13 | if (0 < count($modules)) { 14 | $gm->setQuery('update rex_module set input = :input, output = :output, updatedate = :updatedate, updateuser = :updateuser where id = :module_id', [ 15 | ':input' => rex_file::get(rex_path::addon('poll', 'module/module_input.inc')), 16 | ':output' => rex_file::get(rex_path::addon('poll', 'module/module_output.inc')), 17 | ':updatedate' => date('Y-m-d H:i:s'), 18 | ':updateuser' => 'poll-addon', 19 | ':module_id' => $modules[0]['id'], 20 | ]); 21 | 22 | } else { 23 | $gm->setQuery('insert into rex_module set name = :name, input = :input, output = :output, updatedate = :updatedate, updateuser = :updateuser', [ 24 | ':name' => $yform_module_name, 25 | ':input' => rex_file::get(rex_path::addon('poll', 'module/module_input.inc')), 26 | ':output' => rex_file::get(rex_path::addon('poll', 'module/module_output.inc')), 27 | ':updatedate' => date('Y-m-d H:i:s'), 28 | ':updateuser' => 'poll-addon', 29 | ]); 30 | 31 | } 32 | 33 | $name = 'poll_user'; 34 | 35 | $templates = $gm->getArray('select * from rex_yform_email_template where name = ?', [$name]); 36 | $subject = 'Bestätigung für Umfrage "REX_YFORM_DATA[field="poll-title"]"'; 37 | $body = 'Hallo, 38 | 39 | vielen Dank für deine Beteiligung an der Umfrage. Bitte bestätige deine Wahl unter folgendem Link: REX_YFORM_DATA[field="poll-link"] 40 | 41 | Vielen Dank, 42 | Das Poll-System'; 43 | 44 | if (0 < count($templates)) { 45 | $gm->setQuery('update rex_yform_email_template set subject = :subject, body = :body where id = :template_id', [ 46 | ':subject' => $subject, 47 | ':body' => $body, 48 | ':template_id' => $templates[0]['id'], 49 | ]); 50 | 51 | } else { 52 | 53 | $gm->setQuery('insert into rex_yform_email_template set subject = :subject, body = :body, name = :name', [ 54 | ':name' => $name, 55 | ':subject' => $subject, 56 | ':body' => $body, 57 | ]); 58 | 59 | } 60 | -------------------------------------------------------------------------------- /lang/sv_se.lang: -------------------------------------------------------------------------------- 1 | poll = Enkät 2 | navigation_poll = Enkät 3 | 4 | poll_finished = Enkäten är över 5 | poll_main = Enkät 6 | poll_statistic = Statistiken 7 | poll_options = Enkätalternativ 8 | poll_option = Option 9 | poll_error_pleasechooseapoll = Var god välj en Enkät 10 | poll_option_image = Bild 11 | poll_option_description = Beskrivning 12 | poll_option_link = Länk 13 | poll_option_link_info = inkl. http:// 14 | 15 | poll_email_label = E-post adress 16 | poll_email_note = För bekräftelse av ditt val skickar vi dig ett mail. Ange en giltig e-postadress. 17 | 18 | poll_title = Enkät 19 | poll_online = Online 20 | poll_type = Typ av enkät 21 | poll_type_direct = Enkät direkt 22 | poll_type_email = Enkät begränsat (E-post) 23 | poll_type_hash = Enkät begränsat (Hash) 24 | 25 | poll_showresult = Visa resultat 26 | 27 | poll_result = Resultat 28 | poll_result_always = alltid 29 | poll_result_ifvoted = om det har blivit omröstat 30 | poll_result_never = aldrig 31 | poll_result_ifended = om enkäten avslutades 32 | 33 | poll_votes = Enkät inlägg 34 | poll_create_datetime = Skapelsedatum 35 | poll_userhash = Userhash (identifierare) 36 | 37 | poll_settings = Inställningar 38 | 39 | poll_votes_taken = {0} röster har redan lämnats in 40 | poll_vote_success = Tack! Rösten togs 41 | poll_vote_fail = Röstning kunde inte godtas 42 | poll_vote_confirm = Vi har skickat ett e-postmeddelande till adressen du angav. Vänligen öppna den medföljande länken för att bekräfta ditt val. 43 | poll_vote_exists = Undersökning har redan besvarats 44 | poll_vote_notexists = Ingen omröstning hittades 45 | 46 | poll_error = Ett fel har inträffat 47 | 48 | 49 | ip = IP 50 | 51 | poll_module = Enkät modul 52 | 53 | poll_validate_email = Ange en giltig e-postadress 54 | poll_validate_option = Välj ett alternativ 55 | 56 | statistics = Statistik 57 | comment = Kommentar 58 | with_comment = Med kommentar 59 | 60 | # Sample Data Language Variables 61 | poll_sample_data = Exempeldata 62 | poll_sample_data_info = Installera Exempelenkät 63 | poll_sample_data_description = Installera en exempelenkät "Batman vs Superman vs Wonder Woman" för att bättre förstå och prova systemet. 64 | poll_sample_preview = Förhandsvisning av Exempelenkät 65 | poll_sample_install_button = Installera Exempelenkät 66 | poll_sample_installed_success = Exempelenkäten har installerats framgångsrikt! 67 | poll_sample_install_error = Fel vid installation av exempelenkät 68 | poll_sample_already_exists = En enkät med denna titel finns redan 69 | poll_sample_already_installed = Exempelenkät redan installerad 70 | poll_sample_already_installed_desc = Exempelenkäten har redan installerats. Du kan redigera eller ta bort den i YForm-hanteringen. 71 | poll_sample_votes = Exempelröster 72 | poll_sample_votes_count = röster inkluderade 73 | -------------------------------------------------------------------------------- /lang/es_es.lang: -------------------------------------------------------------------------------- 1 | poll = Encuesta 2 | navigation_poll = Encuesta 3 | 4 | poll_finished = La encuesta ha terminado 5 | poll_main = Encuestas 6 | poll_statistic = Estadística 7 | poll_options = Opciones de encuesta 8 | poll_option = Opción 9 | poll_error_pleasechooseapoll = Por favor seleccione una encuesta 10 | poll_option_image = Imagen 11 | poll_option_description = Descripción 12 | poll_option_link = Enlace 13 | poll_option_link_info = incluyendo http:// 14 | 15 | poll_email_label = E-Mail-Dirección 16 | poll_email_note = Para la confirmación de su elección le enviaremos un email. Por favor, introduzca una dirección de email válida. 17 | 18 | poll_title = Encuesta 19 | poll_online = En línea 20 | poll_type = Tipo de encuesta 21 | poll_type_direct = Encuesta directamente 22 | poll_type_email = Encuesta limitada (email) 23 | poll_type_hash = Encuesta limitada (hash) 24 | 25 | poll_showresult = Mostrar resultado 26 | 27 | poll_result = Resultado 28 | poll_result_always = Siempre 29 | poll_result_ifvoted = Si ha sido votado 30 | poll_result_never = No 31 | poll_result_ifended = Cuando termina la encuesta 32 | 33 | poll_votes = Entradas de encuestas 34 | poll_create_datetime = fecha de creación 35 | poll_userhash = Hash de usuario (identificador) 36 | 37 | poll_settings = Ajustes 38 | 39 | poll_votes_taken = {0} votos ya han sido emitidos 40 | poll_vote_success = Muchas gracias La votación fue tomada 41 | poll_vote_fail = Votación no pudo ser aceptada 42 | poll_vote_confirm = Hemos enviado un email a la dirección que proporcionó. Por favor llame al enlace incluido para confirmar su elección. 43 | poll_vote_exists = Encuesta ya ha sido respondida 44 | poll_vote_notexists = No se encontró voto 45 | 46 | poll_error = Ha ocurrido un error 47 | 48 | 49 | ip = IP 50 | 51 | poll_module = Módulo de encuesta 52 | 53 | poll_validate_email = Por favor, introduzca una dirección de email válida 54 | poll_validate_option = Por favor elige una opción 55 | 56 | statistics = Estadística 57 | 58 | # Sample Data Language Variables 59 | poll_sample_data = Datos de Ejemplo 60 | poll_sample_data_info = Instalar Encuesta de Ejemplo 61 | poll_sample_data_description = Instala una encuesta de ejemplo "Batman vs Superman vs Wonder Woman" para entender mejor y probar el sistema. 62 | poll_sample_preview = Vista Previa de la Encuesta de Ejemplo 63 | poll_sample_install_button = Instalar Encuesta de Ejemplo 64 | poll_sample_installed_success = ¡La encuesta de ejemplo se ha instalado con éxito! 65 | poll_sample_install_error = Error al instalar la encuesta de ejemplo 66 | poll_sample_already_exists = Ya existe una encuesta con este título 67 | poll_sample_already_installed = Encuesta de ejemplo ya instalada 68 | poll_sample_already_installed_desc = La encuesta de ejemplo ya ha sido instalada. Puedes editarla o eliminarla en la gestión de YForm. 69 | poll_sample_votes = Votos de Ejemplo 70 | poll_sample_votes_count = votos incluidos -------------------------------------------------------------------------------- /install/sample_poll.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Batman vs Superman vs Wonder Woman", 3 | "description": "Wer ist euer liebster Superheld? Stimmt ab für euren Favoriten!", 4 | "type": "direct", 5 | "status": 1, 6 | "comment": 0, 7 | "showresult": 1, 8 | "questions": [ 9 | { 10 | "title": "Wer ist euer liebster Superheld?", 11 | "description": "Wählt aus den drei beliebtesten DC-Superhelden", 12 | "media": "", 13 | "url": "", 14 | "choices": [ 15 | { 16 | "title": "Batman - Der dunkle Ritter" 17 | }, 18 | { 19 | "title": "Superman - Der Mann aus Stahl" 20 | }, 21 | { 22 | "title": "Wonder Woman - Die Amazonin" 23 | } 24 | ] 25 | } 26 | ], 27 | "sample_votes": [ 28 | { 29 | "user_hash": "sample_user_001", 30 | "choice_index": 0, 31 | "create_datetime": "2024-01-15 10:30:00", 32 | "comment": "Batman ist einfach der coolste Superheld!" 33 | }, 34 | { 35 | "user_hash": "sample_user_002", 36 | "choice_index": 0, 37 | "create_datetime": "2024-01-15 11:15:00", 38 | "comment": "" 39 | }, 40 | { 41 | "user_hash": "sample_user_003", 42 | "choice_index": 1, 43 | "create_datetime": "2024-01-15 12:00:00", 44 | "comment": "Superman rettet die Welt!" 45 | }, 46 | { 47 | "user_hash": "sample_user_004", 48 | "choice_index": 0, 49 | "create_datetime": "2024-01-15 14:20:00", 50 | "comment": "" 51 | }, 52 | { 53 | "user_hash": "sample_user_005", 54 | "choice_index": 2, 55 | "create_datetime": "2024-01-15 15:45:00", 56 | "comment": "Wonder Woman ist eine starke Heldin!" 57 | }, 58 | { 59 | "user_hash": "sample_user_006", 60 | "choice_index": 0, 61 | "create_datetime": "2024-01-15 16:30:00", 62 | "comment": "" 63 | }, 64 | { 65 | "user_hash": "sample_user_007", 66 | "choice_index": 1, 67 | "create_datetime": "2024-01-16 09:10:00", 68 | "comment": "" 69 | }, 70 | { 71 | "user_hash": "sample_user_008", 72 | "choice_index": 0, 73 | "create_datetime": "2024-01-16 10:25:00", 74 | "comment": "Gotham braucht Batman!" 75 | }, 76 | { 77 | "user_hash": "sample_user_009", 78 | "choice_index": 2, 79 | "create_datetime": "2024-01-16 11:40:00", 80 | "comment": "" 81 | }, 82 | { 83 | "user_hash": "sample_user_010", 84 | "choice_index": 1, 85 | "create_datetime": "2024-01-16 13:55:00", 86 | "comment": "Kein Superheld ist stärker als Superman!" 87 | }, 88 | { 89 | "user_hash": "sample_user_011", 90 | "choice_index": 0, 91 | "create_datetime": "2024-01-16 15:20:00", 92 | "comment": "" 93 | }, 94 | { 95 | "user_hash": "sample_user_012", 96 | "choice_index": 2, 97 | "create_datetime": "2024-01-16 17:35:00", 98 | "comment": "Wonder Woman für immer!" 99 | } 100 | ] 101 | } -------------------------------------------------------------------------------- /fragments/addons/poll/poll.php: -------------------------------------------------------------------------------- 1 | poll; 8 | $hash = rex_request('hash', 'string') != '' ? rex_request('hash', 'string') : User::getHash(); 9 | 10 | echo '

{{ poll_title }}: ' . rex_escape($poll->getTitle()) . '

'; 11 | if ('' !== trim($poll->getDescription())) { 12 | echo '

' . rex_escape(nl2br($poll->getDescription()), 'html_simplified') . '

'; 13 | } 14 | 15 | echo $poll->getOutput(); 16 | 17 | if ($poll->showResult($hash)) { 18 | $items = []; 19 | $hitsAll = 0; 20 | foreach ($poll->getQuestions() as $question) { 21 | $choices = $question->getChoices(); 22 | 23 | if ($choices->isEmpty()) { 24 | continue; 25 | } 26 | 27 | $description = ''; 28 | if ('' !== $question->getDescription()) { 29 | $description = '
' . $question->getDescription() . '
'; 30 | } 31 | 32 | $picture = ''; 33 | if (rex_media::get($question->media)) { 34 | $picture = '
'; 35 | } 36 | 37 | $link = ''; 38 | if ('' != $question->getUrl()) { 39 | $link = ''; 40 | } 41 | 42 | 43 | $progressBar = []; 44 | $progressBarOut = ''; // Initialisieren der Variable, um undefined zu vermeiden 45 | $hitsAll = $question->getHits(); 46 | if ($hitsAll != 0) { 47 | foreach ($choices as $choice) { 48 | $hits = $choice->getHits(); 49 | $percent = (int)($hits / $hitsAll * 100); 50 | 51 | $progressBar[] = 52 | '
53 | ' . rex_escape($choice->getTitle()) . ' 54 | ' . $percent . '% 55 | ' . $percent . '%[' . $hits . '] 56 |
'; 57 | } 58 | $progressBarOut = '
59 | ' . implode('', $progressBar) . ' 60 |
'; 61 | } 62 | 63 | $items[] = 64 | '
  • 65 |
    ' . $question->getTitle() . '
    66 | ' . $description . ' 67 | ' . $picture . ' 68 | ' . $link . ' 69 | ' . $progressBarOut . ' 70 |
  • '; 71 | } 72 | 73 | if ($hitsAll != 0) { 74 | echo 75 | '
    76 |

    {{ poll_result }}

    77 | ' . ($poll->getHits() > 0 ? '

    {{ poll_votes_taken }} ' . $poll->getHits() . '

    ' : '') . ' 78 |
      ' . implode('', $items) . '
    79 |
    '; 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /lib/yform/action/poll_executevote.php: -------------------------------------------------------------------------------- 1 | www.yakamara.de 11 | */ 12 | 13 | class rex_yform_action_poll_executevote extends rex_yform_action_abstract 14 | { 15 | public function executeAction(): void 16 | { 17 | $pollId = $this->params['value_pool']['sql'][$this->getElement(2)]; 18 | $email = isset($this->params['value_pool']['sql'][$this->getElement(3)]) ? $this->params['value_pool']['sql'][$this->getElement(3)] : ''; 19 | $templateId = $this->getElement(4); 20 | $comment = isset($this->params['value_pool']['sql'][$this->getElement(5)]) ? $this->params['value_pool']['sql'][$this->getElement(5)] : ''; 21 | 22 | $poll = Poll::get($pollId); 23 | if ($poll) { 24 | $hash = User::getHash(); 25 | if ('' != $email) { 26 | $hash = User::getHash($email . $poll->getId() . rex::getProperty('instname')); 27 | } 28 | 29 | $answers = []; 30 | foreach ($poll->getQuestions() as $question) { 31 | $choices = $question->getChoices(); 32 | if ($choices->isEmpty()) { 33 | if (isset($this->params['value_pool']['sql']['poll-question-' . $question->getId() . '-answer'])) { 34 | $answers[$question->getId()]['text'] = $this->params['value_pool']['sql']['poll-question-' . $question->getId() . '-answer']; 35 | } 36 | } else { 37 | foreach ($question->getChoices() as $choice) { 38 | if (isset($this->params['value_pool']['sql']['poll-question-' . $question->getId() . '-choice'])) { 39 | $answers[$question->getId()]['choice_id'] = $this->params['value_pool']['sql']['poll-question-' . $question->getId() . '-choice']; 40 | } 41 | } 42 | } 43 | } 44 | 45 | if ($poll->executeVote($answers, $hash, $comment)) { 46 | if ('direct' == $poll->getType()) { 47 | $_REQUEST['vote_success'] = true; 48 | } 49 | if ('email' == $poll->getType()) { 50 | $this->params['value_pool']['email']['poll-link'] = rtrim(rex::getServer(), '/') . rex_getUrl(rex_article::getCurrentid(), rex_clang::getCurrentid(), ['hash' => $hash]); 51 | 52 | $etpl = $poll->getEmailTemplateById($templateId); 53 | if ($etpl) { 54 | $etpl = rex_yform_email_template::replaceVars($etpl, $this->params['value_pool']['email']); 55 | 56 | $etpl['mail_to'] = $email; 57 | $etpl['mail_to_name'] = $email; 58 | 59 | if (!rex_yform_email_template::sendMail($etpl)) { 60 | return; 61 | } 62 | } 63 | 64 | return; 65 | } 66 | } 67 | } 68 | } 69 | 70 | public function getDescription(): string 71 | { 72 | return 'action|poll_executevote|label poll id|label email|email template|comment'; 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /lang/en_gb.lang: -------------------------------------------------------------------------------- 1 | poll = Poll 2 | navigation_poll = Poll 3 | 4 | poll_finished = The poll has ended 5 | poll_main = Polls 6 | poll_statistic = Statistics 7 | poll_options = Poll options 8 | poll_option = Option 9 | poll_error_pleasechooseapoll = Please select a poll 10 | poll_option_image = Image 11 | poll_option_description = Description 12 | poll_option_link = Link 13 | poll_option_link_info = incl. http:// 14 | 15 | poll_email_label = Email address 16 | poll_email_note = To confirm your selection we will send you an email. Please enter a valid email address. 17 | 18 | poll_title = Poll 19 | poll_online = Online 20 | poll_type = Poll type 21 | poll_type_direct = Poll directly 22 | poll_type_email = Restricted poll (email) 23 | poll_type_hash = Restricted poll (hash) 24 | 25 | poll_showresult = Show results 26 | 27 | poll_result = Result 28 | poll_result_always = always 29 | poll_result_ifvoted = when voted 30 | poll_result_never = never 31 | poll_result_ifended = when poll has ended 32 | 33 | poll_votes = Poll entries 34 | poll_create_datetime = Create date 35 | poll_userhash = User hash (key) 36 | 37 | poll_settings = Settings 38 | 39 | poll_votes_taken = {0} entries have been recorded 40 | poll_vote_success = Thank you! Your vote has been received. 41 | poll_vote_fail = Vote could not be received. 42 | poll_vote_confirm = We've sent you an email to the address you entered. Please click on the link in the message to confirm your vote. 43 | poll_vote_exists = Your vote has already been received. 44 | poll_vote_notexists = Poll not found 45 | 46 | poll_error = An error occurred. 47 | 48 | 49 | ip = IP 50 | 51 | poll_module = Poll module 52 | 53 | poll_validate_email = Please enter a valid email address 54 | poll_validate_option = Please select an option 55 | 56 | statistics = Statistics 57 | comment = comment 58 | with_comment = with comment 59 | 60 | poll_answers = Answers 61 | poll_answer = Answer 62 | poll_answer_text = Answer text 63 | poll_questions = Questions 64 | poll_question = Question 65 | poll_question_image = Image 66 | poll_question_description = Description 67 | poll_question_url = Link 68 | poll_question_url_info = incl. https:// 69 | poll_question_choices = Answer choices 70 | poll_question_choice = Answer choice 71 | poll_question_no_choices = If no answer choices are provided, a text field will be displayed for answering. 72 | 73 | poll_dashboard = Dashboard 74 | poll_status_active = Online 75 | poll_status_inactive = Offline 76 | poll_status = Status 77 | poll_dashboard_summary = Summary 78 | poll_dashboard_total_polls = Total polls 79 | poll_dashboard_active_polls = Active polls 80 | poll_dashboard_total_votes = Total votes 81 | poll_dashboard_votes_per_poll = Votes per poll 82 | poll_dashboard_detailed_results = Detailed results 83 | poll_dashboard_activity_over_time = Activity over time 84 | poll_select_poll_to_analyze = Select poll to analyze 85 | poll_please_select = Please select 86 | poll_votes = Votes 87 | poll_votes_per_day = Votes per day 88 | poll_option = Option 89 | poll_percentage = Percentage 90 | poll_text_responses = Text responses 91 | poll_no_text_responses = No text responses available 92 | poll_show_chart = Show chart 93 | 94 | # Sample Data Language Variables 95 | poll_sample_data = Sample Data 96 | poll_sample_data_info = Install Sample Poll 97 | poll_sample_data_description = Install a sample poll "Batman vs Superman vs Wonder Woman" to better understand and try out the system. 98 | poll_sample_preview = Sample Poll Preview 99 | poll_sample_install_button = Install Sample Poll 100 | poll_sample_installed_success = The sample poll has been successfully installed! 101 | poll_sample_install_error = Error installing the sample poll 102 | poll_sample_already_exists = A poll with this title already exists 103 | poll_sample_already_installed = Sample poll already installed 104 | poll_sample_already_installed_desc = The sample poll has already been installed. You can edit or delete it in the YForm management. 105 | poll_sample_votes = Sample Votes 106 | poll_sample_votes_count = votes included 107 | -------------------------------------------------------------------------------- /lang/de_de.lang: -------------------------------------------------------------------------------- 1 | poll = Umfrage 2 | navigation_poll = Umfrage 3 | 4 | poll_finished = Die Umfrage ist beendet 5 | poll_main = Umfragen 6 | poll_statistic = Statistiken 7 | poll_dashboard = Dashboard 8 | poll_options = Umfrage Optionen 9 | poll_option = Option 10 | poll_error_pleasechooseapoll = Bitte eine Umfrage auswählen 11 | poll_option_image = Bild 12 | poll_option_description = Beschreibung 13 | poll_option_link = Link 14 | poll_option_link_info = inkl. http:// 15 | 16 | poll_email_label = E-Mail-Adresse 17 | poll_email_note = Für die Bestätigung deiner Wahl senden wir dir eine E-Mail. Gib hierzu bitte eine gültige E-Mail-Adresse an. 18 | 19 | poll_title = Umfrage 20 | poll_online = Online 21 | poll_status_active = Online 22 | poll_status_inactive = Offline 23 | poll_type = Umfragetyp 24 | poll_type_direct = Umfrage direkt 25 | poll_type_email = Umfrage beschränkt (E-Mail) 26 | poll_type_hash = Umfrage beschränkt (Hash) 27 | 28 | poll_showresult = Ergebnis anzeigen 29 | 30 | poll_result = Ergebnis 31 | poll_result_always = immer 32 | poll_result_ifvoted = wenn gevoted wurde 33 | poll_result_never = nie 34 | poll_result_ifended = wenn Umfrage beendet 35 | 36 | poll_votes = Umfragen Einträge 37 | poll_create_datetime = Erstellungsdatum 38 | poll_userhash = Userhash (Kennung) 39 | 40 | poll_settings = Einstellungen 41 | 42 | poll_votes_taken = Es wurden bereits {0} Stimmen abgegeben 43 | poll_vote_success = Vielen Dank! Die Abstimmung wurde aufgenommen 44 | poll_vote_fail = Abstimmung konnte nicht aufgenommen werden 45 | poll_vote_confirm = Wir haben eine E-Mail an die von dir angegebene Adresse gesendet. Bitte rufe den darin enthaltenen Link auf um deine Wahl zu bestätigen. 46 | poll_vote_exists = Umfrage wurde bereits beantwortet 47 | poll_vote_notexists = Es wurde keine Abstimmung gefunden 48 | 49 | poll_error = Es ist ein Fehler aufgetreten 50 | 51 | 52 | ip = IP 53 | 54 | poll_module = Umfrage Modul 55 | 56 | poll_validate_email = Bitte gib eine gültige E-Mail-Adresse an 57 | poll_validate_option = Bitte wähle eine Option 58 | 59 | statistics = Statistiken 60 | comment = Kommentar 61 | with_comment = Mit Kommentar 62 | 63 | 64 | poll_answers = Antworten 65 | poll_answer = Antwort 66 | poll_answer_text = Antworttext 67 | 68 | poll_questions = Fragen 69 | poll_question = Frage 70 | poll_question_image = Bild 71 | poll_question_description = Beschreibung 72 | poll_question_url = Link 73 | poll_question_url_info = inkl. https:// 74 | 75 | poll_question_choices = Antwortmöglichkeiten 76 | poll_question_choice = Antwortmöglichkeit 77 | poll_question_no_choices = Wenn keine Antwortmöglichkeiten vorgegeben werden, wird ein Textfeld zur Beantwortung angezeigt. 78 | 79 | poll_status = Status 80 | 81 | poll_votes_answer = Umfragen Einträge mit Antworten 82 | poll_vote = Eintrag 83 | 84 | poll_error_please_choose_a_question = Bitte eine Frage auswählen 85 | 86 | # Dashboard Sprachvariablen 87 | poll_dashboard_summary = Zusammenfassung 88 | poll_dashboard_total_polls = Umfragen insgesamt 89 | poll_dashboard_active_polls = Aktive Umfragen 90 | poll_dashboard_total_votes = Gesamtstimmen 91 | poll_dashboard_votes_per_poll = Stimmen pro Umfrage 92 | poll_dashboard_detailed_results = Detaillierte Ergebnisse 93 | poll_dashboard_activity_over_time = Aktivitätsverlauf 94 | poll_select_poll_to_analyze = Umfrage zur Analyse auswählen 95 | poll_please_select = Bitte wählen 96 | poll_votes = Stimmen 97 | poll_votes_per_day = Stimmen pro Tag 98 | poll_option = Option 99 | poll_votes = Stimmen 100 | poll_percentage = Prozent 101 | poll_text_responses = Textantworten 102 | poll_no_text_responses = Keine Textantworten vorhanden 103 | poll_show_chart = Zeige Chart 104 | 105 | # Sample Data Language Variables 106 | poll_sample_data = Beispieldaten 107 | poll_sample_data_info = Beispiel-Umfrage installieren 108 | poll_sample_data_description = Installiere eine Beispiel-Umfrage "Batman vs Superman vs Wonder Woman", um das System besser zu verstehen und auszuprobieren. 109 | poll_sample_preview = Vorschau der Beispiel-Umfrage 110 | poll_sample_install_button = Beispiel-Umfrage installieren 111 | poll_sample_installed_success = Die Beispiel-Umfrage wurde erfolgreich installiert! 112 | poll_sample_install_error = Fehler beim Installieren der Beispiel-Umfrage 113 | poll_sample_already_exists = Eine Umfrage mit diesem Titel existiert bereits 114 | poll_sample_already_installed = Beispiel-Umfrage bereits installiert 115 | poll_sample_already_installed_desc = Die Beispiel-Umfrage wurde bereits installiert. Du kannst sie in der YForm-Verwaltung bearbeiten oder löschen. 116 | poll_sample_votes = Beispiel-Stimmen 117 | poll_sample_votes_count = Stimmen enthalten -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Umfragen 2 | 3 | Erstellt und verwaltet Umfragen in REDAXO 5, bei Bedarf mit E-Mailbestätigung. 4 | 5 | ![Screenshot](https://raw.githubusercontent.com/FriendsOfREDAXO/poll/assets/poll.png) 6 | 7 | ## Installation 8 | 9 | Installationsvoraussetzungen: YForm >4.0, REDAXO ^5.16 10 | 11 | * Ins Backend einloggen und mit dem Installer installieren 12 | 13 | ## Funktionsweise 14 | 15 | Das Poll-AddOn ermöglicht die Erstellung und Verwaltung von Umfragen in REDAXO. Es bietet verschiedene Typen von Umfragen: 16 | 17 | * **Direkt**: Benutzer können direkt abstimmen, ohne weitere Überprüfung 18 | * **Hash**: Jeder Benutzer kann nur einmal abstimmen (Browser-Cookie-basiert) 19 | * **E-Mail**: Benutzer müssen ihre E-Mail-Adresse angeben und erhalten einen Bestätigungslink 20 | 21 | ### Ablauf 22 | 23 | 1. Eine Umfrage erstellen mit den verschiedenen Optionen 24 | 2. Das Umfragemodul auf einer Seite einbinden und dort die entsprechende Umfrage festlegen 25 | 3. Ausgabe in der Modulausgabe anpassen 26 | 4. In YForm das Email-Template anpassen 27 | 28 | ## Dashboard & Datenauswertung 29 | 30 | Das Poll-AddOn bietet ein umfassendes Dashboard zur Auswertung aller Umfragen: 31 | 32 | * Übersicht aller aktiven und inaktiven Umfragen 33 | * Grafische Darstellung der Ergebnisse mit selbst implementierten Balken- und Kreis-Diagrammen 34 | * Detaillierte Auswertung jeder einzelnen Frage einer Umfrage 35 | * Zeitlicher Verlauf der Teilnahmen 36 | * Anzeige von Freitext-Antworten 37 | 38 | Das Dashboard verwendet keine externen Bibliotheken und ist vollständig mit eigenen CSS/JS-Assets implementiert. 39 | 40 | ## Eigene Module erstellen 41 | 42 | ### Grundlegendes Modul 43 | 44 | Hier ist ein Beispiel für ein einfaches Poll-Modul: 45 | 46 | ```php 47 | 52 |
    53 | 54 | setName('REX_INPUT_VALUE[1]'); 57 | $select->setId('poll_id'); 58 | $select->setSize(1); 59 | $select->setAttribute('class', 'form-control'); 60 | $select->addOption('Bitte wählen', ''); 61 | 62 | $polls = Poll::query()->where('status', 1)->orderBy('title')->find(); 63 | 64 | foreach ($polls as $poll) { 65 | $select->addOption($poll->getTitle(), $poll->getId()); 66 | } 67 | $select->setSelected('REX_VALUE[1]'); 68 | echo $select->get(); 69 | ?> 70 |
    71 | 72 | 75 |
    76 | setVar('poll', $poll); 82 | echo $fragment->parse('addons/poll/poll.php'); 83 | } 84 | } 85 | ?> 86 |
    87 | ``` 88 | 89 | ### Anpassung des vorhandenen Moduls 90 | 91 | Um die Ausgabe des Fragments `addons/poll/poll.php` anzupassen, kann man eine eigene Version erstellen: 92 | 93 | ### Project-AddOn 94 | 1. Die Datei `/redaxo/src/addons/poll/fragments/addons/poll/poll.php` nach `/redaxo/src/addons/project/fragments/addons/poll/poll.php` kopieren 95 | 2. Dann gestalterisch anpassen. 96 | 97 | 98 | #### Theme-AddOn 99 | 1. Die Datei `/redaxo/src/addons/poll/fragments/addons/poll/poll.php` nach `/theme/private/fragments/addons/poll/poll.php` kopieren 100 | 2. Dann gestalterisch anpassen. 101 | 102 | ## Sprog-Integration (CSV-Liste) 103 | 104 | Hier sind die für das Sprog-AddOn benötigten Variablen: 105 | 106 | ```csv 107 | key;de_de 108 | poll_title;Umfrage 109 | poll_result;Ergebnis 110 | poll_votes_taken;Anzahl der abgegebenen Stimmen: 111 | poll_vote_success;Ihre Stimme wurde erfolgreich gespeichert! 112 | poll_vote_confirm;Bitte bestätigen Sie Ihre Abstimmung über den Link in der E-Mail. 113 | poll_vote_exists;Sie haben bereits an dieser Umfrage teilgenommen. 114 | poll_vote_fail;Die Aktivierung Ihrer Stimme ist fehlgeschlagen. 115 | poll_finished;Diese Umfrage ist beendet. 116 | poll_answer;Ihre Antwort 117 | poll_validate_question;Bitte beantworten Sie diese Frage. 118 | poll_validate_email;Bitte geben Sie eine gültige E-Mail-Adresse ein. 119 | poll_email_label;E-Mail-Adresse 120 | poll_email_note;Sie erhalten einen Link zur Bestätigung Ihrer Abstimmung per E-Mail. 121 | poll_submit_poll;Abstimmen 122 | poll_comment_legend;Kommentar 123 | poll_comment_label;Ihr Kommentar (optional) 124 | poll_datenschutz_checkbox;Ich stimme der Verarbeitung meiner Daten gemäß Datenschutzerklärung zu. 125 | poll_datenschutz_checkbox_error;Bitte stimmen Sie der Datenverarbeitung zu. 126 | ``` 127 | 128 | ## Changelog 129 | 130 | Siehe [CHANGELOG.md](CHANGELOG.md). 131 | 132 | ## Lizenz 133 | 134 | [MIT Lizenz](LICENSE.md) 135 | 136 | ## Autor 137 | 138 | * [@FriendsOfREDAXO](https://github.com/FriendsOfREDAXO/poll/graphs/contributors) 139 | -------------------------------------------------------------------------------- /assets/poll.css: -------------------------------------------------------------------------------- 1 | /* PollDashboard Styles */ 2 | .poll-dashboard-container { 3 | margin-bottom: 30px; 4 | } 5 | 6 | /* Statistik-Boxen */ 7 | .poll-stats-grid { 8 | display: flex; 9 | flex-wrap: wrap; 10 | gap: 20px; 11 | margin-bottom: 30px; 12 | } 13 | 14 | .poll-stat-box { 15 | flex: 1 1 200px; 16 | padding: 20px; 17 | background-color: #fff; 18 | border-radius: 4px; 19 | box-shadow: 0 1px 3px rgba(0,0,0,0.1); 20 | text-align: center; 21 | } 22 | 23 | .poll-stat-box h3 { 24 | margin-top: 0; 25 | color: #333; 26 | font-size: 16px; 27 | font-weight: 500; 28 | } 29 | 30 | .poll-stat-box p { 31 | margin-bottom: 0; 32 | font-size: 24px; 33 | font-weight: 700; 34 | color: #4b9ad9; 35 | } 36 | 37 | /* Karten */ 38 | .poll-card { 39 | background-color: #fff; 40 | border-radius: 4px; 41 | box-shadow: 0 1px 3px rgba(0,0,0,0.1); 42 | margin-bottom: 30px; 43 | } 44 | 45 | .poll-card-header { 46 | padding: 15px 20px; 47 | background-color: #f5f5f5; 48 | border-bottom: 1px solid #e5e5e5; 49 | font-weight: 500; 50 | } 51 | 52 | .poll-card-body { 53 | padding: 20px; 54 | } 55 | 56 | /* Chart Styles */ 57 | .poll-bar-chart { 58 | width: 100%; 59 | margin-bottom: 20px; 60 | } 61 | 62 | .poll-bar-table { 63 | width: 100%; 64 | border-collapse: collapse; 65 | } 66 | 67 | .poll-bar-table th, 68 | .poll-bar-table td { 69 | padding: 8px 12px; 70 | text-align: left; 71 | border-bottom: 1px solid #e5e5e5; 72 | } 73 | 74 | .poll-bar-table-title { 75 | width: 30%; 76 | } 77 | 78 | .poll-bar-table-bar { 79 | width: 55%; 80 | } 81 | 82 | .poll-bar-table-count { 83 | width: 15%; 84 | text-align: right; 85 | } 86 | 87 | .poll-bar-table-container { 88 | width: 100%; 89 | background-color: #f5f5f5; 90 | border-radius: 3px; 91 | height: 24px; 92 | position: relative; 93 | } 94 | 95 | .poll-bar-table-element { 96 | height: 24px; 97 | background-color: #4b9ad9; 98 | border-radius: 3px; 99 | transition: width 0.5s ease; 100 | } 101 | 102 | /* Pie Chart */ 103 | .poll-pie-chart { 104 | margin: 0 auto; 105 | position: relative; 106 | width: 100%; 107 | padding-bottom: 20px; 108 | } 109 | 110 | .modal-body .poll-pie-chart { 111 | min-height: 500px; 112 | } 113 | 114 | .poll-pie-svg { 115 | margin: 0 auto; 116 | display: block; 117 | } 118 | 119 | .poll-pie-legend { 120 | margin-top: 20px; 121 | text-align: left; 122 | max-width: 100%; 123 | margin-left: auto; 124 | margin-right: auto; 125 | } 126 | 127 | .poll-pie-legend-item { 128 | display: flex; 129 | align-items: center; 130 | margin-bottom: 8px; 131 | padding: 5px; 132 | border-radius: 3px; 133 | transition: background-color 0.2s; 134 | } 135 | 136 | .poll-pie-legend-item-highlight { 137 | background-color: #f5f5f5; 138 | } 139 | 140 | .poll-pie-legend-color { 141 | width: 16px; 142 | height: 16px; 143 | border-radius: 3px; 144 | margin-right: 10px; 145 | flex-shrink: 0; 146 | } 147 | 148 | .poll-pie-legend-text { 149 | font-size: 14px; 150 | } 151 | 152 | .poll-pie-segment-path { 153 | transition: opacity 0.2s; 154 | } 155 | 156 | .poll-pie-segment-path:hover, 157 | .poll-pie-segment-path-highlight { 158 | opacity: 0.8; 159 | } 160 | 161 | /* Details */ 162 | .poll-details { 163 | margin-top: 20px; 164 | } 165 | 166 | .poll-question { 167 | margin-bottom: 30px; 168 | border: 1px solid #e5e5e5; 169 | border-radius: 4px; 170 | padding: 15px; 171 | } 172 | 173 | .poll-question h4 { 174 | margin-top: 0; 175 | margin-bottom: 15px; 176 | padding-bottom: 10px; 177 | border-bottom: 1px solid #e5e5e5; 178 | } 179 | 180 | .poll-question-results { 181 | display: flex; 182 | flex-wrap: wrap; 183 | gap: 20px; 184 | } 185 | 186 | .poll-question-table { 187 | flex: 1 1 400px; 188 | } 189 | 190 | .poll-results-table { 191 | width: 100%; 192 | border-collapse: collapse; 193 | } 194 | 195 | .poll-results-table th, 196 | .poll-results-table td { 197 | padding: 8px 12px; 198 | text-align: left; 199 | border-bottom: 1px solid #e5e5e5; 200 | } 201 | 202 | .poll-option-col { 203 | width: 40%; 204 | } 205 | 206 | .poll-votes-col { 207 | width: 15%; 208 | text-align: right; 209 | } 210 | 211 | .poll-percentage-col { 212 | width: 45%; 213 | } 214 | 215 | .poll-percentage-bar { 216 | width: 100%; 217 | background-color: #f5f5f5; 218 | border-radius: 3px; 219 | height: 24px; 220 | position: relative; 221 | } 222 | 223 | .poll-percentage-fill { 224 | height: 24px; 225 | background-color: #4b9ad9; 226 | border-radius: 3px; 227 | transition: width 0.5s ease; 228 | } 229 | 230 | .poll-percentage-text { 231 | position: absolute; 232 | right: 8px; 233 | top: 50%; 234 | transform: translateY(-50%); 235 | color: #333; 236 | font-size: 12px; 237 | font-weight: 500; 238 | } 239 | 240 | .poll-question-chart-button { 241 | display: flex; 242 | justify-content: center; 243 | margin-top: 15px; 244 | } 245 | 246 | .poll-text-answers { 247 | max-height: 300px; 248 | overflow-y: auto; 249 | border: 1px solid #e5e5e5; 250 | border-radius: 4px; 251 | } 252 | 253 | .poll-text-item { 254 | padding: 10px; 255 | border-bottom: 1px solid #e5e5e5; 256 | } 257 | 258 | .poll-text-item:last-child { 259 | border-bottom: none; 260 | } 261 | 262 | .poll-no-data { 263 | padding: 20px; 264 | text-align: center; 265 | color: #999; 266 | font-style: italic; 267 | } 268 | 269 | /* Modal anpassen für Pie Charts */ 270 | .modal-lg { 271 | width: 800px; 272 | max-width: 90%; 273 | } 274 | 275 | .modal-body { 276 | overflow: visible !important; 277 | } 278 | 279 | /* Timeline */ 280 | .poll-timeline { 281 | height: 200px; 282 | position: relative; 283 | margin-bottom: 40px; 284 | } 285 | 286 | .poll-timeline-axis { 287 | height: 30px; 288 | position: relative; 289 | border-top: 1px solid #e5e5e5; 290 | } 291 | 292 | .poll-timeline-line { 293 | position: absolute; 294 | bottom: 0; 295 | left: 0; 296 | height: 1px; 297 | background-color: #e5e5e5; 298 | } 299 | 300 | .poll-timeline-point { 301 | width: 10px; 302 | height: 10px; 303 | border-radius: 50%; 304 | background-color: #4b9ad9; 305 | position: absolute; 306 | transform: translate(-50%, -50%); 307 | cursor: pointer; 308 | } 309 | 310 | .poll-timeline-label { 311 | position: absolute; 312 | top: 5px; 313 | transform: translateX(-50%); 314 | font-size: 12px; 315 | color: #999; 316 | } 317 | 318 | @media (max-width: 768px) { 319 | .poll-question-results { 320 | flex-direction: column; 321 | } 322 | 323 | .poll-timeline-label { 324 | font-size: 10px; 325 | transform: translateX(-50%) rotate(-45deg); 326 | transform-origin: top left; 327 | } 328 | } -------------------------------------------------------------------------------- /pages/sample.php: -------------------------------------------------------------------------------- 1 | i18n('poll') . ' - ' . $this->i18n('poll_sample_data')); 17 | 18 | $content = ''; 19 | $success = ''; 20 | $error = ''; 21 | 22 | // Handle sample data installation 23 | if (rex_post('install_sample', 'boolean') && rex_csrf_token::factory('poll-sample')->isValid()) { 24 | try { 25 | // Load sample poll data 26 | $sampleData = json_decode(rex_file::get(rex_path::addon('poll', 'install/sample_poll.json')), true); 27 | 28 | if (!$sampleData) { 29 | throw new Exception('Could not load sample data'); 30 | } 31 | 32 | // Check if sample poll already exists 33 | $existingPoll = Poll::query()->where('title', $sampleData['title'])->findOne(); 34 | if ($existingPoll) { 35 | $error = rex_i18n::msg('poll_sample_already_exists'); 36 | } else { 37 | // Create the poll 38 | $poll = Poll::create(); 39 | $poll->title = $sampleData['title']; 40 | $poll->description = $sampleData['description']; 41 | $poll->type = $sampleData['type']; 42 | $poll->status = $sampleData['status']; 43 | $poll->comment = $sampleData['comment']; 44 | $poll->showresult = $sampleData['showresult']; 45 | 46 | if (!$poll->save()) { 47 | throw new Exception('Could not save poll'); 48 | } 49 | 50 | // Create questions and choices 51 | $choiceIds = []; 52 | foreach ($sampleData['questions'] as $questionData) { 53 | $question = Question::create(); 54 | $question->poll_id = $poll->getId(); 55 | $question->title = $questionData['title']; 56 | $question->description = $questionData['description']; 57 | $question->media = $questionData['media']; 58 | $question->url = $questionData['url']; 59 | 60 | if (!$question->save()) { 61 | throw new Exception('Could not save question'); 62 | } 63 | 64 | // Create choices and store their IDs 65 | foreach ($questionData['choices'] as $index => $choiceData) { 66 | $choice = Choice::create(); 67 | $choice->question_id = $question->getId(); 68 | $choice->title = $choiceData['title']; 69 | 70 | if (!$choice->save()) { 71 | throw new Exception('Could not save choice'); 72 | } 73 | 74 | $choiceIds[$index] = $choice->getId(); 75 | } 76 | 77 | // Create sample votes if provided 78 | if (isset($sampleData['sample_votes']) && !empty($sampleData['sample_votes'])) { 79 | foreach ($sampleData['sample_votes'] as $voteData) { 80 | // Create vote 81 | $vote = Vote::create(); 82 | $vote->status = 1; 83 | $vote->poll_id = $poll->getId(); 84 | $vote->create_datetime = $voteData['create_datetime']; 85 | $vote->user_hash = $voteData['user_hash']; 86 | $vote->comment = $voteData['comment']; 87 | 88 | if (!$vote->save()) { 89 | throw new Exception('Could not save vote'); 90 | } 91 | 92 | // Create answer for this vote 93 | $answer = Answer::create(); 94 | $answer->vote_id = $vote->getId(); 95 | $answer->question_id = $question->getId(); 96 | $answer->question_choice_id = $choiceIds[$voteData['choice_index']]; 97 | $answer->text = $voteData['comment']; 98 | $answer->create_datetime = $voteData['create_datetime']; 99 | 100 | if (!$answer->save()) { 101 | throw new Exception('Could not save answer'); 102 | } 103 | } 104 | } 105 | } 106 | 107 | $success = rex_i18n::msg('poll_sample_installed_success'); 108 | } 109 | } catch (Exception $e) { 110 | $error = rex_i18n::msg('poll_sample_install_error') . ': ' . $e->getMessage(); 111 | } 112 | } 113 | 114 | // Check if sample data already exists 115 | $sampleData = json_decode(rex_file::get(rex_path::addon('poll', 'install/sample_poll.json')), true); 116 | $existingPoll = null; 117 | if ($sampleData) { 118 | $existingPoll = Poll::query()->where('title', $sampleData['title'])->findOne(); 119 | } 120 | 121 | // Display messages 122 | if ($success) { 123 | $content .= rex_view::success($success); 124 | } 125 | if ($error) { 126 | $content .= rex_view::error($error); 127 | } 128 | 129 | // Main content 130 | $content .= '
    '; 131 | 132 | if ($existingPoll) { 133 | $content .= '
    '; 134 | $content .= '

    ' . rex_i18n::msg('poll_sample_already_installed') . '

    '; 135 | $content .= '

    ' . rex_i18n::msg('poll_sample_already_installed_desc') . '

    '; 136 | $content .= '

    ' . rex_i18n::msg('poll_title') . ': ' . rex_escape($existingPoll->getTitle()) . '

    '; 137 | $content .= '

    ' . rex_i18n::msg('poll_status') . ': ' . ($existingPoll->isOnline() ? rex_i18n::msg('poll_status_active') : rex_i18n::msg('poll_status_inactive')) . '

    '; 138 | $content .= '
    '; 139 | } else { 140 | $content .= '
    '; 141 | $content .= '

    ' . rex_i18n::msg('poll_sample_data_info') . '

    '; 142 | $content .= '

    ' . rex_i18n::msg('poll_sample_data_description') . '

    '; 143 | $content .= '
    '; 144 | 145 | if ($sampleData) { 146 | $content .= '
    '; 147 | $content .= '

    ' . rex_i18n::msg('poll_sample_preview') . '

    '; 148 | $content .= '
    '; 149 | $content .= '

    ' . rex_i18n::msg('poll_title') . ': ' . rex_escape($sampleData['title']) . '

    '; 150 | $content .= '

    ' . rex_i18n::msg('poll_question') . ': ' . rex_escape($sampleData['questions'][0]['title']) . '

    '; 151 | $content .= '

    ' . rex_i18n::msg('poll_question_choices') . ':

    '; 152 | $content .= '
      '; 153 | foreach ($sampleData['questions'][0]['choices'] as $choice) { 154 | $content .= '
    • ' . rex_escape($choice['title']) . '
    • '; 155 | } 156 | $content .= '
    '; 157 | 158 | // Show sample votes info 159 | if (isset($sampleData['sample_votes']) && !empty($sampleData['sample_votes'])) { 160 | $content .= '

    ' . rex_i18n::msg('poll_sample_votes') . ': ' . count($sampleData['sample_votes']) . ' ' . rex_i18n::msg('poll_sample_votes_count') . '

    '; 161 | 162 | // Count votes per choice 163 | $voteCounts = []; 164 | foreach ($sampleData['sample_votes'] as $vote) { 165 | $choiceIndex = $vote['choice_index']; 166 | if (!isset($voteCounts[$choiceIndex])) { 167 | $voteCounts[$choiceIndex] = 0; 168 | } 169 | ++$voteCounts[$choiceIndex]; 170 | } 171 | 172 | $content .= '
    '; 173 | foreach ($sampleData['questions'][0]['choices'] as $index => $choice) { 174 | $count = $voteCounts[$index] ?? 0; 175 | $content .= '
    ' . rex_escape($choice['title']) . ': ' . $count . ' ' . rex_i18n::msg('poll_votes') . '
    '; 176 | } 177 | $content .= '
    '; 178 | } 179 | 180 | $content .= '
    '; 181 | $content .= '
    '; 182 | } 183 | 184 | $content .= '
    '; 185 | $content .= '
    '; 186 | 187 | $content .= '
    '; 188 | $content .= rex_csrf_token::factory('poll-sample')->getHiddenField(); 189 | $content .= ''; 190 | $content .= '
    '; 191 | $content .= '
    '; 192 | $content .= ''; 193 | $content .= '
    '; 194 | $content .= '
    '; 195 | $content .= '
    '; 196 | 197 | $content .= '
    '; 198 | $content .= '
    '; 199 | } 200 | 201 | $content .= '
    '; 202 | 203 | // Output the page 204 | $fragment = new rex_fragment(); 205 | $fragment->setVar('title', rex_i18n::msg('poll_sample_data'), false); 206 | $fragment->setVar('body', $content, false); 207 | echo $fragment->parse('core/page/section.php'); 208 | -------------------------------------------------------------------------------- /install/tablesets/poll_tables.json: -------------------------------------------------------------------------------- 1 | {"rex_poll":{"table":{"status":"1","table_name":"rex_poll","name":"translate:poll","description":"","list_amount":"50","list_sortfield":"id","list_sortorder":"ASC","search":"1","hidden":"0","export":"0","import":"0","mass_deletion":"0","mass_edit":"0","schema_overwrite":"1","history":"0"},"fields":[{"table_name":"rex_poll","prio":"1","type_id":"value","type_name":"text","db_type":"","list_hidden":"0","search":"1","name":"title","label":"Bezeichnung","not_required":"","default":"","no_db":"","notice":"","attributes":"","prepend":"","append":""},{"table_name":"rex_poll","prio":"2","type_id":"value","type_name":"textarea","db_type":"","list_hidden":"1","search":"1","name":"description","label":"Beschreibung","not_required":"","default":"","no_db":"","notice":"","attributes":""},{"table_name":"rex_poll","prio":"3","type_id":"value","type_name":"choice","db_type":"text","list_hidden":"0","search":"1","name":"type","label":"translate:poll_type","not_required":"","multiple":"0","expanded":"0","choices":"translate:poll_type_direct=direct,translate:poll_type_hash=hash,translate:poll_type_email=email","choice_attributes":"","default":"","no_db":"","notice":"","attributes":"","placeholder":"","group_by":"","group_attributes":""},{"table_name":"rex_poll","prio":"6","type_id":"value","type_name":"checkbox","db_type":"","list_hidden":"0","search":"1","name":"status","label":"translate:poll_online","not_required":"","default":"1","no_db":"","notice":"","attributes":"","output_values":""},{"table_name":"rex_poll","prio":"7","type_id":"value","type_name":"checkbox","db_type":"tinyint(1)","list_hidden":"0","search":"1","name":"comment","label":"translate:with_comment","not_required":"","default":"0","no_db":"0","notice":"","attributes":"","output_values":""},{"table_name":"rex_poll","prio":"8","type_id":"value","type_name":"choice","db_type":"text","list_hidden":"1","search":"1","name":"showresult","label":"translate:poll_showresult","not_required":"","multiple":"0","expanded":"0","choices":"translate:poll_result_always=0,translate:poll_result_ifvoted=1,translate:poll_result_never=2,translate:poll_result_ifended=3","choice_attributes":"","default":"0","no_db":"","notice":"","attributes":"","placeholder":"","group_by":"","group_attributes":""},{"table_name":"rex_poll","prio":"9","type_id":"value","type_name":"choice","db_type":"text","list_hidden":"1","search":"0","name":"emailtemplate","label":"E-Mail-Template","not_required":"","multiple":"0","expanded":"0","choices":"select id, name from rex_yform_email_template","choice_attributes":"","default":"","no_db":"","notice":"","attributes":"","placeholder":"","group_by":"","group_attributes":""},{"table_name":"rex_poll","prio":"10","type_id":"value","type_name":"be_manager_relation","db_type":"int","list_hidden":"0","search":"1","name":"questions","label":"translate:poll_questions","not_required":"","type":"5","size":"","table":"rex_poll_question","field":"poll_id","empty_value":"","empty_option":"0","notice":"","attributes":"","relation_table":"","filter":""}]},"rex_poll_question":{"table":{"status":"1","table_name":"rex_poll_question","name":"translate:poll_questions","description":"","list_amount":"50","list_sortfield":"id","list_sortorder":"ASC","search":"0","hidden":"0","export":"0","import":"0","mass_deletion":"0","mass_edit":"0","schema_overwrite":"1","history":"0"},"fields":[{"table_name":"rex_poll_question","prio":"1","type_id":"value","type_name":"be_manager_relation","db_type":"int","list_hidden":"0","search":"1","name":"poll_id","label":"translate:poll_title","not_required":"","type":"0","size":"","table":"rex_poll","field":"title, ' [',id,']'","empty_value":"translate:poll_error_pleasechooseapoll","empty_option":"0","notice":"","attributes":"","relation_table":"","filter":""},{"table_name":"rex_poll_question","prio":"2","type_id":"value","type_name":"text","db_type":"varchar(191)","list_hidden":"0","search":"1","name":"title","label":"translate:poll_question","not_required":"","default":"","no_db":"0","notice":"","attributes":"","prepend":"","append":""},{"table_name":"rex_poll_question","prio":"3","type_id":"value","type_name":"be_media","db_type":"text","list_hidden":"1","search":"1","name":"media","label":"translate:poll_question_image","not_required":"","multiple":"0","types":"jpg,gif,png","notice":"","category":"","preview":"0"},{"table_name":"rex_poll_question","prio":"4","type_id":"value","type_name":"textarea","db_type":"text","list_hidden":"1","search":"1","name":"description","label":"translate:poll_question_description","not_required":"","default":"","no_db":"0","notice":"","attributes":""},{"table_name":"rex_poll_question","prio":"5","type_id":"value","type_name":"text","db_type":"varchar(191)","list_hidden":"1","search":"1","name":"url","label":"translate:poll_question_url","not_required":"","default":"","no_db":"0","notice":"translate:poll_question_url_info","attributes":"","prepend":"","append":""},{"table_name":"rex_poll_question","prio":"6","type_id":"value","type_name":"be_manager_relation","db_type":"int","list_hidden":"0","search":"1","name":"choices","label":"translate:poll_question_choices","not_required":"","type":"5","size":"","table":"rex_poll_question_choice","field":"question_id","empty_value":"","empty_option":"0","notice":"translate:poll_question_no_choices","attributes":"","relation_table":"","filter":""}]},"rex_poll_question_choice":{"table":{"status":"1","table_name":"rex_poll_question_choice","name":"translate:poll_question_choices","description":"","list_amount":"50","list_sortfield":"id","list_sortorder":"ASC","search":"0","hidden":"0","export":"0","import":"0","mass_deletion":"0","mass_edit":"0","schema_overwrite":"1","history":"0"},"fields":[{"table_name":"rex_poll_question_choice","prio":"1","type_id":"value","type_name":"be_manager_relation","db_type":"int","list_hidden":"0","search":"1","name":"question_id","label":"translate:poll_question","not_required":"","type":"0","size":"","table":"rex_poll_question","field":"title, ' [',id,']'","empty_value":"translate:poll_error_please_choose_a_question","empty_option":"0","notice":"","attributes":"","relation_table":"","filter":""},{"table_name":"rex_poll_question_choice","prio":"2","type_id":"value","type_name":"text","db_type":"varchar(191)","list_hidden":"0","search":"1","name":"title","label":"translate:poll_question_choice","not_required":"","default":"","no_db":"0","notice":"","attributes":"","prepend":"","append":""}]},"rex_poll_vote":{"table":{"status":"1","table_name":"rex_poll_vote","name":"translate:poll_votes","description":"","list_amount":"50","list_sortfield":"id","list_sortorder":"ASC","search":"1","hidden":"0","export":"1","import":"1","mass_deletion":"0","mass_edit":"0","schema_overwrite":"1","history":"0"},"fields":[{"table_name":"rex_poll_vote","prio":"1","type_id":"value","type_name":"choice","db_type":"text","list_hidden":"0","search":"0","name":"status","label":"Status","not_required":"","multiple":"0","expanded":"0","choices":"Deaktiviert=0,Aktiviert=1","choice_attributes":"","default":"","no_db":"","notice":"","attributes":"","placeholder":"","group_by":"","group_attributes":""},{"table_name":"rex_poll_vote","prio":"2","type_id":"value","type_name":"be_manager_relation","db_type":"","list_hidden":"0","search":"1","name":"poll_id","label":"translate:poll","not_required":"","type":"0","size":"","table":"rex_poll","field":"title","empty_value":"translate:poll_error_pleasechooseapoll","empty_option":"0","notice":"","attributes":"","relation_table":"","filter":""},{"table_name":"rex_poll_vote","prio":"3","type_id":"value","type_name":"datestamp","db_type":"","list_hidden":"0","search":"1","name":"create_datetime","label":"translate:poll_create_datetime","not_required":"","no_db":"","only_empty":"1","format":"Y-m-d H:i:s"},{"table_name":"rex_poll_vote","prio":"4","type_id":"value","type_name":"text","db_type":"","list_hidden":"0","search":"1","name":"user_hash","label":"translate:poll_userhash","not_required":"","default":"","no_db":"","notice":"","attributes":"","prepend":"","append":""},{"table_name":"rex_poll_vote","prio":"5","type_id":"value","type_name":"textarea","db_type":"text","list_hidden":"0","search":"1","name":"comment","label":"translate:comment","not_required":"","default":"","no_db":"0","notice":"","attributes":""},{"table_name":"rex_poll_vote","prio":"6","type_id":"value","type_name":"be_manager_relation","db_type":"int","list_hidden":"0","search":"1","name":"answers","label":"translate:poll_answers","not_required":"","type":"5","size":"","table":"rex_poll_vote_answer","field":"vote_id","empty_value":"","empty_option":"0","notice":"","attributes":"","relation_table":"","filter":""}]},"rex_poll_vote_answer":{"table":{"status":"1","table_name":"rex_poll_vote_answer","name":"translate:poll_votes_answer","description":"","list_amount":"50","list_sortfield":"id","list_sortorder":"ASC","search":"1","hidden":"0","export":"1","import":"1","mass_deletion":"0","mass_edit":"0","schema_overwrite":"1","history":"0"},"fields":[{"table_name":"rex_poll_vote_answer","prio":"1","type_id":"value","type_name":"be_manager_relation","db_type":"int","list_hidden":"1","search":"1","name":"vote_id","label":"translate:poll_vote","not_required":"","type":"0","size":"","table":"rex_poll_vote","field":"id","empty_value":"","empty_option":"0","notice":"","attributes":"","relation_table":"","filter":""},{"table_name":"rex_poll_vote_answer","prio":"2","type_id":"value","type_name":"be_manager_relation","db_type":"int","list_hidden":"0","search":"1","name":"question_id","label":"translate:poll_question","not_required":"","type":"0","size":"","table":"rex_poll_question","field":"title, ' [',id,']'","empty_value":"translate:poll_error_please_choose_a_question","empty_option":"0","notice":"","attributes":"","relation_table":"","filter":""},{"table_name":"rex_poll_vote_answer","prio":"3","type_id":"value","type_name":"be_manager_relation","db_type":"int","list_hidden":"0","search":"1","name":"question_choice_id","label":"translate:poll_question_choice","not_required":"","type":"0","size":"","table":"rex_poll_question_choice","field":"title, ' [',id,']'","empty_value":"","empty_option":"1","notice":"","attributes":"","relation_table":"","filter":""},{"table_name":"rex_poll_vote_answer","prio":"4","type_id":"value","type_name":"textarea","db_type":"text","list_hidden":"0","search":"1","name":"text","label":"translate:poll_answer_text","not_required":"","default":"","no_db":"0","notice":"","attributes":""},{"table_name":"rex_poll_vote_answer","prio":"5","type_id":"value","type_name":"datestamp","db_type":"datetime","list_hidden":"0","search":"1","name":"create_datetime","label":"translate:poll_create_datetime","not_required":"","no_db":"0","only_empty":"1","format":"Y-m-d H:i:s"}]}} -------------------------------------------------------------------------------- /pages/dashboard.php: -------------------------------------------------------------------------------- 1 | i18n('poll') . ' - Dashboard'); 14 | 15 | // CSS für animiertes Symbol 16 | echo ' 17 | 37 | '; 38 | 39 | // Statistik-Übersicht erzeugen 40 | $content = ''; 41 | $pollData = []; 42 | $pollLabels = []; 43 | $pollVotes = []; 44 | $pollColors = []; 45 | $pollStatus = []; // Status jeder Umfrage speichern 46 | 47 | // Grundlegende Statistiken sammeln 48 | $totalPolls = count(Poll::getAll()); 49 | $activePolls = count(Poll::query()->where('status', 1)->find()); 50 | 51 | $totalVotes = Vote::query()->where('status', 1)->count(); 52 | 53 | // Chart-Daten für alle Umfragen sammeln 54 | $polls = Poll::query()->orderBy('title')->find(); 55 | foreach ($polls as $poll) { 56 | // Titel mit Statusanzeige 57 | $statusIcon = $poll->isOnline() ? ' ' : ' '; 58 | $pollLabels[] = $statusIcon . $poll->getTitle(); 59 | $pollVotes[] = $poll->getHits(); 60 | $pollStatus[] = $poll->isOnline(); // Status speichern für spätere Verwendung 61 | 62 | // Farbe für jede Umfrage 63 | $pollColors[] = 'rgba(' . random_int(0, 150) . ',' . random_int(0, 150) . ',' . random_int(150, 255) . ', 0.6)'; 64 | } 65 | 66 | // Aktivitätsdaten für Timeline sammeln 67 | $activityData = []; 68 | $activityDates = []; 69 | 70 | // Votes nach Datum gruppieren (letzte 30 Tage) 71 | $query = ' 72 | SELECT 73 | DATE(create_datetime) as vote_date, 74 | COUNT(*) as vote_count 75 | FROM 76 | ' . rex::getTablePrefix() . 'poll_vote 77 | WHERE 78 | status = 1 79 | AND create_datetime >= DATE_SUB(NOW(), INTERVAL 30 DAY) 80 | GROUP BY 81 | DATE(create_datetime) 82 | ORDER BY 83 | vote_date ASC 84 | '; 85 | 86 | $result = rex_sql::factory()->setQuery($query); 87 | $votesByDate = $result->getArray(); 88 | 89 | // Letzten 30 Tage als Basis verwenden (auch Tage ohne Votes) 90 | $endDate = new DateTime(); 91 | $startDate = new DateTime(); 92 | $startDate->modify('-29 days'); // 30 Tage inkl. heute 93 | 94 | $currentDate = clone $startDate; 95 | while ($currentDate <= $endDate) { 96 | $dateString = $currentDate->format('Y-m-d'); 97 | $formattedDate = $currentDate->format('d.m.Y'); 98 | 99 | // Prüfen ob es für dieses Datum Votes gibt 100 | $voteCount = 0; 101 | foreach ($votesByDate as $vote) { 102 | if ($vote['vote_date'] === $dateString) { 103 | $voteCount = (int) $vote['vote_count']; 104 | break; 105 | } 106 | } 107 | 108 | $activityData[] = $voteCount; 109 | $activityDates[] = $formattedDate; 110 | 111 | $currentDate->modify('+1 day'); 112 | } 113 | 114 | $content .= '
    '; 115 | 116 | // Dashboard-Header mit Kernzahlen 117 | $content .= ' 118 |
    119 |
    120 |

    ' . rex_i18n::msg('poll_dashboard_total_polls') . '

    121 |

    ' . $totalPolls . '

    122 |
    123 |
    124 |

    ' . rex_i18n::msg('poll_dashboard_active_polls') . '

    125 |

    ' . $activePolls . '

    126 |
    127 |
    128 |

    ' . rex_i18n::msg('poll_dashboard_total_votes') . '

    129 |

    ' . $totalVotes . '

    130 |
    131 |
    '; 132 | 133 | // Balkendiagramm für alle Umfragen 134 | $content .= ' 135 |
    136 |
    ' . rex_i18n::msg('poll_dashboard_votes_per_poll') . '
    137 |
    138 |
    142 |
    143 |
    '; 144 | 145 | // Detaillierte Aufschlüsselung für jede Umfrage 146 | $content .= '
    '; 147 | $content .= '
    ' . rex_i18n::msg('poll_dashboard_detailed_results') . '
    '; 148 | $content .= '
    '; 149 | 150 | // Umfrage-Selector 151 | $content .= '
    '; 152 | $content .= ''; 153 | $content .= ''; 162 | $content .= '
    '; 163 | 164 | // Container für detaillierte Umfragestatistiken 165 | $i = 0; 166 | foreach ($polls as $poll) { 167 | $statistics = $poll->getStatistics(); 168 | 169 | $content .= ''; // Ende poll-details 279 | } 280 | 281 | $content .= '
    '; // Ende poll-card-body 282 | $content .= '
    '; // Ende poll-card 283 | 284 | $content .= '
    '; // Ende poll-dashboard-container 285 | 286 | $nonce = rex_response::getNonce(); 287 | // JavaScript für das Dropdown mit HTML-Inhalt 288 | $content .= ' 289 | 307 | '; 308 | 309 | // Ausgabe der Seite 310 | $fragment = new rex_fragment(); 311 | $fragment->setVar('title', rex_i18n::msg('poll_dashboard'), false); 312 | $fragment->setVar('body', $content, false); 313 | echo $fragment->parse('core/page/section.php'); 314 | -------------------------------------------------------------------------------- /assets/poll-dashboard.css: -------------------------------------------------------------------------------- 1 | /* Poll Dashboard CSS */ 2 | 3 | :root, .rex-theme-light { 4 | --poll-text-color: #333; 5 | --poll-bg-color: #fff; 6 | --poll-accent-color: #4b9ad9; 7 | --poll-accent-hover: #3a8bc5; 8 | --poll-border-color: #ddd; 9 | --poll-border-light: #eee; 10 | --poll-bg-light: #f5f5f5; 11 | --poll-bg-hover: #f9f9f9; 12 | --poll-shadow: 0 1px 3px rgba(0,0,0,0.12), 0 1px 2px rgba(0,0,0,0.24); 13 | --poll-secondary-text: #666; 14 | --poll-timeline-grid: rgba(235,235,235,0.3); 15 | --poll-timeline-point: #4b9ad9; 16 | --poll-timeline-line: rgba(75, 154, 217, 0.5); 17 | } 18 | 19 | .rex-theme-dark { 20 | --poll-text-color: #e4e4e4; 21 | --poll-bg-color: #252525; 22 | --poll-accent-color: #3b89c5; 23 | --poll-accent-hover: #4b9ad9; 24 | --poll-border-color: #444; 25 | --poll-border-light: #333; 26 | --poll-bg-light: #333; 27 | --poll-bg-hover: #2d2d2d; 28 | --poll-shadow: 0 1px 3px rgba(0,0,0,0.3), 0 1px 2px rgba(0,0,0,0.4); 29 | --poll-secondary-text: #aaa; 30 | --poll-timeline-grid: rgba(80,80,80,0.3); 31 | --poll-timeline-point: #3b89c5; 32 | --poll-timeline-line: rgba(59, 137, 197, 0.5); 33 | } 34 | 35 | @media (prefers-color-scheme: dark) { 36 | body:not(.rex-theme-light) { 37 | --poll-text-color: #e4e4e4; 38 | --poll-bg-color: #252525; 39 | --poll-accent-color: #3b89c5; 40 | --poll-accent-hover: #4b9ad9; 41 | --poll-border-color: #444; 42 | --poll-border-light: #333; 43 | --poll-bg-light: #333; 44 | --poll-bg-hover: #2d2d2d; 45 | --poll-shadow: 0 1px 3px rgba(0,0,0,0.3), 0 1px 2px rgba(0,0,0,0.4); 46 | --poll-secondary-text: #aaa; 47 | --poll-timeline-grid: rgba(80,80,80,0.3); 48 | --poll-timeline-point: #3b89c5; 49 | --poll-timeline-line: rgba(59, 137, 197, 0.5); 50 | } 51 | } 52 | 53 | .poll-dashboard-container { 54 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; 55 | color: var(--poll-text-color); 56 | } 57 | 58 | /* Card-Styling */ 59 | .poll-card { 60 | background: var(--poll-bg-color); 61 | border-radius: 5px; 62 | box-shadow: var(--poll-shadow); 63 | margin-bottom: 20px; 64 | overflow: hidden; 65 | } 66 | 67 | .poll-card-header { 68 | background: var(--poll-accent-color); 69 | color: #fff; 70 | padding: 10px 15px; 71 | font-weight: 500; 72 | } 73 | 74 | .poll-card-header i { 75 | margin-right: 5px; 76 | } 77 | 78 | .poll-card-body { 79 | padding: 15px; 80 | } 81 | 82 | /* Stats Grid */ 83 | .poll-stats-grid { 84 | display: flex; 85 | flex-wrap: wrap; 86 | gap: 20px; 87 | margin-bottom: 20px; 88 | } 89 | 90 | .poll-stat-box { 91 | flex: 1; 92 | min-width: 200px; 93 | background: var(--poll-bg-color); 94 | padding: 15px; 95 | border-radius: 5px; 96 | box-shadow: var(--poll-shadow); 97 | } 98 | 99 | .poll-stat-box h3 { 100 | margin-top: 0; 101 | margin-bottom: 5px; 102 | color: var(--poll-secondary-text); 103 | font-size: 14px; 104 | } 105 | 106 | .poll-stat-box p { 107 | font-size: 24px; 108 | font-weight: 500; 109 | margin: 0; 110 | color: var(--poll-accent-color); 111 | } 112 | 113 | /* Bar Chart */ 114 | .poll-bar-chart { 115 | width: 100%; 116 | overflow: hidden; 117 | margin: 20px 0; 118 | } 119 | 120 | .poll-bar-container { 121 | position: relative; 122 | height: 300px; 123 | margin-top: 20px; 124 | } 125 | 126 | .poll-bar { 127 | position: absolute; 128 | bottom: 0; 129 | width: 30px; 130 | background: var(--poll-accent-color); 131 | border-radius: 3px 3px 0 0; 132 | transition: height 0.5s ease; 133 | } 134 | 135 | .poll-bar-label { 136 | position: absolute; 137 | bottom: -25px; 138 | font-size: 12px; 139 | transform: rotate(-45deg); 140 | transform-origin: left top; 141 | white-space: nowrap; 142 | margin-left: 5px; 143 | } 144 | 145 | .poll-bar-value { 146 | position: absolute; 147 | top: -20px; 148 | width: 100%; 149 | text-align: center; 150 | font-size: 12px; 151 | font-weight: bold; 152 | } 153 | 154 | .poll-chart-legend { 155 | margin-top: 30px; 156 | border-top: 1px solid var(--poll-border-light); 157 | padding-top: 10px; 158 | } 159 | 160 | /* Pie Chart - Verbesserte Darstellung */ 161 | .poll-pie-chart { 162 | position: relative; 163 | width: 220px; 164 | height: 220px; 165 | margin: 0 auto; 166 | } 167 | 168 | .poll-pie-container { 169 | position: relative; 170 | width: 100%; 171 | height: 100%; 172 | border-radius: 50%; 173 | overflow: hidden; 174 | background-color: var(--poll-bg-light); 175 | } 176 | 177 | .poll-pie-segment { 178 | position: absolute; 179 | width: 100%; 180 | height: 100%; 181 | transform-origin: center; 182 | top: 0; 183 | left: 0; 184 | } 185 | 186 | .poll-pie-legend { 187 | margin-top: 20px; 188 | } 189 | 190 | .poll-pie-legend-item { 191 | display: flex; 192 | align-items: center; 193 | margin-bottom: 8px; 194 | padding: 3px 0; 195 | } 196 | 197 | .poll-pie-legend-color { 198 | display: inline-block; 199 | width: 16px; 200 | height: 16px; 201 | margin-right: 8px; 202 | border-radius: 3px; 203 | } 204 | 205 | .poll-no-data { 206 | display: flex; 207 | align-items: center; 208 | justify-content: center; 209 | height: 100%; 210 | color: var(--poll-secondary-text); 211 | font-style: italic; 212 | } 213 | 214 | /* Kreisdiagramm - Verbesserte SVG-Darstellung */ 215 | .poll-pie-chart { 216 | position: relative; 217 | width: 100%; 218 | margin-bottom: 20px; 219 | display: flex; 220 | flex-direction: column; 221 | align-items: center; 222 | } 223 | 224 | .poll-pie-svg { 225 | width: 220px; 226 | height: 220px; 227 | display: block; 228 | margin: 0 auto; 229 | } 230 | 231 | .poll-pie-segment-path { 232 | transition: opacity 0.2s ease; 233 | stroke: var(--poll-bg-color); 234 | stroke-width: 1px; 235 | } 236 | 237 | .poll-pie-segment-path-highlight { 238 | opacity: 0.8; 239 | transform: scale(1.02); 240 | transform-origin: center; 241 | } 242 | 243 | .poll-pie-legend { 244 | width: 100%; 245 | max-width: 300px; 246 | margin: 15px auto 0; 247 | padding: 10px; 248 | background-color: var(--poll-bg-color); 249 | border: 1px solid var(--poll-border-light); 250 | border-radius: 5px; 251 | box-shadow: var(--poll-shadow); 252 | } 253 | 254 | .poll-pie-legend-item { 255 | display: flex; 256 | align-items: center; 257 | padding: 6px 8px; 258 | margin-bottom: 4px; 259 | border-radius: 4px; 260 | transition: background-color 0.2s ease; 261 | } 262 | 263 | .poll-pie-legend-item:last-child { 264 | margin-bottom: 0; 265 | } 266 | 267 | .poll-pie-legend-item-highlight { 268 | background-color: var(--poll-bg-hover); 269 | } 270 | 271 | .poll-pie-legend-color { 272 | display: inline-block; 273 | width: 16px; 274 | height: 16px; 275 | margin-right: 10px; 276 | border-radius: 3px; 277 | flex-shrink: 0; 278 | } 279 | 280 | .poll-pie-legend-text { 281 | flex-grow: 1; 282 | font-size: 14px; 283 | } 284 | 285 | .poll-no-data { 286 | width: 100%; 287 | padding: 20px; 288 | text-align: center; 289 | color: var(--poll-secondary-text); 290 | font-style: italic; 291 | } 292 | 293 | /* Details Selector */ 294 | .poll-selector { 295 | width: 100%; 296 | padding: 10px; 297 | border: 1px solid var(--poll-border-color); 298 | border-radius: 4px; 299 | margin-bottom: 20px; 300 | background-color: var(--poll-bg-color); 301 | color: var(--poll-text-color); 302 | } 303 | 304 | .poll-details { 305 | margin-top: 20px; 306 | } 307 | 308 | /* Fragen und detaillierte Ergebnisse */ 309 | .poll-question { 310 | position: relative; 311 | border-bottom: 1px solid var(--poll-border-light); 312 | padding-bottom: 30px; 313 | margin-bottom: 30px; 314 | overflow: hidden; /* Verhindert Überlauf */ 315 | } 316 | 317 | .poll-question:last-child { 318 | border-bottom: none; 319 | padding-bottom: 0; 320 | } 321 | 322 | .poll-question h4 { 323 | color: var(--poll-accent-color); 324 | margin-top: 0; 325 | margin-bottom: 15px; 326 | } 327 | 328 | /* Verbesserte Darstellung für Frageoptionen */ 329 | .poll-question-results { 330 | display: flex; 331 | flex-wrap: wrap; 332 | gap: 20px; 333 | clear: both; /* Vermeidet Probleme mit dem Float */ 334 | } 335 | 336 | .poll-question-table { 337 | flex: 1; 338 | min-width: 300px; 339 | } 340 | 341 | .poll-question-chart { 342 | flex: 1; 343 | min-width: 300px; 344 | display: flex; 345 | align-items: center; 346 | justify-content: center; 347 | } 348 | 349 | /* Verbesserte Tabellen für Ergebnisse */ 350 | .poll-results-table { 351 | width: 100%; 352 | border-collapse: collapse; 353 | margin-bottom: 20px; 354 | table-layout: fixed; /* Fixierte Breite der Spalten */ 355 | } 356 | 357 | .poll-results-table th, 358 | .poll-results-table td { 359 | border: 1px solid var(--poll-border-color); 360 | padding: 8px; 361 | text-align: left; 362 | } 363 | 364 | .poll-results-table th { 365 | background-color: var(--poll-bg-light); 366 | font-weight: 500; 367 | } 368 | 369 | .poll-results-table tr:nth-child(even) { 370 | background-color: var(--poll-bg-hover); 371 | } 372 | 373 | .poll-results-table .poll-option-col { 374 | width: 50%; 375 | word-wrap: break-word; /* Zeilenumbruch bei langen Texten */ 376 | } 377 | 378 | .poll-results-table .poll-votes-col { 379 | width: 20%; 380 | text-align: center; 381 | } 382 | 383 | .poll-results-table .poll-percentage-col { 384 | width: 30%; 385 | position: relative; 386 | } 387 | 388 | /* Fortschrittsbalken für Prozentanzeige */ 389 | .poll-percentage-bar { 390 | position: relative; 391 | height: 20px; 392 | background-color: var(--poll-bg-light); 393 | border-radius: 3px; 394 | overflow: hidden; 395 | margin-top: 5px; 396 | } 397 | 398 | .poll-percentage-fill { 399 | position: absolute; 400 | left: 0; 401 | top: 0; 402 | height: 100%; 403 | background-color: var(--poll-accent-color); 404 | border-radius: 3px; 405 | } 406 | 407 | .poll-percentage-text { 408 | position: absolute; 409 | right: 8px; 410 | top: 50%; 411 | transform: translateY(-50%); 412 | font-weight: 500; 413 | color: var(--poll-text-color); 414 | } 415 | 416 | /* Tables */ 417 | .poll-table { 418 | width: 100%; 419 | border-collapse: collapse; 420 | margin-bottom: 20px; 421 | } 422 | 423 | .poll-table th, 424 | .poll-table td { 425 | border: 1px solid var(--poll-border-color); 426 | padding: 8px; 427 | text-align: left; 428 | } 429 | 430 | .poll-table th { 431 | background-color: var(--poll-bg-light); 432 | } 433 | 434 | .poll-table tr:nth-child(even) { 435 | background-color: var(--poll-bg-hover); 436 | } 437 | 438 | /* Text Answers */ 439 | .poll-text-answers { 440 | max-height: 300px; 441 | overflow-y: auto; 442 | border: 1px solid var(--poll-border-color); 443 | border-radius: 4px; 444 | } 445 | 446 | .poll-text-item { 447 | padding: 10px; 448 | border-bottom: 1px solid var(--poll-border-light); 449 | background-color: var(--poll-bg-color); 450 | } 451 | 452 | .poll-text-item:last-child { 453 | border-bottom: none; 454 | } 455 | 456 | /* Activity Timeline */ 457 | .poll-timeline { 458 | position: relative; 459 | height: 200px; 460 | margin: 20px 0; 461 | background: linear-gradient(0deg, var(--poll-timeline-grid) 1px, transparent 1px); 462 | background-size: 100% 25%; 463 | background-position: 0 100%; 464 | background-repeat: repeat-y; 465 | } 466 | 467 | .poll-timeline-point { 468 | position: absolute; 469 | width: 6px; 470 | height: 6px; 471 | background: var(--poll-timeline-point); 472 | border-radius: 50%; 473 | transform: translate(-3px, -3px); 474 | } 475 | 476 | .poll-timeline-line { 477 | position: absolute; 478 | height: 1px; 479 | background: var(--poll-timeline-line); 480 | bottom: 0; 481 | } 482 | 483 | .poll-timeline-axis { 484 | position: relative; 485 | height: 20px; 486 | margin-top: 10px; 487 | } 488 | 489 | .poll-timeline-label { 490 | position: absolute; 491 | font-size: 10px; 492 | transform: translateX(-50%); 493 | color: var(--poll-secondary-text); 494 | } 495 | 496 | /* Tabellarisches Balkendiagramm */ 497 | .poll-bar-table { 498 | width: 100%; 499 | border-collapse: collapse; 500 | margin: 15px 0; 501 | } 502 | 503 | .poll-bar-table th { 504 | text-align: left; 505 | padding: 8px; 506 | border-bottom: 2px solid var(--poll-border-color); 507 | font-weight: bold; 508 | } 509 | 510 | .poll-bar-table td { 511 | padding: 10px 8px; 512 | border-bottom: 1px solid var(--poll-border-light); 513 | vertical-align: middle; 514 | } 515 | 516 | .poll-bar-table-title { 517 | width: 40%; 518 | font-weight: normal; 519 | } 520 | 521 | .poll-bar-table-bar { 522 | width: 45%; 523 | } 524 | 525 | .poll-bar-table-count { 526 | width: 15%; 527 | text-align: right; 528 | font-weight: 600; 529 | color: var(--poll-accent-color); 530 | } 531 | 532 | .poll-bar-table-container { 533 | width: 100%; 534 | background-color: var(--poll-bg-light); 535 | border-radius: 3px; 536 | overflow: hidden; 537 | } 538 | 539 | .poll-bar-table-element { 540 | height: 24px; 541 | background-color: var(--poll-accent-color); 542 | border-radius: 3px; 543 | transition: width 0.5s ease; 544 | } 545 | 546 | /* Hover-Effekte für bessere Benutzererfahrung */ 547 | .poll-bar-table tr:hover { 548 | background-color: var(--poll-bg-hover); 549 | } 550 | 551 | .poll-bar-table tr:hover .poll-bar-table-element { 552 | opacity: 0.8; 553 | } 554 | 555 | /* Responsive Design */ 556 | @media (max-width: 768px) { 557 | .poll-stats-grid { 558 | flex-direction: column; 559 | } 560 | 561 | .poll-stat-box { 562 | width: 100%; 563 | } 564 | 565 | .poll-question-results { 566 | flex-direction: column; 567 | } 568 | 569 | .poll-question-table, 570 | .poll-question-chart { 571 | min-width: 100%; 572 | } 573 | } -------------------------------------------------------------------------------- /lib/poll.php: -------------------------------------------------------------------------------- 1 | populateRelation('questions'); 30 | } 31 | 32 | /** 33 | * Gets questions associated with this poll. 34 | * 35 | * @return rex_yform_manager_collection|array 36 | */ 37 | public function getQuestions(): rex_yform_manager_collection 38 | { 39 | return $this->getRelatedCollection('questions'); 40 | } 41 | 42 | /** 43 | * Gets poll description. 44 | */ 45 | public function getDescription(): string 46 | { 47 | return (string) $this->description; 48 | } 49 | 50 | /** 51 | * Gets poll title. 52 | */ 53 | public function getTitle(): string 54 | { 55 | return (string) $this->title; 56 | } 57 | 58 | /** 59 | * Gets poll type. 60 | */ 61 | public function getType(): string 62 | { 63 | return (string) $this->type; 64 | } 65 | 66 | /** 67 | * Checks if poll is online. 68 | */ 69 | public function isOnline(): bool 70 | { 71 | return 1 == $this->status; 72 | } 73 | 74 | /** 75 | * Returns whether the poll result should be shown. 76 | * 77 | * @param string|null $hash User hash 78 | */ 79 | public function showResult(?string $hash = null): bool 80 | { 81 | $hash = $hash ?? ('' != rex_request('hash', 'string') ? rex_request('hash', 'string') : User::getHash()); 82 | 83 | // always=0,ifvoted=1,never=2,ifended=3 84 | if (0 == $this->showresult) { 85 | return true; 86 | } 87 | if (2 == $this->showresult) { 88 | return false; 89 | } 90 | if (3 == $this->showresult && 0 == $this->status) { 91 | return true; 92 | } 93 | if (1 == $this->showresult) { 94 | if ('direct' == $this->type && '1' == rex_request('vote_success', 'string')) { 95 | return true; 96 | } 97 | if ('hash' == $this->type && User::getVote($this, $hash)) { 98 | return true; 99 | } 100 | if ('email' == $this->type && User::getVote($this, $hash)) { 101 | return true; 102 | } 103 | } 104 | return false; 105 | } 106 | 107 | /** 108 | * Gets number of votes for this poll. 109 | */ 110 | public function getHits(): int 111 | { 112 | $hits = Answer::query() 113 | ->alias('a') 114 | ->joinRelation('vote_id', 'v') 115 | ->where('a.question_choice_id', 0, '!=') 116 | ->where('v.poll_id', $this->getId()) 117 | ->where('v.status', 1) 118 | ->groupBy('a.vote_id') 119 | ->find(); 120 | 121 | return count($hits); 122 | } 123 | 124 | /** 125 | * Executes a vote for this poll. 126 | * 127 | * @param array $answers The answers 128 | * @param string $hash User hash 129 | * @param string $comment Optional comment 130 | * @return bool Success status 131 | */ 132 | public function executeVote(array $answers, string $hash, string $comment = ''): bool 133 | { 134 | if (!$this->isOnline()) { 135 | return false; 136 | } 137 | 138 | $cleanAnswers = []; 139 | foreach ($answers as $questionId => $answer) { 140 | if (isset($answer['choice_id'])) { 141 | if ($this->checkChoiceByQuestionId((int) $answer['choice_id'], (int) $questionId)) { 142 | $cleanAnswers[$questionId]['choice_id'] = (int) $answer['choice_id']; 143 | } 144 | } 145 | if (isset($answer['text'])) { 146 | if ($this->checkQuestion((int) $questionId)) { 147 | $cleanAnswers[$questionId]['text'] = $answer['text']; 148 | } 149 | } 150 | } 151 | 152 | switch ($this->getType()) { 153 | case 'hash': 154 | if (User::getVote($this, $hash)) { 155 | return false; 156 | } 157 | 158 | if (!empty($cleanAnswers)) { 159 | $vote = Vote::create(); 160 | $vote->poll_id = $this->getId(); 161 | $vote->status = 1; 162 | $vote->user_hash = $hash; 163 | $vote->comment = $comment; 164 | 165 | if (!$vote->save()) { 166 | // Dump error messages, consider changing to proper error logging 167 | return false; 168 | } 169 | foreach ($cleanAnswers as $questionId => $cleanAnswer) { 170 | $this->executeAnswer($vote, $questionId, $cleanAnswer); 171 | } 172 | } 173 | break; 174 | 175 | case 'email': 176 | if (User::getVote($this, $hash)) { 177 | return false; 178 | } 179 | 180 | if (!empty($cleanAnswers)) { 181 | $vote = Vote::create(); 182 | $vote->poll_id = $this->getId(); 183 | $vote->status = 0; 184 | $vote->user_hash = $hash; 185 | $vote->comment = $comment; 186 | 187 | if (!$vote->save()) { 188 | // Dump error messages, consider changing to proper error logging 189 | return false; 190 | } 191 | foreach ($cleanAnswers as $questionId => $cleanAnswer) { 192 | $this->executeAnswer($vote, $questionId, $cleanAnswer); 193 | } 194 | } 195 | break; 196 | 197 | default: 198 | if (!empty($cleanAnswers)) { 199 | $vote = Vote::create(); 200 | $vote->poll_id = $this->getId(); 201 | $vote->status = 1; 202 | $vote->comment = $comment; 203 | 204 | if (!$vote->save()) { 205 | // Dump error messages, consider changing to proper error logging 206 | return false; 207 | } 208 | foreach ($cleanAnswers as $questionId => $cleanAnswer) { 209 | $this->executeAnswer($vote, $questionId, $cleanAnswer); 210 | } 211 | } 212 | } 213 | 214 | return true; 215 | } 216 | 217 | /** 218 | * Executes an answer for a vote. 219 | * 220 | * @param Vote $vote The vote 221 | * @param int $questionId Question ID 222 | * @param array $cleanAnswer Answer data 223 | */ 224 | private function executeAnswer(Vote $vote, int $questionId, array $cleanAnswer): void 225 | { 226 | $answer = Answer::create(); 227 | $answer->vote_id = $vote->getId(); 228 | $answer->question_id = $questionId; 229 | if (isset($cleanAnswer['choice_id'])) { 230 | $answer->question_choice_id = $cleanAnswer['choice_id']; 231 | } 232 | if (isset($cleanAnswer['text'])) { 233 | $answer->text = $cleanAnswer['text']; 234 | } 235 | 236 | if (!$answer->save()) { 237 | $vote->delete(); 238 | return; 239 | } 240 | } 241 | 242 | /** 243 | * Checks if a choice belongs to a question in this poll. 244 | * 245 | * @param int $choiceId Choice ID 246 | * @param int $questionId Question ID 247 | */ 248 | public function checkChoiceByQuestionId(int $choiceId, int $questionId): bool 249 | { 250 | $choice = Choice::get($choiceId); 251 | if ($choice && $choice->getQuestion() && $choice->getQuestion()->getPoll() 252 | && $choice->getQuestion()->getPoll()->getId() == $this->getId() && $choice->getQuestion()->getId() == $questionId) { 253 | return true; 254 | } 255 | 256 | return false; 257 | } 258 | 259 | /** 260 | * Checks if a question belongs to this poll. 261 | * 262 | * @param int $questionId Question ID 263 | */ 264 | public function checkQuestion(int $questionId): bool 265 | { 266 | $question = Question::get($questionId); 267 | if ($question && $question->poll_id == $this->getId()) { 268 | return true; 269 | } 270 | 271 | return false; 272 | } 273 | 274 | /** 275 | * Gets email template by ID. 276 | * 277 | * @param int $id Template ID 278 | */ 279 | public function getEmailTemplateById(int $id): array|false 280 | { 281 | $gt = rex_sql::factory(); 282 | $gt->setQuery('SELECT * FROM ' . rex::getTable('yform_email_template') . ' WHERE id=:id', [':id' => $id]); 283 | if (1 == $gt->getRows()) { 284 | $b = $gt->getArray(); 285 | return current($b); 286 | } 287 | return false; 288 | } 289 | 290 | /** 291 | * Returns the YForm object for this poll based on its type. 292 | */ 293 | public function getFormByType(): rex_yform 294 | { 295 | $formDataQuestions = []; 296 | foreach ($this->getQuestions() as $question) { 297 | $formDataQuestions[] = 'fieldset|poll-question-' . $question->getId() . '|' . $question->getTitle(); 298 | 299 | $questionChoices = []; 300 | $choices = $question->getChoices(); 301 | if ($choices->isEmpty()) { 302 | $formDataQuestions[] = 'textarea|poll-question-' . $question->getId() . '-answer|{{ poll_answer }}'; 303 | $formDataQuestions[] = 'validate|empty|poll-question-' . $question->getId() . '-answer|{{ poll_validate_question }}'; 304 | } else { 305 | foreach ($choices as $choice) { 306 | $questionChoices[$choice->getTitle()] = $choice->getId(); 307 | } 308 | $formDataQuestions[] = 'choice|poll-question-' . $question->getId() . '-choice||' . json_encode($questionChoices) . '|1|0'; 309 | $formDataQuestions[] = 'validate|empty|poll-question-' . $question->getId() . '-choice|{{ poll_validate_question }}'; 310 | } 311 | } 312 | 313 | $comment = ''; 314 | if (1 == $this->getValue('comment')) { 315 | $comment .= 'fieldset|poll-comment|{{ poll_comment_legend }}' . "\n"; 316 | $comment .= 'textarea|poll-comment|{{ poll_comment_label }}' . "\n"; 317 | } 318 | 319 | switch ($this->getType()) { 320 | case 'hash': 321 | $form_data = ' 322 | hidden|poll-id|' . $this->getId() . ' 323 | 324 | ' . implode("\n", $formDataQuestions) . ' 325 | 326 | ' . $comment . ' 327 | 328 | action|poll_executevote|poll-id|||poll-comment 329 | action|showtext|

    {{ poll_vote_success }}

    |||1 330 | '; 331 | break; 332 | case 'email': 333 | $form_data = ' 334 | hidden|poll-id|' . $this->getId() . ' 335 | hidden|poll-title|' . $this->getTitle() . '|no-db 336 | hidden|poll-link||no-db 337 | 338 | ' . implode("\n", $formDataQuestions) . ' 339 | 340 | html|email_note|

    {{ poll_email_note }}

    341 | text|poll-email|{{ poll_email_label }} 342 | 343 | validate|empty|poll-email|{{ poll_validate_email }} 344 | validate|type|poll-email|email|{{ poll_validate_email }} 345 | 346 | ' . $comment . ' 347 | 348 | checkbox|ds|{{ poll_datenschutz_checkbox }}|0|no_db 349 | validate|empty|ds|{{ poll_datenschutz_checkbox_error }} 350 | 351 | action|poll_executevote|poll-id|poll-email|' . $this->getValue('emailtemplate') . '|poll-comment 352 | action|showtext|

    {{ poll_vote_confirm }}

    |||1 353 | '; 354 | break; 355 | default: 356 | $form_data = ' 357 | objparams|form_name|form-' . $this->getId() . ' 358 | hidden|poll-id|' . $this->getId() . ' 359 | 360 | ' . implode("\n", $formDataQuestions) . ' 361 | 362 | ' . $comment . ' 363 | 364 | action|poll_executevote|poll-id|||poll-comment 365 | action|showtext|

    {{ poll_vote_success }}

    |||1 366 | '; 367 | } 368 | 369 | $yform = new rex_yform(); 370 | $form_data = trim(str_replace('
    ', '', rex_yform::unhtmlentities($form_data))); 371 | $yform->setFormData($form_data); 372 | $yform->setObjectparams('form_action', rex_getUrl(rex_article::getCurrentId(), rex_clang::getCurrentId())); 373 | $yform->setObjectparams('form_class', 'form-voting'); 374 | $yform->setObjectparams('real_field_names', false); 375 | $yform->setObjectparams('submit_btn_label', '{{ poll_submit_poll }}'); 376 | 377 | return $yform; 378 | } 379 | 380 | /** 381 | * Returns the HTML output for this poll. 382 | */ 383 | public function getOutput(): string 384 | { 385 | $out = ''; 386 | 387 | switch ($this->getType()) { 388 | case 'hash': 389 | if ($this->isOnline()) { 390 | if (!rex::isBackend()) { 391 | $vote = User::getVote($this, User::getHash()); 392 | if ($vote) { 393 | $out = '{{ poll_vote_exists }}'; 394 | } else { 395 | // Konvertiere das YForm-Objekt zu einem String über die getForm()-Methode 396 | $out = '
    ' . $this->getFormByType()->getForm() . '
    '; 397 | } 398 | } 399 | } else { 400 | $out = '

    {{ poll_finished }}

    '; 401 | } 402 | 403 | return $out; 404 | 405 | case 'email': 406 | $hash = rex_request('hash', 'string') ?: ''; 407 | 408 | if ($this->isOnline()) { 409 | if (!rex::isBackend()) { 410 | if ('' != $hash) { 411 | $vote = User::getVote($this, $hash); 412 | if ($vote) { 413 | if (0 == $vote->status) { 414 | if ($vote->activate()) { 415 | $out = ' {{ poll_vote_success }}'; 416 | } else { 417 | $out = '{{ poll_vote_fail }}'; 418 | } 419 | } else { 420 | $out = '{{ poll_vote_exists }}'; 421 | } 422 | } else { 423 | $out = '{{ poll_vote_fail }}'; 424 | } 425 | } else { 426 | // Konvertiere das YForm-Objekt zu einem String über die getForm()-Methode 427 | $out = '
    ' . $this->getFormByType()->getForm() . '
    '; 428 | } 429 | } 430 | } else { 431 | $out = '

    {{ poll_finished }}

    '; 432 | } 433 | 434 | return $out; 435 | 436 | default: 437 | if ($this->isOnline()) { 438 | if (!rex::isBackend()) { 439 | // Konvertiere das YForm-Objekt zu einem String über die getForm()-Methode 440 | $out = '
    ' . $this->getFormByType()->getForm() . '
    '; 441 | } 442 | } else { 443 | $out = '

    {{ poll_finished }}

    '; 444 | } 445 | 446 | return $out; 447 | } 448 | } 449 | 450 | /** 451 | * Gets statistics for a specific poll. 452 | * 453 | * @return array Statistics data 454 | */ 455 | public function getStatistics(): array 456 | { 457 | $statistics = []; 458 | $statistics['total_votes'] = $this->getHits(); 459 | 460 | // Statistik pro Frage 461 | $questionStats = []; 462 | foreach ($this->getQuestions() as $question) { 463 | $questionData = [ 464 | 'id' => $question->getId(), 465 | 'title' => $question->getTitle(), 466 | 'choices' => [], 467 | ]; 468 | 469 | $choices = $question->getChoices(); 470 | if (!$choices->isEmpty()) { 471 | foreach ($choices as $choice) { 472 | $count = Answer::query() 473 | ->alias('a') 474 | ->joinRelation('vote_id', 'v') 475 | ->where('a.question_choice_id', $choice->getId()) 476 | ->where('v.poll_id', $this->getId()) 477 | ->where('v.status', 1) 478 | ->count(); 479 | 480 | $questionData['choices'][] = [ 481 | 'id' => $choice->getId(), 482 | 'title' => $choice->getTitle(), 483 | 'count' => $count, 484 | 'percentage' => $statistics['total_votes'] > 0 ? round(($count / $statistics['total_votes']) * 100, 1) : 0, 485 | ]; 486 | } 487 | } else { 488 | // Freie Textantworten 489 | $answers = Answer::query() 490 | ->alias('a') 491 | ->joinRelation('vote_id', 'v') 492 | ->where('a.question_id', $question->getId()) 493 | ->where('v.poll_id', $this->getId()) 494 | ->where('v.status', 1) 495 | ->find(); 496 | 497 | $textAnswers = []; 498 | foreach ($answers as $answer) { 499 | if ($answer->text) { 500 | $textAnswers[] = $answer->text; 501 | } 502 | } 503 | $questionData['text_answers'] = $textAnswers; 504 | } 505 | 506 | $questionStats[] = $questionData; 507 | } 508 | 509 | $statistics['questions'] = $questionStats; 510 | return $statistics; 511 | } 512 | } 513 | -------------------------------------------------------------------------------- /assets/poll-dashboard.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Poll Dashboard JavaScript 3 | * 4 | * Bietet Funktionen zur Erstellung von Diagrammen und interaktiven Elementen 5 | * für das Poll-AddOn Dashboard ohne externe Bibliotheken. 6 | */ 7 | 8 | (function() { 9 | 'use strict'; 10 | 11 | /** 12 | * PollDashboard Namespace 13 | */ 14 | var PollDashboard = { 15 | 16 | /** 17 | * Initialisiert das Dashboard 18 | */ 19 | init: function() { 20 | document.addEventListener('DOMContentLoaded', function() { 21 | // Umfrage-Selector Listener 22 | var pollSelector = document.getElementById('poll-selector'); 23 | if (pollSelector) { 24 | pollSelector.addEventListener('change', PollDashboard.handlePollSelection); 25 | } 26 | 27 | // Initialisiere alle Diagramme 28 | PollDashboard.initAllCharts(); 29 | 30 | // Event-Listener für Bootstrap Modals 31 | PollDashboard.initModalEventListeners(); 32 | }); 33 | }, 34 | 35 | /** 36 | * Konvertiert HTML-Entities in Text zu tatsächlichem HTML 37 | * Wird verwendet, um escaped HTML-Tags wie <i class="fa"> in echte Icons zu konvertieren 38 | */ 39 | decodeHtmlEntities: function(text) { 40 | if (!text || typeof text !== 'string') return text; 41 | 42 | var textArea = document.createElement('textarea'); 43 | textArea.innerHTML = text; 44 | return textArea.value; 45 | }, 46 | 47 | /** 48 | * Initialisiert Event-Listener für Modal-Fenster 49 | */ 50 | initModalEventListeners: function() { 51 | // Wenn ein Modal geöffnet wird, initialisiere das Kreisdiagramm 52 | $(document).on('shown.bs.modal', '.modal', function() { 53 | var modal = $(this); 54 | var chartContainer = modal.find('.poll-pie-chart'); 55 | 56 | if (chartContainer.length) { 57 | // Initialisiere das Chart im Modal 58 | var data = JSON.parse(chartContainer.attr('data-values') || '[]'); 59 | var labels = JSON.parse(chartContainer.attr('data-labels') || '[]'); 60 | var colors = JSON.parse(chartContainer.attr('data-colors') || '[]'); 61 | 62 | if (data.length) { 63 | // Leere den Container für das Chart 64 | chartContainer.empty(); 65 | 66 | // Erstelle das Chart 67 | PollDashboard.createPieChart(chartContainer[0], data, labels, colors); 68 | 69 | // Markiere als initialisiert 70 | chartContainer.attr('data-initialized', 'true'); 71 | } 72 | } 73 | }); 74 | }, 75 | 76 | /** 77 | * Verarbeitet die Auswahl einer Umfrage im Selector 78 | */ 79 | handlePollSelection: function() { 80 | var selector = document.getElementById('poll-selector'); 81 | if (!selector) return; 82 | 83 | // Alle Poll-Details ausblenden 84 | var details = document.querySelectorAll('.poll-details'); 85 | for (var i = 0; i < details.length; i++) { 86 | details[i].style.display = 'none'; 87 | } 88 | 89 | // Ausgewählte Details anzeigen 90 | var selectedId = selector.value; 91 | if (selectedId) { 92 | var selected = document.getElementById(selectedId); 93 | if (selected) { 94 | selected.style.display = 'block'; 95 | } 96 | } 97 | }, 98 | 99 | /** 100 | * Initialisiert alle Diagramme auf der Seite 101 | */ 102 | initAllCharts: function() { 103 | PollDashboard.initBarCharts(); 104 | // Kreisdiagramme werden jetzt bei Bedarf (im Modal) initialisiert 105 | PollDashboard.initTimeline(); 106 | }, 107 | 108 | /** 109 | * Initialisiert alle Balken-Diagramme 110 | */ 111 | initBarCharts: function() { 112 | var barCharts = document.querySelectorAll('.poll-bar-chart'); 113 | 114 | barCharts.forEach(function(chart) { 115 | var data = JSON.parse(chart.getAttribute('data-values') || '[]'); 116 | var labels = JSON.parse(chart.getAttribute('data-labels') || '[]'); 117 | var colors = JSON.parse(chart.getAttribute('data-colors') || '[]'); 118 | 119 | if (data.length) { 120 | PollDashboard.createBarChart(chart.id, data, labels, colors); 121 | } 122 | }); 123 | }, 124 | 125 | /** 126 | * Erstellt ein Balken-Diagramm 127 | */ 128 | createBarChart: function(containerId, data, labels, colors) { 129 | var container = document.getElementById(containerId); 130 | if (!container) return; 131 | 132 | // Lösche vorhandene Inhalte 133 | container.innerHTML = ''; 134 | 135 | // Erstelle eine Tabelle für bessere Darstellung 136 | var table = document.createElement('table'); 137 | table.className = 'poll-bar-table'; 138 | container.appendChild(table); 139 | 140 | var maxValue = Math.max.apply(null, data); 141 | if (maxValue === 0) maxValue = 1; // Vermeidet Division durch Null 142 | 143 | // Legende und Spaltenüberschriften 144 | var thead = document.createElement('thead'); 145 | var headerRow = document.createElement('tr'); 146 | 147 | var thTitle = document.createElement('th'); 148 | thTitle.textContent = 'Umfrage'; 149 | thTitle.className = 'poll-bar-table-title'; 150 | headerRow.appendChild(thTitle); 151 | 152 | var thBar = document.createElement('th'); 153 | thBar.textContent = 'Stimmen'; 154 | thBar.className = 'poll-bar-table-bar'; 155 | headerRow.appendChild(thBar); 156 | 157 | var thCount = document.createElement('th'); 158 | thCount.textContent = 'Anzahl'; 159 | thCount.className = 'poll-bar-table-count'; 160 | headerRow.appendChild(thCount); 161 | 162 | thead.appendChild(headerRow); 163 | table.appendChild(thead); 164 | 165 | // Tabellenkörper mit Datenzeilen 166 | var tbody = document.createElement('tbody'); 167 | 168 | for (var i = 0; i < data.length; i++) { 169 | var row = document.createElement('tr'); 170 | 171 | // Umfragetitel 172 | var titleCell = document.createElement('td'); 173 | // HTML-Entitäten dekodieren, um die Icons korrekt anzuzeigen 174 | var decodedLabel = PollDashboard.decodeHtmlEntities(labels[i] || ''); 175 | titleCell.innerHTML = decodedLabel; 176 | titleCell.className = 'poll-bar-table-title'; 177 | row.appendChild(titleCell); 178 | 179 | // Balken 180 | var barCell = document.createElement('td'); 181 | barCell.className = 'poll-bar-table-bar'; 182 | 183 | var barContainer = document.createElement('div'); 184 | barContainer.className = 'poll-bar-table-container'; 185 | 186 | var barElement = document.createElement('div'); 187 | barElement.className = 'poll-bar-table-element'; 188 | 189 | var width = Math.max((data[i] / maxValue * 100), 3); // Mindestbreite 3% 190 | barElement.style.width = width + '%'; 191 | barElement.style.backgroundColor = colors[i] || '#4b9ad9'; 192 | 193 | barContainer.appendChild(barElement); 194 | barCell.appendChild(barContainer); 195 | row.appendChild(barCell); 196 | 197 | // Zahlenwert 198 | var countCell = document.createElement('td'); 199 | countCell.textContent = data[i]; 200 | countCell.className = 'poll-bar-table-count'; 201 | row.appendChild(countCell); 202 | 203 | tbody.appendChild(row); 204 | } 205 | 206 | table.appendChild(tbody); 207 | 208 | // Hinweis hinzufügen, wenn keine Daten vorhanden 209 | if (data.length === 0) { 210 | var noDataRow = document.createElement('tr'); 211 | var noDataCell = document.createElement('td'); 212 | noDataCell.colSpan = 3; 213 | noDataCell.textContent = 'Keine Daten vorhanden'; 214 | noDataCell.style.textAlign = 'center'; 215 | noDataCell.style.padding = '20px'; 216 | noDataRow.appendChild(noDataCell); 217 | tbody.appendChild(noDataRow); 218 | } 219 | }, 220 | 221 | /** 222 | * Initialisiert alle Kreis-Diagramme 223 | * Wird jetzt hauptsächlich bei Modal-Öffnung verwendet 224 | */ 225 | initPieCharts: function() { 226 | var pieCharts = document.querySelectorAll('.poll-pie-chart'); 227 | 228 | pieCharts.forEach(function(chart) { 229 | // Verhindere doppelte Initialisierung 230 | if (chart.getAttribute('data-initialized') === 'true') { 231 | return; 232 | } 233 | 234 | try { 235 | var data = JSON.parse(chart.getAttribute('data-values') || '[]'); 236 | var labels = JSON.parse(chart.getAttribute('data-labels') || '[]'); 237 | var colors = JSON.parse(chart.getAttribute('data-colors') || '[]'); 238 | 239 | if (data.length) { 240 | // Leere das Element vor dem Erstellen des Charts 241 | chart.innerHTML = ''; 242 | PollDashboard.createPieChart(chart, data, labels, colors); 243 | 244 | // Markiere als initialisiert 245 | chart.setAttribute('data-initialized', 'true'); 246 | } 247 | } catch (e) { 248 | console.error('Fehler beim Initialisieren des Pie-Charts:', e); 249 | } 250 | }); 251 | }, 252 | 253 | /** 254 | * Erstellt ein Kreis-Diagramm mit SVG für bessere Browser-Kompatibilität 255 | * 256 | * @param {HTMLElement} container - Der Container für das Diagramm 257 | * @param {Array} data - Array mit Zahlenwerten 258 | * @param {Array} labels - Array mit Beschriftungen 259 | * @param {Array} colors - Array mit Farben 260 | */ 261 | createPieChart: function(container, data, labels, colors) { 262 | // Berechne Gesamtsumme 263 | var total = data.reduce(function(sum, value) { 264 | return sum + value; 265 | }, 0); 266 | 267 | if (total === 0) { 268 | container.innerHTML = '
    Keine Daten verfügbar
    '; 269 | return; 270 | } 271 | 272 | // Prüfen, ob wir im Modal sind 273 | var isModal = $(container).closest('.modal-body').length > 0; 274 | 275 | // Container für die neue Grid-Layout-Struktur (besser für 2-Spalten im Modal) 276 | var chartContainer = document.createElement('div'); 277 | chartContainer.className = 'poll-chart-container'; 278 | 279 | if (isModal) { 280 | // Im Modal verwenden wir ein Grid-Layout für bessere Kontrolle 281 | chartContainer.style.display = 'grid'; 282 | chartContainer.style.gridTemplateColumns = 'minmax(400px, 1fr) minmax(250px, 1fr)'; 283 | chartContainer.style.gridGap = '20px'; 284 | chartContainer.style.alignItems = 'start'; 285 | chartContainer.style.width = '100%'; 286 | } else { 287 | // Außerhalb des Modals behalten wir das Flex-Layout bei 288 | chartContainer.style.display = 'flex'; 289 | chartContainer.style.flexDirection = 'row'; 290 | chartContainer.style.alignItems = 'center'; 291 | chartContainer.style.gap = '20px'; 292 | } 293 | 294 | // Größe des Diagramms anpassen 295 | var size = isModal ? 400 : 220; 296 | var radius = size / 2 * 0.9; // Etwas kleiner als der halbe Container, um sicher zu gehen 297 | var center = size / 2; 298 | 299 | // SVG-Container erstellen 300 | var svgContainer = document.createElement('div'); 301 | svgContainer.className = 'poll-pie-svg-container'; 302 | 303 | if (isModal) { 304 | // Im Modal: Container soll die volle Höhe haben und zentriert sein 305 | svgContainer.style.width = '100%'; 306 | svgContainer.style.display = 'flex'; 307 | svgContainer.style.justifyContent = 'center'; 308 | svgContainer.style.alignItems = 'center'; 309 | } else { 310 | // Normale Anzeige: Vermeide Schrumpfen 311 | svgContainer.style.flexShrink = '0'; 312 | } 313 | 314 | // SVG-Element erstellen 315 | var svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); 316 | svg.setAttribute('width', size); 317 | svg.setAttribute('height', size); 318 | svg.setAttribute('viewBox', '0 0 ' + size + ' ' + size); 319 | svg.setAttribute('class', 'poll-pie-svg'); 320 | 321 | // Wir fügen jetzt die Kreissegmente direkt dem SVG hinzu, ohne eine zusätzliche Gruppe 322 | 323 | // Startwinkel (in Grad) 324 | var startAngle = 0; 325 | 326 | // Erstelle für jeden Datenpunkt ein Segment 327 | for (var i = 0; i < data.length; i++) { 328 | if (data[i] === 0) continue; 329 | 330 | var percentage = data[i] / total; 331 | var angle = percentage * 360; 332 | var endAngle = startAngle + angle; 333 | 334 | // Berechne die Koordinaten für den Arc-Pfad 335 | // Konvertiere Winkel in Radianten und berücksichtige die Verschiebung (-90 Grad) 336 | var startRad = (startAngle - 90) * Math.PI / 180; 337 | var endRad = (endAngle - 90) * Math.PI / 180; 338 | 339 | // Berechne die Punkte auf dem Kreisumfang 340 | var x1 = center + radius * Math.cos(startRad); 341 | var y1 = center + radius * Math.sin(startRad); 342 | var x2 = center + radius * Math.cos(endRad); 343 | var y2 = center + radius * Math.sin(endRad); 344 | 345 | // Flag für große Bogen (größer als 180 Grad) 346 | var largeArcFlag = angle > 180 ? 1 : 0; 347 | 348 | // SVG-Pfad für das Kreissegment erstellen 349 | var path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); 350 | 351 | // Pfad-Definition 352 | var d = [ 353 | 'M', center, center, // Bewege zum Zentrum 354 | 'L', x1, y1, // Linie zum ersten Punkt am Kreisrand 355 | 'A', radius, radius, // Kreisbogen-Radien (x,y) 356 | 0, // Rotationswinkel der Ellipse 357 | largeArcFlag, // Großer Bogen (1) oder kleiner Bogen (0) 358 | 1, // Sweepflag (1 = im Uhrzeigersinn) 359 | x2, y2, // Endpunkt des Bogens 360 | 'Z' // Schließe den Pfad zum Zentrum 361 | ].join(' '); 362 | 363 | path.setAttribute('d', d); 364 | path.setAttribute('fill', colors[i] || PollDashboard.getRandomColor()); 365 | path.setAttribute('class', 'poll-pie-segment-path'); 366 | path.setAttribute('data-index', i); 367 | 368 | // Event-Listener für Hover-Effekt 369 | path.addEventListener('mouseenter', function() { 370 | var index = this.getAttribute('data-index'); 371 | var legendItem = container.querySelector('.poll-pie-legend-item[data-index="' + index + '"]'); 372 | if (legendItem) { 373 | legendItem.classList.add('poll-pie-legend-item-highlight'); 374 | } 375 | this.setAttribute('stroke', '#ffffff'); 376 | this.setAttribute('stroke-width', '2'); 377 | }); 378 | 379 | path.addEventListener('mouseleave', function() { 380 | var index = this.getAttribute('data-index'); 381 | var legendItem = container.querySelector('.poll-pie-legend-item[data-index="' + index + '"]'); 382 | if (legendItem) { 383 | legendItem.classList.remove('poll-pie-legend-item-highlight'); 384 | } 385 | this.setAttribute('stroke', 'none'); 386 | this.setAttribute('stroke-width', '0'); 387 | }); 388 | 389 | svg.appendChild(path); 390 | 391 | startAngle = endAngle; // Für das nächste Segment 392 | } 393 | 394 | svgContainer.appendChild(svg); 395 | 396 | // Legende erstellen 397 | var legend = document.createElement('div'); 398 | legend.className = 'poll-pie-legend'; 399 | 400 | if (isModal) { 401 | // Im Modal: Legende mit fester Breite und scrollbar 402 | legend.style.width = '100%'; 403 | legend.style.maxHeight = size + 'px'; // Gleiche Höhe wie das SVG 404 | legend.style.overflowY = 'auto'; 405 | legend.style.paddingRight = '10px'; 406 | legend.style.boxSizing = 'border-box'; 407 | } else { 408 | // Normale Anzeige: Flexibel wachsen 409 | legend.style.flexGrow = '1'; 410 | } 411 | 412 | // Titel für die Legende hinzufügen (nur im Modal) 413 | if (isModal) { 414 | var legendTitle = document.createElement('h4'); 415 | legendTitle.textContent = 'Legende'; 416 | legendTitle.style.marginTop = '0'; 417 | legendTitle.style.marginBottom = '10px'; 418 | legend.appendChild(legendTitle); 419 | } 420 | 421 | // Legendeneinträge erstellen 422 | for (var j = 0; j < data.length; j++) { 423 | if (data[j] === 0) continue; 424 | 425 | var percent = Math.round((data[j] / total) * 100); 426 | 427 | var legendItem = document.createElement('div'); 428 | legendItem.className = 'poll-pie-legend-item'; 429 | legendItem.setAttribute('data-index', j); 430 | 431 | var colorBox = document.createElement('span'); 432 | colorBox.className = 'poll-pie-legend-color'; 433 | colorBox.style.backgroundColor = colors[j] || PollDashboard.getRandomColor(); 434 | 435 | var labelText = document.createElement('span'); 436 | labelText.className = 'poll-pie-legend-text'; 437 | labelText.textContent = labels[j] + ': ' + data[j] + ' (' + percent + '%)'; 438 | 439 | legendItem.appendChild(colorBox); 440 | legendItem.appendChild(labelText); 441 | 442 | // Event-Listener für Hover-Effekt 443 | legendItem.addEventListener('mouseenter', function() { 444 | var index = this.getAttribute('data-index'); 445 | var segment = svg.querySelector('.poll-pie-segment-path[data-index="' + index + '"]'); 446 | if (segment) { 447 | segment.setAttribute('stroke', '#ffffff'); 448 | segment.setAttribute('stroke-width', '2'); 449 | } 450 | this.classList.add('poll-pie-legend-item-highlight'); 451 | }); 452 | 453 | legendItem.addEventListener('mouseleave', function() { 454 | var index = this.getAttribute('data-index'); 455 | var segment = svg.querySelector('.poll-pie-segment-path[data-index="' + index + '"]'); 456 | if (segment) { 457 | segment.setAttribute('stroke', 'none'); 458 | segment.setAttribute('stroke-width', '0'); 459 | } 460 | this.classList.remove('poll-pie-legend-item-highlight'); 461 | }); 462 | 463 | legend.appendChild(legendItem); 464 | } 465 | 466 | // Füge SVG und Legende zum Container hinzu 467 | chartContainer.appendChild(svgContainer); 468 | chartContainer.appendChild(legend); 469 | 470 | // Leere den ursprünglichen Container und füge den Chart-Container hinzu 471 | container.innerHTML = ''; 472 | container.appendChild(chartContainer); 473 | }, 474 | 475 | /** 476 | * Initialisiert die Aktivitäts-Timeline 477 | */ 478 | initTimeline: function() { 479 | var timeline = document.getElementById('poll-activity-timeline'); 480 | if (!timeline) return; 481 | 482 | var data = JSON.parse(timeline.getAttribute('data-values') || '[]'); 483 | var dates = JSON.parse(timeline.getAttribute('data-dates') || '[]'); 484 | 485 | if (data.length && dates.length) { 486 | PollDashboard.createTimeline(timeline.id, data, dates); 487 | } else { 488 | // Statt zufällige Daten zu zeigen, einen Hinweis anzeigen 489 | timeline.innerHTML = '
    Keine Aktivitätsdaten verfügbar
    '; 490 | } 491 | }, 492 | 493 | /** 494 | * Erstellt eine Aktivitäts-Timeline 495 | */ 496 | createTimeline: function(containerId, data, dates) { 497 | var container = document.getElementById(containerId); 498 | if (!container) return; 499 | 500 | // Container leeren 501 | container.innerHTML = ''; 502 | 503 | // Timeline Container 504 | var timelineContainer = document.createElement('div'); 505 | timelineContainer.className = 'poll-timeline'; 506 | container.appendChild(timelineContainer); 507 | 508 | // Zeitachse 509 | var axis = document.createElement('div'); 510 | axis.className = 'poll-timeline-axis'; 511 | container.appendChild(axis); 512 | 513 | // Finde Maximum 514 | var maxValue = Math.max.apply(null, data) || 1; 515 | 516 | // Horizontale Linie 517 | var line = document.createElement('div'); 518 | line.className = 'poll-timeline-line'; 519 | line.style.width = '100%'; 520 | timelineContainer.appendChild(line); 521 | 522 | // Datenpunkte hinzufügen 523 | for (var i = 0; i < data.length; i++) { 524 | var x = (i / (data.length - 1) * 100); 525 | var y = 100 - (data[i] / maxValue * 100); 526 | 527 | var point = document.createElement('div'); 528 | point.className = 'poll-timeline-point'; 529 | point.style.left = x + '%'; 530 | point.style.top = y + '%'; 531 | point.title = dates[i] + ': ' + data[i]; 532 | 533 | timelineContainer.appendChild(point); 534 | 535 | // Zeigen wir nur einige Labels an, um Überlappung zu vermeiden 536 | if (i % Math.ceil(data.length / 10) === 0 || i === data.length - 1) { 537 | var label = document.createElement('div'); 538 | label.className = 'poll-timeline-label'; 539 | label.textContent = dates[i]; 540 | label.style.left = x + '%'; 541 | axis.appendChild(label); 542 | } 543 | } 544 | }, 545 | 546 | /** 547 | * Generiert eine zufällige Farbe 548 | */ 549 | getRandomColor: function() { 550 | return 'rgba(' + 551 | Math.floor(Math.random() * 150) + ',' + 552 | Math.floor(Math.random() * 150) + ',' + 553 | (Math.floor(Math.random() * 105) + 150) + ',' + 554 | '0.6)'; 555 | } 556 | }; 557 | 558 | // Exportieren des PollDashboard-Objekts 559 | window.PollDashboard = PollDashboard; 560 | 561 | // Initialisierung beim Laden 562 | PollDashboard.init(); 563 | 564 | })(); --------------------------------------------------------------------------------