From b075e3ecfcf406723bca53a1e51a37f93003f484 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fromme?= Date: Thu, 25 Jun 2026 14:09:03 +0200 Subject: [PATCH] wip: implement admin crud --- assets/admin.js | 8 + assets/controllers/autocomplete_controller.js | 84 +++++ assets/controllers/datepicker_controller.js | 56 ++++ assets/controllers/multiselect_controller.js | 36 +++ assets/controllers/sortable_controller.js | 42 +++ assets/images/icons.svg | 1 + assets/mixins/use_fetch.js | 29 ++ assets/styles/_components.css | 1 - assets/styles/_components_admin.css | 10 + assets/styles/admin.css | 7 + assets/styles/components/autocomplete.css | 33 ++ assets/styles/components/badge.css | 3 + assets/styles/components/datepicker.css | 13 + composer.json | 1 + composer.lock | 91 +++++- config/bundles.php | 1 + config/packages/twig_component.yaml | 5 + migrations/Version20260625154838.php | 31 ++ migrations/Version20260625173941.php | 31 ++ migrations/Version20260627121823.php | 35 +++ migrations/Version20260627130625.php | 31 ++ package-lock.json | 45 ++- package.json | 6 + .../Admin/AccommodationController.php | 121 +++++++ .../Admin/AccommodationPriceController.php | 145 +++++++++ .../Admin/AdditionalServiceController.php | 148 +++++++++ .../Admin/BoardServiceController.php | 147 +++++++++ .../Admin/SortingControllerTrait.php | 18 ++ src/Entity/Groups/Accommodation.php | 16 +- src/Entity/Groups/AccommodationPrice.php | 90 ++++++ src/Entity/Groups/AdditionalService.php | 19 +- src/Entity/Groups/BoardService.php | 7 +- src/Entity/Groups/SortableEntityInterface.php | 10 + src/Enum/Groups/AdditionalServiceType.php | 5 + .../Admin/Groups/AccommodationPriceType.php | 74 +++++ src/Form/Admin/Groups/AccommodationType.php | 48 +++ .../Admin/Groups/AdditionalServiceType.php | 60 ++++ src/Form/Admin/Groups/BoardServiceType.php | 49 +++ src/Form/DatepickerType.php | 39 +++ src/Menu/AdminMenuBuilder.php | 12 + src/Service/CmsDataProvider.php | 120 +++++-- symfony.lock | 12 + templates/_partials/_flashes_admin.html.twig | 9 + templates/_partials/_menu.html.twig | 9 +- templates/_partials/_toast.html.twig | 10 + templates/admin/accommodation/_form.html.twig | 296 ++++++++++++++++++ .../admin/accommodation/create.html.twig | 6 + templates/admin/accommodation/edit.html.twig | 6 + templates/admin/accommodation/index.html.twig | 71 +++++ .../accommodation/modal_delete.html.twig | 7 + .../admin/accommodation_price/_form.html.twig | 24 ++ .../accommodation_price/create.html.twig | 6 + .../admin/accommodation_price/edit.html.twig | 6 + .../modal_delete.html.twig | 7 + .../admin/additional_service/_form.html.twig | 21 ++ .../admin/additional_service/create.html.twig | 6 + .../admin/additional_service/edit.html.twig | 6 + .../additional_service/modal_delete.html.twig | 7 + templates/admin/board_service/_form.html.twig | 19 ++ .../admin/board_service/create.html.twig | 6 + templates/admin/board_service/edit.html.twig | 6 + .../board_service/modal_delete.html.twig | 7 + .../admin/booking_edit_draft/index.html.twig | 41 +-- .../admin/booking_edit_draft/show.html.twig | 4 +- templates/admin/log/index.html.twig | 17 +- templates/admin/log/xml_dumps.html.twig | 8 +- templates/admin/user/index.html.twig | 4 +- templates/base_admin.html.twig | 4 +- templates/components/badge.html.twig | 11 + .../components/dropdown/hxbutton.html.twig | 20 ++ .../dropdown/index.html.twig} | 13 +- templates/components/dropdown/link.html.twig | 10 + templates/components/page/heading.html.twig | 1 + templates/forms_admin.html.twig | 235 ++++++++++++++ templates/layout_admin.html.twig | 3 +- translations/messages.de.yaml | 7 + translations/validators.de.yaml | 2 + webpack.config.js | 1 + 78 files changed, 2555 insertions(+), 101 deletions(-) create mode 100644 assets/admin.js create mode 100644 assets/controllers/autocomplete_controller.js create mode 100644 assets/controllers/datepicker_controller.js create mode 100644 assets/controllers/multiselect_controller.js create mode 100644 assets/controllers/sortable_controller.js create mode 100644 assets/mixins/use_fetch.js create mode 100644 assets/styles/_components_admin.css create mode 100644 assets/styles/admin.css create mode 100644 assets/styles/components/autocomplete.css create mode 100644 assets/styles/components/badge.css create mode 100644 assets/styles/components/datepicker.css create mode 100644 config/packages/twig_component.yaml create mode 100644 migrations/Version20260625154838.php create mode 100644 migrations/Version20260625173941.php create mode 100644 migrations/Version20260627121823.php create mode 100644 migrations/Version20260627130625.php create mode 100644 src/Controller/Admin/AccommodationController.php create mode 100644 src/Controller/Admin/AccommodationPriceController.php create mode 100644 src/Controller/Admin/AdditionalServiceController.php create mode 100644 src/Controller/Admin/BoardServiceController.php create mode 100644 src/Controller/Admin/SortingControllerTrait.php create mode 100644 src/Entity/Groups/SortableEntityInterface.php create mode 100644 src/Form/Admin/Groups/AccommodationPriceType.php create mode 100644 src/Form/Admin/Groups/AccommodationType.php create mode 100644 src/Form/Admin/Groups/AdditionalServiceType.php create mode 100644 src/Form/Admin/Groups/BoardServiceType.php create mode 100644 src/Form/DatepickerType.php create mode 100644 templates/_partials/_flashes_admin.html.twig create mode 100644 templates/_partials/_toast.html.twig create mode 100644 templates/admin/accommodation/_form.html.twig create mode 100644 templates/admin/accommodation/create.html.twig create mode 100644 templates/admin/accommodation/edit.html.twig create mode 100644 templates/admin/accommodation/index.html.twig create mode 100644 templates/admin/accommodation/modal_delete.html.twig create mode 100644 templates/admin/accommodation_price/_form.html.twig create mode 100644 templates/admin/accommodation_price/create.html.twig create mode 100644 templates/admin/accommodation_price/edit.html.twig create mode 100644 templates/admin/accommodation_price/modal_delete.html.twig create mode 100644 templates/admin/additional_service/_form.html.twig create mode 100644 templates/admin/additional_service/create.html.twig create mode 100644 templates/admin/additional_service/edit.html.twig create mode 100644 templates/admin/additional_service/modal_delete.html.twig create mode 100644 templates/admin/board_service/_form.html.twig create mode 100644 templates/admin/board_service/create.html.twig create mode 100644 templates/admin/board_service/edit.html.twig create mode 100644 templates/admin/board_service/modal_delete.html.twig create mode 100644 templates/components/badge.html.twig create mode 100644 templates/components/dropdown/hxbutton.html.twig rename templates/{_partials/_dropdown.html.twig => components/dropdown/index.html.twig} (50%) create mode 100644 templates/components/dropdown/link.html.twig create mode 100644 templates/components/page/heading.html.twig create mode 100644 templates/forms_admin.html.twig create mode 100644 translations/validators.de.yaml diff --git a/assets/admin.js b/assets/admin.js new file mode 100644 index 00000000..7ce65f09 --- /dev/null +++ b/assets/admin.js @@ -0,0 +1,8 @@ +import htmx from 'htmx.org' +htmx.config.includeIndicatorStyles = false +htmx.config.historyCacheSize = 0 +htmx.config.allowScriptTags = false + +import './bootstrap.js' + +import './styles/admin.css' diff --git a/assets/controllers/autocomplete_controller.js b/assets/controllers/autocomplete_controller.js new file mode 100644 index 00000000..d75d401b --- /dev/null +++ b/assets/controllers/autocomplete_controller.js @@ -0,0 +1,84 @@ +import { Controller } from '@hotwired/stimulus' +import { useFetch } from '../mixins/use_fetch' +import Autocomplete from '@trevoreyre/autocomplete-js' + +export default class extends Controller { + + static targets = [ 'input', 'field', 'resetButton', 'searchIcon' ] + + static values = { url: String, choices: Array, selected: Boolean } + + initialize() { + useFetch(this) + } + + connect() { + const searchHandler = input => { + return new Promise(resolve => { + // Check input minimum length + if (input.length < 2) { + return resolve([]) + } + + // Search choices if provided + if (this.choicesValue.length > 0) { + return resolve(this.choicesValue.filter(choice => { + return -1 !== choice.text.toLowerCase().search(input.toLowerCase()) + })) + } + + // Use API otherwise + let data = { search: input } + fetch(this.urlValue, { method: 'POST', body: JSON.stringify(data), credentials: 'include' }) + .then(this.checkStatus) + .then(this.parseJSON) + .then(data => { + resolve(data) + }) + }) + } + + const submitHandler = result => { + this.fieldTarget.value = result.value + this.selectedValue = true + this.element.dispatchEvent(new CustomEvent('select', { + detail: { result } + })) + } + + this.autocomplete = new Autocomplete(this.element, { + search: searchHandler, + getResultValue: result => result.text, + submitOnEnter: true, + onSubmit: submitHandler, + debounceTime: 250, + }) + + if (this.fieldTarget.value) { + this.selectedValue = true + } + + // prevent form submission when selecting entry in autocomplete list with enter key + this.element.addEventListener('keypress', e => { + if (e.key === 'Enter') { + e.preventDefault() + } + }) + } + + reset() { + this.inputTarget.value = null + this.fieldTarget.value = null + this.selectedValue = false + this.element.dispatchEvent(new CustomEvent('reset')) + } + + selectedValueChanged() { + this.resetButtonTarget.classList.toggle('hidden', false === this.selectedValue) + this.searchIconTarget.classList.toggle('hidden', this.selectedValue) + } + + disconnect() { + this.autocomplete.destroy() + } +} diff --git a/assets/controllers/datepicker_controller.js b/assets/controllers/datepicker_controller.js new file mode 100644 index 00000000..0440bdab --- /dev/null +++ b/assets/controllers/datepicker_controller.js @@ -0,0 +1,56 @@ +import { Controller } from '@hotwired/stimulus' +import flatpickr from 'flatpickr'; +import { German } from 'flatpickr/dist/l10n/de'; + +/* stimulusFetch: 'lazy' */ +export default class extends Controller { + + static targets = [ 'field' ] + static values = { + minDate: String, + maxDate: String, + disableWeekends: Boolean, + } + + initialize() { + flatpickr.localize(German) + } + + connect() { + const minDate = this.minDateValue + const maxDate = this.maxDateValue + const options = { + dateFormat: 'Y-m-d', + altFormat: 'd.m.Y', + minDate, + maxDate, + altInput: true, + allowInput: true, + onClose(dates, currentdatestring, instance) { + instance.setDate(instance.altInput.value, true, instance.config.altFormat) + this.element.dispatchEvent(new CustomEvent('datepicker:closed')) + }, + onChange(dates, currentdatestring, instance) { + this.element.dispatchEvent(new CustomEvent('datepicker:picked', { + detail: { + date: instance.altInput.value + } + })) + }, + } + + if (true === this.disableWeekendsValue) { + options['disable'] = [ + function(date) { + return (date.getDay() === 0 || date.getDay() === 6); + } + ] + } + + this.picker = flatpickr(this.fieldTarget, options) + } + + disconnect() { + this.picker.destroy() + } +} diff --git a/assets/controllers/multiselect_controller.js b/assets/controllers/multiselect_controller.js new file mode 100644 index 00000000..a822d63e --- /dev/null +++ b/assets/controllers/multiselect_controller.js @@ -0,0 +1,36 @@ +import { Controller } from '@hotwired/stimulus' +import { useClickOutside } from 'stimulus-use' + +export default class extends Controller { + + static targets = [ 'label', 'dropdown', 'choice' ] + static values = { placeholder: String } + + connect() { + this.update() + useClickOutside(this) + } + + toggle() { + this.dropdownTarget.classList.toggle('hidden') + } + + update() { + let labels = [] + + this.choiceTargets.forEach((choice) => { + if (choice.checked) { + labels.push(choice.dataset.label) + } + this.labelTarget.innerText = labels.join(', ') + }) + + if (0 === labels.length) { + this.labelTarget.innerText = this.placeholderValue + } + } + + clickOutside(e) { + this.dropdownTarget.classList.toggle('hidden', true) + } +} \ No newline at end of file diff --git a/assets/controllers/sortable_controller.js b/assets/controllers/sortable_controller.js new file mode 100644 index 00000000..cc2722b2 --- /dev/null +++ b/assets/controllers/sortable_controller.js @@ -0,0 +1,42 @@ +import { Controller } from '@hotwired/stimulus' +import Sortable from 'sortablejs' +import { useFetch } from '../mixins/use_fetch' + +/* stimulusFetch: 'lazy' */ +export default class extends Controller { + + static targets = [ 'item', 'handle', 'positionField', 'positionLabel' ] + static values = { url: String } + + connect() { + useFetch(this) + new Sortable(this.element, { + item: '[data-sortable-target="item"]', + handle: '[data-sortable-target="handle"]', + sort: true, + onEnd: () => { + this.updateOrdering() + if (this.urlValue) { + this.saveOrdering() + } + } + }) + } + + updateOrdering() { + this.positionFieldTargets.forEach((field, index) => { + field.value = index + 1 + }) + this.positionLabelTargets.forEach((label, index) => { + label.innerText = index + 1 + }) + } + + saveOrdering() { + const body = new FormData() + this.positionFieldTargets.forEach(field => { + body.append(`sorting[${field.dataset.id}]`, field.value) + }) + fetch(this.urlValue, this.getInitObject('post', body)) + } +} diff --git a/assets/images/icons.svg b/assets/images/icons.svg index 6d39de6f..dd69085a 100644 --- a/assets/images/icons.svg +++ b/assets/images/icons.svg @@ -181,6 +181,7 @@ + diff --git a/assets/mixins/use_fetch.js b/assets/mixins/use_fetch.js new file mode 100644 index 00000000..81b35453 --- /dev/null +++ b/assets/mixins/use_fetch.js @@ -0,0 +1,29 @@ +export const useFetch = controller => { + Object.assign(controller, { + checkStatus(response) { + if (response.status >= 200 && response.status < 300) { + return response + } else { + const error = new Error(response.statusText) + error.response = response + throw error + } + }, + parseJSON(response) { + return response.json() + }, + parseHTML(response) { + return response.text() + }, + getInitObject(method = 'get', body = null) { + return { + method, + body, + credentials: 'include', + headers: { + 'X-Requested-With': 'XMLHttpRequest' + } + } + } + }) +} diff --git a/assets/styles/_components.css b/assets/styles/_components.css index 062c292a..cfcf3d03 100644 --- a/assets/styles/_components.css +++ b/assets/styles/_components.css @@ -1,5 +1,4 @@ @import "components/button.css"; -@import "components/datatable.css"; @import "components/forms.css"; @import "components/pagination.css"; @import "components/toast.css"; diff --git a/assets/styles/_components_admin.css b/assets/styles/_components_admin.css new file mode 100644 index 00000000..a1a098ba --- /dev/null +++ b/assets/styles/_components_admin.css @@ -0,0 +1,10 @@ +@import "components/autocomplete.css"; +@import "components/badge.css"; +@import "components/button.css"; +@import "components/datatable.css"; +@import "components/datepicker.css"; +@import "components/forms.css"; +@import "components/pagination.css"; +@import "components/toast.css"; +@import "components/tooltip.css"; +@import "components/typography.css"; diff --git a/assets/styles/admin.css b/assets/styles/admin.css new file mode 100644 index 00000000..cff7915f --- /dev/null +++ b/assets/styles/admin.css @@ -0,0 +1,7 @@ +@import "tailwindcss/base"; +@import "_base.css"; + +@import "tailwindcss/components"; +@import "_components_admin.css"; + +@import "tailwindcss/utilities"; diff --git a/assets/styles/components/autocomplete.css b/assets/styles/components/autocomplete.css new file mode 100644 index 00000000..dbccfef0 --- /dev/null +++ b/assets/styles/components/autocomplete.css @@ -0,0 +1,33 @@ +.autocomplete { + @apply relative w-full; +} + +.autocomplete[data-loading="true"]:after { + @apply block absolute top-0 right-0; + @apply absolute w-4 h-4 rounded-full right-0 mr-4; + content: ""; + border: 3px solid rgba(0, 0, 0, 0.12); + border-right: 3px solid rgba(0, 0, 0, 0.48); + top: 50%; + transform: translateY(-50%); + animation: rotate 1s infinite linear; +} + +.autocomplete-icon { + @apply absolute top-1/2 right-0 transform -translate-y-1/2 mr-4; +} +.autocomplete[data-loading="true"] .autocomplete-icon { + @apply hidden; +} + +.autocomplete-result-list { + @apply bg-white shadow-md max-h-64 overflow-y-auto; +} + +.autocomplete-result { + @apply p-2; +} +.autocomplete-result:hover, +.autocomplete-result[aria-selected="true"] { + @apply bg-gray-200; +} diff --git a/assets/styles/components/badge.css b/assets/styles/components/badge.css new file mode 100644 index 00000000..0fe7b42c --- /dev/null +++ b/assets/styles/components/badge.css @@ -0,0 +1,3 @@ +.badge { + @apply inline-flex items-center rounded-md px-1.5 py-0.5 text-xs font-medium whitespace-nowrap; +} diff --git a/assets/styles/components/datepicker.css b/assets/styles/components/datepicker.css new file mode 100644 index 00000000..d70f11c9 --- /dev/null +++ b/assets/styles/components/datepicker.css @@ -0,0 +1,13 @@ +@import "flatpickr/dist/flatpickr.css"; + +.flatpickr-day.selected, +.flatpickr-day.selected:hover, +.flatpickr-day.selected:focus { + background-color: theme('colors.secondary'); + border-color: theme('colors.secondary'); +} + +.flatpickr-monthDropdown-months, +.flatpickr-current-month .flatpickr-monthDropdown-months { + @apply form-select pt-0 pb-0 focus:outline-none focus:shadow-none; +} diff --git a/composer.json b/composer.json index 14d8f403..24528eef 100644 --- a/composer.json +++ b/composer.json @@ -56,6 +56,7 @@ "symfony/translation": "6.4.*", "symfony/twig-bundle": "6.4.*", "symfony/uid": "6.4.*", + "symfony/ux-twig-component": "^2.36", "symfony/validator": "6.4.*", "symfony/web-link": "6.4.*", "symfony/webpack-encore-bundle": "^2.2", diff --git a/composer.lock b/composer.lock index 9a058fc7..3963adca 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "aa69dee5968ec6f7c3f152b8a8a6100d", + "content-hash": "f04202a445d42ebff0dd0e8dce335163", "packages": [ { "name": "carbonphp/carbon-doctrine-types", @@ -10080,6 +10080,95 @@ ], "time": "2025-12-23T15:07:59+00:00" }, + { + "name": "symfony/ux-twig-component", + "version": "v2.36.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/ux-twig-component.git", + "reference": "d64b068d8339e905cd48974bdd6e9ba54dc8f247" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/ux-twig-component/zipball/d64b068d8339e905cd48974bdd6e9ba54dc8f247", + "reference": "d64b068d8339e905cd48974bdd6e9ba54dc8f247", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "symfony/dependency-injection": "^5.4|^6.0|^7.0|^8.0", + "symfony/deprecation-contracts": "^2.2|^3.0", + "symfony/event-dispatcher": "^5.4|^6.0|^7.0|^8.0", + "symfony/property-access": "^5.4|^6.0|^7.0|^8.0", + "twig/twig": "^3.10.3" + }, + "conflict": { + "symfony/config": "<5.4.0" + }, + "require-dev": { + "symfony/console": "^5.4|^6.0|^7.0|^8.0", + "symfony/css-selector": "^5.4|^6.0|^7.0|^8.0", + "symfony/dom-crawler": "^5.4|^6.0|^7.0|^8.0", + "symfony/framework-bundle": "^5.4|^6.0|^7.0|^8.0", + "symfony/phpunit-bridge": "^6.0|^7.0|^8.0", + "symfony/stimulus-bundle": "^2.9.1|^3.0", + "symfony/twig-bundle": "^5.4|^6.0|^7.0|^8.0", + "symfony/webpack-encore-bundle": "^1.15|^2.3.0", + "twig/extra-bundle": "^3.10.3", + "twig/html-extra": "^3.10.3" + }, + "type": "symfony-bundle", + "extra": { + "thanks": { + "url": "https://github.com/symfony/ux", + "name": "symfony/ux" + } + }, + "autoload": { + "psr-4": { + "Symfony\\UX\\TwigComponent\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Twig components for Symfony", + "homepage": "https://symfony.com", + "keywords": [ + "components", + "symfony-ux", + "twig" + ], + "support": { + "source": "https://github.com/symfony/ux-twig-component/tree/v2.36.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-03T05:13:59+00:00" + }, { "name": "symfony/validator", "version": "v6.4.42", diff --git a/config/bundles.php b/config/bundles.php index de9164d5..7bc0cb7f 100644 --- a/config/bundles.php +++ b/config/bundles.php @@ -19,4 +19,5 @@ return [ Flagception\Bundle\FlagceptionBundle\FlagceptionBundle::class => ['all' => true], Knp\Bundle\MenuBundle\KnpMenuBundle::class => ['all' => true], Knp\Bundle\PaginatorBundle\KnpPaginatorBundle::class => ['all' => true], + Symfony\UX\TwigComponent\TwigComponentBundle::class => ['all' => true], ]; diff --git a/config/packages/twig_component.yaml b/config/packages/twig_component.yaml new file mode 100644 index 00000000..fd17ac69 --- /dev/null +++ b/config/packages/twig_component.yaml @@ -0,0 +1,5 @@ +twig_component: + anonymous_template_directory: 'components/' + defaults: + # Namespace & directory for components + App\Twig\Components\: 'components/' diff --git a/migrations/Version20260625154838.php b/migrations/Version20260625154838.php new file mode 100644 index 00000000..27076f2b --- /dev/null +++ b/migrations/Version20260625154838.php @@ -0,0 +1,31 @@ +addSql('ALTER TABLE accommodation_price ADD price_per_night_override INT DEFAULT NULL'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE accommodation_price DROP price_per_night_override'); + } +} diff --git a/migrations/Version20260625173941.php b/migrations/Version20260625173941.php new file mode 100644 index 00000000..05d216b1 --- /dev/null +++ b/migrations/Version20260625173941.php @@ -0,0 +1,31 @@ +addSql('ALTER TABLE additional_service ADD selection_group VARCHAR(128) DEFAULT NULL, DROP `grouping`'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE additional_service ADD `grouping` VARCHAR(128) NOT NULL, DROP selection_group'); + } +} diff --git a/migrations/Version20260627121823.php b/migrations/Version20260627121823.php new file mode 100644 index 00000000..0932ce64 --- /dev/null +++ b/migrations/Version20260627121823.php @@ -0,0 +1,35 @@ +addSql('ALTER TABLE accommodation_price ADD accept_undersubscription TINYINT(1) NOT NULL'); + $this->addSql('DROP INDEX IDX_NEWSLETTER_CONSENT_EMAIL ON newsletter_consent'); + $this->addSql('ALTER TABLE newsletter_opt_in_request RENAME INDEX uniq_70505214b3bc57da TO UNIQ_EC0365DB3BC57DA'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE accommodation_price DROP accept_undersubscription'); + $this->addSql('CREATE INDEX IDX_NEWSLETTER_CONSENT_EMAIL ON newsletter_consent (email)'); + $this->addSql('ALTER TABLE newsletter_opt_in_request RENAME INDEX uniq_ec0365db3bc57da TO UNIQ_70505214B3BC57DA'); + } +} diff --git a/migrations/Version20260627130625.php b/migrations/Version20260627130625.php new file mode 100644 index 00000000..75e2ab8b --- /dev/null +++ b/migrations/Version20260627130625.php @@ -0,0 +1,31 @@ +addSql('ALTER TABLE accommodation_price ADD price_additional_person_override INT DEFAULT NULL'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE accommodation_price DROP price_additional_person_override'); + } +} diff --git a/package-lock.json b/package-lock.json index 87b7509e..eaffb3c0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,6 +6,12 @@ "": { "name": "myep", "license": "WTFPL", + "dependencies": { + "@trevoreyre/autocomplete-js": "^3.0.3", + "flatpickr": "^4.6.13", + "sortablejs": "^1.15.7", + "stimulus-use": "^0.52.3" + }, "devDependencies": { "@babel/core": "^7.17.0", "@babel/preset-env": "^7.16.0", @@ -1645,7 +1651,6 @@ "version": "3.2.2", "resolved": "https://registry.npmjs.org/@hotwired/stimulus/-/stimulus-3.2.2.tgz", "integrity": "sha512-eGeIqNOQpXoPAIP7tC1+1Yc1yl1xnwYqg+3mzqxyrbE5pg5YFBZcA6YoTiByJB6DKAEsiWtl6tjTJS4IYtbB7A==", - "dev": true, "license": "MIT" }, "node_modules/@hotwired/stimulus-webpack-helpers": { @@ -2045,6 +2050,12 @@ "tailwindcss": ">=3.0.0 || >=4.0.0 || insiders" } }, + "node_modules/@trevoreyre/autocomplete-js": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@trevoreyre/autocomplete-js/-/autocomplete-js-3.0.3.tgz", + "integrity": "sha512-Z0KhA4EtMOixzYOFR+OiMc60IgNy9HkPnVH0kfxFkbP1I4my//sB/MRoPkqKmKgR+qHMujXhjdflMk5TpwVN2Q==", + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -4382,6 +4393,12 @@ "flat": "cli.js" } }, + "node_modules/flatpickr": { + "version": "4.6.13", + "resolved": "https://registry.npmjs.org/flatpickr/-/flatpickr-4.6.13.tgz", + "integrity": "sha512-97PMG/aywoYpB4IvbvUJi0RQi8vearvU0oov1WW3k0WZPBMrTQVqekSX5CjSG/M4Q3i6A/0FKXC7RyAoAUUSPw==", + "license": "MIT" + }, "node_modules/forever-agent": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz", @@ -4670,6 +4687,16 @@ "node": ">= 0.4" } }, + "node_modules/hotkeys-js": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/hotkeys-js/-/hotkeys-js-4.0.4.tgz", + "integrity": "sha512-hseNiqaskxSnujuGp8aRMLJfcjaFiTSS0I2GQhqru82N/sx6CGyUf6pvU5X1iycvw2EqmvILkFIb5OzYFXY+9A==", + "license": "MIT", + "peer": true, + "funding": { + "url": "https://jaywcjlove.github.io/#/sponsor" + } + }, "node_modules/htmlparser2": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-6.1.0.tgz", @@ -7588,6 +7615,12 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/sortablejs": { + "version": "1.15.7", + "resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.15.7.tgz", + "integrity": "sha512-Kk8wLQPlS+yi1ZEf48a4+fzHa4yxjC30M/Sr2AnQu+f/MPwvvX9XjZ6OWejiz8crBsLwSq8GHqaxaET7u6ux0A==", + "license": "MIT" + }, "node_modules/source-list-map": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/source-list-map/-/source-list-map-2.0.1.tgz", @@ -7659,6 +7692,16 @@ "dev": true, "license": "MIT" }, + "node_modules/stimulus-use": { + "version": "0.52.3", + "resolved": "https://registry.npmjs.org/stimulus-use/-/stimulus-use-0.52.3.tgz", + "integrity": "sha512-stZ5dID6FUrGCR/ChWUa0FT5Z8iqkzT6lputOAb50eF+Ayg7RzJj4U/HoRlp2NV333QfvoRidru9HLbom4hZVw==", + "license": "MIT", + "peerDependencies": { + "@hotwired/stimulus": ">= 3", + "hotkeys-js": ">= 3" + } + }, "node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", diff --git a/package.json b/package.json index dd7d9cf0..c15a9cb9 100644 --- a/package.json +++ b/package.json @@ -30,5 +30,11 @@ "watch": "encore dev --watch", "build": "encore production --progress", "cy:open": "cypress open" + }, + "dependencies": { + "@trevoreyre/autocomplete-js": "^3.0.3", + "flatpickr": "^4.6.13", + "sortablejs": "^1.15.7", + "stimulus-use": "^0.52.3" } } diff --git a/src/Controller/Admin/AccommodationController.php b/src/Controller/Admin/AccommodationController.php new file mode 100644 index 00000000..8d7ede0b --- /dev/null +++ b/src/Controller/Admin/AccommodationController.php @@ -0,0 +1,121 @@ +accommodationRepository->findAll(); + + return $this->render('admin/accommodation/index.html.twig', [ + 'accommodations' => $accommodations, + ]); + } + + #[Route('/admin/accommodation/create', name: 'app_admin_accommodation_create')] + public function create(Request $request): Response + { + $accommodation = new Accommodation(); + + $form = $this->createForm(AccommodationType::class, $accommodation); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $this->entityManager->persist($accommodation); + $this->entityManager->flush(); + + $this->addFlash('success', 'Das Gruppenhaus wurde angelegt'); + + $this->logger->info('Created accommodation', [ + 'name' => $accommodation->getName(), + 'code' => $accommodation->getCode(), + ]); + + return $this->redirectToRoute('app_admin_accommodation'); + } + + return $this->render('admin/accommodation/create.html.twig', [ + 'form' => $form, + ]); + } + + #[Route('/admin/accommodation/{id}/edit', name: 'app_admin_accommodation_edit')] + public function edit(Accommodation $accommodation, Request $request): Response + { + // fetch additional data from cms if available + $cmsData = []; + + if (null !== $accommodation->getCode()) { + $cmsData = $this->cmsDataProvider->getHotelDetails($accommodation->getCode()); + } + + $form = $this->createForm(AccommodationType::class, $accommodation); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $this->entityManager->persist($accommodation); + $this->entityManager->flush(); + + $this->addFlash('success', 'Das Gruppenhaus wurde aktualisiert'); + + $this->logger->info('Updated accommodation', [ + 'name' => $accommodation->getName(), + 'code' => $accommodation->getCode(), + ]); + + return $this->redirectToRoute('app_admin_accommodation_edit', ['id' => $accommodation->getId()]); + } + + return $this->render('admin/accommodation/edit.html.twig', [ + 'accommodation' => $accommodation, + 'form' => $form, + 'cmsData' => $cmsData, + ]); + } + + #[Route('/admin/accommodation/{id}/delete', name: 'app_admin_accommodation_delete')] + public function delete(Accommodation $accommodation, Request $request): Response + { + if (true === $request->isMethod(Request::METHOD_POST)) { + $this->entityManager->remove($accommodation); + $this->entityManager->flush(); + + $this->addFlash('success', 'Das Gruppenhaus wurde gelöscht'); + + $this->logger->info('Deleted accommodation', [ + 'name' => $accommodation->getName(), + 'code' => $accommodation->getCode(), + ]); + + return new HxRedirectResponse($this->generateUrl('app_admin_accommodation')); + } + + return $this->render('admin/accommodation/modal_delete.html.twig', [ + 'accommodation' => $accommodation, + ]); + } +} diff --git a/src/Controller/Admin/AccommodationPriceController.php b/src/Controller/Admin/AccommodationPriceController.php new file mode 100644 index 00000000..4a70c7ca --- /dev/null +++ b/src/Controller/Admin/AccommodationPriceController.php @@ -0,0 +1,145 @@ +getForm($accommodationPrice, null); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $accommodation->addAccommodationPrice($accommodationPrice); + + $this->entityManager->persist($accommodationPrice); + $this->entityManager->flush(); + + $this->addFlash('success', 'Der Preis wurde angelegt'); + + $this->logger->info('Created accommodation price', [ + 'name' => $accommodation->getName(), + 'code' => $accommodation->getCode(), + 'date_range' => [ + 'from' => $accommodationPrice->getDateFrom()->format('Y-m-d'), + 'to' => $accommodationPrice->getDateTo()->format('Y-m-d'), + ] + ]); + + return $this->redirectToRoute('app_admin_accommodation_edit', [ + 'id' => $accommodation->getId(), + ]); + } + + return $this->render('admin/accommodation_price/create.html.twig', [ + 'accommodation' => $accommodation, + 'form' => $form, + ]); + } + + #[Route('/admin/accommodation-price/{id}/edit', name: 'app_admin_accommodationprice_edit', defaults: ['duplicate' => false])] + #[Route('/admin/accommodation-price/{id}/duplicate', name: 'app_admin_accommodationprice_duplicate', defaults: ['duplicate' => true])] + public function edit(AccommodationPrice $accommodationPrice, bool $duplicate, Request $request): Response + { + $accommodation = $accommodationPrice->getAccommodation(); + + if (true === $duplicate) { + $accommodationPrice = clone $accommodationPrice; + } + + $form = $this->getForm($accommodationPrice, $accommodation); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + if (true === $duplicate) { + $accommodation->addAccommodationPrice($accommodationPrice); + } + + $this->entityManager->persist($accommodationPrice); + $this->entityManager->flush(); + + $this->addFlash('success', 'Der Preis wurde aktualisiert'); + + $this->logger->info('Edited accommodation price', [ + 'name' => $accommodation->getName(), + 'code' => $accommodation->getCode(), + 'date_range' => [ + 'from' => $accommodationPrice->getDateFrom()->format('Y-m-d'), + 'to' => $accommodationPrice->getDateTo()->format('Y-m-d'), + ] + ]); + + return $this->redirectToRoute('app_admin_accommodation_edit', [ + 'id' => $accommodation->getId(), + ]); + } + + return $this->render('admin/accommodation_price/edit.html.twig', [ + 'accommodation' => $accommodation, + 'accommodationPrice' => $accommodationPrice, + 'form' => $form, + 'duplicate' => $duplicate, + ]); + } + + #[Route('/admin/accommodation-price/{id}/delete', name: 'app_admin_accommodationprice_delete')] + public function delete(AccommodationPrice $accommodationPrice, Request $request): Response + { + $accommodation = $accommodationPrice->getAccommodation(); + + if (true === $request->isMethod(Request::METHOD_POST)) { + $this->entityManager->remove($accommodationPrice); + $this->entityManager->flush(); + + $this->addFlash('success', 'Der Preis wurde gelöscht'); + + $this->logger->info('Deleted accommodation price', [ + 'name' => $accommodation->getName(), + 'code' => $accommodation->getCode(), + 'date_range' => [ + 'from' => $accommodationPrice->getDateFrom()->format('Y-m-d'), + 'to' => $accommodationPrice->getDateTo()->format('Y-m-d'), + ] + ]); + + $returnUrl = $this->generateUrl('app_admin_accommodation_edit', [ + 'id' => $accommodation->getId(), + ]); + + return new HxRedirectResponse($returnUrl); + } + + return $this->render('admin/accommodation_price/modal_delete.html.twig', [ + 'accommodationPrice' => $accommodationPrice, + 'accommodation' => $accommodation, + ]); + } + + private function getForm(AccommodationPrice $accommodationPrice, ?Accommodation $accommodation): FormInterface + { + return $this->createForm(AccommodationPriceType::class, $accommodationPrice, ['currency' => $accommodation->getCurrency()]); + } +} diff --git a/src/Controller/Admin/AdditionalServiceController.php b/src/Controller/Admin/AdditionalServiceController.php new file mode 100644 index 00000000..07df6053 --- /dev/null +++ b/src/Controller/Admin/AdditionalServiceController.php @@ -0,0 +1,148 @@ +getForm($additionalService, $accommodation); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $accommodation->addAdditionalService($additionalService); + + $this->entityManager->persist($additionalService); + $this->entityManager->flush(); + + $this->addFlash('success', 'Die Zusatzleistung wurde angelegt'); + + $this->logger->info('Created additional service', [ + 'name' => $accommodation->getName(), + 'code' => $accommodation->getCode(), + 'label' => $additionalService->getLabel(), + ]); + + return $this->redirectToRoute('app_admin_accommodation_edit', [ + 'id' => $accommodation->getId(), + ]); + } + + return $this->render('admin/additional_service/create.html.twig', [ + 'accommodation' => $accommodation, + 'form' => $form, + ]); + } + + #[Route('/admin/additional-service/{id}/edit', name: 'app_admin_additionalservice_edit', defaults: ['duplicate' => false])] + #[Route('/admin/additional-service/{id}/duplicate', name: 'app_admin_additionalservice_duplicate', defaults: ['duplicate' => true])] + public function edit(AdditionalService $additionalService, bool $duplicate, Request $request): Response + { + $accommodation = $additionalService->getAccommodation(); + + if (true === $duplicate) { + $additionalService = clone $additionalService; + } + + $form = $this->getForm($additionalService, $accommodation); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + if (true === $duplicate) { + $accommodation->addAdditionalService($additionalService); + } + + $this->entityManager->persist($additionalService); + $this->entityManager->flush(); + + $this->addFlash('success', 'Die Zusatzleistung wurde aktualisiert'); + + $this->logger->info('Edited additional service', [ + 'name' => $accommodation->getName(), + 'code' => $accommodation->getCode(), + 'label' => $additionalService->getLabel(), + ]); + + return $this->redirectToRoute('app_admin_accommodation_edit', [ + 'id' => $accommodation->getId(), + ]); + } + + return $this->render('admin/additional_service/edit.html.twig', [ + 'accommodation' => $accommodation, + 'additionalService' => $additionalService, + 'form' => $form, + 'duplicate' => $duplicate, + ]); + } + + #[Route('/admin/additional-service/{id}/delete', name: 'app_admin_additionalservice_delete')] + public function delete(AdditionalService $additionalService, Request $request): Response + { + $accommodation = $additionalService->getAccommodation(); + + if (true === $request->isMethod(Request::METHOD_POST)) { + $this->entityManager->remove($additionalService); + $this->entityManager->flush(); + + $this->addFlash('success', 'Die Zusatzleistung wurde gelöscht'); + + $this->logger->info('Deleted additional service', [ + 'name' => $accommodation->getName(), + 'code' => $accommodation->getCode(), + 'label' => $additionalService->getLabel(), + ]); + + $returnUrl = $this->generateUrl('app_admin_accommodation_edit', [ + 'id' => $accommodation->getId(), + ]); + + return new HxRedirectResponse($returnUrl); + } + + return $this->render('admin/additional_service/modal_delete.html.twig', [ + 'additionalService' => $additionalService, + 'accommodation' => $accommodation, + ]); + } + + #[Route('/admin/additional-service/sort/{id}', name: 'app_admin_additionalservice_sort', methods: ['POST'])] + public function sort(Accommodation $accommodation, Request $request): Response + { + $this->applySorting($accommodation->getAdditionalServices(), $request->request->all('sorting')); + + $this->entityManager->flush(); + + return new Response('', Response::HTTP_NO_CONTENT); + } + + private function getForm(AdditionalService $additionalService, ?Accommodation $accommodation): FormInterface + { + return $this->createForm(AdditionalServiceType::class, $additionalService, ['currency' => $accommodation->getCurrency()]); + } +} diff --git a/src/Controller/Admin/BoardServiceController.php b/src/Controller/Admin/BoardServiceController.php new file mode 100644 index 00000000..87e790ee --- /dev/null +++ b/src/Controller/Admin/BoardServiceController.php @@ -0,0 +1,147 @@ +getForm($boardService, $accommodation); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $accommodation->addBoardService($boardService); + + $this->entityManager->persist($boardService); + $this->entityManager->flush(); + + $this->addFlash('success', 'Die Verpflegungsleistung wurde angelegt'); + + $this->logger->info('Created board service', [ + 'name' => $accommodation->getName(), + 'code' => $accommodation->getCode(), + 'label' => $boardService->getLabel(), + ]); + + return $this->redirectToRoute('app_admin_accommodation_edit', [ + 'id' => $accommodation->getId(), + ]); + } + + return $this->render('admin/board_service/create.html.twig', [ + 'accommodation' => $accommodation, + 'form' => $form, + ]); + } + + #[Route('/admin/board-service/{id}/edit', name: 'app_admin_boardservice_edit', defaults: ['duplicate' => false])] + #[Route('/admin/board-service/{id}/duplicate', name: 'app_admin_boardservice_duplicate', defaults: ['duplicate' => true])] + public function edit(BoardService $boardService, bool $duplicate, Request $request): Response + { + $accommodation = $boardService->getAccommodation(); + + if (true === $duplicate) { + $boardService = clone $boardService; + } + + $form = $this->getForm($boardService, $accommodation); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + if (true === $duplicate) { + $accommodation->addBoardService($boardService); + } + + $this->entityManager->persist($boardService); + $this->entityManager->flush(); + + $this->addFlash('success', 'Die Verpflegungsleistung wurde aktualisiert'); + + $this->logger->info('Edited board service', [ + 'name' => $accommodation->getName(), + 'code' => $accommodation->getCode(), + 'label' => $boardService->getLabel(), + ]); + + return $this->redirectToRoute('app_admin_accommodation_edit', [ + 'id' => $accommodation->getId(), + ]); + } + + return $this->render('admin/board_service/edit.html.twig', [ + 'accommodation' => $accommodation, + 'boardService' => $boardService, + 'form' => $form, + 'duplicate' => $duplicate, + ]); + } + + #[Route('/admin/board-service/{id}/delete', name: 'app_admin_boardservice_delete')] + public function delete(BoardService $boardService, Request $request): Response + { + $accommodation = $boardService->getAccommodation(); + + if (true === $request->isMethod(Request::METHOD_POST)) { + $this->entityManager->remove($boardService); + $this->entityManager->flush(); + + $this->addFlash('success', 'Die Verpflegungsleistung wurde gelöscht'); + + $this->logger->info('Deleted board service', [ + 'name' => $accommodation->getName(), + 'code' => $accommodation->getCode(), + 'label' => $boardService->getLabel(), + ]); + + $returnUrl = $this->generateUrl('app_admin_accommodation_edit', [ + 'id' => $accommodation->getId(), + ]); + + return new HxRedirectResponse($returnUrl); + } + + return $this->render('admin/board_service/modal_delete.html.twig', [ + 'boardService' => $boardService, + 'accommodation' => $accommodation, + ]); + } + + #[Route('/admin/board-service/sort/{id}', name: 'app_admin_boardservice_sort', methods: ['POST'])] + public function sort(Accommodation $accommodation, Request $request): Response + { + $this->applySorting($accommodation->getBoardServices(), $request->request->all('sorting')); + + $this->entityManager->flush(); + + return new Response('', Response::HTTP_NO_CONTENT); + } + + private function getForm(BoardService $boardService, ?Accommodation $accommodation): FormInterface + { + return $this->createForm(BoardServiceType::class, $boardService, ['currency' => $accommodation->getCurrency()]); + } +} diff --git a/src/Controller/Admin/SortingControllerTrait.php b/src/Controller/Admin/SortingControllerTrait.php new file mode 100644 index 00000000..fdb6253e --- /dev/null +++ b/src/Controller/Admin/SortingControllerTrait.php @@ -0,0 +1,18 @@ +getId()])) { + $entity->setSorting((int) $sortingMap[$entity->getId()]); + } + } + } +} diff --git a/src/Entity/Groups/Accommodation.php b/src/Entity/Groups/Accommodation.php index e8a9ba13..d88d4a9c 100644 --- a/src/Entity/Groups/Accommodation.php +++ b/src/Entity/Groups/Accommodation.php @@ -10,6 +10,7 @@ use App\Repository\Groups\AccommodationRepository; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; +use Symfony\Component\Validator\Constraints as Assert; #[ORM\Entity(repositoryClass: AccommodationRepository::class)] class Accommodation implements BlameableEntityInterface, TimestampableEntityInterface @@ -23,36 +24,45 @@ class Accommodation implements BlameableEntityInterface, TimestampableEntityInte private ?int $id = null; #[ORM\Column(length: 255)] + #[Assert\NotBlank(message: 'required')] private ?string $name = null; #[ORM\Column(length: 16, unique: true)] + #[Assert\NotBlank(message: 'required')] private ?string $code = null; #[ORM\Column] + #[Assert\NotNull(message: 'required')] private ?int $minPaxSummer = null; /** * @var Collection */ - #[ORM\OneToMany(targetEntity: AccommodationPrice::class, mappedBy: 'accommodation')] + #[ORM\OneToMany(targetEntity: AccommodationPrice::class, mappedBy: 'accommodation', cascade: ['all'])] + #[ORM\OrderBy(['dateFrom' => 'ASC'])] private Collection $accommodationPrices; /** * @var Collection */ - #[ORM\OneToMany(targetEntity: BoardService::class, mappedBy: 'accommodation')] + #[ORM\OneToMany(targetEntity: BoardService::class, mappedBy: 'accommodation', cascade: ['all'])] + #[ORM\OrderBy(['sorting' => 'ASC'])] private Collection $boardServices; /** * @var Collection */ - #[ORM\OneToMany(targetEntity: AdditionalService::class, mappedBy: 'accommodation')] + #[ORM\OneToMany(targetEntity: AdditionalService::class, mappedBy: 'accommodation', cascade: ['all'])] + #[ORM\OrderBy(['sorting' => 'ASC'])] private Collection $additionalServices; #[ORM\Column] + #[Assert\NotNull(message: 'required')] private ?int $maxAdolescentAge = null; #[ORM\Column(length: 3)] + #[Assert\NotBlank(message: 'required')] + #[Assert\Choice(choices: ['EUR', 'CHF'], message: 'invalid')] private ?string $currency = 'EUR'; public function __construct() diff --git a/src/Entity/Groups/AccommodationPrice.php b/src/Entity/Groups/AccommodationPrice.php index 5e6e583b..5067167b 100644 --- a/src/Entity/Groups/AccommodationPrice.php +++ b/src/Entity/Groups/AccommodationPrice.php @@ -9,6 +9,8 @@ use App\Entity\TimestampableEntityInterface; use App\Repository\Groups\AccommodationPriceRepository; use Doctrine\DBAL\Types\Types; use Doctrine\ORM\Mapping as ORM; +use Symfony\Component\Validator\Constraints as Assert; +use Symfony\Component\Validator\Context\ExecutionContextInterface; #[ORM\Entity(repositoryClass: AccommodationPriceRepository::class)] class AccommodationPrice implements BlameableEntityInterface, TimestampableEntityInterface @@ -26,23 +28,44 @@ class AccommodationPrice implements BlameableEntityInterface, TimestampableEntit private ?Accommodation $accommodation = null; #[ORM\Column(type: Types::DATE_IMMUTABLE)] + #[Assert\NotNull(message: 'required')] private ?\DateTimeImmutable $dateFrom = null; #[ORM\Column(type: Types::DATE_IMMUTABLE)] + #[Assert\NotNull(message: 'required')] + #[Assert\GreaterThan(propertyPath: 'dateFrom', message: 'Das Bis-Datum muss nach dem Von-Datum liegen')] private ?\DateTimeImmutable $dateTo = null; #[ORM\Column] + #[Assert\NotNull(message: 'required')] private ?int $includedPax = null; #[ORM\Column] + #[Assert\NotNull(message: 'required')] private ?int $pricePerNight = null; #[ORM\Column] + #[Assert\NotNull(message: 'required')] private ?int $priceAdditionalPerson = null; #[ORM\Column] + #[Assert\NotNull(message: 'required')] private ?int $minNights = null; + #[ORM\Column(nullable: true)] + private ?int $pricePerNightOverride = null; + + #[ORM\Column(nullable: true)] + private ?int $priceAdditionalPersonOverride = null; + + #[ORM\Column] + private bool $acceptUndersubscription = false; + + public function __clone() + { + $this->id = null; + } + public function getId(): ?int { return $this->id; @@ -131,4 +154,71 @@ class AccommodationPrice implements BlameableEntityInterface, TimestampableEntit return $this; } + + public function getPricePerNightOverride(): ?int + { + return $this->pricePerNightOverride; + } + + public function setPricePerNightOverride(?int $pricePerNightOverride): self + { + $this->pricePerNightOverride = $pricePerNightOverride; + + return $this; + } + + public function getEffectivePricePerNight(): int + { + return $this->pricePerNightOverride ?? $this->pricePerNight; + } + + public function getPriceAdditionalPersonOverride(): ?int + { + return $this->priceAdditionalPersonOverride; + } + + public function setPriceAdditionalPersonOverride(?int $priceAdditionalPersonOverride): self + { + $this->priceAdditionalPersonOverride = $priceAdditionalPersonOverride; + + return $this; + } + + public function getEffectivePriceAdditionalPerson(): int + { + return $this->priceAdditionalPersonOverride ?? $this->priceAdditionalPerson; + } + + public function isAcceptUndersubscription(): bool + { + return $this->acceptUndersubscription; + } + + public function setAcceptUndersubscription(bool $acceptUndersubscription): self + { + $this->acceptUndersubscription = $acceptUndersubscription; + + return $this; + } + + #[Assert\Callback] + public function assertDatesValid(ExecutionContextInterface $context): void + { + // we only check new entities + if (null !== $this->getId()) { + return; + } + + // come on, we need something to work with + if (null === $this->getDateFrom() || null === $this->getDateTo()) { + return; + } + + if ($this->getDateFrom() < new \DateTimeImmutable()) { + $context + ->buildViolation('Das Von-Datum darf nicht in der Vergangenheit liegen') + ->atPath('dateFrom') + ->addViolation(); + } + } } diff --git a/src/Entity/Groups/AdditionalService.php b/src/Entity/Groups/AdditionalService.php index 772040f8..35e1922f 100644 --- a/src/Entity/Groups/AdditionalService.php +++ b/src/Entity/Groups/AdditionalService.php @@ -12,7 +12,7 @@ use Doctrine\DBAL\Types\Types; use Doctrine\ORM\Mapping as ORM; #[ORM\Entity(repositoryClass: AdditionalServiceRepository::class)] -class AdditionalService implements BlameableEntityInterface, TimestampableEntityInterface +class AdditionalService implements BlameableEntityInterface, TimestampableEntityInterface, SortableEntityInterface { use BlameableEntity; use TimestampableEntity; @@ -41,12 +41,17 @@ class AdditionalService implements BlameableEntityInterface, TimestampableEntity #[ORM\Column(type: Types::DATE_IMMUTABLE)] private ?\DateTimeImmutable $dateTo = null; - #[ORM\Column(length: 128)] - private ?string $grouping = null; + #[ORM\Column(length: 128, nullable: true)] + private ?string $selectionGroup = null; #[ORM\Column] private ?int $sorting = 0; + public function __clone() + { + $this->id = null; + } + public function getId(): ?int { return $this->id; @@ -124,14 +129,14 @@ class AdditionalService implements BlameableEntityInterface, TimestampableEntity return $this; } - public function getGrouping(): ?string + public function getSelectionGroup(): ?string { - return $this->grouping; + return $this->selectionGroup; } - public function setGrouping(?string $grouping): self + public function setSelectionGroup(?string $selectionGroup): self { - $this->grouping = $grouping; + $this->selectionGroup = $selectionGroup; return $this; } diff --git a/src/Entity/Groups/BoardService.php b/src/Entity/Groups/BoardService.php index cca8850d..9f2ae0b6 100644 --- a/src/Entity/Groups/BoardService.php +++ b/src/Entity/Groups/BoardService.php @@ -11,7 +11,7 @@ use Doctrine\DBAL\Types\Types; use Doctrine\ORM\Mapping as ORM; #[ORM\Entity(repositoryClass: BoardServiceRepository::class)] -class BoardService implements BlameableEntityInterface, TimestampableEntityInterface +class BoardService implements BlameableEntityInterface, TimestampableEntityInterface, SortableEntityInterface { use BlameableEntity; use TimestampableEntity; @@ -40,6 +40,11 @@ class BoardService implements BlameableEntityInterface, TimestampableEntityInter #[ORM\Column] private ?int $sorting = 0; + public function __clone() + { + $this->id = null; + } + public function getId(): ?int { return $this->id; diff --git a/src/Entity/Groups/SortableEntityInterface.php b/src/Entity/Groups/SortableEntityInterface.php new file mode 100644 index 00000000..5bf3e26f --- /dev/null +++ b/src/Entity/Groups/SortableEntityInterface.php @@ -0,0 +1,10 @@ +value; + } } diff --git a/src/Form/Admin/Groups/AccommodationPriceType.php b/src/Form/Admin/Groups/AccommodationPriceType.php new file mode 100644 index 00000000..3b789b10 --- /dev/null +++ b/src/Form/Admin/Groups/AccommodationPriceType.php @@ -0,0 +1,74 @@ +add('dateFrom', DatepickerType::class, [ + 'label' => 'Datum von', + 'html5' => true, + 'widget' => 'single_text', + ]) + ->add('dateTo', DatepickerType::class, [ + 'label' => 'Datum bis', + 'html5' => true, + 'widget' => 'single_text', + ]) + ->add('includedPax', IntegerType::class, [ + 'label' => 'Inklusiv-Personen', + ]) + ->add('minNights', IntegerType::class, [ + 'label' => 'Mindestbelegung (Nächte)', + ]) + ->add('pricePerNight', MoneyType::class, [ + 'label' => 'Preis pro Nacht', + 'currency' => $options['currency'], + 'divisor' => 100, + ]) + ->add('pricePerNightOverride', MoneyType::class, [ + 'label' => 'Streich-Preis pro Nacht', + 'required' => false, + 'currency' => $options['currency'], + 'divisor' => 100, + ]) + ->add('priceAdditionalPerson', MoneyType::class, [ + 'label' => 'Preis weitere Person', + 'currency' => $options['currency'], + 'divisor' => 100, + ]) + ->add('priceAdditionalPersonOverride', MoneyType::class, [ + 'label' => 'Streich-Preis weitere Person', + 'required' => false, + 'currency' => $options['currency'], + 'divisor' => 100, + ]) + ->add('acceptUndersubscription', CheckboxType::class, [ + 'label' => 'Unterbelegung akzeptieren', + 'required' => false, + ]) + ; + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'data_class' => AccommodationPrice::class, + 'currency' => 'EUR', + ]); + $resolver->setAllowedValues('currency', ['EUR', 'CHF']); + } +} diff --git a/src/Form/Admin/Groups/AccommodationType.php b/src/Form/Admin/Groups/AccommodationType.php new file mode 100644 index 00000000..8e8e1233 --- /dev/null +++ b/src/Form/Admin/Groups/AccommodationType.php @@ -0,0 +1,48 @@ +add('name', TextType::class, [ + 'label' => 'Name', + ]) + ->add('code', TextType::class, [ + 'label' => 'Code', + ]) + ->add('minPaxSummer', IntegerType::class, [ + 'label' => 'Mindestbelegung Sommer', + ]) + ->add('maxAdolescentAge', IntegerType::class, [ + 'label' => 'Altersgrenze Jugendliche (6-X Jahre)', + ]) + ->add('currency', ChoiceType::class, [ + 'label' => 'Währung', + 'choices' => [ + 'EUR' => 'EUR', + 'CHF' => 'CHF', + ], + ]) + ; + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'data_class' => Accommodation::class, + ]); + } +} diff --git a/src/Form/Admin/Groups/AdditionalServiceType.php b/src/Form/Admin/Groups/AdditionalServiceType.php new file mode 100644 index 00000000..241ec90b --- /dev/null +++ b/src/Form/Admin/Groups/AdditionalServiceType.php @@ -0,0 +1,60 @@ +add('label', TextType::class, [ + 'label' => 'Bezeichnung', + ]) + ->add('type', EnumType::class, [ + 'label' => 'Preistyp', + 'class' => AdditionalServiceTypeEnum::class, + 'choice_label' => fn($item) => $item->label(), + ]) + ->add('price', MoneyType::class, [ + 'label' => 'Preis', + 'currency' => $options['currency'], + 'divisor' => 100, + ]) + ->add('selectionGroup', TextType::class, [ + 'label' => 'Auswahlgruppe', + 'required' => false, + ]) + ->add('dateFrom', DatepickerType::class, [ + 'label' => 'Datum von', + 'html5' => true, + 'widget' => 'single_text', + ]) + ->add('dateTo', DatepickerType::class, [ + 'label' => 'Datum bis', + 'html5' => true, + 'widget' => 'single_text', + ]) + ; + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'data_class' => AdditionalService::class, + 'currency' => 'EUR', + ]); + $resolver->setAllowedValues('currency', ['EUR', 'CHF']); + } +} diff --git a/src/Form/Admin/Groups/BoardServiceType.php b/src/Form/Admin/Groups/BoardServiceType.php new file mode 100644 index 00000000..c76f8972 --- /dev/null +++ b/src/Form/Admin/Groups/BoardServiceType.php @@ -0,0 +1,49 @@ +add('label', TextType::class, [ + 'label' => 'Bezeichnung', + ]) + ->add('price', MoneyType::class, [ + 'label' => 'Preis pro Person/Nacht', + 'currency' => $options['currency'], + 'divisor' => 100, + ]) + ->add('dateFrom', DatepickerType::class, [ + 'label' => 'Datum von', + 'html5' => true, + 'widget' => 'single_text', + ]) + ->add('dateTo', DatepickerType::class, [ + 'label' => 'Datum bis', + 'html5' => true, + 'widget' => 'single_text', + ]) + ; + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'data_class' => BoardService::class, + 'currency' => 'EUR', + ]); + $resolver->setAllowedValues('currency', ['EUR', 'CHF']); + } +} diff --git a/src/Form/DatepickerType.php b/src/Form/DatepickerType.php new file mode 100644 index 00000000..c79a5833 --- /dev/null +++ b/src/Form/DatepickerType.php @@ -0,0 +1,39 @@ +setDefaults([ + 'html5' => false, + 'widget' => 'single_text', + 'input' => 'datetime_immutable', + 'min_date' => null, + 'max_date' => null, + 'disable_weekends' => false, + ]); + $resolver->setAllowedTypes('min_date', ['null', \DateTimeImmutable::class]); + $resolver->setAllowedTypes('max_date', ['null', \DateTimeImmutable::class]); + $resolver->setAllowedTypes('disable_weekends', 'bool'); + } + + public function buildView(FormView $view, FormInterface $form, array $options): void + { + $view->vars['min_date'] = $options['min_date']; + $view->vars['max_date'] = $options['max_date']; + $view->vars['disable_weekends'] = $options['disable_weekends']; + } + + public function getParent(): string + { + return DateType::class; + } +} diff --git a/src/Menu/AdminMenuBuilder.php b/src/Menu/AdminMenuBuilder.php index a480b029..ed526ae9 100644 --- a/src/Menu/AdminMenuBuilder.php +++ b/src/Menu/AdminMenuBuilder.php @@ -29,6 +29,7 @@ class AdminMenuBuilder extends AbstractMenuBuilder ], 'extras' => [ 'icon' => 'edit', + 'routes' => [['pattern' => '/^app_admin_bookingeditdraft/']], ], ]); $menu->addChild('Benutzer', [ @@ -40,6 +41,16 @@ class AdminMenuBuilder extends AbstractMenuBuilder 'icon' => 'users', ], ]); + $menu->addChild('Gruppenhäuser', [ + 'route' => 'app_admin_accommodation', + 'linkAttributes' => [ + 'title' => 'Gruppenhäuser', + ], + 'extras' => [ + 'icon' => 'house', + 'routes' => [['pattern' => '/^app_admin_accommodation/']], + ], + ]); $menu->addChild('Logs', [ 'route' => 'app_admin_log', 'linkAttributes' => [ @@ -47,6 +58,7 @@ class AdminMenuBuilder extends AbstractMenuBuilder ], 'extras' => [ 'icon' => 'list', + 'routes' => [['pattern' => '/^app_admin_log/']], ], ]); diff --git a/src/Service/CmsDataProvider.php b/src/Service/CmsDataProvider.php index 1d385ebe..247ab90d 100644 --- a/src/Service/CmsDataProvider.php +++ b/src/Service/CmsDataProvider.php @@ -2,13 +2,18 @@ namespace App\Service; +use Symfony\Contracts\Cache\CacheInterface; +use Symfony\Contracts\Cache\ItemInterface; use Symfony\Contracts\HttpClient\Exception\ExceptionInterface; use Symfony\Contracts\HttpClient\HttpClientInterface; class CmsDataProvider { - public function __construct(private readonly HttpClientInterface $httpClient, private readonly string $apiKey) - { + public function __construct( + private readonly HttpClientInterface $httpClient, + private readonly CacheInterface $cache, + private readonly string $apiKey, + ) { } /** @@ -27,33 +32,98 @@ class CmsDataProvider return $result['hotel']['images'] ?? null; } + /** + * @return array|null + */ + public function getHotelImages(string $hotelCode): ?array + { + $result = $this->getHotelDetails($hotelCode); + + if (true === isset($result['success']) && false === $result['success']) { + return null; + } + + return $result['images'] ?? null; + } + + /** @return array */ + public function getHotelDetails(string $hotelCode): array + { + return $this->cache->get( + sprintf('cms_hotel_%s', $hotelCode), + function (ItemInterface $item) use ($hotelCode): array { + $item->expiresAfter(3600); + + try { + $request = $this->httpClient->request('GET', 'api/hotel', [ + 'query' => [ + 'hotel' => $hotelCode, + 'key' => $this->apiKey, + ], + ]); + } catch (ExceptionInterface $e) { + $item->expiresAfter(0); + + return [ + 'success' => false, + 'message' => $e->getMessage(), + ]; + } + + try { + $data = $request->toArray(); + } catch (ExceptionInterface $e) { + $item->expiresAfter(0); + + return [ + 'success' => false, + 'message' => $e->getMessage(), + ]; + } + + return $data; + } + ); + } + /** @return array */ public function getProductDetails(string $productCode, ?string $hotelCode = null): array { - try { - $request = $this->httpClient->request('GET', 'api/product', [ - 'query' => [ - 'product' => $productCode, - 'hotel' => $hotelCode, - 'key' => $this->apiKey, - ], - ]); - } catch (ExceptionInterface $e) { - return [ - 'success' => false, - 'message' => $e->getMessage(), - ]; - } + return $this->cache->get( + sprintf('cms_product_%s_%s', $productCode, $hotelCode ?? ''), + function (ItemInterface $item) use ($productCode, $hotelCode): array { + $item->expiresAfter(3600); - try { - $data = $request->toArray(); - } catch (ExceptionInterface $e) { - return [ - 'success' => false, - 'message' => $e->getMessage(), - ]; - } + try { + $request = $this->httpClient->request('GET', 'api/product', [ + 'query' => [ + 'product' => $productCode, + 'hotel' => $hotelCode, + 'key' => $this->apiKey, + ], + ]); + } catch (ExceptionInterface $e) { + $item->expiresAfter(0); - return $data; + return [ + 'success' => false, + 'message' => $e->getMessage(), + ]; + } + + try { + $data = $request->toArray(); + } catch (ExceptionInterface $e) { + $item->expiresAfter(0); + + return [ + 'success' => false, + 'message' => $e->getMessage(), + ]; + } + + return $data; + } + ); } } diff --git a/symfony.lock b/symfony.lock index 381463e5..22dc9e2d 100644 --- a/symfony.lock +++ b/symfony.lock @@ -358,6 +358,18 @@ "config/packages/uid.yaml" ] }, + "symfony/ux-twig-component": { + "version": "2.36", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "2.13", + "ref": "f367ae2a1faf01c503de2171f1ec22567febeead" + }, + "files": [ + "config/packages/twig_component.yaml" + ] + }, "symfony/validator": { "version": "6.4", "recipe": { diff --git a/templates/_partials/_flashes_admin.html.twig b/templates/_partials/_flashes_admin.html.twig new file mode 100644 index 00000000..ed4261b8 --- /dev/null +++ b/templates/_partials/_flashes_admin.html.twig @@ -0,0 +1,9 @@ +{% if app.session.flashBag.peek('error')|length > 0 %} + {% include '_partials/_toast.html.twig' with { level: 'warning', messages: app.flashes('error') } %} +{% endif %} +{% if app.session.flashBag.peek('success')|length > 0 %} + {% include '_partials/_toast.html.twig' with { level: 'success', messages: app.flashes('success') } %} +{% endif %} +{% if app.session.flashBag.peek('info')|length > 0 %} + {% include '_partials/_toast.html.twig' with { level: 'info', messages: app.flashes('info') } %} +{% endif %} diff --git a/templates/_partials/_menu.html.twig b/templates/_partials/_menu.html.twig index 47c2e74e..e9e77c88 100644 --- a/templates/_partials/_menu.html.twig +++ b/templates/_partials/_menu.html.twig @@ -41,9 +41,10 @@ {% import _self as knp_menu %} {{ block('label') }} @@ -73,7 +74,7 @@ {% block label %} {% if item.extras.icon is defined %} - {{ icon(item.extras.icon, 'h-5 w-5 shrink-0 text-gray-400') }} + {{ icon(item.extras.icon, 'h-5 w-5 shrink-0 ' ~ ((matcher.isCurrent(item) or matcher.isAncestor(item)) ? 'text-gray-600' : 'text-gray-400')) }} {{ item.label|raw }} {% else %} {{ item.label|raw }} diff --git a/templates/_partials/_toast.html.twig b/templates/_partials/_toast.html.twig new file mode 100644 index 00000000..e5785c38 --- /dev/null +++ b/templates/_partials/_toast.html.twig @@ -0,0 +1,10 @@ +{% apply spaceless %} + {% for message in messages %} + {% if message is iterable %} + {% set message = message.id | trans(message.parameters | default({}), message.domain | default(null), message.locale | default(null)) | raw %} + {% else %} + {% set message = message | trans | raw %} + {% endif %} +
+ {% endfor %} +{% endapply %} diff --git a/templates/admin/accommodation/_form.html.twig b/templates/admin/accommodation/_form.html.twig new file mode 100644 index 00000000..6a02bb5e --- /dev/null +++ b/templates/admin/accommodation/_form.html.twig @@ -0,0 +1,296 @@ +{% form_theme form 'forms_admin.html.twig' %} + +

+ Allgemein +

+ +{{ form_start(form) }} +
+ {{ form_row(form.name) }} + {{ form_row(form.code) }} + {{ form_row(form.minPaxSummer) }} + {{ form_row(form.maxAdolescentAge) }} + {{ form_row(form.currency) }} +
+
+ + zurück + + +
+{{ form_rest(form) }} +{{ form_end(form) }} + +
+ +

+ Preise +

+
+
+ + + + + + + + + + + + + + {% for price in accommodation.accommodationPrices %} + + + + + + + + + + {% else %} + + + + {% endfor %} + +
+ Datums-Bereich + + Preis pro Nacht + + Preise weitere Person + + Mindestbelegung (Nächte) + + Unterbelegung +
+ {{ price.dateFrom | date('d.m.Y') }} - {{ price.dateTo | date('d.m.Y') }} + + {{ (price.effectivePricePerNight / 100) | format_currency(accommodation.currency) }} + {% if price.pricePerNightOverride is not null %} + {{ (price.pricePerNight / 100) | format_currency(accommodation.currency) }} + {% endif %} + + {{ (price.effectivePriceAdditionalPerson / 100) | format_currency(accommodation.currency) }} + {% if price.priceAdditionalPersonOverride is not null %} + {{ (price.priceAdditionalPerson / 100) | format_currency(accommodation.currency) }} + {% endif %} + + {{ price.minNights }} + + {% if price.acceptUndersubscription %} + {{ icon('check', 'w-4 h-4') }} + {% endif %} + + + + bearbeiten + + + duplizieren + + + löschen + + +
+ Keine Daten +
+
+
+ + +
+ +

+ Verpflegungsleistungen +

+
+
+ + + + + + + + + + + + {% for service in accommodation.boardServices %} + + + + + + + + {% else %} + + + + {% endfor %} + +
+ Datums-Bereich + + Bezeichnung + + Preis pro Person/Nacht +
+ {{ icon('draggable', 'w-4 h-4') }} + + {{ service.dateFrom | date('d.m.Y') }} - {{ service.dateTo | date('d.m.Y') }} + + {{ service.label }} + + + {{ (service.price / 100) | format_currency(accommodation.currency) }} + + + + bearbeiten + + + duplizieren + + + löschen + + +
+ Keine Daten +
+
+
+ + +
+ +

+ Optionale Zusatzleistungen +

+
+
+ + + + + + + + + + + + + {% for service in accommodation.additionalServices %} + + + + + + + + + {% else %} + + + + {% endfor %} + +
+ Datums-Bereich + + Bezeichnung + + Preistyp + + Preis +
+ {{ icon('draggable', 'w-4 h-4') }} + + {{ service.dateFrom | date('d.m.Y') }} - {{ service.dateTo | date('d.m.Y') }} + + {{ service.label }} + + + {{ service.type.label | trans }} + + {{ (service.price / 100) | format_currency(accommodation.currency) }} + + + + bearbeiten + + + duplizieren + + + löschen + + +
+ Keine Daten +
+
+
+ + +
+ +{% if cmsData is defined %} +

+ CMS-Daten +

+
+
+
+

+ Adresse +

+ {{ cmsData.name }} +
+ {{ cmsData.address | default('-') | nl2br }} +
+
+

+ Beschreibung +

+
+ {{ cmsData.description | default('-') | raw }} +
+
+
+
+

+ Bilder +

+
+ {% for image in cmsData.images.resized.s %} + {{ image.alt }} + {% endfor %} +
+
+
+{% endif %} + diff --git a/templates/admin/accommodation/create.html.twig b/templates/admin/accommodation/create.html.twig new file mode 100644 index 00000000..1b46b6d7 --- /dev/null +++ b/templates/admin/accommodation/create.html.twig @@ -0,0 +1,6 @@ +{% extends 'layout_admin.html.twig' %} + +{% block content %} + Gruppenhaus neu + {% include 'admin/accommodation/_form.html.twig' %} +{% endblock %} diff --git a/templates/admin/accommodation/edit.html.twig b/templates/admin/accommodation/edit.html.twig new file mode 100644 index 00000000..6cdc4e2a --- /dev/null +++ b/templates/admin/accommodation/edit.html.twig @@ -0,0 +1,6 @@ +{% extends 'layout_admin.html.twig' %} + +{% block content %} + Gruppenhaus {{ accommodation.name }} + {% include 'admin/accommodation/_form.html.twig' %} +{% endblock %} diff --git a/templates/admin/accommodation/index.html.twig b/templates/admin/accommodation/index.html.twig new file mode 100644 index 00000000..fd277286 --- /dev/null +++ b/templates/admin/accommodation/index.html.twig @@ -0,0 +1,71 @@ +{% extends 'layout_admin.html.twig' %} + +{% block content %} + Gruppenhäuser +
+
+ + + + + + + + + + + + {% for accommodation in accommodations %} + + + + + + + + {% else %} + + + + {% endfor %} + +
+ Name + + Code + + erstellt + + aktualisiert +
+ {{ accommodation.name }} + + {{ accommodation.code }} + + {{ accommodation.createdAt | date('d.m.Y, H:i') }} + + {% if accommodation.updatedAt is not null %} + {{ accommodation.updatedAt | date('d.m.Y, H:i') }} + {% else %} + - + {% endif %} + + + + bearbeiten + + + löschen + + +
+ Keine Daten... +
+
+
+ +{% endblock %} diff --git a/templates/admin/accommodation/modal_delete.html.twig b/templates/admin/accommodation/modal_delete.html.twig new file mode 100644 index 00000000..730d6766 --- /dev/null +++ b/templates/admin/accommodation/modal_delete.html.twig @@ -0,0 +1,7 @@ +{% extends 'htmx_confirmation_modal.html.twig' %} + +{% block content %} +
+ Möchtest du das Gruppenhaus {{ accommodation.name }} wirklich löschen? +
+{% endblock %} diff --git a/templates/admin/accommodation_price/_form.html.twig b/templates/admin/accommodation_price/_form.html.twig new file mode 100644 index 00000000..11af6e34 --- /dev/null +++ b/templates/admin/accommodation_price/_form.html.twig @@ -0,0 +1,24 @@ +{% form_theme form 'forms_admin.html.twig' %} + +{{ form_start(form) }} +
+ {{ form_row(form.dateFrom) }} + {{ form_row(form.dateTo) }} + {{ form_row(form.pricePerNight) }} + {{ form_row(form.pricePerNightOverride) }} + {{ form_row(form.priceAdditionalPerson) }} + {{ form_row(form.priceAdditionalPersonOverride) }} + {{ form_row(form.includedPax) }} + {{ form_row(form.minNights) }} + {{ form_row(form.acceptUndersubscription) }} +
+
+ + zurück + + +
+{{ form_rest(form) }} +{{ form_end(form) }} diff --git a/templates/admin/accommodation_price/create.html.twig b/templates/admin/accommodation_price/create.html.twig new file mode 100644 index 00000000..03b47f76 --- /dev/null +++ b/templates/admin/accommodation_price/create.html.twig @@ -0,0 +1,6 @@ +{% extends 'layout_admin.html.twig' %} + +{% block content %} + Preis neu für Gruppenhaus {{ accommodation.name }} + {% include 'admin/accommodation_price/_form.html.twig' %} +{% endblock %} diff --git a/templates/admin/accommodation_price/edit.html.twig b/templates/admin/accommodation_price/edit.html.twig new file mode 100644 index 00000000..b23557f1 --- /dev/null +++ b/templates/admin/accommodation_price/edit.html.twig @@ -0,0 +1,6 @@ +{% extends 'layout_admin.html.twig' %} + +{% block content %} + Preis für Gruppenhaus {{ accommodation.name }}{% if duplicate %} duplizieren{% endif %} + {% include 'admin/accommodation_price/_form.html.twig' %} +{% endblock %} diff --git a/templates/admin/accommodation_price/modal_delete.html.twig b/templates/admin/accommodation_price/modal_delete.html.twig new file mode 100644 index 00000000..17951be5 --- /dev/null +++ b/templates/admin/accommodation_price/modal_delete.html.twig @@ -0,0 +1,7 @@ +{% extends 'htmx_confirmation_modal.html.twig' %} + +{% block content %} +
+ Möchtest du den Gruppenhaus-Preis {{ accommodation.name }}, {{ accommodationPrice.dateFrom | date('d.m.Y') }} - {{ accommodationPrice.dateTo | date('d.m.Y') }} wirklich löschen? +
+{% endblock %} diff --git a/templates/admin/additional_service/_form.html.twig b/templates/admin/additional_service/_form.html.twig new file mode 100644 index 00000000..54479804 --- /dev/null +++ b/templates/admin/additional_service/_form.html.twig @@ -0,0 +1,21 @@ +{% form_theme form 'forms_admin.html.twig' %} + +{{ form_start(form) }} +
+ {{ form_row(form.label) }} + {{ form_row(form.type) }} + {{ form_row(form.price) }} + {{ form_row(form.selectionGroup) }} + {{ form_row(form.dateFrom) }} + {{ form_row(form.dateTo) }} +
+
+ + zurück + + +
+{{ form_rest(form) }} +{{ form_end(form) }} diff --git a/templates/admin/additional_service/create.html.twig b/templates/admin/additional_service/create.html.twig new file mode 100644 index 00000000..9e306965 --- /dev/null +++ b/templates/admin/additional_service/create.html.twig @@ -0,0 +1,6 @@ +{% extends 'layout_admin.html.twig' %} + +{% block content %} + Zusatzleistung neu für Gruppenhaus {{ accommodation.name }} + {% include 'admin/additional_service/_form.html.twig' %} +{% endblock %} diff --git a/templates/admin/additional_service/edit.html.twig b/templates/admin/additional_service/edit.html.twig new file mode 100644 index 00000000..a38e4794 --- /dev/null +++ b/templates/admin/additional_service/edit.html.twig @@ -0,0 +1,6 @@ +{% extends 'layout_admin.html.twig' %} + +{% block content %} + Zusatzleistung für Gruppenhaus {{ accommodation.name }}{% if duplicate %} duplizieren{% endif %} + {% include 'admin/additional_service/_form.html.twig' %} +{% endblock %} diff --git a/templates/admin/additional_service/modal_delete.html.twig b/templates/admin/additional_service/modal_delete.html.twig new file mode 100644 index 00000000..bca6b1a1 --- /dev/null +++ b/templates/admin/additional_service/modal_delete.html.twig @@ -0,0 +1,7 @@ +{% extends 'htmx_confirmation_modal.html.twig' %} + +{% block content %} +
+ Möchtest du die Zusatzleistung {{ additionalService.label }}, {{ accommodation.name }} wirklich löschen? +
+{% endblock %} diff --git a/templates/admin/board_service/_form.html.twig b/templates/admin/board_service/_form.html.twig new file mode 100644 index 00000000..0f5e5621 --- /dev/null +++ b/templates/admin/board_service/_form.html.twig @@ -0,0 +1,19 @@ +{% form_theme form 'forms_admin.html.twig' %} + +{{ form_start(form) }} +
+ {{ form_row(form.label) }} + {{ form_row(form.price) }} + {{ form_row(form.dateFrom) }} + {{ form_row(form.dateTo) }} +
+
+ + zurück + + +
+{{ form_rest(form) }} +{{ form_end(form) }} diff --git a/templates/admin/board_service/create.html.twig b/templates/admin/board_service/create.html.twig new file mode 100644 index 00000000..1f13fec1 --- /dev/null +++ b/templates/admin/board_service/create.html.twig @@ -0,0 +1,6 @@ +{% extends 'layout_admin.html.twig' %} + +{% block content %} + Verpflegungsleistung neu für Gruppenhaus {{ accommodation.name }} + {% include 'admin/board_service/_form.html.twig' %} +{% endblock %} diff --git a/templates/admin/board_service/edit.html.twig b/templates/admin/board_service/edit.html.twig new file mode 100644 index 00000000..ce95848e --- /dev/null +++ b/templates/admin/board_service/edit.html.twig @@ -0,0 +1,6 @@ +{% extends 'layout_admin.html.twig' %} + +{% block content %} + Verpflegungsleistung für Gruppenhaus {{ accommodation.name }}{% if duplicate %} duplizieren{% endif %} + {% include 'admin/board_service/_form.html.twig' %} +{% endblock %} diff --git a/templates/admin/board_service/modal_delete.html.twig b/templates/admin/board_service/modal_delete.html.twig new file mode 100644 index 00000000..7da5ad04 --- /dev/null +++ b/templates/admin/board_service/modal_delete.html.twig @@ -0,0 +1,7 @@ +{% extends 'htmx_confirmation_modal.html.twig' %} + +{% block content %} +
+ Möchtest du die Verpflegungsleistung {{ boardService.label }}, {{ accommodation.name }} wirklich löschen? +
+{% endblock %} diff --git a/templates/admin/booking_edit_draft/index.html.twig b/templates/admin/booking_edit_draft/index.html.twig index b294ba58..6ff0270f 100644 --- a/templates/admin/booking_edit_draft/index.html.twig +++ b/templates/admin/booking_edit_draft/index.html.twig @@ -1,9 +1,7 @@ {% extends 'layout_admin.html.twig' %} {% block content %} -

- Buchungsentwürfe -

+ Buchungsentwürfe
@@ -44,32 +42,17 @@ {% endif %} {% else %} diff --git a/templates/admin/booking_edit_draft/show.html.twig b/templates/admin/booking_edit_draft/show.html.twig index 7181cfdc..4ca9df5a 100644 --- a/templates/admin/booking_edit_draft/show.html.twig +++ b/templates/admin/booking_edit_draft/show.html.twig @@ -1,9 +1,7 @@ {% extends 'layout_admin.html.twig' %} {% block content %} -

- Buchungsentwurf -

+ Buchungsentwurf

Vorgang {{ draft.bookingNumber }}

diff --git a/templates/admin/log/index.html.twig b/templates/admin/log/index.html.twig index ad6a91b3..d81698ac 100644 --- a/templates/admin/log/index.html.twig +++ b/templates/admin/log/index.html.twig @@ -1,9 +1,7 @@ {% extends 'layout_admin.html.twig' %} {% block content %} -

- Logs -

+ Logs
- {% embed '_partials/_dropdown.html.twig' %} - {% block content %} - - Details - - - XLS-Export - - - {% endblock %} - {% endembed %} - + + + Details + + + XLS-Export + + + löschen + +
@@ -53,16 +51,11 @@ diff --git a/templates/admin/log/xml_dumps.html.twig b/templates/admin/log/xml_dumps.html.twig index ff13a3e1..7b181c3b 100644 --- a/templates/admin/log/xml_dumps.html.twig +++ b/templates/admin/log/xml_dumps.html.twig @@ -1,9 +1,7 @@ {% extends 'layout_admin.html.twig' %} {% block content %} -

- XML-Dumps für Request ID {{ logEntry.requestId }} -

+ XML-Dumps für Request ID {{ logEntry.requestId }} {% if dumps is empty %}
@@ -37,9 +35,9 @@
{% if entry.channel == 'bpn' %} - {% embed '_partials/_dropdown.html.twig' %} - {% block content %} - + + XML-Dumps - - {% endblock %} - {% endembed %} + + {% endif %}
{% if dump.type == 'request' %} - Request + Request {% else %} - Response + Response {% endif %} diff --git a/templates/admin/user/index.html.twig b/templates/admin/user/index.html.twig index d5b1d41f..c4178cf1 100644 --- a/templates/admin/user/index.html.twig +++ b/templates/admin/user/index.html.twig @@ -1,9 +1,7 @@ {% extends 'layout_admin.html.twig' %} {% block content %} -

- Benutzeraccounts -

+ Benutzeraccounts
diff --git a/templates/base_admin.html.twig b/templates/base_admin.html.twig index a22f9279..eda8abd5 100644 --- a/templates/base_admin.html.twig +++ b/templates/base_admin.html.twig @@ -9,10 +9,10 @@ {% block stylesheets %} - {{ encore_entry_link_tags('app') }} + {{ encore_entry_link_tags('admin') }} {% endblock %} {% block javascripts %} - {{ encore_entry_script_tags('app') }} + {{ encore_entry_script_tags('admin') }} {% endblock %} diff --git a/templates/components/badge.html.twig b/templates/components/badge.html.twig new file mode 100644 index 00000000..a5b5a49c --- /dev/null +++ b/templates/components/badge.html.twig @@ -0,0 +1,11 @@ +{% set variant = variant ?? 'primary' %} +{% set cva = html_cva('badge', { + variant: { + primary: 'bg-primary text-white', + success: 'bg-green-500 text-white', + warning: 'bg-red-500 text-white', + } +}, [], { variant: 'primary' }) %} + + {{ block('content') }} + diff --git a/templates/components/dropdown/hxbutton.html.twig b/templates/components/dropdown/hxbutton.html.twig new file mode 100644 index 00000000..442a9cba --- /dev/null +++ b/templates/components/dropdown/hxbutton.html.twig @@ -0,0 +1,20 @@ +{% set warning = warning ?? false %} +{% set method = method ?? 'get' %} + diff --git a/templates/_partials/_dropdown.html.twig b/templates/components/dropdown/index.html.twig similarity index 50% rename from templates/_partials/_dropdown.html.twig rename to templates/components/dropdown/index.html.twig index d4abf096..39d53223 100644 --- a/templates/_partials/_dropdown.html.twig +++ b/templates/components/dropdown/index.html.twig @@ -1,9 +1,16 @@ -
- - + {% include '_partials/_flashes_admin.html.twig' %} {% endblock %} diff --git a/translations/messages.de.yaml b/translations/messages.de.yaml index 8e50e5f2..6d2799cf 100644 --- a/translations/messages.de.yaml +++ b/translations/messages.de.yaml @@ -13,6 +13,13 @@ service: mixed: separator: ' und ' +enum: + additional_service: + flat: pauschal + per_person: pro Person + per_night: pro Nacht + per_person_per_night: pro Person und Nacht + paginator: previous: vorherige next: nächste diff --git a/translations/validators.de.yaml b/translations/validators.de.yaml new file mode 100644 index 00000000..12d97687 --- /dev/null +++ b/translations/validators.de.yaml @@ -0,0 +1,2 @@ +required: Bitte angeben +invalid: Bitte einen gültigen Wert angeben diff --git a/webpack.config.js b/webpack.config.js index 8bc39b02..15222e6c 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -8,6 +8,7 @@ Encore .setOutputPath('public/build/') .setPublicPath('/build') .addEntry('app', './assets/app.js') + .addEntry('admin', './assets/admin.js') .splitEntryChunks() // enables the Symfony UX Stimulus bridge (used in assets/bootstrap.js)