diff --git a/.gitignore b/.gitignore index 6eff00acb..ebdc16e56 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,5 @@ node_modules/* /.php-cs-fixer.cache /.idea/ /.psalm/ + +node_modules/* diff --git a/package.json b/package.json new file mode 100644 index 000000000..17b02c63a --- /dev/null +++ b/package.json @@ -0,0 +1,67 @@ +{ + "name": "chill", + "version": "2.0.0", + "devDependencies": { + "@alexlafroscia/yaml-merge": "^4.0.0", + "@apidevtools/swagger-cli": "^4.0.4", + "@babel/core": "^7.20.5", + "@babel/preset-env": "^7.20.2", + "@ckeditor/ckeditor5-build-classic": "^35.3.2", + "@ckeditor/ckeditor5-dev-utils": "^31.1.13", + "@ckeditor/ckeditor5-dev-webpack-plugin": "^31.1.13", + "@ckeditor/ckeditor5-markdown-gfm": "^35.3.2", + "@ckeditor/ckeditor5-theme-lark": "^35.3.2", + "@ckeditor/ckeditor5-vue": "^4.0.1", + "@symfony/webpack-encore": "^4.1.0", + "@tsconfig/node14": "^1.0.1", + "bindings": "^1.5.0", + "bootstrap": "^5.0.1", + "chokidar": "^3.5.1", + "fork-awesome": "^1.1.7", + "jquery": "^3.6.0", + "node-sass": "^8.0.0", + "popper.js": "^1.16.1", + "postcss-loader": "^7.0.2", + "raw-loader": "^4.0.2", + "sass-loader": "^13.0.0", + "select2": "^4.0.13", + "select2-bootstrap-theme": "0.1.0-beta.10", + "style-loader": "^3.3.1", + "ts-loader": "^9.3.1", + "typescript": "^4.7.2", + "vue-loader": "^17.0.0", + "webpack": "^5.75.0", + "webpack-cli": "^5.0.1" + }, + "dependencies": { + "@fullcalendar/core": "^5.11.0", + "@fullcalendar/daygrid": "^5.11.0", + "@fullcalendar/interaction": "^5.11.0", + "@fullcalendar/list": "^5.11.0", + "@fullcalendar/timegrid": "^5.11.0", + "@fullcalendar/vue3": "^5.11.1", + "@popperjs/core": "^2.9.2", + "dropzone": "^5.7.6", + "es6-promise": "^4.2.8", + "leaflet": "^1.7.1", + "masonry-layout": "^4.2.2", + "mime": "^3.0.0", + "swagger-ui": "^4.15.5", + "vis-network": "^9.1.0", + "vue": "^3.2.37", + "vue-i18n": "^9.1.6", + "vue-multiselect": "3.0.0-alpha.2", + "vue-toast-notification": "^2.0", + "vuex": "^4.0.0" + }, + "browserslist": [ + "Firefox ESR" + ], + "scripts": { + "dev-server": "encore dev-server", + "dev": "encore dev", + "watch": "encore dev --watch", + "build": "encore production --progress" + }, + "private": true +} diff --git a/src/Bundle/ChillActivityBundle/Export/Aggregator/ActivityTypeAggregator.php b/src/Bundle/ChillActivityBundle/Export/Aggregator/ActivityTypeAggregator.php index 8c98e1049..071ccd232 100644 --- a/src/Bundle/ChillActivityBundle/Export/Aggregator/ActivityTypeAggregator.php +++ b/src/Bundle/ChillActivityBundle/Export/Aggregator/ActivityTypeAggregator.php @@ -44,7 +44,7 @@ class ActivityTypeAggregator implements AggregatorInterface public function alterQuery(QueryBuilder $qb, $data) { if (!in_array('acttype', $qb->getAllAliases(), true)) { - $qb->join('activity.activityType', 'acttype'); + $qb->leftJoin('activity.activityType', 'acttype'); } $qb->addSelect(sprintf('IDENTITY(activity.activityType) AS %s', self::KEY)); diff --git a/src/Bundle/ChillActivityBundle/Export/Export/LinkedToACP/ListActivity.php b/src/Bundle/ChillActivityBundle/Export/Export/LinkedToACP/ListActivity.php new file mode 100644 index 000000000..77444e414 --- /dev/null +++ b/src/Bundle/ChillActivityBundle/Export/Export/LinkedToACP/ListActivity.php @@ -0,0 +1,165 @@ +helper = $helper; + $this->entityManager = $entityManager; + $this->translatableStringExportLabelHelper = $translatableStringExportLabelHelper; + } + + public function buildForm(FormBuilderInterface $builder) + { + $this->helper->buildForm($builder); + } + + public function getAllowedFormattersTypes() + { + return $this->helper->getAllowedFormattersTypes(); + } + + public function getDescription() + { + return ListActivityHelper::MSG_KEY . 'List activities linked to an accompanying course'; + } + + public function getGroup(): string + { + return 'Exports of activities linked to an accompanying period'; + } + + public function getLabels($key, array $values, $data) + { + switch ($key) { + case 'acpId': + return static function ($value) { + if ('_header' === $value) { + return ListActivityHelper::MSG_KEY . 'accompanying course id'; + } + + return $value ?? ''; + }; + + case 'scopesNames': + return $this->translatableStringExportLabelHelper->getLabelMulti($key, $values, ListActivityHelper::MSG_KEY . 'course circles'); + + default: + return $this->helper->getLabels($key, $values, $data); + } + } + + public function getQueryKeys($data) + { + return + array_merge( + $this->helper->getQueryKeys($data), + [ + 'acpId', + 'scopesNames', + ] + ); + } + + public function getResult($query, $data) + { + return $this->helper->getResult($query, $data); + } + + public function getTitle() + { + return ListActivityHelper::MSG_KEY . 'List activity linked to a course'; + } + + public function getType() + { + return $this->helper->getType(); + } + + public function initiateQuery(array $requiredModifiers, array $acl, array $data = []) + { + $centers = array_map(static function ($el) { + return $el['center']; + }, $acl); + + $qb = $this->entityManager->createQueryBuilder(); + + $qb + ->distinct() + ->from(Activity::class, 'activity') + ->join('activity.accompanyingPeriod', 'acp') + ->leftJoin('acp.participations', 'acppart') + ->leftJoin('acppart.person', 'person') + ->andWhere('acppart.startDate != acppart.endDate OR acppart.endDate IS NULL') + ->andWhere( + $qb->expr()->exists( + 'SELECT 1 + FROM ' . PersonCenterHistory::class . ' acl_count_person_history + WHERE acl_count_person_history.person = person + AND acl_count_person_history.center IN (:authorized_centers) + ' + ) + ) + // some grouping are necessary + ->addGroupBy('acp.id') + ->addOrderBy('activity.date') + ->addOrderBy('activity.id') + ->setParameter('authorized_centers', $centers); + + $this->helper->addSelect($qb); + + // add select for this step + $qb + ->addSelect('acp.id AS acpId') + ->addSelect('(SELECT AGGREGATE(acpScope.name) FROM ' . Scope::class . ' acpScope WHERE acpScope MEMBER OF acp.scopes) AS scopesNames') + ->addGroupBy('scopesNames'); + + return $qb; + } + + public function requiredRole(): string + { + return ActivityStatsVoter::LISTS; + } + + public function supportsModifiers() + { + return array_merge( + $this->helper->supportsModifiers(), + [ + \Chill\PersonBundle\Export\Declarations::ACP_TYPE, + ] + ); + } +} diff --git a/src/Bundle/ChillActivityBundle/Export/Export/ListActivityHelper.php b/src/Bundle/ChillActivityBundle/Export/Export/ListActivityHelper.php new file mode 100644 index 000000000..0e8b28ab4 --- /dev/null +++ b/src/Bundle/ChillActivityBundle/Export/Export/ListActivityHelper.php @@ -0,0 +1,269 @@ +activityPresenceRepository = $activityPresenceRepository; + $this->activityTypeRepository = $activityTypeRepository; + $this->dateTimeHelper = $dateTimeHelper; + $this->labelPersonHelper = $labelPersonHelper; + $this->labelThirdPartyHelper = $labelThirdPartyHelper; + $this->translator = $translator; + $this->translatableStringHelper = $translatableStringHelper; + $this->translatableStringLabelHelper = $translatableStringLabelHelper; + $this->userHelper = $userHelper; + } + + public function addSelect(QueryBuilder $qb): void + { + $qb + ->addSelect('activity.id AS id') + ->addSelect('activity.date') + ->addSelect('IDENTITY(activity.activityType) AS typeName') + ->leftJoin('activity.reasons', 'reasons') + ->addSelect('AGGREGATE(reasons.name) AS listReasons') + ->leftJoin('activity.persons', 'actPerson') + ->addSelect('AGGREGATE(actPerson.id) AS personsIds') + ->addSelect('AGGREGATE(actPerson.id) AS personsNames') + ->leftJoin('activity.users', 'users_u') + ->addSelect('AGGREGATE(users_u.id) AS usersIds') + ->addSelect('AGGREGATE(users_u.id) AS usersNames') + ->leftJoin('activity.thirdParties', 'thirdparty') + ->addSelect('AGGREGATE(thirdparty.id) AS thirdPartiesIds') + ->addSelect('AGGREGATE(thirdparty.id) AS thirdPartiesNames') + ->addSelect('IDENTITY(activity.attendee) AS attendeeName') + ->addSelect('activity.durationTime') + ->addSelect('activity.travelTime') + ->addSelect('activity.emergency') + ->leftJoin('activity.location', 'location') + ->addSelect('location.name AS locationName') + ->addSelect('activity.sentReceived') + ->addSelect('IDENTITY(activity.createdBy) AS createdBy') + ->addSelect('activity.createdAt') + ->addSelect('IDENTITY(activity.updatedBy) AS updatedBy') + ->addSelect('activity.updatedAt') + ->addGroupBy('activity.id') + ->addGroupBy('location.id'); + } + + public function buildForm(FormBuilderInterface $builder) + { + } + + public function getAllowedFormattersTypes() + { + return [FormatterInterface::TYPE_LIST]; + } + + public function getLabels($key, array $values, $data) + { + switch ($key) { + case 'createdAt': + case 'updatedAt': + return $this->dateTimeHelper->getLabel($key); + + case 'createdBy': + case 'updatedBy': + return $this->userHelper->getLabel($key, $values, $key); + + case 'date': + return $this->dateTimeHelper->getLabel(self::MSG_KEY . $key); + + case 'attendeeName': + return function ($value) { + if ('_header' === $value) { + return 'Attendee'; + } + + if (null === $value || null === $presence = $this->activityPresenceRepository->find($value)) { + return ''; + } + + return $this->translatableStringHelper->localize($presence->getName()); + }; + + case 'listReasons': + return $this->translatableStringLabelHelper->getLabelMulti($key, $values, 'Activity Reasons'); + + case 'typeName': + return function ($value) { + if ('_header' === $value) { + return 'Activity type'; + } + + if (null === $value || null === $type = $this->activityTypeRepository->find($value)) { + return ''; + } + + return $this->translatableStringHelper->localize($type->getName()); + }; + + case 'usersNames': + return $this->userHelper->getLabelMulti($key, $values, self::MSG_KEY . 'users name'); + + case 'usersIds': + case 'thirdPartiesIds': + case 'personsIds': + return static function ($value) use ($key) { + if ('_header' === $value) { + switch ($key) { + case 'usersIds': + return self::MSG_KEY . 'users ids'; + + case 'thirdPartiesIds': + return self::MSG_KEY . 'third parties ids'; + + case 'personsIds': + return self::MSG_KEY . 'persons ids'; + + default: + throw new LogicException('key not supported'); + } + } + + $decoded = json_decode($value); + + return implode( + '|', + array_unique( + array_filter($decoded, static fn (?int $id) => null !== $id), + SORT_NUMERIC + ) + ); + }; + + case 'personsNames': + return $this->labelPersonHelper->getLabelMulti($key, $values, self::MSG_KEY . 'persons name'); + + case 'thirdPartiesNames': + return $this->labelThirdPartyHelper->getLabelMulti($key, $values, self::MSG_KEY . 'thirds parties'); + + case 'sentReceived': + return function ($value) { + if ('_header' === $value) { + return self::MSG_KEY . 'sent received'; + } + + if (null === $value) { + return ''; + } + + return $this->translator->trans($value); + }; + + default: + return function ($value) use ($key) { + if ('_header' === $value) { + return self::MSG_KEY . $key; + } + + if (null === $value) { + return ''; + } + + return $this->translator->trans($value); + }; + } + } + + public function getQueryKeys($data) + { + return [ + 'id', + 'date', + 'typeName', + 'listReasons', + 'attendeeName', + 'durationTime', + 'travelTime', + 'emergency', + 'locationName', + 'sentReceived', + 'personsIds', + 'personsNames', + 'usersIds', + 'usersNames', + 'thirdPartiesIds', + 'thirdPartiesNames', + 'createdBy', + 'createdAt', + 'updatedBy', + 'updatedAt', + ]; + } + + public function getResult($query, $data) + { + return $query->getQuery()->getResult(AbstractQuery::HYDRATE_SCALAR); + } + + public function getType(): string + { + return Declarations::ACTIVITY; + } + + public function supportsModifiers() + { + return [ + Declarations::ACTIVITY, + ]; + } +} diff --git a/src/Bundle/ChillActivityBundle/Export/Filter/PersonFilters/ActivityReasonFilter.php b/src/Bundle/ChillActivityBundle/Export/Filter/PersonFilters/ActivityReasonFilter.php index 5634358c7..c55d579e4 100644 --- a/src/Bundle/ChillActivityBundle/Export/Filter/PersonFilters/ActivityReasonFilter.php +++ b/src/Bundle/ChillActivityBundle/Export/Filter/PersonFilters/ActivityReasonFilter.php @@ -50,7 +50,7 @@ class ActivityReasonFilter implements ExportElementValidatedInterface, FilterInt { $where = $qb->getDQLPart('where'); $join = $qb->getDQLPart('join'); - $clause = $qb->expr()->in('reasons', ':selected_activity_reasons'); + $clause = $qb->expr()->in('actreasons', ':selected_activity_reasons'); if (!in_array('actreasons', $qb->getAllAliases(), true)) { $qb->join('activity.reasons', 'actreasons'); @@ -77,6 +77,7 @@ class ActivityReasonFilter implements ExportElementValidatedInterface, FilterInt 'class' => ActivityReason::class, 'choice_label' => fn (ActivityReason $reason) => $this->translatableStringHelper->localize($reason->getName()), 'group_by' => fn (ActivityReason $reason) => $this->translatableStringHelper->localize($reason->getCategory()->getName()), + 'attr' => ['class' => 'select2 '], 'multiple' => true, 'expanded' => false, ]); diff --git a/src/Bundle/ChillActivityBundle/Repository/ActivityPresenceRepository.php b/src/Bundle/ChillActivityBundle/Repository/ActivityPresenceRepository.php new file mode 100644 index 000000000..2cf9f9470 --- /dev/null +++ b/src/Bundle/ChillActivityBundle/Repository/ActivityPresenceRepository.php @@ -0,0 +1,51 @@ +repository = $entityManager->getRepository($this->getClassName()); + } + + public function find($id): ?ActivityPresence + { + return $this->repository->find($id); + } + + public function findAll(): array + { + return $this->repository->findAll(); + } + + public function findBy(array $criteria, ?array $orderBy = null, ?int $limit = null, ?int $offset = null): array + { + return $this->findBy($criteria, $orderBy, $limit, $offset); + } + + public function findOneBy(array $criteria): ?ActivityPresence + { + return $this->findOneBy($criteria); + } + + public function getClassName(): string + { + return ActivityPresence::class; + } +} diff --git a/src/Bundle/ChillActivityBundle/Repository/ActivityPresenceRepositoryInterface.php b/src/Bundle/ChillActivityBundle/Repository/ActivityPresenceRepositoryInterface.php new file mode 100644 index 000000000..228d70856 --- /dev/null +++ b/src/Bundle/ChillActivityBundle/Repository/ActivityPresenceRepositoryInterface.php @@ -0,0 +1,33 @@ + { - const i18n = _createI18n(appMessages, true); + const i18n = _createI18n(appMessages, false); const app = createApp({ template: ``, diff --git a/src/Bundle/ChillMainBundle/Command/LoadAddressesBEFromBestAddressCommand.php b/src/Bundle/ChillMainBundle/Command/LoadAddressesBEFromBestAddressCommand.php index 09bf85d0d..cc26b7687 100644 --- a/src/Bundle/ChillMainBundle/Command/LoadAddressesBEFromBestAddressCommand.php +++ b/src/Bundle/ChillMainBundle/Command/LoadAddressesBEFromBestAddressCommand.php @@ -37,8 +37,9 @@ class LoadAddressesBEFromBestAddressCommand extends Command { $this ->setName('chill:main:address-ref-from-best-addresses') - ->addArgument('lang', InputArgument::REQUIRED) - ->addArgument('list', InputArgument::IS_ARRAY, 'The list to add'); + ->addArgument('lang', InputArgument::REQUIRED, "Language code, for example 'fr'") + ->addArgument('list', InputArgument::IS_ARRAY, "The list to add, for example 'full', or 'extract' (dev) or '1xxx' (brussel CP)") + ->setDescription('Import BE addresses from BeST Address (see https://osoc19.github.io/best/)'); } protected function execute(InputInterface $input, OutputInterface $output): int diff --git a/src/Bundle/ChillMainBundle/Command/LoadAddressesFRFromBANOCommand.php b/src/Bundle/ChillMainBundle/Command/LoadAddressesFRFromBANOCommand.php index 6d2737a66..40772d52b 100644 --- a/src/Bundle/ChillMainBundle/Command/LoadAddressesFRFromBANOCommand.php +++ b/src/Bundle/ChillMainBundle/Command/LoadAddressesFRFromBANOCommand.php @@ -31,7 +31,7 @@ class LoadAddressesFRFromBANOCommand extends Command { $this->setName('chill:main:address-ref-from-bano') ->addArgument('departementNo', InputArgument::REQUIRED | InputArgument::IS_ARRAY, 'a list of departement numbers') - ->setDescription('Import addresses from bano (see https://bano.openstreetmap.fr'); + ->setDescription('Import FR addresses from bano (see https://bano.openstreetmap.fr'); } protected function execute(InputInterface $input, OutputInterface $output): int diff --git a/src/Bundle/ChillMainBundle/Entity/Workflow/EntityWorkflowStep.php b/src/Bundle/ChillMainBundle/Entity/Workflow/EntityWorkflowStep.php index 8b3175408..d86770560 100644 --- a/src/Bundle/ChillMainBundle/Entity/Workflow/EntityWorkflowStep.php +++ b/src/Bundle/ChillMainBundle/Entity/Workflow/EntityWorkflowStep.php @@ -133,8 +133,7 @@ class EntityWorkflowStep if (!$this->destUser->contains($user)) { $this->destUser[] = $user; $this->getEntityWorkflow() - ->addSubscriberToFinal($user) - ->addSubscriberToStep($user); + ->addSubscriberToFinal($user); } return $this; @@ -145,8 +144,7 @@ class EntityWorkflowStep if (!$this->destUserByAccessKey->contains($user) && !$this->destUser->contains($user)) { $this->destUserByAccessKey[] = $user; $this->getEntityWorkflow() - ->addSubscriberToFinal($user) - ->addSubscriberToStep($user); + ->addSubscriberToFinal($user); } return $this; diff --git a/src/Bundle/ChillMainBundle/Export/Helper/TranslatableStringExportLabelHelper.php b/src/Bundle/ChillMainBundle/Export/Helper/TranslatableStringExportLabelHelper.php new file mode 100644 index 000000000..44ce2b194 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Export/Helper/TranslatableStringExportLabelHelper.php @@ -0,0 +1,70 @@ +translatableStringHelper = $translatableStringHelper; + } + + public function getLabel(string $key, array $values, string $header) + { + return function ($value) use ($header) { + if ('_header' === $value) { + return $header; + } + + if (null === $value) { + return ''; + } + + return $this->translatableStringHelper->localize(json_decode($value, true)); + }; + } + + public function getLabelMulti(string $key, array $values, string $header) + { + return function ($value) use ($header) { + if ('_header' === $value) { + return $header; + } + + if (null === $value) { + return ''; + } + + $decoded = json_decode($value, true); + + return implode( + '|', + array_unique( + array_map( + fn (array $translatableString) => $this->translatableStringHelper->localize($translatableString), + array_filter($decoded, static fn ($elem) => null !== $elem) + ) + ) + ); + }; + } +} diff --git a/src/Bundle/ChillMainBundle/Export/Helper/UserHelper.php b/src/Bundle/ChillMainBundle/Export/Helper/UserHelper.php index 98c4c5579..d8eb7e9cc 100644 --- a/src/Bundle/ChillMainBundle/Export/Helper/UserHelper.php +++ b/src/Bundle/ChillMainBundle/Export/Helper/UserHelper.php @@ -13,6 +13,8 @@ namespace Chill\MainBundle\Export\Helper; use Chill\MainBundle\Repository\UserRepositoryInterface; use Chill\MainBundle\Templating\Entity\UserRender; +use function count; +use const SORT_NUMERIC; class UserHelper { @@ -40,4 +42,43 @@ class UserHelper return $this->userRender->renderString($user, []); }; } + + public function getLabelMulti($key, array $values, string $header): callable + { + return function ($value) { + if ('_header' === $value) { + return 'users name'; + } + + if (null === $value) { + return ''; + } + + $decoded = json_decode($value); + + if (0 === count($decoded)) { + return ''; + } + + return + implode( + '|', + array_map( + function (int $userId) { + $user = $this->userRepository->find($userId); + + if (null === $user) { + return ''; + } + + return $this->userRender->renderString($user, []); + }, + array_unique( + array_filter($decoded, static fn (?int $userId) => null !== $userId), + SORT_NUMERIC + ) + ) + ); + }; + } } diff --git a/src/Bundle/ChillMainBundle/Resources/public/chill/chillmain.scss b/src/Bundle/ChillMainBundle/Resources/public/chill/chillmain.scss index 04b1da942..3c9fc8601 100644 --- a/src/Bundle/ChillMainBundle/Resources/public/chill/chillmain.scss +++ b/src/Bundle/ChillMainBundle/Resources/public/chill/chillmain.scss @@ -82,6 +82,7 @@ header { border-radius: 0; z-index: 1500; a.dropdown-item { + padding: 0.5rem 1rem; width: 120%; border: 0; border-bottom: 1px solid $gray-200; diff --git a/src/Bundle/ChillMainBundle/Resources/public/module/bootstrap/_shared.scss b/src/Bundle/ChillMainBundle/Resources/public/module/bootstrap/_shared.scss index c216c5cf8..96da20779 100644 --- a/src/Bundle/ChillMainBundle/Resources/public/module/bootstrap/_shared.scss +++ b/src/Bundle/ChillMainBundle/Resources/public/module/bootstrap/_shared.scss @@ -14,9 +14,11 @@ // 4. Include any default map overrides here @import "custom/_maps"; +@import "bootstrap/scss/maps"; // 5. Include remainder of required parts @import "bootstrap/scss/mixins"; +@import "bootstrap/scss/utilities"; @import "bootstrap/scss/root"; diff --git a/src/Bundle/ChillMainBundle/Resources/public/vuejs/_components/Modal.vue b/src/Bundle/ChillMainBundle/Resources/public/vuejs/_components/Modal.vue index 39815de3e..11bb9db2f 100644 --- a/src/Bundle/ChillMainBundle/Resources/public/vuejs/_components/Modal.vue +++ b/src/Bundle/ChillMainBundle/Resources/public/vuejs/_components/Modal.vue @@ -2,25 +2,27 @@ @@ -33,7 +35,7 @@ import {defineComponent} from "vue"; * [+] with 'v-if:showModal' directive:parameter, html scope is added/removed not just shown/hidden * [+] with slot we can pass content from parent component * [+] some classes are passed from parent component - * and Bootstrap 4.6 _modal.scss module + * and Bootstrap 5 _modal.scss module * [+] using bootstrap css classes, the modal have a responsive behaviour, * [+] modal design can be configured using css classes (size, scroll) */ @@ -56,6 +58,9 @@ export default defineComponent({