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 a3b9907..b1df6ec 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -1,6 +1,6 @@ import '../css/app.css'; import * as Highcharts from "highcharts"; -// import $ from 'jquery'; +require('../css/app.css'); -console.info('App startup'); +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/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)], 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') %}