diff --git a/src/Bundle/ChillMainBundle/Resources/public/scss/_alert-first-child.scss b/src/Bundle/ChillMainBundle/Resources/public/scss/_alert-first-child.scss new file mode 100644 index 000000000..9a089c8a7 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Resources/public/scss/_alert-first-child.scss @@ -0,0 +1,10 @@ +/* + * when an alert is the first child of the page, with a banner, we do not want the alert to be merged with the banner + */ +div.container.content { + & > div { + div.alert:nth-child(2) { + margin-top: 1rem; + } + } +} diff --git a/src/Bundle/ChillMainBundle/Resources/public/scss/_alert-with-actions.scss b/src/Bundle/ChillMainBundle/Resources/public/scss/_alert-with-actions.scss new file mode 100644 index 000000000..0779ca7b3 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Resources/public/scss/_alert-with-actions.scss @@ -0,0 +1,29 @@ + +div.alert.alert-with-actions { + display: flex; + flex-direction: row; + + ul.record_actions { + display: flex; + flex-direction: column; + padding: 0; + margin: 0; + + li:nth-child(1n+2) { + margin-top: 0.5rem; + } + + li { + margin-right: 0; + } + } + + @media screen and (max-width: 1050px) { + flex-direction: column; + + ul.record_actions { + margin-top: 1rem; + text-align: center; + } + } +} diff --git a/src/Bundle/ChillMainBundle/Resources/public/scss/chillmain.scss b/src/Bundle/ChillMainBundle/Resources/public/scss/chillmain.scss index 741dfb119..1d0ad253f 100644 --- a/src/Bundle/ChillMainBundle/Resources/public/scss/chillmain.scss +++ b/src/Bundle/ChillMainBundle/Resources/public/scss/chillmain.scss @@ -7,6 +7,10 @@ */ +@import 'alert-first-child'; +@import 'alert-with-actions'; + + /* [hack] /!\ Contourne le positionnement problématique du div#content_conainter suivant, * car sa position: relative le place au-dessus du bandeau et les liens sont incliquables */ div.subheader { diff --git a/src/Bundle/ChillMainBundle/Resources/views/layout.html.twig b/src/Bundle/ChillMainBundle/Resources/views/layout.html.twig index 0381233a4..13fe8de26 100644 --- a/src/Bundle/ChillMainBundle/Resources/views/layout.html.twig +++ b/src/Bundle/ChillMainBundle/Resources/views/layout.html.twig @@ -164,10 +164,12 @@ {{ encore_entry_script_tags('ckeditor5') }} {% endif %} - {% block js%}{% endblock %} - - + }); + + {% block js%}{% endblock %} + + diff --git a/src/Bundle/ChillMainBundle/Tests/Util/DateRangeCoveringTest.php b/src/Bundle/ChillMainBundle/Tests/Util/DateRangeCoveringTest.php new file mode 100644 index 000000000..c06b4a4f4 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Tests/Util/DateRangeCoveringTest.php @@ -0,0 +1,216 @@ +add(new \DateTime('2010-01-01'), new \DateTime('2010-12-01'), 1) + ->add(new \DateTime('2010-06-01'), new \DateTime('2011-06-01'), 2) + ->add(new \DateTime('2019-06-01'), new \DateTime('2019-06-01'), 3) + ->compute() + ; + + $this->assertTrue($cover->hasIntersections()); + $this->assertIsArray($cover->getIntersections()); + $this->assertCount(1, $cover->getIntersections()); + $this->assertEquals( + new \DateTime('2010-06-01'), + $cover->getIntersections()[0][0], + "assert date start are the intersection" + ); + $this->assertEquals( + new \DateTime('2010-12-01'), + $cover->getIntersections()[0][1], + "assert date end are the intersection" + ); + $this->assertIsArray($cover->getIntersections()[0][2]); + $this->assertContains(1, $cover->getIntersections()[0][2]); + $this->assertContains(2, $cover->getIntersections()[0][2]); + $this->assertNotContains(3, $cover->getIntersections()[0][2]); + } + + public function testCoveringWithMinCover1WithTwoIntersections() + { + $cover = new DateRangeCovering(1, new \DateTimeZone('Europe/Brussels')); + $cover + ->add(new \DateTime('2010-01-01'), new \DateTime('2010-12-01'), 1) + ->add(new \DateTime('2010-06-01'), new \DateTime('2011-06-01'), 2) + ->add(new \DateTime('2019-01-01'), new \DateTime('2019-12-01'), 3) + ->add(new \DateTime('2019-06-01'), new \DateTime('2020-06-01'), 4) + ->compute() + ; + + $this->assertTrue($cover->hasIntersections()); + $this->assertIsArray($cover->getIntersections()); + $this->assertCount(2, $cover->getIntersections()); + + $intersections = $cover->getIntersections(); + + // sort the intersections to compare them in expected order + \usort($intersections, function($a, $b) { + if ($a[0] === $b[0]) { + return $a[1] <=> $b[1]; + } + + return $a[0] <=> $b[0]; + }); + + // first intersection + $this->assertEquals( + new \DateTime('2010-06-01'), + $intersections[0][0], + "assert date start are the intersection" + ); + $this->assertEquals( + new \DateTime('2010-12-01'), + $intersections[0][1], + "assert date end are the intersection" + ); + $this->assertIsArray($intersections[0][2]); + $this->assertContains(1, $intersections[0][2]); + $this->assertContains(2, $intersections[0][2]); + $this->assertNotContains(3, $intersections[0][2]); + $this->assertNotContains(4, $intersections[0][2]); + + // second intersection + $this->assertEquals( + new \DateTime('2019-06-01'), + $intersections[1][0], + "assert date start are the intersection" + ); + $this->assertEquals( + new \DateTime('2019-12-01'), + $intersections[1][1], + "assert date end are the intersection" + ); + $this->assertIsArray($intersections[1][2]); + $this->assertContains(3, $intersections[1][2]); + $this->assertContains(4, $intersections[1][2]); + $this->assertNotContains(1, $intersections[1][2]); + $this->assertNotContains(2, $intersections[1][2]); + } + + public function testCoveringWithMinCover2() + { + $cover = new DateRangeCovering(2, new \DateTimeZone('Europe/Brussels')); + $cover + ->add(new \DateTime('2010-01-01'), new \DateTime('2010-10-01'), 1) + ->add(new \DateTime('2010-06-01'), new \DateTime('2010-09-01'), 2) + ->add(new \DateTime('2010-04-01'), new \DateTime('2010-12-01'), 3) + ->add(new \DateTime('2019-01-01'), new \DateTime('2019-10-01'), 4) + ->add(new \DateTime('2019-06-01'), new \DateTime('2019-09-01'), 5) + ->compute() + ; + $this->assertTrue($cover->hasIntersections()); + $this->assertIsArray($cover->getIntersections()); + $this->assertCount(1, $cover->getIntersections()); + + $this->assertEquals( + new \DateTime('2010-06-01'), + $cover->getIntersections()[0][0], + "assert date start are the intersection" + ); + $this->assertEquals( + new \DateTime('2010-09-01'), + $cover->getIntersections()[0][1], + "assert date end are the intersection" + ); + $this->assertIsArray($cover->getIntersections()[0][2]); + $this->assertContains(1, $cover->getIntersections()[0][2]); + $this->assertContains(2, $cover->getIntersections()[0][2]); + $this->assertContains(3, $cover->getIntersections()[0][2]); + $this->assertNotContains(4, $cover->getIntersections()[0][2]); + $this->assertNotContains(5, $cover->getIntersections()[0][2]); + } + + public function testCoveringWithMinCover2AndThreePeriodsCovering() + { + $cover = new DateRangeCovering(2, new \DateTimeZone('Europe/Brussels')); + $cover + ->add(new \DateTime('2010-01-01'), new \DateTime('2010-10-01'), 1) + ->add(new \DateTime('2010-06-01'), new \DateTime('2010-09-01'), 2) + ->add(new \DateTime('2010-04-01'), new \DateTime('2010-12-01'), 3) + ->add(new \DateTime('2009-01-01'), new \DateTime('2010-09-15'), 4) + ->add(new \DateTime('2019-01-01'), new \DateTime('2019-10-01'), 5) + ->add(new \DateTime('2019-06-01'), new \DateTime('2019-09-01'), 6) + ->compute() + ; + + $this->assertTrue($cover->hasIntersections()); + $this->assertIsArray($cover->getIntersections()); + $this->assertCount(1, $cover->getIntersections()); + + $this->assertEquals( + new \DateTime('2010-04-01'), + $cover->getIntersections()[0][0], + "assert date start are the intersection" + ); + $this->assertEquals( + new \DateTime('2010-09-15'), + $cover->getIntersections()[0][1], + "assert date end are the intersection" + ); + $this->assertIsArray($cover->getIntersections()[0][2]); + $this->assertContains(1, $cover->getIntersections()[0][2]); + $this->assertContains(2, $cover->getIntersections()[0][2]); + $this->assertContains(3, $cover->getIntersections()[0][2]); + $this->assertContains(4, $cover->getIntersections()[0][2]); + $this->assertNotContains(5, $cover->getIntersections()[0][2]); + $this->assertNotContains(6, $cover->getIntersections()[0][2]); + } + + public function testCoveringWithMinCover2AndThreePeriodsCoveringWithNullMetadata() + { + $cover = new DateRangeCovering(2, new \DateTimeZone('Europe/Brussels')); + $cover + ->add(new \DateTime('2010-01-01'), new \DateTime('2010-10-01'), null) + ->add(new \DateTime('2010-06-01'), new \DateTime('2010-09-01'), null) + ->add(new \DateTime('2010-04-01'), new \DateTime('2010-12-01'), null) + ->add(new \DateTime('2009-01-01'), new \DateTime('2010-09-15'), null) + ->add(new \DateTime('2019-01-01'), new \DateTime('2019-10-01'), null) + ->add(new \DateTime('2019-06-01'), new \DateTime('2019-09-01'), null) + ->compute() + ; + + $this->assertTrue($cover->hasIntersections()); + $this->assertIsArray($cover->getIntersections()); + $this->assertCount(1, $cover->getIntersections()); + + $this->assertEquals( + new \DateTime('2010-04-01'), + $cover->getIntersections()[0][0], + "assert date start are the intersection" + ); + $this->assertEquals( + new \DateTime('2010-09-15'), + $cover->getIntersections()[0][1], + "assert date end are the intersection" + ); + $this->assertIsArray($cover->getIntersections()[0][2]); + } + + + public function testCoveringWithMinCover3Absent() + { + $cover = new DateRangeCovering(3, new \DateTimeZone('Europe/Brussels')); + $cover + ->add(new \DateTime('2010-01-01'), new \DateTime('2010-10-01'), 1) + ->add(new \DateTime('2010-06-01'), new \DateTime('2010-09-01'), 2) + ->add(new \DateTime('2010-04-01'), new \DateTime('2010-12-01'), 3) + ->add(new \DateTime('2019-01-01'), new \DateTime('2019-10-01'), 4) + ->add(new \DateTime('2019-06-01'), new \DateTime('2019-09-01'), 5) + ->compute() + ; + $this->assertFalse($cover->hasIntersections()); + $this->assertIsArray($cover->getIntersections()); + $this->assertCount(0, $cover->getIntersections()); + } +} diff --git a/src/Bundle/ChillMainBundle/Util/DateRangeCovering.php b/src/Bundle/ChillMainBundle/Util/DateRangeCovering.php new file mode 100644 index 000000000..2b168f18b --- /dev/null +++ b/src/Bundle/ChillMainBundle/Util/DateRangeCovering.php @@ -0,0 +1,224 @@ +add(new \DateTime('2010-01-01'), new \DateTime('2010-10-01'), null) + * ->add(new \DateTime('2010-06-01'), new \DateTime('2010-09-01'), null) + * ->add(new \DateTime('2010-04-01'), new \DateTime('2010-12-01'), null) + * ->add(new \DateTime('2009-01-01'), new \DateTime('2010-09-15'), null) + * ->add(new \DateTime('2019-01-01'), new \DateTime('2019-10-01'), null) + * ->add(new \DateTime('2019-06-01'), new \DateTime('2019-09-01'), null) + * ->compute() + * ; + * $cover->getIntersections(); + * ``` + */ +class DateRangeCovering +{ + private bool $computed = false; + + private array $intersections = []; + + private array $intervals = []; + + private int $minCover; + + private int $uniqueKeyCounter = 0; + + private array $metadatas = []; + + private array $sequence = []; + + private \DateTimeZone $tz; + + /** + * @param int $minCover the minimum of covering required + */ + public function __construct(int $minCover, \DateTimeZone $tz) + { + if ($minCover < 0) { + throw new \LogicException("argument minCover cannot be lower than 0"); + } + + $this->minCover = $minCover; + $this->tz = $tz; + } + + public function add(\DateTimeInterface $start, \DateTimeInterface $end = null, $metadata = null): self + { + if ($this->computed) { + throw new \LogicException("You cannot add intervals to a computed instance"); + } + + $k = $this->uniqueKeyCounter++; + $this->intervals[$k] = [$start, $end]; + $this->metadatas[$k] = $metadata; + + $this->addToSequence($start->getTimestamp(), $k, null); + $this->addToSequence( + NULL === $end ? PHP_INT_MAX : $end->getTimestamp(), null, $k + ); + + return $this; + } + + private function addToSequence($timestamp, int $start = null, int $end = null) + { + if (!\array_key_exists($timestamp, $this->sequence)) { + $this->sequence[$timestamp] = [ 's' => [], 'e' => [] ]; + } + + if (NULL !== $start) { + $this->sequence[$timestamp]['s'][] = $start; + } + if (NULL !== $end) { + $this->sequence[$timestamp]['e'][] = $end; + } + } + + public function compute(): self + { + \ksort($this->sequence); + + $currentPeriod = []; + $currents = []; + $isOpen = false; + $overs = []; + + foreach ($this->sequence as $ts => $moves) { + $currents = \array_merge($currents, $moves['s']); + $currents = \array_diff($currents, $moves['e']); + + if (count($currents) > $this->minCover && !$isOpen) { + $currentPeriod[0] = $ts; + $currentPeriod[2] = $currents; + $isOpen = true; + } elseif ($isOpen && count($currents) <= $this->minCover) { + $currentPeriod[1] = $ts; + $overs[] = $currentPeriod; + $currentPeriod = []; + $isOpen = false; + } elseif ($isOpen) { + $currentPeriod[2] = \array_merge($currentPeriod[2], $currents); + } + } + + // process metadata + foreach ($overs as list($start, $end, $metadata)) { + $this->intersections[] = [ + (new \DateTimeImmutable('@'.$start)) + ->setTimezone($this->tz), + $end === PHP_INT_MAX ? null : (new \DateTimeImmutable('@'.$end)) + ->setTimezone($this->tz), + \array_values( + \array_intersect_key( + $this->metadatas, + \array_flip(\array_unique($metadata)) + ) + ) + ]; + } + + $this->computed = true; + + return $this; + } + + private function process(array $intersections): array + { + $result = []; + $starts = []; + $ends = []; + $metadatas = []; + + while (null !== ($current = \array_pop($intersections))) { + list($cStart, $cEnd, $cMetadata) = $current; + $n = count($cMetadata); + + foreach ($intersections as list($iStart, $iEnd, $iMetadata)) { + $start = max($cStart, $iStart); + $end = min($cEnd, $iEnd); + + if ($start <= $end) { + if (FALSE !== ($key = \array_search($start, $starts))) { + if ($ends[$key] === $end) { + $metadatas[$key] = \array_unique(\array_merge($metadatas[$key], $iMetadata)); + continue; + } + } + $starts[] = $start; + $ends[] = $end; + $metadatas[] = \array_unique(\array_merge($iMetadata, $cMetadata)); + } + } + } + + // recompose results + foreach ($starts as $k => $start) { + $result[] = [$start, $ends[$k], \array_unique($metadatas[$k])]; + } + + return $result; + } + + private function addToIntersections(array $intersections, array $intersection) + { + $foundExisting = false; + list($nStart, $nEnd, $nMetadata) = $intersection; + + \array_walk($intersections, + function(&$i, $key) use ($nStart, $nEnd, $nMetadata, $foundExisting) { + if ($foundExisting) { + return; + }; + if ($i[0] === $nStart && $i[1] === $nEnd) { + $foundExisting = true; + $i[2] = \array_merge($i[2], $nMetadata); + } + } + ); + + if (!$foundExisting) { + $intersections[] = $intersection; + } + + return $intersections; + } + + public function hasIntersections(): bool + { + if (!$this->computed) { + throw new \LogicException(sprintf("You cannot call the method %s before ". + "'process'", __METHOD)); + } + + return count($this->intersections) > 0; + } + + public function getIntersections(): array + { + if (!$this->computed) { + throw new \LogicException(sprintf("You cannot call the method %s before ". + "'process'", __METHOD)); + } + + return $this->intersections; + } + +} diff --git a/src/Bundle/ChillPersonBundle/Controller/AccompanyingCourseController.php b/src/Bundle/ChillPersonBundle/Controller/AccompanyingCourseController.php index e5b0cdda7..c33fc636e 100644 --- a/src/Bundle/ChillPersonBundle/Controller/AccompanyingCourseController.php +++ b/src/Bundle/ChillPersonBundle/Controller/AccompanyingCourseController.php @@ -86,8 +86,19 @@ class AccompanyingCourseController extends Controller */ public function indexAction(AccompanyingPeriod $accompanyingCourse): Response { + // compute some warnings + // get persons without household + $withoutHousehold = []; + foreach ($accompanyingCourse->getParticipations() as + $p) { + if (FALSE === $p->getPerson()->isSharingHousehold()) { + $withoutHousehold[] = $p->getPerson(); + } + } + return $this->render('@ChillPerson/AccompanyingCourse/index.html.twig', [ - 'accompanyingCourse' => $accompanyingCourse + 'accompanyingCourse' => $accompanyingCourse, + 'withoutHousehold' => $withoutHousehold ]); } diff --git a/src/Bundle/ChillPersonBundle/Controller/HouseholdController.php b/src/Bundle/ChillPersonBundle/Controller/HouseholdController.php index bd70a5ed4..36d34677f 100644 --- a/src/Bundle/ChillPersonBundle/Controller/HouseholdController.php +++ b/src/Bundle/ChillPersonBundle/Controller/HouseholdController.php @@ -3,10 +3,13 @@ namespace Chill\PersonBundle\Controller; use Chill\MainBundle\Entity\Address; +use Chill\PersonBundle\Form\HouseholdType; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; +use Symfony\Component\Form\FormInterface; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Annotation\Route; +use Symfony\Component\Translation\TranslatorInterface; use Sensio\Bundle\FrameworkExtraBundle\Configuration\ParamConverter; use Chill\PersonBundle\Entity\Household\Household; use Chill\PersonBundle\Entity\Household\Position; @@ -16,6 +19,16 @@ use Chill\PersonBundle\Entity\Household\Position; */ class HouseholdController extends AbstractController { + private TranslatorInterface $translator; + + /** + * @param TranslatorInterface $translator + */ + public function __construct(TranslatorInterface $translator) + { + $this->translator = $translator; + } + /** * @Route( * "/{household_id}/summary", @@ -66,10 +79,17 @@ class HouseholdController extends AbstractController // some queries $household->getMembers()->initialize(); + if ($request->query->has('edit')) { + $form = $this->createMetadataForm($household); + } else { + $form = null; + } + return $this->render('@ChillPerson/Household/members.html.twig', [ 'household' => $household, - 'positions' => $positions + 'positions' => $positions, + 'form' => NULL !== $form ? $form->createView(): $form ] ); } @@ -143,4 +163,53 @@ class HouseholdController extends AbstractController ] ); } + + /** + * @Route( + * "/{household_id}/members/metadata/edit", + * name="chill_person_household_members_metadata_edit", + * methods={"GET", "POST"} + * ) + * @ParamConverter("household", options={"id" = "household_id"}) + */ + public function editHouseholdMetadata(Request $request, Household $household) + { + // TODO ACL + $form = $this->createMetadataForm($household); + + $form->handleRequest($request); + + if ($form->isSubmitted() and $form->isValid()) { + $this->getDoctrine()->getManager()->flush(); + + $this->addFlash('success', $this->translator->trans('household.data_saved')); + + return $this->redirectToRoute('chill_person_household_members', [ + 'household_id' => $household->getId() + ]); + } + + return $this->render('@ChillPerson/Household/edit_member_metadata.html.twig', [ + 'household' => $household, + 'form' => $form->createView() + ]); + } + + private function createMetadataForm(Household $household): FormInterface + { + $form = $this->createForm( + HouseholdType::class, + $household, + [ + 'action' => $this->generateUrl( + 'chill_person_household_members_metadata_edit', + [ + 'household_id' => $household->getId() + ] + ) + ] + ); + + return $form; + } } diff --git a/src/Bundle/ChillPersonBundle/Controller/HouseholdMemberController.php b/src/Bundle/ChillPersonBundle/Controller/HouseholdMemberController.php index e78838e22..d58972a93 100644 --- a/src/Bundle/ChillPersonBundle/Controller/HouseholdMemberController.php +++ b/src/Bundle/ChillPersonBundle/Controller/HouseholdMemberController.php @@ -50,8 +50,12 @@ class HouseholdMemberController extends ApiController // TODO ACL // - // TODO validation - // + $errors = $editor->validate(); + + if (count($errors) > 0) { + return $this->json($errors, 422); + } + $em = $this->getDoctrine()->getManager(); // if new household, persist it @@ -155,7 +159,9 @@ class HouseholdMemberController extends ApiController { // TODO ACL - $form = $this->createForm(HouseholdMemberType::class, $member); + $form = $this->createForm(HouseholdMemberType::class, $member, [ + 'validation_groups' => [ 'household_memberships' ] + ]); $form->handleRequest($request); if ($form->isSubmitted() && $form->isValid()) { diff --git a/src/Bundle/ChillPersonBundle/Controller/PersonController.php b/src/Bundle/ChillPersonBundle/Controller/PersonController.php index 02320aed3..747299e59 100644 --- a/src/Bundle/ChillPersonBundle/Controller/PersonController.php +++ b/src/Bundle/ChillPersonBundle/Controller/PersonController.php @@ -41,6 +41,8 @@ use Chill\PersonBundle\Config\ConfigPersonAltNamesHelper; use Chill\PersonBundle\Repository\PersonNotDuplicateRepository; use Symfony\Component\Validator\Validator\ValidatorInterface; use Doctrine\ORM\EntityManagerInterface; +use Symfony\Component\Routing\Annotation\Route; +use Sensio\Bundle\FrameworkExtraBundle\Configuration\ParamConverter; final class PersonController extends AbstractController { @@ -416,4 +418,29 @@ final class PersonController extends AbstractController return $person; } + + /** + * + * @Route( + * "/{_locale}/person/household/{person_id}/history", + * name="chill_person_household_person_history", + * methods={"GET", "POST"} + * ) + * @ParamConverter("person", options={"id" = "person_id"}) + */ + public function householdHistoryByPerson(Request $request, Person $person): Response + { + $this->denyAccessUnlessGranted('CHILL_PERSON_SEE', $person, + "You are not allowed to see this person."); + + $event = new PrivacyEvent($person); + $this->eventDispatcher->dispatch(PrivacyEvent::PERSON_PRIVACY_EVENT, $event); + + return $this->render( + '@ChillPerson/Person/household_history.html.twig', + [ + 'person' => $person + ] + ); + } } diff --git a/src/Bundle/ChillPersonBundle/Entity/Household/Household.php b/src/Bundle/ChillPersonBundle/Entity/Household/Household.php index 4cc158de9..8cdc47b4a 100644 --- a/src/Bundle/ChillPersonBundle/Entity/Household/Household.php +++ b/src/Bundle/ChillPersonBundle/Entity/Household/Household.php @@ -11,6 +11,8 @@ use Symfony\Component\Validator\Constraints as Assert; use Symfony\Component\Validator\Context\ExecutionContextInterface; use Chill\MainBundle\Entity\Address; use Chill\PersonBundle\Entity\Household\HouseholdMember; +use Chill\PersonBundle\Validator\Constraints\Household\MaxHolder; +use Chill\MainBundle\Entity\Embeddable\CommentEmbeddable; /** * @ORM\Entity @@ -20,6 +22,7 @@ use Chill\PersonBundle\Entity\Household\HouseholdMember; * @Serializer\DiscriminatorMap(typeProperty="type", mapping={ * "household"=Household::class * }) + * @MaxHolder(groups={"household_memberships"}) */ class Household { @@ -27,7 +30,7 @@ class Household * @ORM\Id * @ORM\GeneratedValue * @ORM\Column(type="integer") - * @Serializer\Groups({"write"}) + * @Serializer\Groups({"read"}) */ private ?int $id = null; @@ -52,10 +55,26 @@ class Household */ private Collection $members; + /** + * @ORM\Embedded(class=CommentEmbeddable::class, columnPrefix="comment_members_") + */ + private CommentEmbeddable $commentMembers; + + /** + * @ORM\Column(type="boolean", name="waiting_for_birth", options={"default": false}) + */ + private bool $waitingForBirth = false; + + /** + * @ORM\Column(type="date_immutable", name="waiting_for_birth_date", nullable=true, options={"default": null}) + */ + private ?\DateTimeImmutable $waitingForBirthDate = null; + public function __construct() { $this->addresses = new ArrayCollection(); $this->members = new ArrayCollection(); + $this->commentMembers = new CommentEmbeddable(); } public function getId(): ?int @@ -129,6 +148,53 @@ class Household return $this->members; } + public function getMembersOnRange(\DateTimeImmutable $from, ?\DateTimeImmutable $to): Collection + { + $criteria = new Criteria(); + $expr = Criteria::expr(); + + $criteria->where( + $expr->gte('startDate', $from) + ); + + if (NULL !== $to) { + $criteria->andWhere( + $expr->orX( + $expr->lte('endDate', $to), + $expr->eq('endDate', NULL) + ), + ); + } + + return $this->getMembers() + ->matching($criteria) + ; + } + + public function getMembersDuringMembership(HouseholdMember $membership) + { + return $this->getMembersOnRange( + $membership->getStartDate(), + $membership->getEndDate() + )->filter( + function(HouseholdMember $m) use ($membership) { + return $m !== $membership; + } + ); + } + + public function getMembersHolder(): Collection + { + $criteria = new Criteria(); + $expr = Criteria::expr(); + + $criteria->where( + $expr->eq('holder', true) + ); + + return $this->getMembers()->matching($criteria); + } + public function getCurrentMembers(?\DateTimeImmutable $now = null): Collection { $criteria = new Criteria(); @@ -148,6 +214,22 @@ class Household return $this->getMembers()->matching($criteria); } + /** + * get current members ids + * + * Used in serialization + * + * @Serializer\Groups({"read"}) + * @Serializer\SerializedName("current_members_id") + * + */ + public function getCurrentMembersIds(?\DateTimeImmutable $now = null): Collection + { + return $this->getCurrentMembers($now)->map( + fn (HouseholdMember $m) => $m->getId() + ); + } + /** * Get the persons currently associated to the household. * @@ -236,7 +318,42 @@ class Household } } dump($cond); + } + public function getCommentMembers(): CommentEmbeddable + { + return $this->commentMembers; + } + + public function setCommentMembers(CommentEmbeddable $commentMembers): self + { + $this->commentMembers = $commentMembers; + + return $this; + } + + public function getWaitingForBirth(): bool + { + return $this->waitingForBirth; + } + + public function setWaitingForBirth(bool $waitingForBirth): self + { + $this->waitingForBirth = $waitingForBirth; + + return $this; + } + + public function getWaitingForBirthDate(): ?\DateTimeImmutable + { + return $this->waitingForBirthDate; + } + + public function setWaitingForBirthDate(?\DateTimeImmutable $waitingForBirthDate): self + { + $this->waitingForBirthDate = $waitingForBirthDate; + + return $this; } } diff --git a/src/Bundle/ChillPersonBundle/Entity/Household/HouseholdMember.php b/src/Bundle/ChillPersonBundle/Entity/Household/HouseholdMember.php index b891bbc15..985084623 100644 --- a/src/Bundle/ChillPersonBundle/Entity/Household/HouseholdMember.php +++ b/src/Bundle/ChillPersonBundle/Entity/Household/HouseholdMember.php @@ -7,6 +7,7 @@ use Chill\PersonBundle\Entity\Person; use Chill\PersonBundle\Entity\Household\Household; use Chill\PersonBundle\Entity\Household\Position; use Symfony\Component\Serializer\Annotation as Serializer; +use Symfony\Component\Validator\Constraints as Assert; /** @@ -28,18 +29,25 @@ class HouseholdMember /** * @ORM\ManyToOne(targetEntity=Position::class) * @Serializer\Groups({"read"}) + * @Assert\NotNull(groups={"household_memberships"}) */ private ?Position $position = null; /** * @ORM\Column(type="date_immutable", nullable=true, options={"default": null}) * @Serializer\Groups({"read"}) + * @Assert\NotNull(groups={"household_memberships"}) */ private ?\DateTimeImmutable $startDate = null; /** * @ORM\Column(type="date_immutable", nullable= true, options={"default": null}) * @Serializer\Groups({"read"}) + * @Assert\GreaterThan( + * propertyPath="startDate", + * message="household_membership.The end date must be after start date", + * groups={"household_memberships"} + * ) */ private ?\DateTimeImmutable $endDate = null; @@ -67,6 +75,8 @@ class HouseholdMember * targetEntity="\Chill\PersonBundle\Entity\Person" * ) * @Serializer\Groups({"read"}) + * @Assert\Valid(groups={"household_memberships"}) + * @Assert\NotNull(groups={"household_memberships"}) */ private ?Person $person = null; @@ -76,6 +86,8 @@ class HouseholdMember * @ORM\ManyToOne( * targetEntity="\Chill\PersonBundle\Entity\Household\Household" * ) + * @Assert\Valid(groups={"household_memberships"}) + * @Assert\NotNull(groups={"household_memberships"}) */ private ?Household $household = null; @@ -194,4 +206,13 @@ class HouseholdMember { return $this->holder; } + + public function isCurrent(\DateTimeImmutable $at = null): bool + { + $at = NULL === $at ? new \DateTimeImmutable('now'): $at; + + return $this->getStartDate() < $at && ( + NULL === $this->getEndDate() || $at < $this->getEndDate() + ); + } } diff --git a/src/Bundle/ChillPersonBundle/Entity/Person.php b/src/Bundle/ChillPersonBundle/Entity/Person.php index 4a777221f..dcfca54c1 100644 --- a/src/Bundle/ChillPersonBundle/Entity/Person.php +++ b/src/Bundle/ChillPersonBundle/Entity/Person.php @@ -1219,6 +1219,50 @@ class Person implements HasCenterInterface return $this->householdParticipations; } + /** + * Get participation where the person does share the household. + * + * Order by startDate, desc + */ + public function getHouseholdParticipationsShareHousehold(): Collection + { + $criteria = new Criteria(); + $expr = Criteria::expr(); + + $criteria + ->where( + $expr->eq('shareHousehold', true) + ) + ->orderBy(['startDate' => Criteria::DESC]) + ; + + return $this->getHouseholdParticipations() + ->matching($criteria) + ; + } + + /** + * Get participation where the person does not share the household. + * + * Order by startDate, desc + */ + public function getHouseholdParticipationsNotShareHousehold(): Collection + { + $criteria = new Criteria(); + $expr = Criteria::expr(); + + $criteria + ->where( + $expr->eq('shareHousehold', false) + ) + ->orderBy(['startDate' => Criteria::DESC]) + ; + + return $this->getHouseholdParticipations() + ->matching($criteria) + ; + } + public function getCurrentHousehold(?\DateTimeImmutable $at = null): ?Household { $criteria = new Criteria(); diff --git a/src/Bundle/ChillPersonBundle/Form/HouseholdMemberType.php b/src/Bundle/ChillPersonBundle/Form/HouseholdMemberType.php index 44b364c56..4d8d5a165 100644 --- a/src/Bundle/ChillPersonBundle/Form/HouseholdMemberType.php +++ b/src/Bundle/ChillPersonBundle/Form/HouseholdMemberType.php @@ -35,7 +35,8 @@ class HouseholdMemberType extends AbstractType } $builder ->add('comment', ChillTextareaType::class, [ - 'label' => 'household.Comment' + 'label' => 'household.Comment', + 'required' => false ]) ; } diff --git a/src/Bundle/ChillPersonBundle/Form/HouseholdType.php b/src/Bundle/ChillPersonBundle/Form/HouseholdType.php new file mode 100644 index 000000000..999e2ff79 --- /dev/null +++ b/src/Bundle/ChillPersonBundle/Form/HouseholdType.php @@ -0,0 +1,40 @@ +add('commentMembers', CommentType::class, [ + 'label' => 'household.comment_membership', + 'required' => false + ]) + ->add('waitingForBirth', CheckboxType::class, [ + 'required' => false, + 'label' => 'household.expecting_birth' + ]) + ->add('waitingForBirthDate', ChillDateType::class, [ + 'required' => false, + 'label' => 'household.date_expecting_birth', + 'input' => 'datetime_immutable' + ]) + ; + } + + public function configureOptions(OptionsResolver $resolver) + { + $resolver->setDefaults([ + 'data_class' => Household::class, + ]); + } +} diff --git a/src/Bundle/ChillPersonBundle/Household/MembersEditor.php b/src/Bundle/ChillPersonBundle/Household/MembersEditor.php index bc25c725f..2fd322bb4 100644 --- a/src/Bundle/ChillPersonBundle/Household/MembersEditor.php +++ b/src/Bundle/ChillPersonBundle/Household/MembersEditor.php @@ -3,6 +3,7 @@ namespace Chill\PersonBundle\Household; use Symfony\Component\Validator\ConstraintViolationListInterface; +use Symfony\Component\Validator\ConstraintViolationList; use Doctrine\Common\Collections\Criteria; use Chill\PersonBundle\Entity\Household\HouseholdMember; use Chill\PersonBundle\Entity\Household\Position; @@ -19,9 +20,11 @@ class MembersEditor private array $persistables = []; private array $membershipsAffected = []; + public const VALIDATION_GROUP = 'household_memberships'; + public function __construct(ValidatorInterface $validator, ?Household $household) { - $this->validation = $validator; + $this->validator = $validator; $this->household = $household; } @@ -41,12 +44,12 @@ class MembersEditor $this->household->addMember($membership); if ($position->getShareHousehold()) { - foreach ($person->getHouseholdParticipations() as $participation) { - if (FALSE === $participation->getShareHousehold()) { + foreach ($person->getHouseholdParticipationsShareHousehold() as $participation) { + if ($participation === $membership) { continue; } - if ($participation === $membership) { + if ($participation->getStartDate() > $membership->getStartDate()) { continue; } @@ -92,7 +95,18 @@ class MembersEditor public function validate(): ConstraintViolationListInterface { + if ($this->hasHousehold()) { + $list = $this->validator + ->validate($this->getHousehold(), null, [ self::VALIDATION_GROUP ]); + } else { + $list = new ConstraintViolationList(); + } + foreach ($this->membershipsAffected as $m) { + $list->addAll($this->validator->validate($m, null, [ self::VALIDATION_GROUP ])); + } + + return $list; } public function getPersistable(): array @@ -100,6 +114,7 @@ class MembersEditor return $this->persistables; } + public function getHousehold(): ?Household { return $this->household; diff --git a/src/Bundle/ChillPersonBundle/Menu/PersonMenuBuilder.php b/src/Bundle/ChillPersonBundle/Menu/PersonMenuBuilder.php index b39b14254..fdb8feb3e 100644 --- a/src/Bundle/ChillPersonBundle/Menu/PersonMenuBuilder.php +++ b/src/Bundle/ChillPersonBundle/Menu/PersonMenuBuilder.php @@ -64,6 +64,16 @@ class PersonMenuBuilder implements LocalMenuBuilderInterface 'order' => 50 ]); + $menu->addChild($this->translator->trans('household.person history'), [ + 'route' => 'chill_person_household_person_history', + 'routeParameters' => [ + 'person_id' => $parameters['person']->getId() + ] + ]) + ->setExtras([ + 'order' => 99999 + ]); + $menu->addChild($this->translator->trans('Person duplicate'), [ 'route' => 'chill_person_duplicate_view', 'routeParameters' => [ @@ -71,7 +81,7 @@ class PersonMenuBuilder implements LocalMenuBuilderInterface ] ]) ->setExtras([ - 'order' => 51 + 'order' => 99999 ]); if ($this->showAccompanyingPeriod === 'visible') { diff --git a/src/Bundle/ChillPersonBundle/Resources/public/modules/household_edit_metadata/index.js b/src/Bundle/ChillPersonBundle/Resources/public/modules/household_edit_metadata/index.js new file mode 100644 index 000000000..511ccf90f --- /dev/null +++ b/src/Bundle/ChillPersonBundle/Resources/public/modules/household_edit_metadata/index.js @@ -0,0 +1,25 @@ +import { ShowHide } from 'ShowHide/show_hide.js'; + + let + k = document.getElementById('waitingForBirthContainer'), + waitingForBirthDate = document.getElementById('waitingForBirthDateContainer') + ; + + console.log(k ); + + new ShowHide({ + 'container': [waitingForBirthDate], + 'froms': [k ], + 'event_name': 'input', + 'debug': true, + 'test': function(froms, event) { + for (let f of froms.values()) { + console.log(f); + for (let input of f.querySelectorAll('input').values()) { + return input.checked; + } + } + + return false; + } + }); diff --git a/src/Bundle/ChillPersonBundle/Resources/public/modules/person_household_history/index.js b/src/Bundle/ChillPersonBundle/Resources/public/modules/person_household_history/index.js new file mode 100644 index 000000000..e69de29bb diff --git a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/HouseholdMembersEditor/api.js b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/HouseholdMembersEditor/api.js index 8f3d07d63..27318ba0a 100644 --- a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/HouseholdMembersEditor/api.js +++ b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/HouseholdMembersEditor/api.js @@ -16,32 +16,13 @@ const householdMove = (payload) => { if (response.ok) { return response.json(); } - throw Error('Error with testing move'); - }); -}; - -const householdMoveTest = (payload) => { - const url = `/api/1.0/person/household/members/move/test.json`; - return fetch(url, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(payload), - }) - .then(response => { if (response.status === 422) { return response.json(); } - if (response.ok) { - // return an empty array if ok - return new Promise((resolve, reject) => resolve({ violations: [] }) ); - } throw Error('Error with testing move'); }); }; export { householdMove, - householdMoveTest }; diff --git a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/HouseholdMembersEditor/components/Concerned.vue b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/HouseholdMembersEditor/components/Concerned.vue index c3595418f..b2842de00 100644 --- a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/HouseholdMembersEditor/components/Concerned.vue +++ b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/HouseholdMembersEditor/components/Concerned.vue @@ -30,7 +30,7 @@ -
+
{{ $t('person.born', {'gender': conc.person.gender} ) }} {{ $d(conc.person.birthdate.datetime, 'short') }}
diff --git a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/HouseholdMembersEditor/components/Confirmation.vue b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/HouseholdMembersEditor/components/Confirmation.vue index cc97d7ef9..cc09f3c0b 100644 --- a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/HouseholdMembersEditor/components/Confirmation.vue +++ b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/HouseholdMembersEditor/components/Confirmation.vue @@ -12,6 +12,9 @@
  • {{ $t(msg.m, msg.a) }}
  • +
  • + {{ msg }} +
  • -
    {{ $t('person.born', {'gender': conc.person.gender} ) }}
    +
    {{ $t('person.born', {'gender': conc.person.gender} ) }}