diff --git a/assets/css/app.scss b/assets/css/app.scss index 1229141d..a0dbcc00 100644 --- a/assets/css/app.scss +++ b/assets/css/app.scss @@ -4,6 +4,10 @@ @import "~bootstrap-select/sass/bootstrap-select"; @import "~daterangepicker/daterangepicker.css"; +body { + letter-spacing: 0.01em; +} + label.required:not(.form-check-label)::before { color: $red; content: "*"; diff --git a/assets/css/planning.scss b/assets/css/planning.scss index 9b6536a3..7f309861 100644 --- a/assets/css/planning.scss +++ b/assets/css/planning.scss @@ -1,11 +1,5 @@ $tableBoxSize: 40px; -.search { - .hidden { - display: none; - } -} - .planning-actions-container { margin-bottom: 20px; diff --git a/assets/js/_delete-item-modal.js b/assets/js/_delete-item-modal.js new file mode 100644 index 00000000..bcd311cf --- /dev/null +++ b/assets/js/_delete-item-modal.js @@ -0,0 +1,25 @@ +const $ = require('jquery'); +require('bootstrap'); + +$(document).ready(function () { + const $modal = $('#delete-item-modal'); + if (!$modal.length) { + return; + } + + $('.trigger-delete').on('click', function (e) { + e.preventDefault(); + const $button = $(this); + + $('[data-role="name"]', $modal).text($button.data('display-name')); + $('[data-role="message"]', $modal).text($button.data('message')); + $('button[data-role="submit"]', $modal).data('url', $button.data('href')); + + $modal.modal('show'); + }); + + $('button[data-role="submit"]', $modal).on('click', function () { + window.location = $(this).data('url'); + $(this).closest('.modal').modal('hide'); + }); +}); diff --git a/assets/js/_helpers.js b/assets/js/_helpers.js new file mode 100644 index 00000000..af3f40ac --- /dev/null +++ b/assets/js/_helpers.js @@ -0,0 +1,54 @@ +export function initDatesRange($picker, $from, $to, withTime) { + console.log($picker, $from, $to, withTime); + if (!$picker.length) { + return; + } + + function displayDate() { + if (withTime) { + $picker.val($picker.data('daterangepicker').startDate.format('DD/MM/YYYY HH:mm') + ' à ' + $picker.data('daterangepicker').endDate.format('DD/MM/YYYY HH:mm')); + } else { + $picker.val($picker.data('daterangepicker').startDate.format('DD/MM/YYYY') + ' au ' + $picker.data('daterangepicker').endDate.format('DD/MM/YYYY')); + } + } + + $picker.daterangepicker({ + autoUpdateInput: false, + showDropdowns: false, + timePicker: !!withTime, + timePicker24Hour: true, + timePickerIncrement: 30, + applyClass: 'btn-sm btn-primary', + cancelClass: 'btn-sm btn-default', + locale: { + cancelLabel: 'Supprimer', + format: 'DD/MM/YYYY HH:mm', + separator: ' - ', + applyLabel: 'Valider', + fromLabel: 'De', + toLabel: 'à', + customRangeLabel: 'Custom', + daysOfWeek: ['Dim', 'Lun', 'Mar', 'Mer', 'Jeu', 'Ven', 'Sam'], + monthNames: ['Janvier', 'Février', 'Mars', 'Avril', 'Mai', 'Juin', 'Juillet', 'Août', 'Septembre', 'Octobre', 'Novembre', 'Décembre'], + firstDay: 1, + }, + }); + + if ($from.val() !== '' && $to.val() !== '') { + $picker.data('daterangepicker').setStartDate(new Date($from.val())); + $picker.data('daterangepicker').setEndDate(new Date($to.val())); + displayDate(); + } + + $picker.on('apply.daterangepicker', function (ev, picker) { + displayDate(); + $from.val(picker.startDate.format('YYYY-MM-DDTHH:mm')).trigger('change'); + $to.val(picker.endDate.format('YYYY-MM-DDTHH:mm')); + }); + + $picker.on('cancel.daterangepicker', function () { + $picker.val(''); + $from.val('').trigger('change'); + $to.val(''); + }); +} diff --git a/assets/js/app.js b/assets/js/app.js index f90c4514..cb12312d 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -20,3 +20,5 @@ $.fn.selectpicker.Constructor.DEFAULTS.doneButtonText = 'Fermer'; $.fn.selectpicker.Constructor.DEFAULTS.mobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry/i.test(navigator.userAgent); browserUpdate({ required: { e: -2, f: -2, o: -2, s: -2, c: -4 }, insecure: true, unsupported: true, api: 2020.04 }); + +import './_delete-item-modal'; diff --git a/assets/js/availabilitable-list.js b/assets/js/availabilitable-list.js index 5ae880bc..13c2d2d7 100644 --- a/assets/js/availabilitable-list.js +++ b/assets/js/availabilitable-list.js @@ -1,21 +1,6 @@ const $ = require('jquery'); -require('bootstrap'); $(document).ready(function () { - $('.trigger-delete').on('click', function (e) { - e.preventDefault(); - - $('#to-delete-name').html($(this).data('display-name')); - $('#confirm-update').data('url', $(this).data('href')); - - $($(this).attr('data-modal')).modal('show'); - }); - - $('#confirm-update').on('click', function () { - window.location = $(this).data('url'); - $(this).closest('.modal').modal('hide'); - }); - $('form[name="organization_selector"] select').on('change', function () { let $selectedOption = $('option:selected', this); window.location = $selectedOption.data('url'); diff --git a/assets/js/forecast.js b/assets/js/forecast.js new file mode 100644 index 00000000..2de4db65 --- /dev/null +++ b/assets/js/forecast.js @@ -0,0 +1,7 @@ +import { initDatesRange } from './_helpers'; + +const $ = require('jquery'); + +$(document).ready(function () { + initDatesRange($('#availableRange'), $('#availableFrom'), $('#availableTo'), true); +}); diff --git a/assets/js/mission-type.js b/assets/js/mission-type.js deleted file mode 100644 index c2603e5f..00000000 --- a/assets/js/mission-type.js +++ /dev/null @@ -1,18 +0,0 @@ -const $ = require('jquery'); -require('bootstrap'); - -$(document).ready(function () { - $('.trigger-delete').on('click', function (e) { - e.preventDefault(); - - $('#to-delete-name').html($(this).data('display-name')); - $('#confirm-update').data('url', $(this).data('href')); - - $($(this).attr('data-modal')).modal('show'); - }); - - $('#confirm-update').on('click', function () { - window.location = $(this).data('url'); - $(this).closest('.modal').modal('hide'); - }); -}); diff --git a/assets/js/missions.js b/assets/js/missions.js new file mode 100644 index 00000000..650f1032 --- /dev/null +++ b/assets/js/missions.js @@ -0,0 +1,7 @@ +import { initDatesRange } from './_helpers'; + +const $ = require('jquery'); + +$(document).ready(function () { + initDatesRange($('#fromToRange'), $('#mission_startTime'), $('#mission_endTime'), true); +}); diff --git a/assets/js/planning.js b/assets/js/planning.js index b8d71298..d3ddfff4 100644 --- a/assets/js/planning.js +++ b/assets/js/planning.js @@ -1,58 +1,6 @@ -const $ = require('jquery'); - -function initDatesRange($picker, $from, $to, withTime) { - if (!$picker.length) { - return; - } - - function displayDate() { - if (withTime) { - $picker.val($picker.data('daterangepicker').startDate.format('DD/MM/YYYY HH:mm') + ' à ' + $picker.data('daterangepicker').endDate.format('DD/MM/YYYY HH:mm')); - } else { - $picker.val($picker.data('daterangepicker').startDate.format('DD/MM/YYYY') + ' au ' + $picker.data('daterangepicker').endDate.format('DD/MM/YYYY')); - } - } - - $picker.daterangepicker({ - autoUpdateInput: false, - showDropdowns: false, - timePicker: !!withTime, - timePicker24Hour: true, - timePickerIncrement: 30, - applyClass: 'btn-sm btn-primary', - cancelClass: 'btn-sm btn-default', - locale: { - cancelLabel: 'Supprimer', - format: 'DD/MM/YYYY HH:mm', - separator: ' - ', - applyLabel: 'Valider', - fromLabel: 'De', - toLabel: 'à', - customRangeLabel: 'Custom', - daysOfWeek: ['Dim', 'Lun', 'Mar', 'Mer', 'Jeu', 'Ven', 'Sam'], - monthNames: ['Janvier', 'Février', 'Mars', 'Avril', 'Mai', 'Juin', 'Juillet', 'Août', 'Septembre', 'Octobre', 'Novembre', 'Décembre'], - firstDay: 1, - }, - }); +import { initDatesRange } from './_helpers'; - if ($from.val() !== '' && $to.val() !== '') { - $picker.data('daterangepicker').setStartDate(new Date($from.val())); - $picker.data('daterangepicker').setEndDate(new Date($to.val())); - displayDate(); - } - - $picker.on('apply.daterangepicker', function (ev, picker) { - displayDate(); - $from.val(picker.startDate.format('YYYY-MM-DDTHH:mm')).trigger('change'); - $to.val(picker.endDate.format('YYYY-MM-DDTHH:mm')); - }); - - $picker.on('cancel.daterangepicker', function () { - $picker.val(''); - $from.val('').trigger('change'); - $to.val(''); - }); -} +const $ = require('jquery'); function hideUselessFilters() { $('.search [data-hide="users"]').css('visibility', $('#hideUsers').prop('checked') ? 'hidden' : 'visible'); diff --git a/src/Controller/Organization/Mission/MissionController.php b/src/Controller/Organization/Mission/MissionController.php new file mode 100644 index 00000000..83a71187 --- /dev/null +++ b/src/Controller/Organization/Mission/MissionController.php @@ -0,0 +1,119 @@ +missionRepository = $missionRepository; + } + + /** + * @Route("/", name="app_organization_mission_index", methods={"GET"}) + */ + public function index(): Response + { + /** @var Organization $organization */ + $organization = $this->getUser(); + $missions = $this->missionRepository->findByOrganization($organization); + + return $this->render('organization/mission/index.html.twig', [ + 'missions' => $missions, + ]); + } + + /** + * @Route("/new", name="app_organization_mission_new", methods={"GET","POST"}) + */ + public function new(Request $request): Response + { + /** @var Organization $organization */ + $organization = $this->getUser(); + + $mission = new Mission(); + $mission->organization = $organization; + $form = $this->createForm(MissionType::class, $mission); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $entityManager = $this->getDoctrine()->getManager(); + $entityManager->persist($mission); + $entityManager->flush(); + + return $this->redirectToRoute('app_organization_mission_show', ['id' => $mission->id]); + } + + return $this->render('organization/mission/new.html.twig', [ + 'mission' => $mission, + 'form' => $form->createView(), + ])->setStatusCode($form->isSubmitted() ? Response::HTTP_BAD_REQUEST : Response::HTTP_OK); + } + + /** + * @Route("/{id}", name="app_organization_mission_show", methods={"GET"}) + * @Security("mission.organization == user") + */ + public function show(Mission $mission): Response + { + return $this->render('organization/mission/show.html.twig', [ + 'mission' => $mission, + ]); + } + + /** + * @Route("/{id}/edit", name="app_organization_mission_edit", methods={"GET","POST"}) + * @Security("mission.organization == user") + */ + public function edit(Request $request, Mission $mission): Response + { + $form = $this->createForm(MissionType::class, $mission); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $this->getDoctrine()->getManager()->flush(); + + return $this->redirectToRoute('app_organization_mission_show', ['id' => $mission->id]); + } + + return $this->render('organization/mission/edit.html.twig', [ + 'mission' => $mission, + 'form' => $form->createView(), + ])->setStatusCode($form->isSubmitted() ? Response::HTTP_BAD_REQUEST : Response::HTTP_OK); + } + + /** + * @Route("/{id}/delete", name="app_organization_mission_delete", methods={"GET"}) + * @Security("mission.organization == user") + */ + public function delete(Mission $mission): RedirectResponse + { + $entityManager = $this->getDoctrine()->getManager(); + + $entityManager->remove($mission); + $entityManager->flush(); + + $this->addFlash('success', 'organization.mission.deleteSuccessMessage'); + + return $this->redirectToRoute('app_organization_mission_index'); + } +} diff --git a/src/Controller/Organization/User/UserEditController.php b/src/Controller/Organization/User/UserEditController.php index 8deeecbf..f4efc40d 100644 --- a/src/Controller/Organization/User/UserEditController.php +++ b/src/Controller/Organization/User/UserEditController.php @@ -35,7 +35,7 @@ public function __invoke(Request $request, User $userToEdit): Response return $this->redirectToRoute('app_organization_user_list', ['organization' => $userToEdit->getNotNullOrganization()->id]); } - return $this->render('organization/user/user-edit.html.twig', [ + return $this->render('organization/user/edit.html.twig', [ 'user' => $userToEdit, 'form' => $form->createView(), ])->setStatusCode($form->isSubmitted() ? Response::HTTP_BAD_REQUEST : Response::HTTP_OK); diff --git a/src/Controller/Organization/User/UserListController.php b/src/Controller/Organization/User/UserListController.php index 85ae8963..043f7d21 100644 --- a/src/Controller/Organization/User/UserListController.php +++ b/src/Controller/Organization/User/UserListController.php @@ -38,7 +38,7 @@ public function __invoke(Request $request, Organization $organization): Response $currentOrganization = $this->getUser(); return $this->render( - 'organization/user/user-list.html.twig', + 'organization/user/list.html.twig', [ 'organization' => $organization, 'users' => $this->userRepository->findByOrganization($organization), diff --git a/src/DataFixtures/ApplicationFixtures.php b/src/DataFixtures/ApplicationFixtures.php index 81fa4e27..5ce08c72 100644 --- a/src/DataFixtures/ApplicationFixtures.php +++ b/src/DataFixtures/ApplicationFixtures.php @@ -9,6 +9,7 @@ use App\Entity\AvailabilityInterface; use App\Entity\CommissionableAsset; use App\Entity\CommissionableAssetAvailability; +use App\Entity\Mission; use App\Entity\MissionType; use App\Entity\Organization; use App\Entity\User; @@ -85,14 +86,18 @@ final class ApplicationFixtures extends Fixture /** @var Organization[] */ private array $organizations = []; - /** @var User[] */ + /** @var User[][] */ private array $users = []; - /** @var CommissionableAsset[] */ + /** @var CommissionableAsset[][] */ private array $assets = []; + /** @var AssetType[][] */ private array $assetTypes = []; + /** @var MissionType[][] */ + private array $missionTypes = []; + private SkillSetDomain $skillSetDomain; private int $nbUsers; private int $nbAvailabilities; @@ -135,9 +140,10 @@ public function load(ObjectManager $manager): void $this->loadAssetTypes($manager); $this->loadMissionTypes($manager); $this->loadCommissionableAssets($manager); - $this->loadResourcesAvailabilities($manager, $this->assets, CommissionableAssetAvailability::class); + $this->loadResourcesAvailabilities($manager, array_merge(...$this->assets), CommissionableAssetAvailability::class); $this->loadUsers($manager); - $this->loadResourcesAvailabilities($manager, $this->users, UserAvailability::class); + $this->loadResourcesAvailabilities($manager, array_merge(...$this->users), UserAvailability::class); + $this->loadMissions($manager); $manager->flush(); } @@ -201,35 +207,37 @@ private function loadMissionTypes(ObjectManager $manager): void continue; } - $alphaType = new MissionType(); - $alphaType->name = 'Alpha'; - $alphaType->organization = $organization; - $alphaType->userSkillsRequirement = [ + $missionType = new MissionType(); + $missionType->name = 'Alpha'; + $missionType->organization = $organization; + $missionType->userSkillsRequirement = [ ['skill' => 'ci_bspp', 'number' => 1], ['skill' => 'ch_vpsp', 'number' => 1], ['skill' => 'pse2', 'number' => 1], ]; - $alphaType->assetTypesRequirement = [ + $missionType->assetTypesRequirement = [ ['type' => $this->assetTypes[$organization->id]['VPSP']->id, 'number' => 1], ]; - $this->validateAndPersist($manager, $alphaType); + $this->validateAndPersist($manager, $missionType); + $this->missionTypes[$organization->id]['Alpha'] = $missionType; - $alphaType = new MissionType(); - $alphaType->name = 'Maraude'; - $alphaType->organization = $organization; - $alphaType->userSkillsRequirement = [ + $missionType = new MissionType(); + $missionType->name = 'Maraude'; + $missionType->organization = $organization; + $missionType->userSkillsRequirement = [ ['skill' => 'ce_maraude', 'number' => 1], ['skill' => 'ch_vl', 'number' => 1], ['skill' => 'maraudeur', 'number' => 2], ]; - $alphaType->assetTypesRequirement = [ + $missionType->assetTypesRequirement = [ ['type' => $this->assetTypes[$organization->id]['VL']->id, 'number' => 1], ]; - $this->validateAndPersist($manager, $alphaType); + $this->validateAndPersist($manager, $missionType); + $this->missionTypes[$organization->id]['Maraude'] = $missionType; } $manager->flush(); @@ -290,7 +298,7 @@ private function loadCommissionableAssets(ObjectManager $manager): void $asset->assetType = $this->assetTypes[$organization->getParentOrganization()->id][$type]; $asset->name = $prefix.$ulId.$suffix; $this->validateAndPersist($manager, $asset); - $this->assets[] = $asset; + $this->assets[$organization->getParentOrganization()->id][] = $asset; } } @@ -326,7 +334,7 @@ private function loadUsers(ObjectManager $manager): void $user->fullyEquipped = (bool) random_int(0, 1); $user->drivingLicence = (bool) random_int(0, 1); - $this->users[$user->getIdentificationNumber()] = $user; + $this->users[$organization->getParentOrganization()->id][] = $user; $this->validateAndPersist($manager, $user); ++$x; @@ -339,7 +347,7 @@ private function loadUsers(ObjectManager $manager): void private function loadResourcesAvailabilities(ObjectManager $manager, array $resources, string $class): void { /** @var EntityManagerInterface $manager */ - $today = (new \DateTimeImmutable('today')); + $today = new \DateTimeImmutable('today'); $this->availabilitiesId = 1; // Mixing user @@ -377,17 +385,89 @@ private function loadResourcesAvailabilities(ObjectManager $manager, array $reso implode(', ', $data) ); - $manager->getConnection()->exec(sprintf($insert)); - if (CommissionableAssetAvailability::class === $class) { - $sequence = 'commissionable_asset_availability_id_seq'; - } else { - $sequence = 'user_availability_id_seq'; + $manager->getConnection()->exec($insert); + + $manager->getConnection()->exec(sprintf( + 'SELECT setval(\'%s\', %d, true)', + UserAvailability::class === $class ? 'user_availability_id_seq' : 'commissionable_asset_availability_id_seq', + $this->availabilitiesId + )); + } + + private function loadMissions(ObjectManager $manager): void + { + $addMissionPeriod = static function (Mission $mission): void { + $mission->startTime = (new \DateTimeImmutable('today'))->modify(sprintf( + '+ %d days + %d hours', + random_int(0, 5), + random_int(6, 12), + )); + $mission->endTime = $mission->startTime->modify(sprintf('+ %d hours', random_int(2, 8))); + }; + + $addMissionResources = function (Mission $mission): void { + if (null === $mission->organization) { + return; + } + + for ($j = 0, $jMax = random_int(1, 4); $j < $jMax; ++$j) { + $randUserKey = array_rand($this->users[$mission->organization->id], 1); + if (!\is_int($randUserKey)) { + continue; + } + $user = $this->users[$mission->organization->id][$randUserKey]; + if (!$mission->users->contains($user)) { + $mission->users->add($user); + } + } + + if (random_int(0, 10) > 6) { + return; + } + + $randAssetKey = array_rand($this->assets[$mission->organization->id], 1); + if (\is_int($randAssetKey)) { + $mission->assets->add($this->assets[$mission->organization->id][$randAssetKey]); + } + }; + + foreach ($this->organizations as $organizationNumber => $organization) { + if (!$organization->isParent()) { + continue; + } + + for ($i = 0; $i < 5; ++$i) { + $mission = new Mission(); + $mission->organization = $organization; + $mission->type = $this->missionTypes[$organization->id]['Alpha']; + $mission->name = sprintf('Alpha %d', random_int(1, 8)); + $addMissionPeriod($mission); + $addMissionResources($mission); + + $this->validateAndPersist($manager, $mission); + + $mission = new Mission(); + $mission->organization = $organization; + $mission->type = $this->missionTypes[$organization->id]['Maraude']; + $addMissionPeriod($mission); + $mission->name = null !== $mission->startTime ? $mission->startTime->format('d/m h\h') : 'Maraude'; + $addMissionResources($mission); + + $this->validateAndPersist($manager, $mission); + } + + $mission = new Mission(); + $mission->organization = $organization; + $mission->name = 'Logistique'; + $addMissionResources($mission); + + $this->validateAndPersist($manager, $mission); } - $manager->getConnection()->exec(sprintf('SELECT setval(\''.$sequence.'\', %d, true)', $this->availabilitiesId)); + $manager->flush(); } - private function createAvailabilities(array $objects, \DateTimeInterface $thisWeek, string $globalStatus, bool $partiallyAvailable = false, string $defaultComment = ''): array + private function createAvailabilities(array $objects, \DateTimeImmutable $thisWeek, string $globalStatus, bool $partiallyAvailable = false, string $defaultComment = ''): array { $data = []; @@ -401,7 +481,7 @@ private function createAvailabilities(array $objects, \DateTimeInterface $thisWe $status = AvailabilityInterface::STATUS_LOCKED; } elseif (AvailabilityInterface::STATUS_AVAILABLE === $globalStatus) { if ($partiallyAvailable) { - // If partially available is active whe check if guesser will return an available slot otherwise we skip. + // If partially available is active we check if guesser will return an available slot otherwise we skip. if ($this->slotAvailabilityGuesser->guessAvailableSlot($slot)) { $status = $this->slotBookingGuesser->guessBookedSlot($slot) ? AvailabilityInterface::STATUS_BOOKED : AvailabilityInterface::STATUS_AVAILABLE; } else { diff --git a/src/Entity/AvailabilitableTrait.php b/src/Entity/AvailabilitableTrait.php index d1e0b98a..f5861715 100644 --- a/src/Entity/AvailabilitableTrait.php +++ b/src/Entity/AvailabilitableTrait.php @@ -23,6 +23,7 @@ trait AvailabilitableTrait /** * @ORM\Column(type="datetimetz_immutable") + * @Assert\GreaterThan(propertyPath="startTime") */ public \DateTimeImmutable $endTime; diff --git a/src/Entity/Mission.php b/src/Entity/Mission.php new file mode 100644 index 00000000..06400765 --- /dev/null +++ b/src/Entity/Mission.php @@ -0,0 +1,75 @@ +users = new ArrayCollection(); + $this->assets = new ArrayCollection(); + } + + public function __toString(): string + { + return (null !== $this->type) ? "{$this->type->name} - $this->name" : $this->name; + } +} diff --git a/src/Entity/MissionType.php b/src/Entity/MissionType.php index a43c33a3..a1001aa7 100644 --- a/src/Entity/MissionType.php +++ b/src/Entity/MissionType.php @@ -8,7 +8,6 @@ use Symfony\Component\Validator\Constraints as Assert; /** - * @ORM\Table * @ORM\Entity(repositoryClass="App\Repository\MissionTypeRepository") */ class MissionType diff --git a/src/Entity/User.php b/src/Entity/User.php index 7cb1dc10..7a8cd215 100644 --- a/src/Entity/User.php +++ b/src/Entity/User.php @@ -91,7 +91,7 @@ class User implements UserInterface, AvailabilitableInterface, JsonSerializable public string $occupation = ''; /** - * @ORM\ManyToOne(targetEntity="App\Entity\Organization") + * @ORM\ManyToOne(targetEntity="App\Entity\Organization", fetch="EAGER") * @Assert\NotNull() */ public ?Organization $organization = null; diff --git a/src/Form/Type/MissionType.php b/src/Form/Type/MissionType.php new file mode 100644 index 00000000..36c89332 --- /dev/null +++ b/src/Form/Type/MissionType.php @@ -0,0 +1,90 @@ +getData()->organization ?? null; + if (!$organization instanceof Organization || null === $organization->id) { + throw new \InvalidArgumentException('Mission form must be initialized with an already persisted organization'); + } + + $builder + ->add('name', null, ['label' => 'organization.mission.name']) + ->add('type', EntityType::class, [ + 'label' => 'common.type', + 'class' => MissionTypeEntity::class, + 'query_builder' => static function (MissionTypeRepository $repository) use ($organization) { + return $repository->findByOrganizationQb($organization); + }, + 'choice_label' => 'name', + 'required' => false, + ]) + ->add('startTime', DateTimeType::class, [ + 'widget' => 'single_text', + 'input' => 'datetime_immutable', + 'label' => 'common.start', + 'required' => false, + ]) + ->add('endTime', DateTimeType::class, [ + 'widget' => 'single_text', + 'input' => 'datetime_immutable', + 'label' => 'common.end', + 'required' => false, + ]) + ->add('users', EntityType::class, [ + 'class' => User::class, + 'label' => 'organization.users', + 'query_builder' => static function (UserRepository $repository) use ($organization) { + return $repository->findByOrganizationAndChildrenQb($organization->getParentOrganization(), true); + }, + 'choice_label' => fn (User $user) => (string) $user, + 'multiple' => true, + 'required' => false, + 'attr' => [ + 'class' => 'selectpicker', + 'data-live-search' => 'true', + ], + ]) + ->add('assets', EntityType::class, [ + 'class' => CommissionableAsset::class, + 'label' => 'organization.assets', + 'query_builder' => static function (CommissionableAssetRepository $repository) use ($organization) { + return $repository->findByOrganizationAndChildrenQb($organization->getParentOrganization(), true); + }, + 'choice_label' => fn (CommissionableAsset $asset) => "{$asset->organization->name} / ".$asset, + 'multiple' => true, + 'required' => false, + 'attr' => [ + 'class' => 'selectpicker', + 'data-live-search' => 'true', + ], + ]) + ; + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'data_class' => Mission::class, + ]); + } +} diff --git a/src/Form/Type/MissionTypeType.php b/src/Form/Type/MissionTypeType.php index 1acbd198..c9fa150c 100644 --- a/src/Form/Type/MissionTypeType.php +++ b/src/Form/Type/MissionTypeType.php @@ -36,7 +36,7 @@ public function buildForm(FormBuilderInterface $builder, array $options): void 'entry_type' => MissionTypeAssetTypesType::class, 'allow_add' => true, 'allow_delete' => true, - 'label' => 'organization.vehicles', + 'label' => 'organization.assets', ]) ; } diff --git a/src/Form/Type/PlanningForecastType.php b/src/Form/Type/PlanningForecastType.php index 7fa0b3e0..d1176b55 100644 --- a/src/Form/Type/PlanningForecastType.php +++ b/src/Form/Type/PlanningForecastType.php @@ -8,7 +8,6 @@ use App\Entity\Organization; use App\Repository\MissionTypeRepository; use App\Repository\OrganizationRepository; -use DateTimeImmutable; use Symfony\Bridge\Doctrine\Form\Type\EntityType; use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\Extension\Core\Type\CheckboxType; @@ -20,7 +19,7 @@ class PlanningForecastType extends AbstractType { public function buildForm(FormBuilderInterface $builder, array $options): void { - $organization = $builder->getData()['organization']; + $organization = $builder->getData()['organization'] ?? null; if (!$organization instanceof Organization || null === $organization->id) { throw new \InvalidArgumentException('PlanningForecastType must be initialized with an already persisted organization'); } @@ -37,7 +36,6 @@ public function buildForm(FormBuilderInterface $builder, array $options): void 'widget' => 'single_text', 'input' => 'datetime_immutable', 'label' => 'calendar.to', - 'data' => (new DateTimeImmutable('today'))->add(new \DateInterval('P1D')), 'with_minutes' => false, 'required' => true, ]) diff --git a/src/Migrations/Version20200326034332.php b/src/Migrations/Version20200326034332.php index 550bb5ad..beb3d95f 100644 --- a/src/Migrations/Version20200326034332.php +++ b/src/Migrations/Version20200326034332.php @@ -14,7 +14,7 @@ final class Version20200326034332 extends AbstractMigration { public function getDescription(): string { - return 'Add Vehicle attributes'; + return 'Add asset attributes'; } public function up(Schema $schema): void diff --git a/src/Migrations/Version20200427220959.php b/src/Migrations/Version20200427220959.php new file mode 100644 index 00000000..bd42cffc --- /dev/null +++ b/src/Migrations/Version20200427220959.php @@ -0,0 +1,53 @@ +abortIf('postgresql' !== $this->connection->getDatabasePlatform()->getName(), 'Migration can only be executed safely on \'postgresql\'.'); + + $this->addSql('CREATE SEQUENCE mission_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); + $this->addSql('CREATE TABLE mission (id INT NOT NULL, organization_id INT DEFAULT NULL, type_id INT DEFAULT NULL, name VARCHAR(255) NOT NULL, start_time TIMESTAMP(0) WITH TIME ZONE DEFAULT NULL, end_time TIMESTAMP(0) WITH TIME ZONE DEFAULT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE INDEX IDX_9067F23C32C8A3DE ON mission (organization_id)'); + $this->addSql('CREATE INDEX IDX_9067F23CC54C8C93 ON mission (type_id)'); + $this->addSql('CREATE INDEX mission_start_end_idx ON mission (start_time, end_time)'); + $this->addSql('COMMENT ON COLUMN mission.start_time IS \'(DC2Type:datetimetz_immutable)\''); + $this->addSql('COMMENT ON COLUMN mission.end_time IS \'(DC2Type:datetimetz_immutable)\''); + $this->addSql('CREATE TABLE mission_user (mission_id INT NOT NULL, user_id INT NOT NULL, PRIMARY KEY(mission_id, user_id))'); + $this->addSql('CREATE INDEX IDX_A4D17A46BE6CAE90 ON mission_user (mission_id)'); + $this->addSql('CREATE INDEX IDX_A4D17A46A76ED395 ON mission_user (user_id)'); + $this->addSql('CREATE TABLE mission_commissionable_asset (mission_id INT NOT NULL, commissionable_asset_id INT NOT NULL, PRIMARY KEY(mission_id, commissionable_asset_id))'); + $this->addSql('CREATE INDEX IDX_D40997EFBE6CAE90 ON mission_commissionable_asset (mission_id)'); + $this->addSql('CREATE INDEX IDX_D40997EF6C56C7E5 ON mission_commissionable_asset (commissionable_asset_id)'); + $this->addSql('ALTER TABLE mission ADD CONSTRAINT FK_9067F23C32C8A3DE FOREIGN KEY (organization_id) REFERENCES organization (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE mission ADD CONSTRAINT FK_9067F23CC54C8C93 FOREIGN KEY (type_id) REFERENCES mission_type (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE mission_user ADD CONSTRAINT FK_A4D17A46BE6CAE90 FOREIGN KEY (mission_id) REFERENCES mission (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE mission_user ADD CONSTRAINT FK_A4D17A46A76ED395 FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE mission_commissionable_asset ADD CONSTRAINT FK_D40997EFBE6CAE90 FOREIGN KEY (mission_id) REFERENCES mission (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE mission_commissionable_asset ADD CONSTRAINT FK_D40997EF6C56C7E5 FOREIGN KEY (commissionable_asset_id) REFERENCES commissionable_asset (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); + } + + public function down(Schema $schema): void + { + $this->abortIf('postgresql' !== $this->connection->getDatabasePlatform()->getName(), 'Migration can only be executed safely on \'postgresql\'.'); + + $this->addSql('ALTER TABLE mission_user DROP CONSTRAINT FK_A4D17A46BE6CAE90'); + $this->addSql('ALTER TABLE mission_commissionable_asset DROP CONSTRAINT FK_D40997EFBE6CAE90'); + $this->addSql('DROP SEQUENCE mission_id_seq CASCADE'); + $this->addSql('DROP TABLE mission'); + $this->addSql('DROP TABLE mission_user'); + $this->addSql('DROP TABLE mission_commissionable_asset'); + } +} diff --git a/src/Repository/CommissionableAssetRepository.php b/src/Repository/CommissionableAssetRepository.php index 4bd9c6a9..6ca45779 100644 --- a/src/Repository/CommissionableAssetRepository.php +++ b/src/Repository/CommissionableAssetRepository.php @@ -11,6 +11,7 @@ use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\Common\Persistence\ManagerRegistry; use Doctrine\ORM\AbstractQuery; +use Doctrine\ORM\QueryBuilder; /** * @method CommissionableAsset|null find($id, $lockMode = null, $lockVersion = null) @@ -61,14 +62,29 @@ public function findByIds(array $ids): array public function findByOrganization(Organization $organization): iterable { return $this - ->createQueryBuilder('ca') - ->where('ca.organization = :organization') - ->setParameter('organization', $organization) - ->orderBy('ca.name', 'asc') + ->findByOrganizationAndChildrenQb($organization) ->getQuery() ->getResult(); } + public function findByOrganizationAndChildrenQb(Organization $organization, bool $searchInChildren = false): QueryBuilder + { + $qb = $this->createQueryBuilder('a') + ->join('a.organization', 'o'); + + if ($searchInChildren) { + $qb->andWhere('o = :organization OR o.parent = :organization'); + } else { + $qb->andWhere('o = :organization'); + } + + $qb->setParameter('organization', $organization) + ->addOrderBy('o.name', 'ASC') + ->addOrderBy('a.name', 'ASC'); + + return $qb; + } + /** * @return CommissionableAsset[]|int[] */ diff --git a/src/Repository/MissionRepository.php b/src/Repository/MissionRepository.php new file mode 100644 index 00000000..649020e9 --- /dev/null +++ b/src/Repository/MissionRepository.php @@ -0,0 +1,43 @@ +findByOrganizationQb($organization)->getQuery()->getResult(); + } + + public function findByOrganizationQb(Organization $organization): QueryBuilder + { + $qb = $this->createQueryBuilder('m'); + + $qb + ->join('m.organization', 'o') + ->where($qb->expr()->orX('o.id = :orga', 'o.parent = :orga')) + ->setParameter('orga', $organization->parent ?: $organization) + ->addOrderBy('m.id', 'DESC'); + + return $qb; + } +} diff --git a/src/Repository/UserRepository.php b/src/Repository/UserRepository.php index 89c5af70..af26a512 100644 --- a/src/Repository/UserRepository.php +++ b/src/Repository/UserRepository.php @@ -11,6 +11,7 @@ use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\Common\Persistence\ManagerRegistry; use Doctrine\ORM\AbstractQuery; +use Doctrine\ORM\QueryBuilder; use Symfony\Bridge\Doctrine\Security\User\UserLoaderInterface; /** @@ -65,8 +66,7 @@ public function loadUserByUsername(string $identifier): ?User ->setParameter('identificationNumber', User::normalizeIdentificationNumber($identifier)) ->setParameter('emailAddress', User::normalizeEmailAddress($identifier)) ->getQuery() - ->getOneOrNullResult() - ; + ->getOneOrNullResult(); } public function findByIds(array $ids): array @@ -139,14 +139,29 @@ public function findByFilters(array $formData, bool $onlyIds = false): array /** * @return User[] */ - public function findByOrganization(Organization $organizations): array + public function findByOrganization(Organization $organization): array { - return $this->createQueryBuilder('u') - ->where('u.organization IN (:organizations)') - ->setParameter('organizations', $organizations) - ->addOrderBy('u.lastName', 'ASC') - ->addOrderBy('u.firstName', 'ASC') + return $this->findByOrganizationAndChildrenQb($organization) ->getQuery() ->getResult(); } + + public function findByOrganizationAndChildrenQb(Organization $organization, bool $searchInChildren = false): QueryBuilder + { + $qb = $this->createQueryBuilder('u') + ->join('u.organization', 'o'); + + if ($searchInChildren) { + $qb->andWhere('o = :organization OR o.parent = :organization'); + } else { + $qb->andWhere('o = :organization'); + } + + $qb->setParameter('organization', $organization) + ->addOrderBy('o.name', 'ASC') + ->addOrderBy('u.lastName', 'ASC') + ->addOrderBy('u.firstName', 'ASC'); + + return $qb; + } } diff --git a/src/Twig/Extension/UserExtension.php b/src/Twig/Extension/UserExtension.php index 1877f56f..c07a0c86 100644 --- a/src/Twig/Extension/UserExtension.php +++ b/src/Twig/Extension/UserExtension.php @@ -5,6 +5,7 @@ namespace App\Twig\Extension; use App\Domain\SkillSetDomain; +use App\Entity\User; use Twig\Extension\AbstractExtension; use Twig\TwigFilter; @@ -24,6 +25,7 @@ public function getFilters(): array { return [ new TwigFilter('skillBadge', [$this, 'formatBadge'], ['is_safe' => ['html']]), + new TwigFilter('sortBySkills', [$this, 'sortBySkills']), ]; } @@ -33,4 +35,36 @@ public function formatBadge(string $skill): string return sprintf('%s', \in_array($skill, $importantSkills, true) ? 'primary' : 'secondary', $skill); } + + /** + * @param array|\IteratorAggregate $users + */ + public function sortBySkills($users): array + { + $skillSet = $this->skillSetDomain->getSkillSetKeys(); + + if ($users instanceof \IteratorAggregate) { + $users = iterator_to_array($users->getIterator()); + } + + usort($users, static function (User $a, User $b) use ($skillSet) { + $sortedSkillsA = array_values(array_intersect($skillSet, $a->skillSet)); + $sortedSkillsB = array_values(array_intersect($skillSet, $b->skillSet)); + + $bestSkillPosA = array_search($sortedSkillsA[0] ?? null, $skillSet, true); + $bestSkillPosB = array_search($sortedSkillsB[0] ?? null, $skillSet, true); + + if ($bestSkillPosA === $bestSkillPosB) { + return 0; + } + + if ($bestSkillPosA > $bestSkillPosB) { + return 1; + } + + return -1; + }); + + return $users; + } } diff --git a/templates/organization/_delete_modal.html.twig b/templates/organization/_delete_modal.html.twig new file mode 100644 index 00000000..e14b29d8 --- /dev/null +++ b/templates/organization/_delete_modal.html.twig @@ -0,0 +1,20 @@ + diff --git a/templates/organization/base.html.twig b/templates/organization/base.html.twig index 757082dc..2fdd11fd 100644 --- a/templates/organization/base.html.twig +++ b/templates/organization/base.html.twig @@ -67,6 +67,8 @@ {% include '_footer.html.twig' %} + {% include 'organization/_delete_modal.html.twig' %} + {% block javascripts %} {{ encore_entry_script_tags('app') }} {% endblock %} diff --git a/templates/organization/commissionable_asset/_list.html.twig b/templates/organization/commissionable_asset/_list.html.twig index 368b2a2e..9cf0d396 100644 --- a/templates/organization/commissionable_asset/_list.html.twig +++ b/templates/organization/commissionable_asset/_list.html.twig @@ -1,21 +1,17 @@ -
-
- - {{ 'organization.vehicle.add' | trans }} - -
-
-
- +
- - - + + + + {% if showActions is not defined or showActions %} - + {% endif %} + + {% if showActions is not defined or showActions %} - + {% endif %} + {% for asset in assets %} @@ -24,37 +20,34 @@ - + + {% if showActions is not defined or showActions %} + + {% endif %} + - + {% if showActions is not defined or showActions %} + + {% endif %} {% endfor %}
{{ 'common.type' | trans }}{{ 'organization.vehicle.identificationNumber' | trans }}
{{ 'common.type' | trans }}{{ 'organization.asset.identificationNumber' | trans }}{{ 'common.availabilities' | trans }}{{ 'organization.label' | trans }}{{ 'organization.label' | trans }}{{ 'common.actions' | trans }}
{{ asset.name }} - {{ 'calendar.week.current' | trans }} - {{ 'calendar.week.startMonday' | trans ({'%week%' : 'monday next week' | date('d/m/Y')}) }} - + {{ 'calendar.week.current' | trans }} + {{ 'calendar.week.startMonday' | trans ({'%week%' : 'monday next week' | date('d/m/Y')}) }} + {{ asset.organization.name }} - {{ 'action.edit' | trans }} - {{ 'action.delete' | trans }} - + {{ 'action.edit' | trans }} + {% if withDelete is defined and withDelete %} + + {{ 'action.delete' | trans }} + + {% endif %} +
- - diff --git a/templates/organization/commissionable_asset/form.html.twig b/templates/organization/commissionable_asset/form.html.twig index a7448270..b0df9519 100644 --- a/templates/organization/commissionable_asset/form.html.twig +++ b/templates/organization/commissionable_asset/form.html.twig @@ -2,10 +2,10 @@ {% set actionName = asset.id is defined ? 'Modification' : 'Création' %} -{% block title %}{{ 'organization.vehicle.createEdit' | trans ({ '%action%' : actionName }) }}{% endblock %} +{% block title %}{{ 'organization.asset.createEdit' | trans ({ '%action%' : actionName }) }}{% endblock %} {% block body %} -

{{ 'organization.vehicle.createEdit' | trans ({ '%action%' : actionName }) }}

+

{{ 'organization.asset.createEdit' | trans ({ '%action%' : actionName }) }}

{{ form_start(form) }} {% if form.organization is defined %} diff --git a/templates/organization/commissionable_asset/list.html.twig b/templates/organization/commissionable_asset/list.html.twig index 79655728..2948fed0 100644 --- a/templates/organization/commissionable_asset/list.html.twig +++ b/templates/organization/commissionable_asset/list.html.twig @@ -8,9 +8,17 @@ {% endblock %} {% block body %} -

{{ 'organization.vehicles' | trans }} - {{ organization }}

+

{{ 'organization.assets' | trans }} - {{ organization }}

{{ form(organization_selector_form) }} - {{ include('organization/commissionable_asset/_list.html.twig') }} +
+
+ + {{ 'organization.asset.add' | trans }} + +
+
+ + {{ include('organization/commissionable_asset/_list.html.twig', {withDelete: true}) }} {% endblock %} diff --git a/templates/organization/forecast/_search_type.html.twig b/templates/organization/forecast/_search_type.html.twig index a4cf04d4..c4513892 100644 --- a/templates/organization/forecast/_search_type.html.twig +++ b/templates/organization/forecast/_search_type.html.twig @@ -3,7 +3,7 @@
-
- + {{ form_end(form) }} diff --git a/templates/organization/mission_type/edit.html.twig b/templates/organization/mission_type/edit.html.twig index 2f56f3a5..6df51b26 100644 --- a/templates/organization/mission_type/edit.html.twig +++ b/templates/organization/mission_type/edit.html.twig @@ -8,8 +8,8 @@ {% block title %}{{ 'organization.missionType.editFormTitle' | trans }}{% endblock %} {% block body %} - {{ 'common.backToList' | trans }} +

{{ 'organization.missionType.editFormTitle' | trans }}

{{ include('organization/mission_type/_form.html.twig', {'button_label': 'action.save' | trans}) }} diff --git a/templates/organization/mission_type/index.html.twig b/templates/organization/mission_type/index.html.twig index 34fe8e84..555f91ee 100644 --- a/templates/organization/mission_type/index.html.twig +++ b/templates/organization/mission_type/index.html.twig @@ -2,11 +2,6 @@ {% block title %}{{ 'organization.missionType.mainTitle' | trans }}{% endblock %} -{% block javascripts %} - {{ parent() }} - {{ encore_entry_script_tags('mission-type') }} -{% endblock %} - {% block body %}

{{ 'organization.missionType.mainTitle' | trans }}

@@ -30,7 +25,7 @@ {{ mission_type.name }} {{ 'action.edit' | trans }} - {{ 'action.delete' | trans }} + {{ 'action.delete' | trans }} {% else %} @@ -41,24 +36,4 @@
- - {% endblock %} diff --git a/templates/organization/mission_type/new.html.twig b/templates/organization/mission_type/new.html.twig index 890e905b..3c0794fd 100644 --- a/templates/organization/mission_type/new.html.twig +++ b/templates/organization/mission_type/new.html.twig @@ -5,12 +5,12 @@ {{ encore_entry_script_tags('mission-type-form') }} {% endblock %} -{% block title %}Nouveau type de mission{% endblock %} +{% block title %}{{ 'organization.missionType.addNew' | trans }}{% endblock %} {% block body %} - Retourner à la liste + {{ 'common.backToList' | trans }} -

Ajouter un nouveau type de mission

+

{{ 'organization.missionType.addNew' | trans }}

{{ include('organization/mission_type/_form.html.twig') }} {% endblock %} diff --git a/templates/organization/planning/_search_type.html.twig b/templates/organization/planning/_search_type.html.twig index 96336922..5a493a18 100644 --- a/templates/organization/planning/_search_type.html.twig +++ b/templates/organization/planning/_search_type.html.twig @@ -3,14 +3,14 @@
-
-