From c3c2e1ae2d9c86a7455f744a7fa404c3153994b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fromme?= Date: Thu, 21 Sep 2023 17:51:16 +0200 Subject: [PATCH] WIP: Implement Admin CRUD --- .../confirmation_modal_controller.js | 4 +- assets/controllers/modal_button_controller.js | 13 +--- assets/styles/_utilities.css | 0 assets/styles/app.css | 3 +- composer.json | 1 + composer.lock | 66 ++++++++++++++++- config/services.yaml | 1 + src/BusProNet/ApiClient.php | 35 +++++++++ src/BusProNet/Model/Pickup.php | 72 +++++++++++++++++++ src/BusProNet/Model/PickupsResponse.php | 20 ++++++ src/BusProNet/ResponseParser.php | 28 +++++++- .../Admin/System/Fee/CreateController.php | 47 ++++++++++++ .../Admin/System/Fee/DeleteController.php | 35 +++++++++ .../Admin/System/Fee/EditController.php | 46 ++++++++++++ .../Admin/System/Fee/IndexController.php | 26 +++++++ src/Entity/Fee.php | 5 +- src/Form/FeeType.php | 34 +++++++++ src/Twig/AppExtension.php | 1 + src/Twig/AppRuntime.php | 10 +++ .../_partials/_confirmation_modal.html.twig | 4 +- templates/admin/system/fee/_form.html.twig | 10 +++ templates/admin/system/fee/create.html.twig | 21 ++++++ templates/admin/system/fee/edit.html.twig | 21 ++++++ templates/admin/system/fee/index.html.twig | 47 ++++++++++++ templates/forms.html.twig | 15 +++- tests/BusProNet/ResponseParserTest.php | 23 ++++++ 26 files changed, 568 insertions(+), 20 deletions(-) delete mode 100644 assets/styles/_utilities.css create mode 100644 src/BusProNet/Model/Pickup.php create mode 100644 src/BusProNet/Model/PickupsResponse.php create mode 100644 src/Controller/Admin/System/Fee/CreateController.php create mode 100644 src/Controller/Admin/System/Fee/DeleteController.php create mode 100644 src/Controller/Admin/System/Fee/EditController.php create mode 100644 src/Controller/Admin/System/Fee/IndexController.php create mode 100644 src/Form/FeeType.php create mode 100644 templates/admin/system/fee/_form.html.twig create mode 100644 templates/admin/system/fee/create.html.twig create mode 100644 templates/admin/system/fee/edit.html.twig create mode 100644 templates/admin/system/fee/index.html.twig diff --git a/assets/controllers/confirmation_modal_controller.js b/assets/controllers/confirmation_modal_controller.js index a72d13c..cc8991c 100644 --- a/assets/controllers/confirmation_modal_controller.js +++ b/assets/controllers/confirmation_modal_controller.js @@ -3,8 +3,8 @@ import { Controller } from '@hotwired/stimulus' export default class extends Controller { static targets = [ 'form', 'title', 'content' ] - show({ action, title, content }) { - this.formTarget.action = action + show({ targetUrl, title, content }) { + this.formTarget.action = targetUrl this.titleTarget.innerText = title this.contentTarget.innerHTML = content this.element.classList.remove('hidden') diff --git a/assets/controllers/modal_button_controller.js b/assets/controllers/modal_button_controller.js index b8eab0a..55ac624 100644 --- a/assets/controllers/modal_button_controller.js +++ b/assets/controllers/modal_button_controller.js @@ -3,18 +3,11 @@ import { Controller } from '@hotwired/stimulus' export default class extends Controller { static outlets = [ 'confirmation-modal', 'ajax-modal' ] - confirmation({ params: { title, action, content } }) { - this.confirmationModalOutlet.show({ - title: title, - action: action, - content: content, - }) + confirmation({ params: { title, targetUrl, content } }) { + this.confirmationModalOutlet.show({ title, targetUrl, content }) } ajax({ params: { title, url } }) { - this.ajaxModalOutlet.show({ - title: title, - url: url, - }) + this.ajaxModalOutlet.show({ title, url }) } } diff --git a/assets/styles/_utilities.css b/assets/styles/_utilities.css deleted file mode 100644 index e69de29..0000000 diff --git a/assets/styles/app.css b/assets/styles/app.css index c4a5ab7..aff375a 100644 --- a/assets/styles/app.css +++ b/assets/styles/app.css @@ -4,5 +4,4 @@ @import "tailwindcss/components"; @import "_components.css"; -@import "tailwindcss/utilities"; -@import "_utilities.css"; \ No newline at end of file +@import "tailwindcss/utilities"; \ No newline at end of file diff --git a/composer.json b/composer.json index b898adf..d17b69d 100644 --- a/composer.json +++ b/composer.json @@ -49,6 +49,7 @@ "symfony/yaml": "6.3.*", "twig/extra-bundle": "^2.12|^3.0", "twig/html-extra": "^3.7", + "twig/intl-extra": "^3.7", "twig/twig": "^2.12|^3.0" }, "config": { diff --git a/composer.lock b/composer.lock index 6527102..711629a 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": "2b10ee7954ba92748752ca9e16b1a17a", + "content-hash": "05df8d6ad31865116a5ac469c6e03b20", "packages": [ { "name": "doctrine/cache", @@ -8032,6 +8032,70 @@ ], "time": "2023-07-29T15:34:56+00:00" }, + { + "name": "twig/intl-extra", + "version": "v3.7.1", + "source": { + "type": "git", + "url": "https://github.com/twigphp/intl-extra.git", + "reference": "4f4fe572f635534649cc069e1dafe4a8ad63774d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/twigphp/intl-extra/zipball/4f4fe572f635534649cc069e1dafe4a8ad63774d", + "reference": "4f4fe572f635534649cc069e1dafe4a8ad63774d", + "shasum": "" + }, + "require": { + "php": ">=7.1.3", + "symfony/intl": "^5.4|^6.0", + "twig/twig": "^2.7|^3.0" + }, + "require-dev": { + "symfony/phpunit-bridge": "^5.4|^6.3" + }, + "type": "library", + "autoload": { + "psr-4": { + "Twig\\Extra\\Intl\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com", + "homepage": "http://fabien.potencier.org", + "role": "Lead Developer" + } + ], + "description": "A Twig extension for Intl", + "homepage": "https://twig.symfony.com", + "keywords": [ + "intl", + "twig" + ], + "support": { + "source": "https://github.com/twigphp/intl-extra/tree/v3.7.1" + }, + "funding": [ + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/twig/twig", + "type": "tidelift" + } + ], + "time": "2023-07-29T15:34:56+00:00" + }, { "name": "twig/twig", "version": "v3.6.1", diff --git a/config/services.yaml b/config/services.yaml index d4ca02a..ee3b5db 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -53,6 +53,7 @@ services: App\Twig\AppRuntime: arguments: + $intlExtension: '@twig.extension.intl' $environment: '%kernel.environment%' App\Menu\MenuBuilder: diff --git a/src/BusProNet/ApiClient.php b/src/BusProNet/ApiClient.php index 8fe53b2..9949c0b 100644 --- a/src/BusProNet/ApiClient.php +++ b/src/BusProNet/ApiClient.php @@ -208,6 +208,41 @@ class ApiClient }); } + public function getPickups(): BaseResponse + { + return $this->cache->get('bpn_pickups', function (ItemInterface $item) { + $item->expiresAfter(3600); + + $data = [ + 'anfrage' => [ + 'user' => $this->config['bpn_username'], + 'key' => $this->createKey($this->config['bpn_username'], $this->config['bpn_password'], 'STAMMZUSTIEGE'), + 'satz' => ['@typ' => 'STAMMZUSTIEGE'], + ], + ]; + + $body = $this + ->serializer + ->serialize($data, 'xml'); + + try { + $response = $this->httpClient->request('GET', $this->config['bpn_url'], [ + 'query' => [ + 'operation' => $body, + ] + ]); + + $xml = $response->getContent(); + + return $this->responseParser->parseXmlString($xml); + } catch (\Throwable $e) { + } + + $this->logger->error('API error', ['error' => $e->getMessage()]); + throw new ApiClientException($e->getMessage()); + }); + } + private function createKey(string $username, string $password, string $type): string { $date = (new \DateTimeImmutable())->format('Ymd'); diff --git a/src/BusProNet/Model/Pickup.php b/src/BusProNet/Model/Pickup.php new file mode 100644 index 0000000..cd88b05 --- /dev/null +++ b/src/BusProNet/Model/Pickup.php @@ -0,0 +1,72 @@ +id; + } + + public function setId(?int $id): static + { + $this->id = $id; + + return $this; + } + + public function getBusProId(): ?int + { + return $this->busProId; + } + + public function setBusProId(?int $busProId): static + { + $this->busProId = $busProId; + + return $this; + } + + public function getCode(): ?string + { + return $this->code; + } + + public function setCode(?string $code): static + { + $this->code = $code; + + return $this; + } + + public function getCity(): ?string + { + return $this->city; + } + + public function setCity(?string $city): static + { + $this->city = $city; + + return $this; + } + + public function getStreet(): ?string + { + return $this->street; + } + + public function setStreet(?string $street): static + { + $this->street = $street; + + return $this; + } +} \ No newline at end of file diff --git a/src/BusProNet/Model/PickupsResponse.php b/src/BusProNet/Model/PickupsResponse.php new file mode 100644 index 0000000..c60e07d --- /dev/null +++ b/src/BusProNet/Model/PickupsResponse.php @@ -0,0 +1,20 @@ +pickups; + } + + public function setPickups(array $pickups): static + { + $this->pickups = $pickups; + + return $this; + } +} \ No newline at end of file diff --git a/src/BusProNet/ResponseParser.php b/src/BusProNet/ResponseParser.php index c38a3ad..366fac4 100644 --- a/src/BusProNet/ResponseParser.php +++ b/src/BusProNet/ResponseParser.php @@ -9,6 +9,8 @@ use App\BusProNet\Model\Country; use App\BusProNet\Model\CrmAttribute; use App\BusProNet\Model\CrmAttributesResponse; use App\BusProNet\Model\CrmAttributeGroup; +use App\BusProNet\Model\Pickup; +use App\BusProNet\Model\PickupsResponse; use App\BusProNet\Model\ProfileResponse; use App\BusProNet\Model\BaseResponse; use Symfony\Component\OptionsResolver\OptionsResolver; @@ -28,7 +30,7 @@ class ResponseParser public function parseXmlString(string $content): BaseResponse { $xml = simplexml_load_string($content); - $type = (string) $xml->xpath('satz/@typ')[0]; + $type = (string)$xml->xpath('satz/@typ')[0]; switch ($type) { case 'HINWEIS': @@ -43,6 +45,8 @@ class ResponseParser } case 'STAMMLAENDER': return $this->createCountriesResponse($xml); + case 'STAMMZUSTIEGE': + return $this->createPickupsResponse($xml); } throw new ResponseParserException('Unable to parse XML response'); @@ -180,6 +184,28 @@ class ResponseParser return $response; } + public function createPickupsResponse(\SimpleXMLElement $xml): PickupsResponse + { + $pickups = []; + + foreach ($xml->xpath('zustiege/zustieg') as $item) { + $pickup = new Pickup(); + $pickup + ->setId((int)$item->attributes()['id']) + ->setBusProId((int)$item->attributes()['idbuspro']) + ->setCode((string)$item->attributes()['code']) + ->setCity((string)$item->xpath('ort')[0]) + ->setStreet((string)$item->xpath('strasse')[0]) + ; + $pickups[] = $pickup; + } + + $response = new PickupsResponse(); + $response->setPickups($pickups); + + return $response; + } + private function resolveOptions(array $options): array { $optionsResolver = new OptionsResolver(); diff --git a/src/Controller/Admin/System/Fee/CreateController.php b/src/Controller/Admin/System/Fee/CreateController.php new file mode 100644 index 0000000..2f7c962 --- /dev/null +++ b/src/Controller/Admin/System/Fee/CreateController.php @@ -0,0 +1,47 @@ +createForm(FeeType::class, $fee); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $this->entityManager->persist($fee); + $this->entityManager->flush(); + + $this->addFlash('success', 'Das Honorar wurde angelegt'); + $this->logger->info('Create fee', [ + 'fee' => $fee->getName(), + ]); + + return $this->redirectToRoute('app_admin_system_fee_index'); + } + + return $this->render('admin/system/fee/create.html.twig', [ + 'form' => $form + ]); + } +} \ No newline at end of file diff --git a/src/Controller/Admin/System/Fee/DeleteController.php b/src/Controller/Admin/System/Fee/DeleteController.php new file mode 100644 index 0000000..4c2af23 --- /dev/null +++ b/src/Controller/Admin/System/Fee/DeleteController.php @@ -0,0 +1,35 @@ +entityManager->remove($fee); + $this->entityManager->flush(); + + $this->addFlash('success', 'Das Honorar wurde gelöscht'); + $this->logger->info('Delete fee', [ + 'fee' => $fee->getName(), + ]); + + return $this->redirectToRoute('app_admin_system_fee_index'); + } +} \ No newline at end of file diff --git a/src/Controller/Admin/System/Fee/EditController.php b/src/Controller/Admin/System/Fee/EditController.php new file mode 100644 index 0000000..8bf3e6a --- /dev/null +++ b/src/Controller/Admin/System/Fee/EditController.php @@ -0,0 +1,46 @@ +createForm(FeeType::class, $fee); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $this->entityManager->persist($fee); + $this->entityManager->flush(); + + $this->addFlash('success', 'Das Honorar wurde aktualisiert'); + $this->logger->info('Update fee', [ + 'fee' => $fee->getName(), + ]); + + return $this->redirectToRoute('app_admin_system_fee_index'); + } + + return $this->render('admin/system/fee/edit.html.twig', [ + 'form' => $form + ]); + } +} \ No newline at end of file diff --git a/src/Controller/Admin/System/Fee/IndexController.php b/src/Controller/Admin/System/Fee/IndexController.php new file mode 100644 index 0000000..48ead6c --- /dev/null +++ b/src/Controller/Admin/System/Fee/IndexController.php @@ -0,0 +1,26 @@ +feeRepository->findBy([], ['name' => 'ASC']); + + return $this->render('admin/system/fee/index.html.twig', [ + 'fees' => $fees, + ]); + } +} \ No newline at end of file diff --git a/src/Entity/Fee.php b/src/Entity/Fee.php index 50e423f..1d8bdf9 100644 --- a/src/Entity/Fee.php +++ b/src/Entity/Fee.php @@ -6,6 +6,7 @@ use App\Entity\Traits\BlameableEntity; use App\Entity\Traits\TimestampableEntity; use App\Repository\FeeRepository; use Doctrine\ORM\Mapping as ORM; +use Symfony\Component\Validator\Constraints as Assert; #[ORM\Entity(repositoryClass: FeeRepository::class)] class Fee implements BlameableEntityInterface, TimestampableEntityInterface @@ -19,9 +20,11 @@ class Fee implements BlameableEntityInterface, TimestampableEntityInterface private ?int $id = null; #[ORM\Column] - private ?int $value = null; + #[Assert\NotNull(message: 'Bitte gib den Wert an')] + private ?int $value = null; // Stored as int, divide by 100! #[ORM\Column(length: 255)] + #[Assert\NotBlank(message: 'Bitte gib die Bezeichnung an')] private ?string $name = null; public function getId(): ?int diff --git a/src/Form/FeeType.php b/src/Form/FeeType.php new file mode 100644 index 0000000..5a39c7c --- /dev/null +++ b/src/Form/FeeType.php @@ -0,0 +1,34 @@ +add('name', TextType::class, [ + 'label' => 'Bezeichnung', + ]) + ->add('value', MoneyType::class, [ + 'label' => 'Wert', + 'invalid_message' => 'Bitte gib einen gültigen Geldbetrag ein', + 'divisor' => 100, + ]) + ; + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'data_class' => Fee::class, + ]); + } +} \ No newline at end of file diff --git a/src/Twig/AppExtension.php b/src/Twig/AppExtension.php index 8a62620..f3f38f5 100644 --- a/src/Twig/AppExtension.php +++ b/src/Twig/AppExtension.php @@ -14,6 +14,7 @@ class AppExtension extends AbstractExtension new TwigFilter('file_size', [AppRuntime::class, 'formatBytes']), new TwigFilter('file_icon', [AppRuntime::class, 'fileIconFilter'], ['is_safe' => ['html']]), new TwigFilter('date_diff', [AppRuntime::class, 'dateDiffForHumans']), + new TwigFilter('format_money', [AppRuntime::class, 'formatMoney']), ]; } diff --git a/src/Twig/AppRuntime.php b/src/Twig/AppRuntime.php index 80b0e83..6feab02 100644 --- a/src/Twig/AppRuntime.php +++ b/src/Twig/AppRuntime.php @@ -6,11 +6,13 @@ use Carbon\Carbon; use Symfony\Component\HttpFoundation\RequestStack; use Twig\Environment; use Twig\Extension\RuntimeExtensionInterface; +use Twig\Extra\Intl\IntlExtension; class AppRuntime implements RuntimeExtensionInterface { public function __construct( private readonly RequestStack $requestStack, + private readonly IntlExtension $intlExtension, private readonly string $environment ) { } @@ -35,6 +37,14 @@ class AppRuntime implements RuntimeExtensionInterface return sprintf("%.{$precision}f", $bytes / (1024 ** $factor)).@$size[$factor]; } + public function formatMoney(int $amount): string + { + // Amounts are stored as integers so divide by 100 first + $amount = $amount / 100; + + return $this->intlExtension->formatCurrency($amount, 'EUR'); + } + public function renderIcon(Environment $environment, string $icon, string $classes = 'w-6 h-6'): string { return $environment->render('_partials/_icon.html.twig', [ diff --git a/templates/_partials/_confirmation_modal.html.twig b/templates/_partials/_confirmation_modal.html.twig index ec55db8..59cad8f 100644 --- a/templates/_partials/_confirmation_modal.html.twig +++ b/templates/_partials/_confirmation_modal.html.twig @@ -15,8 +15,8 @@
-
- +{{ form_rest(form) }} +{{ form_end(form) }} diff --git a/templates/admin/system/fee/create.html.twig b/templates/admin/system/fee/create.html.twig new file mode 100644 index 0000000..a13a649 --- /dev/null +++ b/templates/admin/system/fee/create.html.twig @@ -0,0 +1,21 @@ +{% extends 'layout.html.twig' %} + +{% block title %}Honorare{% endblock %} + +{% block content %} +
+ +
+
+

+ Honorar anlegen +

+ {% include 'admin/system/fee/_form.html.twig' %} +
+
+
+{% endblock %} \ No newline at end of file diff --git a/templates/admin/system/fee/edit.html.twig b/templates/admin/system/fee/edit.html.twig new file mode 100644 index 0000000..f82944e --- /dev/null +++ b/templates/admin/system/fee/edit.html.twig @@ -0,0 +1,21 @@ +{% extends 'layout.html.twig' %} + +{% block title %}Honorare{% endblock %} + +{% block content %} +
+ +
+
+

+ Honorar bearbeiten +

+ {% include 'admin/system/fee/_form.html.twig' %} +
+
+
+{% endblock %} \ No newline at end of file diff --git a/templates/admin/system/fee/index.html.twig b/templates/admin/system/fee/index.html.twig new file mode 100644 index 0000000..b879928 --- /dev/null +++ b/templates/admin/system/fee/index.html.twig @@ -0,0 +1,47 @@ +{% extends 'layout.html.twig' %} + +{% block title %}Honorare{% endblock %} + +{% block content %} +
+ +
+

+ Honorare +

+
+ {% for fee in fees %} +
+
+ {{ fee.name }} +
+
+ {{ fee.value|format_money }} +
+
+ + + {{ icon('edit') }} + +
+
+ {% else %} +
Keine Daten...
+ {% endfor %} +
+
+
+{% endblock %} \ No newline at end of file diff --git a/templates/forms.html.twig b/templates/forms.html.twig index 07731ef..cdaa322 100644 --- a/templates/forms.html.twig +++ b/templates/forms.html.twig @@ -89,4 +89,17 @@ {{ form_widget(form.children['month']) }} {{ form_widget(form.children['year']) }}
-{%- endblock -%} \ No newline at end of file +{%- endblock -%} + +{%- block money_widget -%} + {% set currency_class = 'absolute top-1/2 transform -translate-y-1/2 right-0 mr-2' %} + {% if errors|length %} + {% set currency_class = currency_class ~ ' text-red-500' %} + {% else %} + {% set currency_class = currency_class ~ ' text-gray-600' %} + {% endif %} +
+ {{ block('form_widget_simple') }} + +
+{%- endblock money_widget -%} diff --git a/tests/BusProNet/ResponseParserTest.php b/tests/BusProNet/ResponseParserTest.php index aecab1f..6ddb3ed 100644 --- a/tests/BusProNet/ResponseParserTest.php +++ b/tests/BusProNet/ResponseParserTest.php @@ -3,6 +3,8 @@ namespace App\Tests\BusProNet; use App\BusProNet\Model\CrmAttributesResponse; +use App\BusProNet\Model\Pickup; +use App\BusProNet\Model\PickupsResponse; use App\BusProNet\Model\ProfileResponse; use App\BusProNet\ResponseParser; use PHPUnit\Framework\TestCase; @@ -49,6 +51,27 @@ class ResponseParserTest extends TestCase $this->assertTrue($response->isTeamer()); } + public function testParseSuccessfulPickupsResponse(): void + { + $content = 'KarlsruheA5 - Autohof BruchsalBUSFalseTrueTrueBonnBahnhofBUS FalseTrueTrue'; + + $parser = $this->getParserInstance(); + $response = $parser->parseXmlString($content); + + $this->assertInstanceOf(PickupsResponse::class, $response); + + $pickups = $response->getPickups(); + $this->assertCount(2, $pickups); + + $pickup = reset($pickups); + $this->assertInstanceOf(Pickup::class, $pickup); + $this->assertEquals(1, $pickup->getId()); + $this->assertEquals(1, $pickup->getBusProId()); + $this->assertEquals('298', $pickup->getCode()); + $this->assertEquals('Karlsruhe', $pickup->getCity()); + $this->assertEquals('A5 - Autohof Bruchsal', $pickup->getStreet()); + } + private function getParserInstance(): ResponseParser { return new ResponseParser([