From 047a81abb2c9fa1f5f44ab4aa9b493f0acb94c99 Mon Sep 17 00:00:00 2001 From: Alex Rock Ancelet Date: Sat, 1 Feb 2020 17:36:35 +0100 Subject: [PATCH 1/3] Implement basic chart on the operations page (more to come) --- assets/js/app.js | 36 ++++++++++++ src/Controller/OperationChartController.php | 58 +++++++++++++++++++ .../EasyAdminBundle/default/list.html.twig | 6 ++ 3 files changed, 100 insertions(+) create mode 100644 src/Controller/OperationChartController.php create mode 100644 templates/bundles/EasyAdminBundle/default/list.html.twig diff --git a/assets/js/app.js b/assets/js/app.js index a3b9907..617cadd 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -4,3 +4,39 @@ import * as Highcharts from "highcharts"; // import $ from 'jquery'; console.info('App startup'); + +(async function () { + + const chartsContainer = document.getElementById('charts'); + + if (chartsContainer) { + try { + const filtersUrl = chartsContainer.getAttribute('data-filters-url'); + + const url = new URL(filtersUrl); + url.searchParams.append('filters', '...'); + + const result = await fetch(url); + + if (result.status !== 200) { + throw new Error('Filters request returned an error.'); + } + + const json = await result.json(); + + console.info('Filters fetch result: ', json); + + if (!json.highcharts) { + throw new Error('Highcharts field is not defined in response.'); + } + + const myChart = Highcharts.chart('charts', json.highcharts); + + console.info('Chart: ', myChart); + } catch (e) { + console.error('Cannot fetch filters: '+e.message); + } + + } + +})(); diff --git a/src/Controller/OperationChartController.php b/src/Controller/OperationChartController.php new file mode 100644 index 0000000..3562b66 --- /dev/null +++ b/src/Controller/OperationChartController.php @@ -0,0 +1,58 @@ +. + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace App\Controller; + +use Symfony\Component\HttpFoundation\JsonResponse; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\Routing\Annotation\Route; + +class OperationChartController +{ + /** + * @Route("/admin/operation-chart", name="operation_chart") + */ + public function chart(Request $request) + { + $filters = $request->query->get('filters', []); + + return new JsonResponse([ + 'filters' => $filters, + 'highcharts' => [ + 'chart' => ['type' => 'bar'], + 'title' => ['text' => 'Fruit Consumption'], + 'xAxis' => [ + 'categories' => [ + 'Apples', + 'Bananas', + 'Oranges', + ], + ], + 'yAxis' => [ + 'title' => ['text' => 'Fruit eaten'], + ], + 'series' => [ + [ + 'name' => 'Jane', + 'data' => [1, 0, 4], + ], + + [ + 'name' => 'John', + 'data' => [5, 7, 3], + ], + ], + ], + ]); + } +} diff --git a/templates/bundles/EasyAdminBundle/default/list.html.twig b/templates/bundles/EasyAdminBundle/default/list.html.twig new file mode 100644 index 0000000..35e3bc7 --- /dev/null +++ b/templates/bundles/EasyAdminBundle/default/list.html.twig @@ -0,0 +1,6 @@ +{% extends '@!EasyAdmin/default/list.html.twig' %} + +{% block content_header %} + {{ parent() }} +
+{% endblock %} From de954540fc215f1b54c6fd32c990c832cc281f1b Mon Sep 17 00:00:00 2001 From: Alex Rock Ancelet Date: Sun, 2 Feb 2020 00:26:05 +0100 Subject: [PATCH 2/3] Fix accents in fixtures --- src/DataFixtures/TagFixtures.php | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/DataFixtures/TagFixtures.php b/src/DataFixtures/TagFixtures.php index 772f28f..300e8e5 100644 --- a/src/DataFixtures/TagFixtures.php +++ b/src/DataFixtures/TagFixtures.php @@ -51,7 +51,7 @@ protected function getObjects(): array ['name' => 'Cheque-recu', 'parent' => $getParentClosure($earningsParent)], ['name' => 'Dividendes', 'parent' => $getParentClosure($earningsParent)], ['name' => 'Deblocage-emprunt', 'parent' => $getParentClosure($earningsParent)], - ['name' => 'Depôt-argent', 'parent' => $getParentClosure($earningsParent)], + ['name' => 'Depot-argent', 'parent' => $getParentClosure($earningsParent)], ['name' => 'Interets', 'parent' => $getParentClosure($earningsParent)], ['name' => 'Loyers', 'parent' => $getParentClosure($earningsParent)], ['name' => 'Pensions', 'parent' => $getParentClosure($earningsParent)], @@ -96,12 +96,12 @@ protected function getObjects(): array ['name' => 'Pension-alimentaire', 'parent' => $getParentClosure($parent)], ['name' => 'Scolarite-etudes', 'parent' => $getParentClosure($parent)], - ['name' => $parent = 'Impôts-et-taxes', 'parent' => $getParentClosure($expensesParent)], + ['name' => $parent = 'Impots-et-taxes', 'parent' => $getParentClosure($expensesParent)], ['name' => 'Amendes', 'parent' => $getParentClosure($parent)], - ['name' => 'Contributions-sociales-(csg-crds)', 'parent' => $getParentClosure($parent)], - ['name' => 'Impôt-sur-la-fortune', 'parent' => $getParentClosure($parent)], - ['name' => 'Impôt-sur-le-revenu', 'parent' => $getParentClosure($parent)], - ['name' => 'Impôts-et-taxes-autres', 'parent' => $getParentClosure($parent)], + ['name' => 'Contributions-sociales-csg-crds', 'parent' => $getParentClosure($parent)], + ['name' => 'Impot-sur-la-fortune', 'parent' => $getParentClosure($parent)], + ['name' => 'Impot-sur-le-revenu', 'parent' => $getParentClosure($parent)], + ['name' => 'Impots-et-taxes-autres', 'parent' => $getParentClosure($parent)], ['name' => 'Taxe-habitation', 'parent' => $getParentClosure($parent)], ['name' => 'Taxe-fonciere', 'parent' => $getParentClosure($parent)], @@ -135,7 +135,7 @@ protected function getObjects(): array ['name' => $parent = 'Transports-et-vehicules', 'parent' => $getParentClosure($expensesParent)], ['name' => 'Assurance-vehicule', 'parent' => $getParentClosure($parent)], - ['name' => 'Billet-avion, billet de train', 'parent' => $getParentClosure($parent)], + ['name' => 'Billet-avion-ou-train', 'parent' => $getParentClosure($parent)], ['name' => 'Carburant', 'parent' => $getParentClosure($parent)], ['name' => 'Credit-auto', 'parent' => $getParentClosure($parent)], ['name' => 'Entretien-vehicule', 'parent' => $getParentClosure($parent)], From f74f931562432f79ac6fed7fdac8bb587da2a99e Mon Sep 17 00:00:00 2001 From: Alex Rock Ancelet Date: Sun, 2 Feb 2020 00:37:25 +0100 Subject: [PATCH 3/3] Wow, I did my first chart and it works! --- README.md | 8 +- assets/js/app.js | 40 +------- config/packages/easy_admin.yaml | 7 +- src/Controller/AnalyticsController.php | 52 ++++++++++ src/Controller/OperationChartController.php | 58 ----------- src/Highcharts/Chart/AbstractChart.php | 26 +++++ src/Highcharts/Chart/ChartInterface.php | 29 ++++++ src/Highcharts/Chart/TagUsageChart.php | 98 +++++++++++++++++++ src/Repository/OperationRepository.php | 16 +++ src/Twig/SlugifyExtension.php | 44 +++++++++ templates/analytics.html.twig | 20 ++++ .../includes/_select2_widget.html.twig | 4 - .../EasyAdminBundle/default/list.html.twig | 6 -- 13 files changed, 296 insertions(+), 112 deletions(-) create mode 100644 src/Controller/AnalyticsController.php delete mode 100644 src/Controller/OperationChartController.php create mode 100644 src/Highcharts/Chart/AbstractChart.php create mode 100644 src/Highcharts/Chart/ChartInterface.php create mode 100644 src/Highcharts/Chart/TagUsageChart.php create mode 100644 src/Twig/SlugifyExtension.php create mode 100644 templates/analytics.html.twig delete mode 100644 templates/bundles/EasyAdminBundle/default/list.html.twig diff --git a/README.md b/README.md index c366e05..dddc779 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,7 @@ ADMIN_PASSWORD='$argon2id$v=19$m=65536,t=4,p=1$N0R4Zi5hUWQ3QXB0bjVGdg$VsVcHzGRfG Feel free to contribute 😉. * Make many analytics dashboards (that's what this app is for in the first place, probably with Highcharts). +* Support JS closures in Chart objects (by using a placeholder to remove quotes maybe?). * Add a lot of fixtures to play with. * Add translations for tags (maybe using an extension like gedmo or knp?). * Implement more source file types like xls, ods, etc., that could be transformed to CSV before importing them. [PHPSpreadsheet](https://phpspreadsheet.readthedocs.io/) is already installed, though not used yet. @@ -97,9 +98,10 @@ Feel free to contribute 😉. * Operation tags (insurance, internet provider, car loan, etc.). Multiple tags per operation. * Demo app at https://piers.ovh/compotes/ with credentials `admin`/`admin` and database reset every day. -* Added default tags (in French only for now) -* Docker setup with Compose -* Added tons of other commands to the Makefile +* Added default tags (in French only for now). +* Docker setup with Compose. +* Added tons of other commands to the Makefile. +* Made a first PoC for the analytics dashboard. # License diff --git a/assets/js/app.js b/assets/js/app.js index 617cadd..b1df6ec 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -1,42 +1,6 @@ import '../css/app.css'; import * as Highcharts from "highcharts"; -// import $ from 'jquery'; +require('../css/app.css'); -console.info('App startup'); - -(async function () { - - const chartsContainer = document.getElementById('charts'); - - if (chartsContainer) { - try { - const filtersUrl = chartsContainer.getAttribute('data-filters-url'); - - const url = new URL(filtersUrl); - url.searchParams.append('filters', '...'); - - const result = await fetch(url); - - if (result.status !== 200) { - throw new Error('Filters request returned an error.'); - } - - const json = await result.json(); - - console.info('Filters fetch result: ', json); - - if (!json.highcharts) { - throw new Error('Highcharts field is not defined in response.'); - } - - const myChart = Highcharts.chart('charts', json.highcharts); - - console.info('Chart: ', myChart); - } catch (e) { - console.error('Cannot fetch filters: '+e.message); - } - - } - -})(); +global.Highcharts = Highcharts; diff --git a/config/packages/easy_admin.yaml b/config/packages/easy_admin.yaml index 9a144fc..124118a 100644 --- a/config/packages/easy_admin.yaml +++ b/config/packages/easy_admin.yaml @@ -8,11 +8,12 @@ easy_admin: - { icon: sync, label: "Sync operations", route: apply_rules } - label: '' - - { icon: ruler-vertical, entity: TagRule } - - { icon: tags, entity: Tag } + - { icon: money-check, entity: Operation } + - { icon: chart-bar, label: "Analytics", route: analytics } - label: '' - - { icon: money-check, entity: Operation } + - { icon: ruler-vertical, entity: TagRule } + - { icon: tags, entity: Tag } entities: TagRule: diff --git a/src/Controller/AnalyticsController.php b/src/Controller/AnalyticsController.php new file mode 100644 index 0000000..49bfbb5 --- /dev/null +++ b/src/Controller/AnalyticsController.php @@ -0,0 +1,52 @@ +. + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace App\Controller; + +use App\Highcharts\Chart\TagUsageChart; +use App\Repository\OperationRepository; +use App\Repository\TagRepository; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Routing\Annotation\Route; +use Twig\Environment; + +class AnalyticsController +{ + private Environment $twig; + private TagRepository $tagRepository; + private OperationRepository $operationRepository; + + public function __construct( + Environment $twig, + TagRepository $tagRepository, + OperationRepository $operationRepository + ) { + $this->twig = $twig; + $this->tagRepository = $tagRepository; + $this->operationRepository = $operationRepository; + } + + /** + * @Route("/admin/analytics", name="analytics") + */ + public function analytics(): Response + { + $operations = $this->operationRepository->findWithTags(); + + return new Response($this->twig->render('analytics.html.twig', [ + 'charts' => [ + TagUsageChart::create($operations), + ], + ])); + } +} diff --git a/src/Controller/OperationChartController.php b/src/Controller/OperationChartController.php deleted file mode 100644 index 3562b66..0000000 --- a/src/Controller/OperationChartController.php +++ /dev/null @@ -1,58 +0,0 @@ -. - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace App\Controller; - -use Symfony\Component\HttpFoundation\JsonResponse; -use Symfony\Component\HttpFoundation\Request; -use Symfony\Component\Routing\Annotation\Route; - -class OperationChartController -{ - /** - * @Route("/admin/operation-chart", name="operation_chart") - */ - public function chart(Request $request) - { - $filters = $request->query->get('filters', []); - - return new JsonResponse([ - 'filters' => $filters, - 'highcharts' => [ - 'chart' => ['type' => 'bar'], - 'title' => ['text' => 'Fruit Consumption'], - 'xAxis' => [ - 'categories' => [ - 'Apples', - 'Bananas', - 'Oranges', - ], - ], - 'yAxis' => [ - 'title' => ['text' => 'Fruit eaten'], - ], - 'series' => [ - [ - 'name' => 'Jane', - 'data' => [1, 0, 4], - ], - - [ - 'name' => 'John', - 'data' => [5, 7, 3], - ], - ], - ], - ]); - } -} diff --git a/src/Highcharts/Chart/AbstractChart.php b/src/Highcharts/Chart/AbstractChart.php new file mode 100644 index 0000000..a29d00d --- /dev/null +++ b/src/Highcharts/Chart/AbstractChart.php @@ -0,0 +1,26 @@ +. + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace App\Highcharts\Chart; + +abstract class AbstractChart implements ChartInterface +{ + public function getConfig(): array + { + return $this->getOptions() + ['series' => $this->getSeries()]; + } + + abstract protected function getSeries(): array; + + abstract protected function getOptions(): array; +} diff --git a/src/Highcharts/Chart/ChartInterface.php b/src/Highcharts/Chart/ChartInterface.php new file mode 100644 index 0000000..0c25a4f --- /dev/null +++ b/src/Highcharts/Chart/ChartInterface.php @@ -0,0 +1,29 @@ +. + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace App\Highcharts\Chart; + +interface ChartInterface +{ + public function getName(): string; + + /** + * This corresponds to the options sent to the Highcharts js object. + * + * Config has to be convertible to JSON or JS. + * If your config contains closures, it will be rendered as a string (for now). + * + * @see https://api.highcharts.com/highcharts/ + */ + public function getConfig(): array; +} diff --git a/src/Highcharts/Chart/TagUsageChart.php b/src/Highcharts/Chart/TagUsageChart.php new file mode 100644 index 0000000..fae9cd6 --- /dev/null +++ b/src/Highcharts/Chart/TagUsageChart.php @@ -0,0 +1,98 @@ +. + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace App\Highcharts\Chart; + +use App\Entity\Operation; + +class TagUsageChart extends AbstractChart +{ + /** * @var Operation[] */ + private array $operations = []; + + private function __construct() + { + } + + public function getName(): string + { + return 'Tags'; + } + + public static function create($operations): self + { + $self = new self(); + + foreach ($operations as $operation) { + $self->addOperation($operation); + } + + return $self; + } + + protected function getOptions(): array + { + return [ + 'chart' => [ + 'type' => $type = 'bar', + 'height' => 500, + ], + 'legend' => [ + 'align' => 'right', + 'layout' => 'vertical', + ], + 'title' => ['text' => 'Tags usage'], + 'xAxis' => [ + 'categories' => ['Tags'], + ], + 'yAxis' => [ + 'title' => ['text' => 'Number of operations with these tags'], + ], + 'plotOptions' => [ + $type => [ + 'pointWidth' => 10, + 'borderWidth' => 0, + 'groupPadding' => 0.01, + ], + ], + ]; + } + + protected function getSeries(): array + { + $series = []; + + foreach ($this->operations as $operation) { + foreach ($operation->getTags() as $tag) { + $tagName = $tag->getName(); + if (!isset($series[$tagName])) { + $series[$tagName] = [ + 'name' => $tagName, + 'data' => [0], + ]; + } + + $series[$tagName]['data'][0]++; + } + } + + \ksort($series); + + return \array_values($series); + } + + private function addOperation(Operation $operation): void + { + $this->operations[] = $operation; + } +} diff --git a/src/Repository/OperationRepository.php b/src/Repository/OperationRepository.php index 88b3a96..4d21a1f 100644 --- a/src/Repository/OperationRepository.php +++ b/src/Repository/OperationRepository.php @@ -58,4 +58,20 @@ public function monthIsPopulated(DateTimeImmutable $month): bool return $count > 0; } + + /** + * @return Operation[] + */ + public function findWithTags(): array + { + return $this->_em->createQuery( + <<_entityName} as operation + LEFT JOIN operation.tags as tags + DQL + ) + ->getResult() + ; + } } diff --git a/src/Twig/SlugifyExtension.php b/src/Twig/SlugifyExtension.php new file mode 100644 index 0000000..6880869 --- /dev/null +++ b/src/Twig/SlugifyExtension.php @@ -0,0 +1,44 @@ +. + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace App\Twig; + +use Symfony\Component\String\Slugger\SluggerInterface; +use Twig\Extension\AbstractExtension; +use Twig\TwigFilter; + +class SlugifyExtension extends AbstractExtension +{ + private SluggerInterface $slugger; + + public function __construct(SluggerInterface $slugger) + { + $this->slugger = $slugger; + } + + public function getFilters() + { + return [ + new TwigFilter('slug', [$this, 'slugify']), + ]; + } + + public function slugify($string): string + { + if (\is_object($string)) { + $string = (string) $string; + } + + return $this->slugger->slug($string)->toString(); + } +} diff --git a/templates/analytics.html.twig b/templates/analytics.html.twig new file mode 100644 index 0000000..c728b80 --- /dev/null +++ b/templates/analytics.html.twig @@ -0,0 +1,20 @@ +{% extends '@EasyAdmin/default/layout.html.twig' %} + +{% block content_title %} + Analytics +{% endblock %} + +{% block main %} + {% for chart in charts %} +
+ {% endfor %} +{% endblock %} + +{% block body_custom_javascript %} + {{ parent() }} + +{% endblock %} diff --git a/templates/bundles/EasyAdminBundle/default/includes/_select2_widget.html.twig b/templates/bundles/EasyAdminBundle/default/includes/_select2_widget.html.twig index 0563e29..bea6f29 100644 --- a/templates/bundles/EasyAdminBundle/default/includes/_select2_widget.html.twig +++ b/templates/bundles/EasyAdminBundle/default/includes/_select2_widget.html.twig @@ -3,9 +3,6 @@ {% if (_entity_config.name|default('')) is same as('TagRule') %}