Merge conflicts fixed

This commit is contained in:
2022-02-04 10:25:46 +01:00
403 changed files with 15524 additions and 2846 deletions

View File

@@ -31,6 +31,7 @@ use Chill\MainBundle\Security\Resolver\ScopeResolverInterface;
use Chill\MainBundle\Templating\Entity\ChillEntityRenderInterface;
use Chill\MainBundle\Templating\Entity\CompilerPass as RenderEntityCompilerPass;
use Chill\MainBundle\Templating\UI\NotificationCounterInterface;
use Chill\MainBundle\Workflow\EntityWorkflowHandlerInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\HttpKernel\Bundle\Bundle;
@@ -56,6 +57,8 @@ class ChillMainBundle extends Bundle
->addTag('chill_main.notification_handler');
$container->registerForAutoconfiguration(NotificationCounterInterface::class)
->addTag('chill.count_notification.user');
$container->registerForAutoconfiguration(EntityWorkflowHandlerInterface::class)
->addTag('chill_main.workflow_handler');
$container->addCompilerPass(new SearchableServicesCompilerPass());
$container->addCompilerPass(new ConfigConsistencyCompilerPass());

View File

@@ -24,6 +24,7 @@ class LocationTypeApiController extends ApiController
$query->andWhere(
$query->expr()->andX(
$query->expr()->eq('e.availableForUsers', "'TRUE'"),
$query->expr()->eq('e.editableByUsers', "'TRUE'"),
$query->expr()->eq('e.active', "'TRUE'"),
)
);

View File

@@ -13,13 +13,19 @@ namespace Chill\MainBundle\Controller;
use Chill\MainBundle\Entity\Notification;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Pagination\PaginatorFactory;
use Chill\MainBundle\Repository\NotificationRepository;
use Chill\MainBundle\Security\Authorization\NotificationVoter;
use Chill\MainBundle\Serializer\Model\Collection;
use Chill\MainBundle\Serializer\Model\Counter;
use Doctrine\ORM\EntityManagerInterface;
use RuntimeException;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use Symfony\Component\Security\Core\Security;
use Symfony\Component\Serializer\SerializerInterface;
use UnexpectedValueException;
/**
@@ -29,12 +35,26 @@ class NotificationApiController
{
private EntityManagerInterface $entityManager;
private NotificationRepository $notificationRepository;
private PaginatorFactory $paginatorFactory;
private Security $security;
public function __construct(EntityManagerInterface $entityManager, Security $security)
{
private SerializerInterface $serializer;
public function __construct(
EntityManagerInterface $entityManager,
NotificationRepository $notificationRepository,
PaginatorFactory $paginatorFactory,
Security $security,
SerializerInterface $serializer
) {
$this->entityManager = $entityManager;
$this->notificationRepository = $notificationRepository;
$this->paginatorFactory = $paginatorFactory;
$this->security = $security;
$this->serializer = $serializer;
}
/**
@@ -53,6 +73,37 @@ class NotificationApiController
return $this->markAs('unread', $notification);
}
/**
* @Route("/my/unread")
*/
public function myUnreadNotifications(Request $request): JsonResponse
{
$total = $this->notificationRepository->countUnreadByUser($this->security->getUser());
if ($request->query->getBoolean('countOnly')) {
return new JsonResponse(
$this->serializer->serialize(new Counter($total), 'json', ['groups' => ['read']]),
JsonResponse::HTTP_OK,
[],
true
);
}
$paginator = $this->paginatorFactory->create($total);
$notifications = $this->notificationRepository->findUnreadByUser(
$this->security->getUser(),
$paginator->getItemsPerPage(),
$paginator->getCurrentPageFirstItemNumber()
);
$collection = new Collection($notifications, $paginator);
return new JsonResponse(
$this->serializer->serialize($collection, 'json', ['groups' => ['read']]),
JsonResponse::HTTP_OK,
[],
true
);
}
private function markAs(string $target, Notification $notification): JsonResponse
{
if (!$this->security->isGranted(NotificationVoter::NOTIFICATION_TOGGLE_READ_STATUS, $notification)) {

View File

@@ -0,0 +1,118 @@
<?php
/**
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Chill\MainBundle\Controller;
use Chill\MainBundle\Entity\Workflow\EntityWorkflow;
use Doctrine\ORM\EntityManagerInterface;
use LogicException;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use Symfony\Component\Security\Core\Security;
class WorkflowApiController
{
private EntityManagerInterface $entityManager;
private Security $security;
public function __construct(Security $security, EntityManagerInterface $entityManager)
{
$this->entityManager = $entityManager;
$this->security = $security;
}
/**
* @Route("/api/1.0/main/workflow/{id}/subscribe", methods={"POST"})
*/
public function subscribe(EntityWorkflow $entityWorkflow, Request $request): Response
{
return $this->handleSubscription($entityWorkflow, $request, 'subscribe');
}
/**
* @Route("/api/1.0/main/workflow/{id}/unsubscribe", methods={"POST"})
*/
public function unsubscribe(EntityWorkflow $entityWorkflow, Request $request): Response
{
return $this->handleSubscription($entityWorkflow, $request, 'unsubscribe');
}
private function handleSubscription(EntityWorkflow $entityWorkflow, Request $request, string $action): JsonResponse
{
if (!$this->security->isGranted('IS_AUTHENTICATED_REMEMBERED')) {
throw new AccessDeniedException();
}
if (!$request->query->has('subscribe')) {
throw new BadRequestHttpException('missing subscribe parameter');
}
$user = $this->security->getUser();
switch ($request->query->get('subscribe')) {
case 'final':
switch ($action) {
case 'subscribe':
$entityWorkflow->addSubscriberToFinal($user);
break;
case 'unsubscribe':
$entityWorkflow->removeSubscriberToFinal($user);
break;
default:
throw new LogicException();
}
break;
case 'step':
switch ($action) {
case 'subscribe':
$entityWorkflow->addSubscriberToStep($user);
break;
case 'unsubscribe':
$entityWorkflow->removeSubscriberToStep($user);
break;
default:
throw new LogicException();
}
break;
default:
throw new BadRequestHttpException('subscribe parameter must be equal to "step" or "final"');
}
$this->entityManager->flush();
return new JsonResponse(
[
'step' => $entityWorkflow->isUserSubscribedToStep($user),
'final' => $entityWorkflow->isUserSubscribedToFinal($user),
],
JsonResponse::HTTP_OK,
[],
false
);
}
}

View File

@@ -0,0 +1,268 @@
<?php
/**
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Chill\MainBundle\Controller;
use Chill\MainBundle\Entity\Workflow\EntityWorkflow;
use Chill\MainBundle\Entity\Workflow\EntityWorkflowComment;
use Chill\MainBundle\Form\EntityWorkflowCommentType;
use Chill\MainBundle\Form\WorkflowStepType;
use Chill\MainBundle\Pagination\PaginatorFactory;
use Chill\MainBundle\Repository\Workflow\EntityWorkflowRepository;
use Chill\MainBundle\Security\Authorization\EntityWorkflowVoter;
use Chill\MainBundle\Workflow\EntityWorkflowManager;
use Chill\MainBundle\Workflow\Validator\StepDestValid;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Validator\Validator\ValidatorInterface;
use Symfony\Component\Workflow\Registry;
use Symfony\Component\Workflow\TransitionBlocker;
use Symfony\Contracts\Translation\TranslatorInterface;
use function count;
class WorkflowController extends AbstractController
{
private EntityManagerInterface $entityManager;
private EntityWorkflowManager $entityWorkflowManager;
private EntityWorkflowRepository $entityWorkflowRepository;
private PaginatorFactory $paginatorFactory;
private Registry $registry;
private TranslatorInterface $translator;
private ValidatorInterface $validator;
public function __construct(EntityWorkflowManager $entityWorkflowManager, EntityWorkflowRepository $entityWorkflowRepository, ValidatorInterface $validator, PaginatorFactory $paginatorFactory, Registry $registry, EntityManagerInterface $entityManager, TranslatorInterface $translator)
{
$this->entityWorkflowManager = $entityWorkflowManager;
$this->entityWorkflowRepository = $entityWorkflowRepository;
$this->validator = $validator;
$this->paginatorFactory = $paginatorFactory;
$this->registry = $registry;
$this->entityManager = $entityManager;
$this->translator = $translator;
}
/**
* @Route("/{_locale}/main/workflow/create", name="chill_main_workflow_create")
*/
public function create(Request $request): Response
{
if (!$request->query->has('entityClass')) {
throw new BadRequestHttpException('Missing entityClass parameter');
}
if (!$request->query->has('entityId')) {
throw new BadRequestHttpException('missing entityId parameter');
}
if (!$request->query->has('workflow')) {
throw new BadRequestHttpException('missing workflow parameter');
}
$entityWorkflow = new EntityWorkflow();
$entityWorkflow
->setRelatedEntityClass($request->query->get('entityClass'))
->setRelatedEntityId($request->query->getInt('entityId'))
->setWorkflowName($request->query->get('workflow'));
$errors = $this->validator->validate($entityWorkflow, null, ['creation']);
if (count($errors) > 0) {
$msg = [];
foreach ($errors as $error) {
/** @var \Symfony\Component\Validator\ConstraintViolationInterface $error */
$msg[] = $error->getMessage();
}
return new Response(implode("\n", $msg), Response::HTTP_UNPROCESSABLE_ENTITY);
}
$this->denyAccessUnlessGranted(EntityWorkflowVoter::CREATE, $entityWorkflow);
$em = $this->getDoctrine()->getManager();
$em->persist($entityWorkflow);
$em->flush();
return $this->redirectToRoute('chill_main_workflow_show', ['id' => $entityWorkflow->getId()]);
}
/**
* @Route("/{_locale}/main/workflow/list/dest", name="chill_main_workflow_list_dest")
*/
public function myWorkflowsDest(Request $request): Response
{
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_REMEMBERED');
$total = $this->entityWorkflowRepository->countByDest($this->getUser());
$paginator = $this->paginatorFactory->create($total);
$workflows = $this->entityWorkflowRepository->findByDest(
$this->getUser(),
['createdAt' => 'DESC'],
$paginator->getItemsPerPage(),
$paginator->getCurrentPageFirstItemNumber()
);
return $this->render(
'@ChillMain/Workflow/list.html.twig',
[
'workflows' => $this->buildHandler($workflows),
'paginator' => $paginator,
'step' => 'dest',
]
);
}
/**
* @Route("/{_locale}/main/workflow/list/subscribed", name="chill_main_workflow_list_subscribed")
*/
public function myWorkflowsSubscribed(Request $request): Response
{
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_REMEMBERED');
$total = $this->entityWorkflowRepository->countBySubscriber($this->getUser());
$paginator = $this->paginatorFactory->create($total);
$workflows = $this->entityWorkflowRepository->findBySubscriber(
$this->getUser(),
['createdAt' => 'DESC'],
$paginator->getItemsPerPage(),
$paginator->getCurrentPageFirstItemNumber()
);
return $this->render(
'@ChillMain/Workflow/list.html.twig',
[
'workflows' => $this->buildHandler($workflows),
'paginator' => $paginator,
'step' => 'subscribed',
]
);
}
/**
* @Route("/{_locale}/main/workflow/{id}/show", name="chill_main_workflow_show")
*/
public function show(EntityWorkflow $entityWorkflow, Request $request): Response
{
$this->denyAccessUnlessGranted(EntityWorkflowVoter::SEE, $entityWorkflow);
$handler = $this->entityWorkflowManager->getHandler($entityWorkflow);
$workflow = $this->registry->get($entityWorkflow, $entityWorkflow->getWorkflowName());
if (count($workflow->getEnabledTransitions($entityWorkflow)) > 0) {
// possible transition
$transitionForm = $this->createForm(
WorkflowStepType::class,
$entityWorkflow->getCurrentStep(),
['transition' => true, 'entity_workflow' => $entityWorkflow]
);
$transitionForm->handleRequest($request);
if ($transitionForm->isSubmitted() && $transitionForm->isValid()) {
if (!$workflow->can($entityWorkflow, $transition = $transitionForm['transition']->getData()->getName())) {
$blockers = $workflow->buildTransitionBlockerList($entityWorkflow, $transition);
$msgs = array_map(function (TransitionBlocker $tb) {
return $this->translator->trans(
$tb->getMessage(),
$tb->getParameters()
);
}, iterator_to_array($blockers));
throw $this->createAccessDeniedException(
sprintf(
"not allowed to apply transition {$transition}: %s",
implode(', ', $msgs)
)
);
}
$workflow->apply($entityWorkflow, $transition);
foreach ($transitionForm['future_dest_users']->getData() as $user) {
$entityWorkflow->getCurrentStep()->addDestUser($user);
}
$errors = $this->validator->validate(
$entityWorkflow->getCurrentStep(),
new StepDestValid()
);
if (count($errors) === 0) {
$this->entityManager->flush();
return $this->redirectToRoute('chill_main_workflow_show', ['id' => $entityWorkflow->getId()]);
}
return new Response((string) $errors, Response::HTTP_UNPROCESSABLE_ENTITY);
}
if ($transitionForm->isSubmitted() && !$transitionForm->isValid()) {
$this->addFlash('error', $this->translator->trans('This form contains errors'));
}
}
/*
$commentForm = $this->createForm(EntityWorkflowCommentType::class, $newComment = new EntityWorkflowComment());
$commentForm->handleRequest($request);
if ($commentForm->isSubmitted() && $commentForm->isValid()) {
$this->entityManager->persist($newComment);
$this->entityManager->flush();
$this->addFlash('success', $this->translator->trans('workflow.Comment added'));
return $this->redirectToRoute('chill_main_workflow_show', ['id' => $entityWorkflow->getId()]);
} elseif ($commentForm->isSubmitted() && !$commentForm->isValid()) {
$this->addFlash('error', $this->translator->trans('This form contains errors'));
}
*/
return $this->render(
'@ChillMain/Workflow/index.html.twig',
[
'handler_template' => $handler->getTemplate($entityWorkflow),
'handler_template_title' => $handler->getTemplateTitle($entityWorkflow),
'handler_template_data' => $handler->getTemplateData($entityWorkflow),
'transition_form' => isset($transitionForm) ? $transitionForm->createView() : null,
'entity_workflow' => $entityWorkflow,
'transition_form_errors' => $errors ?? [],
//'comment_form' => $commentForm->createView(),
]
);
}
private function buildHandler(array $workflows): array
{
$lines = [];
foreach ($workflows as $workflow) {
$handler = $this->entityWorkflowManager->getHandler($workflow);
$lines[] = [
'handler' => $handler,
'entity_workflow' => $workflow,
];
}
return $lines;
}
}

View File

@@ -168,6 +168,7 @@ class ChillMainExtension extends Extension implements
$loader->load('services/timeline.yaml');
$loader->load('services/search.yaml');
$loader->load('services/serializer.yaml');
$loader->load('services/mailer.yaml');
$this->configureCruds($container, $config['cruds'], $config['apis'], $loader);
}
@@ -391,6 +392,26 @@ class ChillMainExtension extends Extension implements
],
],
],
[
'class' => \Chill\MainBundle\Entity\UserJob::class,
'name' => 'user_job',
'base_path' => '/api/1.0/main/user-job',
'base_role' => 'ROLE_USER',
'actions' => [
'_index' => [
'methods' => [
Request::METHOD_GET => true,
Request::METHOD_HEAD => true,
],
],
'_entity' => [
'methods' => [
Request::METHOD_GET => true,
Request::METHOD_HEAD => true,
],
],
],
],
[
'controller' => \Chill\MainBundle\Controller\AddressReferenceAPIController::class,
'class' => \Chill\MainBundle\Entity\AddressReference::class,

View File

@@ -0,0 +1,56 @@
<?php
/**
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Chill\MainBundle\Doctrine\Model;
use Chill\MainBundle\Entity\User;
use DateTime;
use DateTimeImmutable;
use DateTimeInterface;
use Doctrine\ORM\Mapping as ORM;
trait TrackCreationTrait
{
/**
* @ORM\Column(type="datetime_immutable", nullable=true, options={"default": NULL})
*/
private ?DateTimeImmutable $createdAt = null;
/**
* @ORM\ManyToOne(targetEntity=User::class)
* @ORM\JoinColumn(nullable=true)
*/
private ?User $createdBy = null;
public function getCreatedAt(): ?DateTimeInterface
{
return $this->createdAt;
}
public function getCreatedBy(): ?User
{
return $this->createdBy;
}
public function setCreatedAt(DateTimeInterface $datetime): self
{
$this->createdAt = $datetime instanceof DateTime ? DateTimeImmutable::createFromMutable($datetime) : $datetime;
return $this;
}
public function setCreatedBy(User $user): self
{
$this->createdBy = $user;
return $this;
}
}

View File

@@ -0,0 +1,56 @@
<?php
/**
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Chill\MainBundle\Doctrine\Model;
use Chill\MainBundle\Entity\User;
use DateTime;
use DateTimeImmutable;
use DateTimeInterface;
use Doctrine\ORM\Mapping as ORM;
trait TrackUpdateTrait
{
/**
* @ORM\Column(type="datetime_immutable", nullable=true, options={"default": NULL})
*/
private ?DateTimeImmutable $updatedAt = null;
/**
* @ORM\ManyToOne(targetEntity=User::class)
* @ORM\JoinColumn(nullable=true)
*/
private ?User $updatedBy = null;
public function getUpdatedAt(): ?DateTimeInterface
{
return $this->updatedAt;
}
public function getUpdatedBy(): ?User
{
return $this->updatedBy;
}
public function setUpdatedAt(DateTimeInterface $datetime): self
{
$this->updatedAt = $datetime instanceof DateTime ? DateTimeImmutable::createFromMutable($datetime) : $datetime;
return $this;
}
public function setUpdatedBy(User $user): self
{
$this->updatedBy = $user;
return $this;
}
}

View File

@@ -42,10 +42,16 @@ class Address
*/
private $buildingName;
/**
* @ORM\Column(type="boolean")
* @Groups({"write"})
*/
private bool $confidential = false;
/**
* @var string|null
*
* @ORM\Column(type="string", length=16, nullable=true)
* @ORM\Column(type="string", length=255, nullable=true)
* @Groups({"write"})
*/
private $corridor;
@@ -78,7 +84,7 @@ class Address
/**
* @var string|null
*
* @ORM\Column(type="string", length=16, nullable=true)
* @ORM\Column(type="string", length=255, nullable=true)
* @Groups({"write"})
*/
private $flat;
@@ -86,7 +92,7 @@ class Address
/**
* @var string|null
*
* @ORM\Column(type="string", length=16, nullable=true)
* @ORM\Column(type="string", length=255, nullable=true)
* @Groups({"write"})
*/
private $floor;
@@ -143,7 +149,7 @@ class Address
/**
* @var string|null
*
* @ORM\Column(type="string", length=16, nullable=true)
* @ORM\Column(type="string", length=255, nullable=true)
* @Groups({"write"})
*/
private $steps;
@@ -192,6 +198,7 @@ class Address
return (new Address())
->setAddressReference($original->getAddressReference())
->setBuildingName($original->getBuildingName())
->setConfidential($original->getConfidential())
->setCorridor($original->getCorridor())
->setCustoms($original->getCustoms())
->setDistribution($original->getDistribution())
@@ -229,6 +236,11 @@ class Address
return $this->buildingName;
}
public function getConfidential(): bool
{
return $this->confidential;
}
public function getCorridor(): ?string
{
return $this->corridor;
@@ -369,6 +381,13 @@ class Address
return $this;
}
public function setConfidential(bool $confidential): self
{
$this->confidential = $confidential;
return $this;
}
public function setCorridor(?string $corridor): self
{
$this->corridor = $corridor;

View File

@@ -20,10 +20,9 @@ use Doctrine\ORM\Mapping as ORM;
class CommentEmbeddable
{
/**
* @var string
* @ORM\Column(type="text", nullable=true)
*/
private $comment;
private ?string $comment = null;
/**
* @var DateTime
@@ -39,10 +38,7 @@ class CommentEmbeddable
*/
private $userId;
/**
* @return string
*/
public function getComment()
public function getComment(): ?string
{
return $this->comment;
}
@@ -68,9 +64,6 @@ class CommentEmbeddable
return empty($this->getComment());
}
/**
* @param string $comment
*/
public function setComment(?string $comment)
{
$this->comment = $comment;

View File

@@ -64,7 +64,7 @@ class Location implements TrackCreationInterface, TrackUpdateInterface
/**
* @ORM\Column(type="string", length=255, nullable=true)
* @Serializer\Groups({"read", "write"})
* @Serializer\Groups({"read", "write", "docgen:read"})
*/
private ?string $email = null;

View File

@@ -67,6 +67,12 @@ class LocationType
*/
private ?string $defaultFor = null;
/**
* @ORM\Column(type="boolean")
* @Serializer\Groups({"read"})
*/
private bool $editableByUsers = true;
/**
* @ORM\Id
* @ORM\GeneratedValue
@@ -107,6 +113,11 @@ class LocationType
return $this->defaultFor;
}
public function getEditableByUsers(): ?bool
{
return $this->editableByUsers;
}
public function getId(): ?int
{
return $this->id;
@@ -152,6 +163,13 @@ class LocationType
return $this;
}
public function setEditableByUsers(bool $editableByUsers): self
{
$this->editableByUsers = $editableByUsers;
return $this;
}
public function setTitle(array $title): self
{
$this->title = $title;

View File

@@ -17,11 +17,15 @@ use DateTimeInterface;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
/**
* @ORM\Entity
* @ORM\Table(
* name="chill_main_notification",
* indexes={
* @ORM\Index(name="chill_main_notification_related_entity_idx", columns={"relatedentityclass", "relatedentityid"})
* }
* )
* @ORM\HasLifecycleCallbacks
*/
@@ -32,6 +36,7 @@ class Notification implements TrackUpdateInterface
/**
* @ORM\ManyToMany(targetEntity=User::class)
* @ORM\JoinTable(name="chill_main_notification_addresses_user")
* @Assert\Count(min="1", minMessage="notification.At least one addressee")
*/
private Collection $addressees;
@@ -80,6 +85,7 @@ class Notification implements TrackUpdateInterface
/**
* @ORM\Column(type="text", options={"default": ""})
* @Assert\NotBlank(message="notification.Title must be defined")
*/
private string $title = '';
@@ -286,9 +292,9 @@ class Notification implements TrackUpdateInterface
return $this;
}
public function setMessage(string $message): self
public function setMessage(?string $message): self
{
$this->message = $message;
$this->message = (string) $message;
return $this;
}
@@ -314,9 +320,9 @@ class Notification implements TrackUpdateInterface
return $this;
}
public function setTitle(string $title): Notification
public function setTitle(?string $title): Notification
{
$this->title = $title;
$this->title = (string) $title;
return $this;
}

View File

@@ -0,0 +1,166 @@
<?php
/**
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Chill\MainBundle\Entity;
use Chill\MainBundle\Entity\Embeddable\CommentEmbeddable;
use Chill\MainBundle\Repository\ResidentialAddressRepository;
use Chill\PersonBundle\Entity\Person;
use Chill\ThirdPartyBundle\Entity\ThirdParty;
use DateTimeImmutable;
use Doctrine\ORM\Mapping as ORM;
/**
* @ORM\Entity(repositoryClass=ResidentialAddressRepository::class)
* @ORM\Table(name="chill_main_residential_address")
*/
class ResidentialAddress
{
/**
* @ORM\ManyToOne(targetEntity=Address::class)
* @ORM\JoinColumn(nullable=true)
*/
private ?Address $address = null;
/**
* @ORM\Embedded(class="Chill\MainBundle\Entity\Embeddable\CommentEmbeddable", columnPrefix="residentialAddressComment_")
*/
private CommentEmbeddable $comment;
/**
* @ORM\Column(type="datetime_immutable", nullable=true)
*/
private ?DateTimeImmutable $endDate = null;
/**
* @ORM\ManyToOne(targetEntity=Person::class)
* @ORM\JoinColumn(nullable=true)
*/
private ?Person $hostPerson = null;
/**
* @ORM\ManyToOne(targetEntity=ThirdParty::class)
* @ORM\JoinColumn(nullable=true)
*/
private ?ThirdParty $hostThirdParty = null;
/**
* @ORM\Id
* @ORM\GeneratedValue
* @ORM\Column(type="integer")
*/
private $id;
/**
* @ORM\ManyToOne(targetEntity=Person::class)
* @ORM\JoinColumn(nullable=false)
*/
private Person $person;
/**
* @ORM\Column(type="datetime_immutable")
*/
private ?DateTimeImmutable $startDate = null;
public function __construct()
{
$this->comment = new CommentEmbeddable();
}
public function getAddress(): ?Address
{
return $this->address;
}
public function getComment(): CommentEmbeddable
{
return $this->comment;
}
public function getEndDate(): ?DateTimeImmutable
{
return $this->endDate;
}
public function getHostPerson(): ?Person
{
return $this->hostPerson;
}
public function getHostThirdParty(): ?ThirdParty
{
return $this->hostThirdParty;
}
public function getId(): ?int
{
return $this->id;
}
public function getPerson(): ?Person
{
return $this->person;
}
public function getStartDate(): ?DateTimeImmutable
{
return $this->startDate;
}
public function setAddress(?Address $address): self
{
$this->address = $address;
return $this;
}
public function setComment(CommentEmbeddable $comment): self
{
$this->comment = $comment;
return $this;
}
public function setEndDate(?DateTimeImmutable $endDate): self
{
$this->endDate = $endDate;
return $this;
}
public function setHostPerson(?Person $hostPerson): self
{
$this->hostPerson = $hostPerson;
return $this;
}
public function setHostThirdParty(?ThirdParty $hostThirdParty): self
{
$this->hostThirdParty = $hostThirdParty;
return $this;
}
public function setPerson(?Person $person): self
{
$this->person = $person;
return $this;
}
public function setStartDate(DateTimeImmutable $startDate): self
{
$this->startDate = $startDate;
return $this;
}
}

View File

@@ -97,6 +97,11 @@ class User implements AdvancedUserInterface
*/
private ?Center $mainCenter = null;
/**
* @ORM\ManyToOne(targetEntity=Location::class)
*/
private ?Location $mainLocation = null;
/**
* @ORM\ManyToOne(targetEntity=Scope::class)
*/
@@ -228,6 +233,11 @@ class User implements AdvancedUserInterface
return $this->mainCenter;
}
public function getMainLocation(): ?Location
{
return $this->mainLocation;
}
public function getMainScope(): ?Scope
{
return $this->mainScope;
@@ -405,6 +415,13 @@ class User implements AdvancedUserInterface
return $this;
}
public function setMainLocation(?Location $mainLocation): User
{
$this->mainLocation = $mainLocation;
return $this;
}
public function setMainScope(?Scope $mainScope): User
{
$this->mainScope = $mainScope;

View File

@@ -0,0 +1,461 @@
<?php
/**
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Chill\MainBundle\Entity\Workflow;
use Chill\MainBundle\Doctrine\Model\TrackCreationInterface;
use Chill\MainBundle\Doctrine\Model\TrackCreationTrait;
use Chill\MainBundle\Doctrine\Model\TrackUpdateInterface;
use Chill\MainBundle\Doctrine\Model\TrackUpdateTrait;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Workflow\Validator\EntityWorkflowCreation;
use DateTimeInterface;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Iterator;
use RuntimeException;
use Symfony\Component\Serializer\Annotation as Serializer;
use function count;
use function is_array;
/**
* @ORM\Entity
* @ORM\Table("chill_main_workflow_entity")
* @EntityWorkflowCreation(groups={"creation"})
* @Serializer\DiscriminatorMap(typeProperty="type", mapping={
* "entity_workflow": EntityWorkflow::class
* })
*/
class EntityWorkflow implements TrackCreationInterface, TrackUpdateInterface
{
use TrackCreationTrait;
use TrackUpdateTrait;
/**
* @ORM\OneToMany(targetEntity=EntityWorkflowComment::class, mappedBy="entityWorkflow", orphanRemoval=true)
*
* @var Collection|EntityWorkflowComment[]
*/
private Collection $comments;
/**
* @ORM\Id
* @ORM\GeneratedValue
* @ORM\Column(type="integer")
*/
private ?int $id = null;
/**
* @ORM\Column(type="string", length=255)
*/
private string $relatedEntityClass = '';
/**
* @ORM\Column(type="integer")
*/
private int $relatedEntityId;
/**
* @ORM\OneToMany(targetEntity=EntityWorkflowStep::class, mappedBy="entityWorkflow", orphanRemoval=true, cascade={"persist"})
* @ORM\OrderBy({"transitionAt": "ASC", "id": "ASC"})
*
* @var Collection|EntityWorkflowStep[]
*/
private Collection $steps;
/**
* @var null|array|EntityWorkflowStep[]
*/
private ?array $stepsChainedCache = null;
/**
* @ORM\ManyToMany(targetEntity=User::class)
* @ORM\JoinTable(name="chill_main_workflow_entity_subscriber_to_final")
*
* @var Collection|User[]
*/
private Collection $subscriberToFinal;
/**
* @ORM\ManyToMany(targetEntity=User::class)
* @ORM\JoinTable(name="chill_main_workflow_entity_subscriber_to_step")
*
* @var Collection|User[]
*/
private Collection $subscriberToStep;
/**
* a step which will store all the transition data.
*/
private ?EntityWorkflowStep $transitionningStep = null;
/**
* @ORM\Column(type="text")
*/
private string $workflowName;
public function __construct()
{
$this->subscriberToFinal = new ArrayCollection();
$this->subscriberToStep = new ArrayCollection();
$this->comments = new ArrayCollection();
$this->steps = new ArrayCollection();
$initialStep = new EntityWorkflowStep();
$initialStep
->setCurrentStep('initial');
$this->addStep($initialStep);
}
public function addComment(EntityWorkflowComment $comment): self
{
if (!$this->comments->contains($comment)) {
$this->comments[] = $comment;
$comment->setEntityWorkflow($this);
}
return $this;
}
/**
* @internal You should prepare a step and run a workflow transition instead of manually adding a step
*/
public function addStep(EntityWorkflowStep $step): self
{
if (!$this->steps->contains($step)) {
$this->steps[] = $step;
$step->setEntityWorkflow($this);
}
return $this;
}
public function addSubscriberToFinal(User $user): self
{
if (!$this->subscriberToFinal->contains($user)) {
$this->subscriberToFinal[] = $user;
}
return $this;
}
public function addSubscriberToStep(User $user): self
{
if (!$this->subscriberToStep->contains($user)) {
$this->subscriberToStep[] = $user;
}
return $this;
}
public function getComments(): Collection
{
return $this->comments;
}
public function getCurrentStep(): ?EntityWorkflowStep
{
$step = $this->steps->last();
if (false !== $step) {
return $step;
}
return null;
}
public function getCurrentStepChained(): ?EntityWorkflowStep
{
$steps = $this->getStepsChained();
$currentStep = $this->getCurrentStep();
foreach ($steps as $step) {
if ($step === $currentStep) {
return $step;
}
}
return null;
}
public function getCurrentStepCreatedAt(): ?DateTimeInterface
{
if (null !== $previous = $this->getPreviousStepIfAny()) {
return $previous->getTransitionAt();
}
return null;
}
public function getCurrentStepCreatedBy(): ?User
{
if (null !== $previous = $this->getPreviousStepIfAny()) {
return $previous->getTransitionBy();
}
return null;
}
public function getId(): ?int
{
return $this->id;
}
public function getRelatedEntityClass(): string
{
return $this->relatedEntityClass;
}
public function getRelatedEntityId(): int
{
return $this->relatedEntityId;
}
/**
* Method used by MarkingStore.
*
* get a string representation of the step
*/
public function getStep(): string
{
return $this->getCurrentStep()->getCurrentStep();
}
public function getStepAfter(EntityWorkflowStep $step): ?EntityWorkflowStep
{
$iterator = $this->steps->getIterator();
if ($iterator instanceof Iterator) {
$iterator->rewind();
while ($iterator->valid()) {
$curStep = $iterator->current();
if ($curStep === $step) {
$iterator->next();
if ($iterator->valid()) {
return $iterator->current();
}
return null;
}
$iterator->next();
}
return null;
}
throw new RuntimeException();
}
/**
* @return ArrayCollection|Collection
*/
public function getSteps()
{
return $this->steps;
}
public function getStepsChained(): array
{
if (is_array($this->stepsChainedCache)) {
return $this->stepsChainedCache;
}
$iterator = $this->steps->getIterator();
$current = null;
$steps = [];
$iterator->rewind();
do {
$previous = $current;
$current = $iterator->current();
$steps[] = $current;
$current->setPrevious($previous);
$iterator->next();
if ($iterator->valid()) {
$current->setNext($iterator->current());
} else {
$current->setNext(null);
}
} while ($iterator->valid());
$this->stepsChainedCache = $steps;
return $steps;
}
/**
* @return ArrayCollection|Collection
*/
public function getSubscriberToFinal()
{
return $this->subscriberToFinal;
}
/**
* @return ArrayCollection|Collection
*/
public function getSubscriberToStep()
{
return $this->subscriberToStep;
}
/**
* get the step which is transitionning. Should be called only by event which will
* concern the transition.
*/
public function getTransitionningStep(): ?EntityWorkflowStep
{
return $this->transitionningStep;
}
public function getWorkflowName(): string
{
return $this->workflowName;
}
public function isFinal(): bool
{
$steps = $this->getStepsChained();
if (1 === count($steps)) {
// the initial step cannot be finalized
return false;
}
/** @var EntityWorkflowStep $last */
$last = end($steps);
return $last->isFinal();
}
public function isFreeze(): bool
{
$steps = $this->getStepsChained();
if (1 === count($steps)) {
// the initial step cannot be finalized
return false;
}
/** @var EntityWorkflowStep $last */
$last = end($steps);
return $last->getPrevious()->isFreezeAfter();
}
public function isUserSubscribedToFinal(User $user): bool
{
return $this->subscriberToFinal->contains($user);
}
public function isUserSubscribedToStep(User $user): bool
{
return $this->subscriberToStep->contains($user);
}
public function prepareStepBeforeTransition(EntityWorkflowStep $step): self
{
$this->transitionningStep = $step;
return $this;
}
public function removeComment(EntityWorkflowComment $comment): self
{
if ($this->comments->removeElement($comment)) {
$comment->setEntityWorkflow(null);
}
return $this;
}
public function removeStep(EntityWorkflowStep $step): self
{
if ($this->steps->removeElement($step)) {
$step->setEntityWorkflow(null);
}
return $this;
}
public function removeSubscriberToFinal(User $user): self
{
$this->subscriberToFinal->removeElement($user);
return $this;
}
public function removeSubscriberToStep(User $user): self
{
$this->subscriberToStep->removeElement($user);
return $this;
}
public function setRelatedEntityClass(string $relatedEntityClass): EntityWorkflow
{
$this->relatedEntityClass = $relatedEntityClass;
return $this;
}
public function setRelatedEntityId(int $relatedEntityId): EntityWorkflow
{
$this->relatedEntityId = $relatedEntityId;
return $this;
}
/**
* Method use by marking store.
*
* @return $this
*/
public function setStep(string $step): self
{
$newStep = new EntityWorkflowStep();
$newStep->setCurrentStep($step);
// copy the freeze
if ($this->getCurrentStep()->isFreezeAfter()) {
$newStep->setFreezeAfter(true);
}
$this->addStep($newStep);
return $this;
}
public function setWorkflowName(string $workflowName): EntityWorkflow
{
$this->workflowName = $workflowName;
return $this;
}
private function getPreviousStepIfAny(): ?EntityWorkflowStep
{
if (1 === count($this->steps)) {
return null;
}
return $this->steps->get($this->steps->count() - 2);
}
}

View File

@@ -0,0 +1,78 @@
<?php
/**
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Chill\MainBundle\Entity\Workflow;
use Chill\MainBundle\Doctrine\Model\TrackCreationInterface;
use Chill\MainBundle\Doctrine\Model\TrackCreationTrait;
use Chill\MainBundle\Doctrine\Model\TrackUpdateInterface;
use Chill\MainBundle\Doctrine\Model\TrackUpdateTrait;
use Doctrine\ORM\Mapping as ORM;
/**
* @ORM\Entity
* @ORM\Table("chill_main_workflow_entity_comment")
*/
class EntityWorkflowComment implements TrackCreationInterface, TrackUpdateInterface
{
use TrackCreationTrait;
use TrackUpdateTrait;
/**
* @ORM\Column(type="text", options={"default": ""})
*/
private string $comment = '';
/**
* @ORM\ManyToOne(targetEntity=EntityWorkflow::class, inversedBy="comments")
*/
private ?EntityWorkflow $entityWorkflow = null;
/**
* @ORM\Id
* @ORM\GeneratedValue
* @ORM\Column(type="integer")
*/
private ?int $id = null;
public function getComment(): string
{
return $this->comment;
}
public function getEntityWorkflow(): ?EntityWorkflow
{
return $this->entityWorkflow;
}
public function getId(): ?int
{
return $this->id;
}
public function setComment(string $comment): self
{
$this->comment = $comment;
return $this;
}
/**
* @internal use @see{EntityWorkflow::addComment}
*/
public function setEntityWorkflow(?EntityWorkflow $entityWorkflow): self
{
$this->entityWorkflow = $entityWorkflow;
return $this;
}
}

View File

@@ -0,0 +1,336 @@
<?php
/**
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Chill\MainBundle\Entity\Workflow;
use Chill\MainBundle\Entity\User;
use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\Context\ExecutionContextInterface;
use function count;
use function in_array;
/**
* @ORM\Entity
* @ORM\Table("chill_main_workflow_entity_step")
*/
class EntityWorkflowStep
{
/**
* @ORM\Column(type="text", options={"default": ""})
*/
private string $comment = '';
/**
* @ORM\Column(type="text")
*/
private ?string $currentStep = '';
/**
* @ORM\Column(type="json")
*/
private array $destEmail = [];
/**
* @ORM\ManyToMany(targetEntity=User::class)
* @ORM\JoinTable(name="chill_main_workflow_entity_step_user")
*/
private Collection $destUser;
/**
* @ORM\ManyToOne(targetEntity=EntityWorkflow::class, inversedBy="steps")
*/
private ?EntityWorkflow $entityWorkflow = null;
/**
* @ORM\Column(type="boolean", options={"default": false})
*/
private bool $freezeAfter = false;
/**
* @ORM\Id
* @ORM\GeneratedValue
* @ORM\Column(type="integer")
*/
private ?int $id = null;
/**
* @ORM\Column(type="boolean", options={"default": false})
*/
private bool $isFinal = false;
/**
* filled by @see{EntityWorkflow::getStepsChained}.
*/
private ?EntityWorkflowStep $next = null;
/**
* filled by @see{EntityWorkflow::getStepsChained}.
*/
private ?EntityWorkflowStep $previous = null;
/**
* @ORM\Column(type="text", nullable=true, options={"default": null})
*/
private ?string $transitionAfter = null;
/**
* @ORM\Column(type="datetime_immutable")
*/
private ?DateTimeImmutable $transitionAt = null;
/**
* @ORM\ManyToOne(targetEntity=User::class)
* @ORM\JoinColumn(nullable=true)
*/
private ?User $transitionBy = null;
/**
* @ORM\Column(type="text", nullable=true)
*/
private ?string $transitionByEmail = null;
public function __construct()
{
$this->destUser = new ArrayCollection();
}
public function addDestEmail(string $email): self
{
if (!in_array($email, $this->destEmail, true)) {
$this->destEmail[] = $email;
}
return $this;
}
public function addDestUser(User $user): self
{
if (!$this->destUser->contains($user)) {
$this->destUser[] = $user;
}
return $this;
}
public function getComment(): string
{
return $this->comment;
}
public function getCurrentStep(): ?string
{
return $this->currentStep;
}
public function getDestEmail(): array
{
return $this->destEmail;
}
/**
* @return ArrayCollection|Collection
*/
public function getDestUser()
{
return $this->destUser;
}
public function getEntityWorkflow(): ?EntityWorkflow
{
return $this->entityWorkflow;
}
public function getId(): ?int
{
return $this->id;
}
public function getNext(): ?EntityWorkflowStep
{
return $this->next;
}
public function getPrevious(): ?EntityWorkflowStep
{
return $this->previous;
}
public function getTransitionAfter(): ?string
{
return $this->transitionAfter;
}
public function getTransitionAt(): ?DateTimeImmutable
{
return $this->transitionAt;
}
public function getTransitionBy(): ?User
{
return $this->transitionBy;
}
public function getTransitionByEmail(): ?string
{
return $this->transitionByEmail;
}
public function isFinal(): bool
{
return $this->isFinal;
}
public function isFreezeAfter(): bool
{
return $this->freezeAfter;
}
public function removeDestEmail(string $email): self
{
$this->destEmail = array_filter($this->destEmail, static function (string $existing) use ($email) {
return $email !== $existing;
});
return $this;
}
public function removeDestUser(User $user): self
{
$this->destUser->removeElement($user);
return $this;
}
public function setComment(?string $comment): EntityWorkflowStep
{
$this->comment = (string) $comment;
return $this;
}
public function setCurrentStep(?string $currentStep): EntityWorkflowStep
{
$this->currentStep = $currentStep;
return $this;
}
public function setDestEmail(array $destEmail): EntityWorkflowStep
{
$this->destEmail = $destEmail;
return $this;
}
/**
* @internal use @see(EntityWorkflow::addStep} instead
*/
public function setEntityWorkflow(?EntityWorkflow $entityWorkflow): EntityWorkflowStep
{
$this->entityWorkflow = $entityWorkflow;
return $this;
}
public function setFreezeAfter(bool $freezeAfter): EntityWorkflowStep
{
$this->freezeAfter = $freezeAfter;
return $this;
}
public function setIsFinal(bool $isFinal): EntityWorkflowStep
{
$this->isFinal = $isFinal;
return $this;
}
/**
* @return EntityWorkflowStep
*
* @internal
*/
public function setNext(?EntityWorkflowStep $next): self
{
$this->next = $next;
return $this;
}
/**
* @return EntityWorkflowStep
*
* @internal
*/
public function setPrevious(?EntityWorkflowStep $previous): self
{
$this->previous = $previous;
return $this;
}
public function setTransitionAfter(?string $transitionAfter): EntityWorkflowStep
{
$this->transitionAfter = $transitionAfter;
return $this;
}
public function setTransitionAt(?DateTimeImmutable $transitionAt): EntityWorkflowStep
{
$this->transitionAt = $transitionAt;
return $this;
}
public function setTransitionBy(?User $transitionBy): EntityWorkflowStep
{
$this->transitionBy = $transitionBy;
return $this;
}
public function setTransitionByEmail(?string $transitionByEmail): EntityWorkflowStep
{
$this->transitionByEmail = $transitionByEmail;
return $this;
}
/**
* @Assert\Callback
*
* @param mixed $payload
*/
public function validateOnCreation(ExecutionContextInterface $context, $payload): void
{
return;
if ($this->isFinalizeAfter()) {
if (0 !== count($this->getDestUser())) {
$context->buildViolation('workflow.No dest users when the workflow is finalized')
->atPath('finalizeAfter')
->addViolation();
}
} else {
if (0 === count($this->getDestUser())) {
$context->buildViolation('workflow.The next step must count at least one dest')
->atPath('finalizeAfter')
->addViolation();
}
}
}
}

View File

@@ -0,0 +1,27 @@
<?php
/**
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Chill\MainBundle\Form;
use Chill\MainBundle\Form\Type\ChillTextareaType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
class EntityWorkflowCommentType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->add('comment', ChillTextareaType::class, [
'required' => false,
]);
}
}

View File

@@ -54,7 +54,6 @@ final class LocationFormType extends AbstractType
'label' => 'Address',
'use_valid_from' => false,
'use_valid_to' => false,
'mapped' => false,
])
->add(
'active',

View File

@@ -39,6 +39,17 @@ final class LocationTypeType extends AbstractType
'expanded' => true,
]
)
->add(
'editableByUsers',
ChoiceType::class,
[
'choices' => [
'Yes' => true,
'No' => false,
],
'expanded' => true,
]
)
->add(
'addressRequired',
ChoiceType::class,

View File

@@ -12,14 +12,18 @@ declare(strict_types=1);
namespace Chill\MainBundle\Form\Type\DataTransformer;
use Chill\MainBundle\Entity\User;
use Chill\PersonBundle\Entity\Person;
use Chill\ThirdPartyBundle\Entity\ThirdParty;
use Symfony\Component\Form\DataTransformerInterface;
use Symfony\Component\Form\Exception\TransformationFailedException;
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
use Symfony\Component\Serializer\SerializerInterface;
use UnexpectedValueException;
use function array_key_exists;
class UserToJsonTransformer implements DataTransformerInterface
class EntityToJsonTransformer implements DataTransformerInterface
{
private DenormalizerInterface $denormalizer;
@@ -27,23 +31,36 @@ class UserToJsonTransformer implements DataTransformerInterface
private SerializerInterface $serializer;
public function __construct(DenormalizerInterface $denormalizer, SerializerInterface $serializer, bool $multiple)
private string $type;
public function __construct(DenormalizerInterface $denormalizer, SerializerInterface $serializer, bool $multiple, string $type)
{
$this->denormalizer = $denormalizer;
$this->serializer = $serializer;
$this->multiple = $multiple;
$this->type = $type;
}
public function reverseTransform($value)
{
$denormalized = json_decode($value, true);
if ($this->multiple) {
if (null === $denormalized) {
return [];
}
return array_map(
function ($item) { return $this->denormalizeOne($item); },
json_decode($value, true)
$denormalized
);
}
return $this->denormalizeOne(json_decode($value, true));
if ('' === $value) {
return null;
}
return $this->denormalizeOne($denormalized);
}
/**
@@ -60,7 +77,7 @@ class UserToJsonTransformer implements DataTransformerInterface
]);
}
private function denormalizeOne(array $item): User
private function denormalizeOne(array $item)
{
if (!array_key_exists('type', $item)) {
throw new TransformationFailedException('the key "type" is missing on element');
@@ -70,10 +87,30 @@ class UserToJsonTransformer implements DataTransformerInterface
throw new TransformationFailedException('the key "id" is missing on element');
}
switch ($this->type) {
case 'user':
$class = User::class;
break;
case 'person':
$class = Person::class;
break;
case 'thirdparty':
$class = ThirdParty::class;
break;
default:
throw new UnexpectedValueException('This type is not supported');
}
return
$this->denormalizer->denormalize(
['type' => $item['type'], 'id' => $item['id']],
User::class,
$class,
'json',
[AbstractNormalizer::GROUPS => ['read']],
);

View File

@@ -12,7 +12,7 @@ declare(strict_types=1);
namespace Chill\MainBundle\Form\Type;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Form\Type\DataTransformer\UserToJsonTransformer;
use Chill\MainBundle\Form\Type\DataTransformer\EntityToJsonTransformer;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormInterface;
@@ -38,7 +38,7 @@ class PickUserDynamicType extends AbstractType
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder->addViewTransformer(new UserToJsonTransformer($this->denormalizer, $this->serializer, $options['multiple']));
$builder->addViewTransformer(new EntityToJsonTransformer($this->denormalizer, $this->serializer, $options['multiple'], 'user'));
}
public function buildView(FormView $view, FormInterface $form, array $options)
@@ -58,6 +58,6 @@ class PickUserDynamicType extends AbstractType
public function getBlockPrefix()
{
return 'pick_user_dynamic';
return 'pick_entity_dynamic';
}
}

View File

@@ -0,0 +1,73 @@
<?php
/**
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Chill\MainBundle\Form\Type;
use Chill\MainBundle\Entity\ResidentialAddress;
use Chill\PersonBundle\Form\Type\PickPersonDynamicType;
use Chill\ThirdPartyBundle\Form\Type\PickThirdpartyDynamicType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\DateType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
final class ResidentialAddressType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->add('startDate', DateType::class, [
'required' => true,
'input' => 'datetime_immutable',
'widget' => 'single_text',
])
->add('endDate', DateType::class, [
'required' => false,
'input' => 'datetime_immutable',
'widget' => 'single_text',
])
->add('comment', CommentType::class, [
'required' => false,
]);
if ('person' === $options['kind']) {
$builder
->add('hostPerson', PickPersonDynamicType::class, [
'label' => 'Person',
]);
}
if ('thirdparty' === $options['kind']) {
$builder
->add('hostThirdParty', PickThirdpartyDynamicType::class, [
'label' => 'Third party',
]);
}
if ('address' === $options['kind']) {
$builder
->add('address', PickAddressType::class, [
'required' => false,
'label' => 'Address',
'use_valid_from' => false,
'use_valid_to' => false,
]);
}
}
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults([
'data_class' => ResidentialAddress::class,
'kind' => null,
]);
}
}

View File

@@ -12,6 +12,7 @@ declare(strict_types=1);
namespace Chill\MainBundle\Form;
use Chill\MainBundle\Entity\Center;
use Chill\MainBundle\Entity\Location;
use Chill\MainBundle\Entity\Scope;
use Chill\MainBundle\Entity\UserJob;
use Chill\MainBundle\Templating\TranslatableStringHelper;
@@ -75,6 +76,22 @@ class UserType extends AbstractType
'choice_label' => function (UserJob $c) {
return $this->translatableStringHelper->localize($c->getLabel());
},
])
->add('mainLocation', EntityType::class, [
'label' => 'Main location',
'required' => false,
'placeholder' => 'choose a location',
'class' => Location::class,
'choice_label' => function (Location $l) {
return $this->translatableStringHelper->localize($l->getLocationType()->getTitle()) . ' - ' . $l->getName();
},
'query_builder' => static function (EntityRepository $er) {
$qb = $er->createQueryBuilder('l');
$qb->orderBy('l.locationType');
$qb->where('l.availableForUsers = TRUE');
return $qb;
},
]);
if ($options['is_creation']) {

View File

@@ -0,0 +1,180 @@
<?php
/**
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Chill\MainBundle\Form;
use Chill\MainBundle\Entity\Workflow\EntityWorkflow;
use Chill\MainBundle\Entity\Workflow\EntityWorkflowStep;
use Chill\MainBundle\Form\Type\ChillTextareaType;
use Chill\MainBundle\Form\Type\PickUserDynamicType;
use Chill\MainBundle\Templating\TranslatableStringHelperInterface;
use Chill\MainBundle\Workflow\EntityWorkflowManager;
use LogicException;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Workflow\Registry;
use Symfony\Component\Workflow\Transition;
use function array_key_exists;
class WorkflowStepType extends AbstractType
{
private EntityWorkflowManager $entityWorkflowManager;
private Registry $registry;
private TranslatableStringHelperInterface $translatableStringHelper;
public function __construct(EntityWorkflowManager $entityWorkflowManager, Registry $registry, TranslatableStringHelperInterface $translatableStringHelper)
{
$this->entityWorkflowManager = $entityWorkflowManager;
$this->registry = $registry;
$this->translatableStringHelper = $translatableStringHelper;
}
public function buildForm(FormBuilderInterface $builder, array $options)
{
/** @var \Chill\MainBundle\Entity\Workflow\EntityWorkflow $entityWorkflow */
$entityWorkflow = $options['entity_workflow'];
$handler = $this->entityWorkflowManager->getHandler($entityWorkflow);
$workflow = $this->registry->get($entityWorkflow, $entityWorkflow->getWorkflowName());
$place = $workflow->getMarking($entityWorkflow);
$placeMetadata = $workflow->getMetadataStore()->getPlaceMetadata(array_keys($place->getPlaces())[0]);
if (true === $options['transition']) {
if (null === $options['entity_workflow']) {
throw new LogicException('if transition is true, entity_workflow should be defined');
}
$transitions = $this->registry
->get($options['entity_workflow'], $entityWorkflow->getWorkflowName())
->getEnabledTransitions($entityWorkflow);
$choices = array_combine(
array_map(
static function (Transition $transition) {
return $transition->getName();
},
$transitions
),
$transitions
);
if (array_key_exists('validationFilterInputLabels', $placeMetadata)) {
$inputLabels = $placeMetadata['validationFilterInputLabels'];
$builder->add('transitionFilter', ChoiceType::class, [
'multiple' => false,
'label' => 'workflow.My decision',
'choices' => [
'forward' => 'forward',
'backward' => 'backward',
'neutral' => 'neutral',
],
'choice_label' => function (string $key) use ($inputLabels) {
return $this->translatableStringHelper->localize($inputLabels[$key]);
},
'choice_attr' => static function (string $key) {
return [
$key => $key,
];
},
'mapped' => false,
'expanded' => true,
'data' => 'forward',
]);
}
$builder
->add('transition', ChoiceType::class, [
'label' => 'workflow.Next step',
'mapped' => false,
'multiple' => false,
'expanded' => true,
'choices' => $choices,
'choice_label' => function (Transition $transition) use ($workflow) {
$meta = $workflow->getMetadataStore()->getTransitionMetadata($transition);
if (array_key_exists('label', $meta)) {
return $this->translatableStringHelper->localize($meta['label']);
}
return $transition->getName();
},
'choice_attr' => static function (Transition $transition) use ($workflow) {
$toFinal = true;
$isForward = 'neutral';
$metadata = $workflow->getMetadataStore()->getTransitionMetadata($transition);
if (array_key_exists('isForward', $metadata)) {
if ($metadata['isForward']) {
$isForward = 'forward';
} else {
$isForward = 'backward';
}
}
foreach ($transition->getTos() as $to) {
$meta = $workflow->getMetadataStore()->getPlaceMetadata($to);
if (
!array_key_exists('isFinal', $meta) || false === $meta['isFinal']
) {
$toFinal = false;
}
}
return [
'data-is-transition' => 'data-is-transition',
'data-to-final' => $toFinal ? '1' : '0',
'data-is-forward' => $isForward,
];
},
])
->add('future_dest_users', PickUserDynamicType::class, [
'label' => 'workflow.dest for next steps',
'multiple' => true,
'mapped' => false,
]);
}
if (
$handler->supportsFreeze($entityWorkflow)
&& !$entityWorkflow->isFreeze()
) {
$builder
->add('freezeAfter', CheckboxType::class, [
'required' => false,
'label' => 'workflow.Freeze',
'help' => 'workflow.The associated element will be freezed',
]);
}
$builder
->add('comment', ChillTextareaType::class, [
'required' => false,
'label' => 'Comment',
]);
}
public function configureOptions(OptionsResolver $resolver)
{
$resolver
->setDefined('class', EntityWorkflowStep::class)
->setRequired('transition')
->setAllowedTypes('transition', 'bool')
->setRequired('entity_workflow')
->setAllowedTypes('entity_workflow', EntityWorkflow::class);
}
}

View File

@@ -0,0 +1,36 @@
<?php
/**
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Chill\MainBundle\Form;
use Chill\MainBundle\Entity\Workflow\EntityWorkflow;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class WorkflowTransitionType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->add('current_step', WorkflowStepType::class, [
'transition' => true,
'entity_workflow' => $options['entity_workflow'],
]);
}
public function configureOptions(OptionsResolver $resolver)
{
$resolver
->setRequired('entity_workflow')
->setAllowedTypes('entity_workflow', EntityWorkflow::class);
}
}

View File

@@ -50,7 +50,7 @@ class NotificationMailer
$email = new TemplatedEmail();
$email
->to($dest->getEmail())
->subject('Re: [Chill] ' . $comment->getNotification()->getTitle())
->subject('Re: ' . $comment->getNotification()->getTitle())
->textTemplate('@ChillMain/Notification/email_notification_comment_persist.fr.md.twig')
->context([
'comment' => $comment,
@@ -79,11 +79,13 @@ class NotificationMailer
continue;
}
$email = new Email();
$email
->subject($notification->getTitle());
if ($notification->isSystem()) {
$email = new Email();
$email
->text($notification->getMessage())
->subject('[Chill] ' . $notification->getTitle());
->text($notification->getMessage());
} else {
$email = new TemplatedEmail();
$email

View File

@@ -15,12 +15,15 @@ use Chill\MainBundle\Entity\Notification;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Repository\NotificationRepository;
use Symfony\Component\Security\Core\Security;
use function array_key_exists;
/**
* Helps to find if a notification exist for a given entity.
*/
class NotificationPresence
{
private array $cache = [];
private NotificationRepository $notificationRepository;
private Security $security;
@@ -31,6 +34,29 @@ class NotificationPresence
$this->notificationRepository = $notificationRepository;
}
public function countNotificationsForClassAndEntity(string $relatedEntityClass, int $relatedEntityId): array
{
if (array_key_exists($relatedEntityClass, $this->cache) && array_key_exists($relatedEntityId, $this->cache[$relatedEntityClass])) {
return $this->cache[$relatedEntityClass][$relatedEntityId];
}
$user = $this->security->getUser();
if ($user instanceof User) {
$counter = $this->notificationRepository->countNotificationByRelatedEntityAndUserAssociated(
$relatedEntityClass,
$relatedEntityId,
$user
);
$this->cache[$relatedEntityClass][$relatedEntityId] = $counter;
return $counter;
}
return ['unread' => 0, 'read' => 0];
}
/**
* @return array|Notification[]
*/

View File

@@ -23,6 +23,13 @@ class NotificationTwigExtension extends AbstractExtension
'needs_environment' => true,
'is_safe' => ['html'],
]),
new TwigFunction('chill_count_notifications', [NotificationTwigExtensionRuntime::class, 'countNotificationsFor'], [
'is_safe' => [],
]),
new TwigFunction('chill_counter_notifications', [NotificationTwigExtensionRuntime::class, 'counterNotificationFor'], [
'needs_environment' => true,
'is_safe' => ['html'],
]),
];
}
}

View File

@@ -11,17 +11,42 @@ declare(strict_types=1);
namespace Chill\MainBundle\Notification\Templating;
use Chill\MainBundle\Entity\NotificationComment;
use Chill\MainBundle\Form\NotificationCommentType;
use Chill\MainBundle\Notification\NotificationPresence;
use Symfony\Component\Form\FormFactoryInterface;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Twig\Environment;
use Twig\Extension\RuntimeExtensionInterface;
class NotificationTwigExtensionRuntime implements RuntimeExtensionInterface
{
private FormFactoryInterface $formFactory;
private NotificationPresence $notificationPresence;
public function __construct(NotificationPresence $notificationPresence)
private UrlGeneratorInterface $urlGenerator;
public function __construct(FormFactoryInterface $formFactory, NotificationPresence $notificationPresence, UrlGeneratorInterface $urlGenerator)
{
$this->formFactory = $formFactory;
$this->notificationPresence = $notificationPresence;
$this->urlGenerator = $urlGenerator;
}
public function counterNotificationFor(Environment $environment, string $relatedEntityClass, int $relatedEntityId, array $options = []): string
{
return $environment->render(
'@ChillMain/Notification/extension_counter_notifications_for.html.twig',
[
'counter' => $this->notificationPresence->countNotificationsForClassAndEntity($relatedEntityClass, $relatedEntityId),
]
);
}
public function countNotificationsFor(string $relatedEntityClass, int $relatedEntityId, array $options = []): array
{
return $this->notificationPresence->countNotificationsForClassAndEntity($relatedEntityClass, $relatedEntityId);
}
public function listNotificationsFor(Environment $environment, string $relatedEntityClass, int $relatedEntityId, array $options = []): string
@@ -32,8 +57,24 @@ class NotificationTwigExtensionRuntime implements RuntimeExtensionInterface
return '';
}
$appendCommentForms = [];
foreach ($notifications as $notification) {
$appendComment = new NotificationComment();
$appendCommentForms[$notification->getId()] = $this->formFactory->create(
NotificationCommentType::class,
$appendComment,
[
'action' => $this->urlGenerator->generate(
'chill_main_notification_show',
['id' => $notification->getId()]
),
]
)->createView();
}
return $environment->render('@ChillMain/Notification/extension_list_notifications_for.html.twig', [
'notifications' => $notifications,
'notifications' => $notifications, 'appendCommentForms' => $appendCommentForms,
]);
}
}

View File

@@ -13,6 +13,7 @@ namespace Chill\MainBundle\Repository;
use Chill\MainBundle\Entity\Notification;
use Chill\MainBundle\Entity\User;
use Doctrine\DBAL\Statement;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityRepository;
@@ -24,6 +25,8 @@ final class NotificationRepository implements ObjectRepository
{
private EntityManagerInterface $em;
private ?Statement $notificationByRelatedEntityAndUserAssociatedStatement = null;
private EntityRepository $repository;
public function __construct(EntityManagerInterface $entityManager)
@@ -48,6 +51,30 @@ final class NotificationRepository implements ObjectRepository
->getSingleScalarResult();
}
public function countNotificationByRelatedEntityAndUserAssociated(string $relatedEntityClass, int $relatedEntityId, User $user): array
{
if (null === $this->notificationByRelatedEntityAndUserAssociatedStatement) {
$sql =
'SELECT
SUM((EXISTS (SELECT 1 AS c FROM chill_main_notification_addresses_unread cmnau WHERE user_id = 1812 and cmnau.notification_id = cmn.id))::int) AS unread,
SUM((cmn.sender_id = 1812)::int) AS sent,
COUNT(cmn.*) AS total
FROM chill_main_notification cmn
WHERE relatedentityclass = :relatedEntityClass AND relatedentityid = :relatedEntityId AND sender_id IS NOT NULL';
$this->notificationByRelatedEntityAndUserAssociatedStatement =
$this->em->getConnection()->prepare($sql);
}
$results = $this->notificationByRelatedEntityAndUserAssociatedStatement
->executeQuery(['relatedEntityClass' => $relatedEntityClass, 'relatedEntityId' => $relatedEntityId]);
$result = $results->fetchAssociative();
$results->free();
return $result;
}
public function countUnreadByUser(User $user): int
{
$sql = 'SELECT count(*) AS c FROM chill_main_notification_addresses_unread WHERE user_id = :userId';
@@ -153,11 +180,52 @@ final class NotificationRepository implements ObjectRepository
* @return array|Notification[]
*/
public function findNotificationByRelatedEntityAndUserAssociated(string $relatedEntityClass, int $relatedEntityId, User $user): array
{
return
$this->buildQueryNotificationByRelatedEntityAndUserAssociated($relatedEntityClass, $relatedEntityId, $user)
->select('n')
->getQuery()
->getResult();
}
public function findOneBy(array $criteria, ?array $orderBy = null): ?Notification
{
return $this->repository->findOneBy($criteria, $orderBy);
}
/**
* @return array|Notification[]
*/
public function findUnreadByUser(User $user, int $limit = 20, int $offset = 0): array
{
$rsm = new Query\ResultSetMappingBuilder($this->em);
$rsm->addRootEntityFromClassMetadata(Notification::class, 'cmn');
$sql = 'SELECT ' . $rsm->generateSelectClause(['cmn' => 'cmn']) . ' ' .
'FROM chill_main_notification cmn ' .
'WHERE ' .
'EXISTS (select 1 FROM chill_main_notification_addresses_unread cmnau WHERE cmnau.user_id = :userId and cmnau.notification_id = cmn.id) ' .
'ORDER BY cmn.date DESC ' .
'LIMIT :limit OFFSET :offset';
$nq = $this->em->createNativeQuery($sql, $rsm)
->setParameter('userId', $user->getId())
->setParameter('limit', $limit)
->setParameter('offset', $offset);
return $nq->getResult();
}
public function getClassName()
{
return Notification::class;
}
private function buildQueryNotificationByRelatedEntityAndUserAssociated(string $relatedEntityClass, int $relatedEntityId, User $user): QueryBuilder
{
$qb = $this->repository->createQueryBuilder('n');
$qb
->select('n')
->where($qb->expr()->eq('n.relatedEntityClass', ':relatedEntityClass'))
->andWhere($qb->expr()->eq('n.relatedEntityId', ':relatedEntityId'))
->andWhere($qb->expr()->isNotNull('n.sender'))
@@ -171,17 +239,7 @@ final class NotificationRepository implements ObjectRepository
->setParameter('relatedEntityId', $relatedEntityId)
->setParameter('user', $user);
return $qb->getQuery()->getResult();
}
public function findOneBy(array $criteria, ?array $orderBy = null): ?Notification
{
return $this->repository->findOneBy($criteria, $orderBy);
}
public function getClassName()
{
return Notification::class;
return $qb;
}
private function queryByAddressee(User $addressee, bool $countQuery = false): QueryBuilder

View File

@@ -0,0 +1,59 @@
<?php
/**
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Chill\MainBundle\Repository;
use Chill\MainBundle\Entity\ResidentialAddress;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @method ResidentialAddress|null find($id, $lockMode = null, $lockVersion = null)
* @method ResidentialAddress|null findOneBy(array $criteria, array $orderBy = null)
* @method ResidentialAddress[] findAll()
* @method ResidentialAddress[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
*/
class ResidentialAddressRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, ResidentialAddress::class);
}
// /**
// * @return ResidentialAddress[] Returns an array of ResidentialAddress objects
// */
/*
public function findByExampleField($value)
{
return $this->createQueryBuilder('r')
->andWhere('r.exampleField = :val')
->setParameter('val', $value)
->orderBy('r.id', 'ASC')
->setMaxResults(10)
->getQuery()
->getResult()
;
}
*/
/*
public function findOneBySomeField($value): ?ResidentialAddress
{
return $this->createQueryBuilder('r')
->andWhere('r.exampleField = :val')
->setParameter('val', $value)
->getQuery()
->getOneOrNullResult()
;
}
*/
}

View File

@@ -0,0 +1,138 @@
<?php
/**
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Chill\MainBundle\Repository\Workflow;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Entity\Workflow\EntityWorkflow;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\QueryBuilder;
use Doctrine\Persistence\ObjectRepository;
class EntityWorkflowRepository implements ObjectRepository
{
private EntityRepository $repository;
public function __construct(EntityManagerInterface $entityManager)
{
$this->repository = $entityManager->getRepository(EntityWorkflow::class);
}
public function countByDest(User $user): int
{
$qb = $this->buildQueryByDest($user)->select('count(ew)');
return (int) $qb->getQuery()->getSingleScalarResult();
}
public function countBySubscriber(User $user): int
{
$qb = $this->buildQueryBySubscriber($user)->select('count(ew)');
return (int) $qb->getQuery()->getSingleScalarResult();
}
public function find($id): ?EntityWorkflow
{
return $this->repository->find($id);
}
/**
* @return array|EntityWorkflow[]
*/
public function findAll(): array
{
return $this->repository->findAll();
}
/**
* @param null|mixed $limit
* @param null|mixed $offset
*
* @return array|EntityWorkflow[]
*/
public function findBy(array $criteria, ?array $orderBy = null, $limit = null, $offset = null): array
{
return $this->repository->findBy($criteria, $orderBy, $limit, $offset);
}
public function findByDest(User $user, ?array $orderBy = null, $limit = null, $offset = null): array
{
$qb = $this->buildQueryByDest($user)->select('ew');
foreach ($orderBy as $key => $sort) {
$qb->addOrderBy('ew.' . $key, $sort);
}
$qb->setMaxResults($limit)->setFirstResult($offset);
return $qb->getQuery()->getResult();
}
public function findBySubscriber(User $user, ?array $orderBy = null, $limit = null, $offset = null): array
{
$qb = $this->buildQueryBySubscriber($user)->select('ew');
foreach ($orderBy as $key => $sort) {
$qb->addOrderBy('ew.' . $key, $sort);
}
$qb->setMaxResults($limit)->setFirstResult($offset);
return $qb->getQuery()->getResult();
}
public function findOneBy(array $criteria): ?EntityWorkflow
{
return $this->repository->findOneBy($criteria);
}
public function getClassName(): string
{
return EntityWorkflow::class;
}
private function buildQueryByDest(User $user): QueryBuilder
{
$qb = $this->repository->createQueryBuilder('ew');
$qb->join('ew.steps', 'step');
$qb->where(
$qb->expr()->andX(
$qb->expr()->isMemberOf(':user', 'step.destUser'),
$qb->expr()->isNull('step.transitionAfter'),
$qb->expr()->eq('step.isFinal', "'FALSE'")
)
);
$qb->setParameter('user', $user);
return $qb;
}
private function buildQueryBySubscriber(User $user): QueryBuilder
{
$qb = $this->repository->createQueryBuilder('ew');
$qb->where(
$qb->expr()->orX(
$qb->expr()->isMemberOf(':user', 'ew.subscriberToStep'),
$qb->expr()->isMemberOf(':user', 'ew.subscriberToFinal'),
)
);
$qb->setParameter('user', $user);
return $qb;
}
}

View File

@@ -0,0 +1,79 @@
<?php
/**
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Chill\MainBundle\Repository\Workflow;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Entity\Workflow\EntityWorkflowStep;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\QueryBuilder;
use Doctrine\Persistence\ObjectRepository;
class EntityWorkflowStepRepository implements ObjectRepository
{
private EntityRepository $repository;
public function __construct(EntityManagerInterface $entityManager)
{
$this->repository = $entityManager->getRepository(EntityWorkflowStep::class);
}
public function countUnreadByUser(User $user, ?array $orderBy = null, $limit = null, $offset = null): int
{
$qb = $this->buildQueryByUser($user)->select('count(e)');
return (int) $qb->getQuery()->getSingleScalarResult();
}
public function find($id): ?EntityWorkflowStep
{
return $this->repository->find($id);
}
public function findAll(): array
{
return $this->repository->findAll();
}
public function findBy(array $criteria, ?array $orderBy = null, $limit = null, $offset = null): array
{
return $this->repository->findBy($criteria, $orderBy, $limit, $offset);
}
public function findOneBy(array $criteria): ?EntityWorkflowStep
{
return $this->repository->findOneBy($criteria);
}
public function getClassName()
{
return EntityWorkflow::class;
}
private function buildQueryByUser(User $user): QueryBuilder
{
$qb = $this->repository->createQueryBuilder('e');
$qb->where(
$qb->expr()->andX(
$qb->expr()->isMemberOf(':user', 'e.destUser'),
$qb->expr()->isNull('e.transitionAt'),
$qb->expr()->eq('e.isFinal', ':bool'),
)
);
$qb->setParameter('user', $user);
$qb->setParameter('bool', false);
return $qb;
}
}

View File

@@ -1,5 +1,5 @@
// Access to Bootstrap variables and mixins
@import '~ChillMainAssets/module/bootstrap/shared';
@import 'ChillMainAssets/module/bootstrap/shared';
// Chill variables
@import './scss/chill_variables';
@@ -240,6 +240,10 @@ table.table-bordered {
color: $gray-800;
font-size: 90%;
p {
margin-bottom: 0.75rem !important;
}
// test a bottom right decoration (to be confirmed)
&.test {
position: relative;
@@ -273,11 +277,17 @@ table.table-bordered {
}
}
/// meta-data
div.updatedBy,
div.metadata {
span.user, span.date {
text-decoration: underline dotted;
}
}
div.metadata {
font-size: smaller;
color: $gray-600;
span.user, span.date {
text-decoration: underline dotted;
&:hover {
color: $gray-700;
}
@@ -318,6 +328,7 @@ dl.definition-inline {
.custom_field_no_data,
.chill-no-data-statement {
font-style: italic;
font-size: 90%;
}
/// flash
@@ -418,3 +429,65 @@ span.item-key {
background-color: #0000000a;
//text-decoration: dotted underline;
}
/// Workflows
div.workflow {
section.step {
border: 1px solid $chill-l-gray;
padding: 1em 2em;
div.flex-table {
margin: 1.5em -2em;
}
}
div.to-decision,
div.decided {
font-variant: all-small-caps;
margin-left: 1em;
}
div.to-decision {
font-weight: 300;
}
div.decided {
font-weight: 600;
}
div.breadcrumb {
display: initial;
margin-bottom: 0;
padding-right: 0.5em;
background-color: tint-color($chill-yellow, 90%);
border: 1px solid $chill-yellow;
color: $primary;
border-radius: 1.5em;
font-size: 12pt;
font-weight: 500;
font-variant: small-caps;
span, a {
cursor: pointer;
text-decoration: none;
&:hover {
font-weight: 700;
}
}
}
}
// Override bootstrap popover styles
div.popover {
box-shadow: 0 0 10px -5px $dark;
z-index: 9999;
.popover-arrow {}
.popover-header {}
.popover-body {}
// Specific worflow breadcrumb popover
&.workflow-transition {
.popover-header {
font-variant: small-caps;
}
}
}
// increase toast message z-index (above all modals)
div.v-toast {
z-index: 10000!important;
}

View File

@@ -25,6 +25,7 @@ import { chill } from './js/chill.js';
global.chill = chill;
require('./js/date.js');
require('./js/counter.js');
/// Load fonts
require('./fonts/OpenSans/OpenSans.scss')

View File

@@ -0,0 +1,35 @@
/**
*
* This script search for span.counter elements like
* <span class="counter">Il y a 4 notifications</span>
* and return
* <span class="counter">Il y a <span>4</span> notifications</span>
*
*/
const isNum = (v) => !isNaN(v);
const parseCounter = () => {
document.querySelectorAll('span.counter')
.forEach(el => {
let r = [];
el.innerText
.trim()
.split(' ')
.forEach(w => {
if (isNum(w)) {
r.push(`<span>${w}</span>`);
} else {
r.push(w);
}
})
;
el.innerHTML = r.join(' ');
})
;
};
window.addEventListener('DOMContentLoaded', function (e) {
parseCounter();
});
export { parseCounter };

View File

@@ -12,6 +12,7 @@
display: block;
top: calc(50% - 7px);
right: 10px;
line-height: 11px;
}
}
@@ -62,14 +63,19 @@ ul.list-suggest {
& span:hover {
color: $chill-l-gray;
}
.person-text {
span {
padding-left: 0px;
}
}
}
}
&.remove-items {
li {
position: relative;
span {
& > span {
display: block;
padding-right: .75rem;
padding-right: 1.75rem;
@include remove_link;
}
}

View File

@@ -18,9 +18,11 @@ $chill-theme-buttons: (
"show": $chill-blue,
"view": $chill-blue,
"misc": $gray-300,
"download": $gray-300,
"cancel": $gray-300,
"choose": $gray-300,
"notify": $gray-300,
"notify": $chill-blue,
"search": $gray-300,
"unlink": $chill-red,
"tpchild": $chill-pink,
);
@@ -78,6 +80,8 @@ $chill-theme-buttons: (
&.btn-choose::before,
&.btn-notify::before,
&.btn-tpchild::before,
&.btn-download::before,
&.btn-search::before,
&.btn-cancel::before {
font: normal normal normal 14px/1 ForkAwesome;
margin-right: 0.5em;
@@ -105,6 +109,8 @@ $chill-theme-buttons: (
&.btn-unlink::before { content: "\f127"; } // fa-chain-broken
&.btn-notify::before { content: "\f1d8"; } // fa-paper-plane
&.btn-tpchild::before { content: "\f007"; } // fa-user
&.btn-download::before { content: "\f019"; } // fa-download
&.btn-search::before { content: "\f002"; } // fa-search
}
@@ -130,3 +136,18 @@ $chill-theme-buttons: (
.btn-sm, .btn-group-sm > .btn {
min-width: 36px;
}
// Homepage special fast action buttons
div.sticky-buttons {
position: fixed;
bottom: 3em;
right: 2em;
.btn-circle {
width: 50px; height: 50px;
border-radius: 50%;
text-align: center;
padding: 0.45rem 0.7rem;
display: block;
margin-bottom: 0.5rem;
}
}

View File

@@ -41,6 +41,15 @@ div.flex-table {
margin-right: 5px;
}
}
div.item-meta {
flex-grow: 1 !important;
flex-shrink: 1 !important;
width: unset !important;
display: flex;
flex-direction: column;
justify-content: center;
}
}
/*

View File

@@ -36,27 +36,65 @@ div.notification {
div.notification-list,
div.notification-show {
div.item-bloc {
div.item-row.header {
div.item-col {
&:first-child {
flex-grow: 1;
div.item-row {
&.notification-header {
div.item-col {
&:first-child {
flex-grow: 1;
}
&:last-child {
flex-grow: 0;
}
}
&:last-child {
flex-grow: 0;
ul.small_in_title {
list-style-type: circle;
li {
span.item-key {
display: inline-block;
width: 3em;
}
}
}
}
ul.small_in_title {
list-style-type: circle;
li {
span.item-key {
display: inline-block;
width: 3em;
}
div.notification-content {
margin: 1.5rem;
p {
margin-bottom: 0.75rem;
}
}
}
}
}
// Override bootstrap accordion
div#workflow-fold,
div#notification-fold {
.accordion-button {
padding: 0;
background-color: unset;
&:not(.collapsed) {
background-color: unset;
box-shadow: unset;
}
}
}
// Counter
div.notification-counter {
span.counter {
&:not(:first-child) {
&::before {
content: '/ ';
}
}
}
}
span.counter {
& > span {
font-weight: bold;
background-color: $chill-ll-gray;
padding: 0 0.4rem;
border-radius: 50%;
}
}

View File

@@ -106,6 +106,8 @@ section.chill-entity {
// used for comment-embeddable
&.entity-comment-embeddable {
width: 100%;
/* already defined !!
div.metadata {
font-size: smaller;
color: $gray-600;
@@ -116,5 +118,6 @@ section.chill-entity {
}
}
}
*/
}
}

View File

@@ -85,7 +85,9 @@ const fetchScopes = () => {
const ValidationException = (response) => {
const error = {};
error.name = 'ValidationException';
error.violations = response.violations.map((violation) => `${violation.title}`);
error.violations = response.violations.map((violation) => `${violation.title}: ${violation.propertyPath}`);
error.titles = response.violations.map((violation) => violation.title);
error.propertyPaths = response.violations.map((violation) => violation.propertyPath);
return error;
}

View File

@@ -0,0 +1,12 @@
const buildLinkCreate = function(workflowName, relatedEntityClass, relatedEntityId) {
let params = new URLSearchParams();
params.set('entityClass', relatedEntityClass);
params.set('entityId', relatedEntityId);
params.set('workflow', workflowName);
return `/fr/main/workflow/create?`+params.toString();
};
export {
buildLinkCreate,
};

View File

@@ -1,19 +1,19 @@
/**
* Create a control to show or hide values
*
*
* Possible options are:
*
*
* - froms: an Element, an Array of Element, or a NodeList. A
* listener will be attached to **all** input of those elements
* and will trigger the check on changes
* - test: a function which will test the element and will return true
* - test: a function which will test the element and will return true
* if the content must be shown, false if it must be hidden.
* The function will receive the `froms` as first argument, and the
* The function will receive the `froms` as first argument, and the
* event as second argument.
* - container: an Element, an Array of Element, or a Node List. The
* - container: an Element, an Array of Element, or a Node List. The
* child nodes will be hidden / shown inside this container
* - event_name: the name of the event to listen to. `'change'` by default.
*
*
* @param object options
*/
var ShowHide = function(options) {
@@ -26,8 +26,10 @@ var ShowHide = function(options) {
container_content = [],
debug = 'debug' in options ? options.debug : false,
load_event = 'load_event' in options ? options.load_event : 'load',
id = 'uid' in options ? options.id : Math.random();
id = 'uid' in options ? options.id : Math.random(),
toggle_callback = 'toggle_callback' in options ? options.toggle_callback : null
;
var bootstrap = function(event) {
if (debug) {
console.log('debug is activated on this show-hide', this);
@@ -43,10 +45,10 @@ var ShowHide = function(options) {
// attach the listener on each input
for (let f of froms.values()) {
let
inputs = f.querySelectorAll('input'),
let
inputs = f.querySelectorAll('input'),
selects = f.querySelectorAll('select');
for (let input of inputs.values()) {
if (debug) {
console.log('attaching event to input', input);
@@ -66,10 +68,10 @@ var ShowHide = function(options) {
}
// first launch of the show/hide
onChange(event);
onChange(event);
};
var onChange = function (event) {
var result = test(froms, event), me;
@@ -88,45 +90,53 @@ var ShowHide = function(options) {
} else {
throw "the result of test is not a boolean";
}
};
var forceHide = function() {
if (debug) {
console.log('force hide');
}
for (let contents of container_content.values()) {
for (let el of contents.values()) {
el.remove();
if (toggle_callback !== null) {
toggle_callback(container, 'hide');
} else {
for (let contents of container_content.values()) {
for (let el of contents.values()) {
el.remove();
}
}
}
is_shown = false;
};
var forceShow = function() {
if (debug) {
console.log('show');
}
for (let i of container_content.keys()) {
var contents = container_content[i];
for (let el of contents.values()) {
container[i].appendChild(el);
if (toggle_callback !== null) {
toggle_callback(container, 'show');
} else {
for (let i of container_content.keys()) {
var contents = container_content[i];
for (let el of contents.values()) {
container[i].appendChild(el);
}
}
}
is_shown = true;
};
var forceCompute = function(event) {
onChange(event);
};
if (load_event !== null) {
window.addEventListener('load', bootstrap);
} else {
bootstrap(null);
}
return {
forceHide: forceHide,
forceShow: forceShow,

View File

@@ -1,21 +1,19 @@
require('./blur.scss');
var toggleBlur = function(e){
var btn = e.target;
btn.previousElementSibling.classList.toggle("blur");
btn.classList.toggle("fa-eye");
btn.classList.toggle("fa-eye-slash");
}
var infos = document.getElementsByClassName("confidential");
for(var i=0; i < infos.length; i++){
infos[i].insertAdjacentHTML('beforeend', '<i class="fa fa-eye toggle" aria-hidden="true"></i>');
}
var toggles = document.getElementsByClassName("toggle");
for(var i=0; i < toggles.length; i++){
toggles[i].addEventListener("click", toggleBlur);
}
document.querySelectorAll('.confidential').forEach(function (el) {
let i = document.createElement('i');
const classes = ['fa', 'fa-eye', 'toggle'];
i.classList.add(...classes);
el.appendChild(i);
const toggleBlur = function(e) {
for (let child of el.children) {
if (!child.classList.contains('toggle')) {
child.classList.toggle('blur');
}
}
i.classList.toggle('fa-eye');
i.classList.toggle('fa-eye-slash');
}
i.addEventListener('click', toggleBlur);
toggleBlur();
});

View File

@@ -9,9 +9,10 @@ import Dropdown from 'bootstrap/js/src/dropdown';
import Modal from 'bootstrap/js/dist/modal';
import Collapse from 'bootstrap/js/src/collapse';
import Carousel from 'bootstrap/js/src/carousel';
import Popover from 'bootstrap/js/src/popover';
//
// ACHeaderSlider is a small slider used in banner of AccompanyingCourse Section
// Carousel: ACHeaderSlider is a small slider used in banner of AccompanyingCourse Section
// Initialize options, and show/hide controls in first/last slides
//
let ACHeaderSlider = document.querySelector('#ACHeaderSlider');
@@ -48,3 +49,14 @@ if (ACHeaderSlider) {
}
})
}
//
// Popover: used in workflow breadcrumb,
// (expected in: contextual help, notification-box, workflow-box )
//
const triggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="popover"]'));
const popoverList = triggerList.map(function (el) {
return new Popover(el, {
html: true,
});
});

View File

@@ -0,0 +1,34 @@
import { createApp } from "vue";
import ListWorkflowModalVue from 'ChillMainAssets/vuejs/_components/EntityWorkflow/ListWorkflowModal.vue';
import { _createI18n } from "ChillMainAssets/vuejs/_js/i18n";
const i18n = _createI18n({});
// list workflow
document.querySelectorAll('[data-list-workflows]')
.forEach(function (el) {
const app = {
components: {
ListWorkflowModalVue,
},
template:
'<list-workflow-modal-vue ' +
':workflows="workflows" ' +
':allowCreate="allowCreate" ' +
':relatedEntityClass="relatedEntityClass" ' +
':relatedEntityId="relatedEntityId" ' +
':workflowsAvailables="workflowsAvailables" ' +
'></list-workflow-modal-vue>',
data() {
return {
workflows: JSON.parse(el.dataset.workflows),
allowCreate: el.dataset.allowCreate === "1",
relatedEntityClass: el.dataset.relatedEntityClass,
relatedEntityId: Number.parseInt(el.dataset.relatedEntityId),
workflowsAvailables: JSON.parse(el.dataset.workflowsAvailables),
}
}
};
createApp(app).use(i18n).mount(el);
})
;

View File

@@ -0,0 +1,32 @@
import {createApp} from "vue";
import EntityWorkflowVueSubscriber from 'ChillMainAssets/vuejs/_components/EntityWorkflow/EntityWorkflowVueSubscriber.vue';
import { _createI18n } from 'ChillMainAssets/vuejs/_js/i18n';
import { appMessages } from 'ChillMainAssets/vuejs/PickEntity/i18n';
const i18n = _createI18n(appMessages);
let containers = document.querySelectorAll('[data-entity-workflow-subscribe]');
containers.forEach(container => {
let app = {
components: {
EntityWorkflowVueSubscriber,
},
template: '<entity-workflow-vue-subscriber :entityWorkflowId="this.entityWorkflowId" :subscriberStep="this.subscriberStep" :subscriberFinal="this.subscriberFinal" @subscriptionUpdated="onUpdate"></entity-workflow-vue-subscriber>',
data() {
return {
entityWorkflowId: Number.parseInt(container.dataset.entityWorkflowId),
subscriberStep: container.dataset.subscribeStep === "1",
subscriberFinal: container.dataset.subscribeFinal === "1",
}
},
methods: {
onUpdate(status) {
this.subscriberStep = status.step;
this.subscriberFinal = status.final;
}
}
}
createApp(app).use(i18n).mount(container);
})

View File

@@ -6,7 +6,7 @@ const i18n = _createI18n({});
window.addEventListener('DOMContentLoaded', function (e) {
document.querySelectorAll('.notification_toggle_read_status')
.forEach(function (el) {
.forEach(function (el, i) {
createApp({
template: '<notification-read-toggle ' +
':notificationId="notificationId" ' +
@@ -26,13 +26,25 @@ window.addEventListener('DOMContentLoaded', function (e) {
buttonNoText: 'false' === el.dataset.buttonText,
showUrl: el.dataset.showButtonUrl,
isRead: 1 === +el.dataset.notificationCurrentIsRead,
container: el.dataset.container
}
},
computed: {
getContainer() {
return document.querySelectorAll('div.' + this.container);
}
},
methods: {
onMarkRead() {
if (typeof this.getContainer[i] !== 'undefined') {
this.getContainer[i].classList.replace('read', 'unread');
} else { throw 'data-container attribute is missing' }
this.isRead = false;
},
onMarkUnread() {
if (typeof this.getContainer[i] !== 'undefined') {
this.getContainer[i].classList.replace('unread', 'read');
} else { throw 'data-container attribute is missing' }
this.isRead = true;
},
}
@@ -40,4 +52,4 @@ window.addEventListener('DOMContentLoaded', function (e) {
.use(i18n)
.mount(el);
});
})
});

View File

@@ -5,18 +5,27 @@ import { appMessages } from 'ChillMainAssets/vuejs/PickEntity/i18n';
const i18n = _createI18n(appMessages);
window.addEventListener('DOMContentLoaded', function(e) {
let appsOnPage = new Map();
let apps = document.querySelectorAll('[data-module="pick-dynamic"]');
function loadDynamicPicker(element) {
let apps = element.querySelectorAll('[data-module="pick-dynamic"]');
apps.forEach(function(el) {
const
isMultiple = parseInt(el.dataset.multiple) === 1,
input = document.querySelector('[data-input-uniqid="'+ el.dataset.uniqid +'"]'),
picked = isMultiple ? JSON.parse(input.value) : [JSON.parse(input.value)];
uniqId = el.dataset.uniqid,
input = element.querySelector('[data-input-uniqid="'+ el.dataset.uniqid +'"]'),
picked = (isMultiple) ? (JSON.parse(input.value)) : ((input.value === '[]') ? (null) : ([JSON.parse(input.value)]));
createApp({
if (!isMultiple) {
if (input.value === '[]'){
input.value = null;
}
}
const app = createApp({
template: '<pick-entity ' +
':multiple="multiple" ' +
':types="types" ' +
@@ -31,7 +40,7 @@ window.addEventListener('DOMContentLoaded', function(e) {
return {
multiple: isMultiple,
types: JSON.parse(el.dataset.types),
picked,
picked: picked === null ? [] : picked,
uniqid: el.dataset.uniqid,
}
},
@@ -65,5 +74,36 @@ window.addEventListener('DOMContentLoaded', function(e) {
})
.use(i18n)
.mount(el);
appsOnPage.set(uniqId, app);
});
});
}
document.addEventListener('show-hide-show', function(e) {
console.log('creation event caught')
loadDynamicPicker(e.detail.container)
})
document.addEventListener('show-hide-hide', function(e) {
console.log('hiding event caught')
e.detail.container.querySelectorAll('[data-module="pick-dynamic"]').forEach((el) => {
let uniqId = el.dataset.uniqid;
console.log(uniqId);
if (appsOnPage.has(uniqId)) {
appsOnPage.get(uniqId).unmount();
console.log('App has been unmounted')
appsOnPage.delete(uniqId);
}
})
})
document.addEventListener('DOMContentLoaded', function(e) {
console.log('loaded event', e)
loadDynamicPicker(document)
})

View File

@@ -0,0 +1,29 @@
import { createApp } from 'vue';
import OpenWopiLink from 'ChillMainAssets/vuejs/_components/OpenWopiLink';
import {_createI18n} from "ChillMainAssets/vuejs/_js/i18n";
const i18n = _createI18n({});
window.addEventListener('DOMContentLoaded', function (e) {
document.querySelectorAll('span[data-module="wopi-link"]')
.forEach(function (el) {
createApp({
template: '<open-wopi-link :wopiUrl="wopiUrl" :title="title" :type="type" :button="button"></open-wopi-link>',
components: {
OpenWopiLink
},
data() {
return {
wopiUrl: el.dataset.wopiUrl,
title: el.dataset.docTitle,
type: el.dataset.docType,
button: el.dataset.button ? JSON.parse(el.dataset.button) : {}
}
}
})
.use(i18n)
.mount(el)
;
})
;
});

View File

@@ -0,0 +1,16 @@
import { createApp } from 'vue';
import { _createI18n } from 'ChillMainAssets/vuejs/_js/i18n';
import { appMessages } from 'ChillMainAssets/vuejs/HomepageWidget/js/i18n';
import { store } from 'ChillMainAssets/vuejs/HomepageWidget/js/store';
import App from 'ChillMainAssets/vuejs/HomepageWidget/App';
const i18n = _createI18n(appMessages);
const app = createApp({
template: `<app></app>`,
})
.use(store)
.use(i18n)
.component('app', App)
.mount('#homepage_widget')
;

View File

@@ -0,0 +1,90 @@
import {ShowHide} from 'ChillMainAssets/lib/show_hide/show_hide.js';
window.addEventListener('DOMContentLoaded', function() {
let
divTransitions = document.querySelector('#transitions'),
futureDestUsersContainer = document.querySelector('#futureDestUsers')
;
if (null !== divTransitions) {
new ShowHide({
load_event: null,
froms: [divTransitions],
container: [futureDestUsersContainer],
test: function(divs, arg2, arg3) {
for (let div of divs) {
for (let input of div.querySelectorAll('input')) {
if (input.checked) {
if (input.dataset.toFinal === "1") {
return false;
} else {
return true;
}
}
}
}
return true;
},
});
}
let
transitionFilterContainer = document.querySelector('#transitionFilter'),
transitions = document.querySelector('#transitions')
;
if (null !== transitionFilterContainer) {
transitions.querySelectorAll('.form-check').forEach(function(row) {
const isForward = row.querySelector('input').dataset.isForward;
new ShowHide({
load_event: null,
froms: [transitionFilterContainer],
container: row,
test: function (containers) {
for (let container of containers) {
for (let input of container.querySelectorAll('input')) {
if (input.checked) {
return isForward === input.value;
}
}
}
},
toggle_callback: function (c, dir) {
for (let div of c) {
let input = div.querySelector('input');
if ('hide' === dir) {
input.checked = false;
input.disabled = true;
} else {
input.disabled = false;
}
}
},
});
});
}
// validate form
let form = document.querySelector('form[name="workflow_step"]');
if (form === null) {
console.error('form to validate not found');
}
form.addEventListener('submit', function (event) {
const datas = new FormData(event.target);
if (datas.has('workflow_step[future_dest_users]')) {
const dests = JSON.parse(datas.get('workflow_step[future_dest_users]'));
if (dests === null || (dests instanceof Array && dests.length === 0)) {
event.preventDefault();
console.log('no users!');
window.alert('Indiquez un utilisateur pour traiter la prochaine étape.');
}
}
});
});

View File

@@ -98,6 +98,8 @@
v-bind:defaultz="this.defaultz"
v-bind:entity="this.entity"
v-bind:flag="this.flag"
v-bind:errors="this.errors"
v-bind:checkErrors="this.checkErrors"
@getCities="getCities"
@getReferenceAddresses="getReferenceAddresses">
</edit-pane>
@@ -123,6 +125,8 @@
v-bind:defaultz="this.defaultz"
v-bind:entity="this.entity"
v-bind:flag="this.flag"
v-bind:errors="this.errors"
v-bind:checkErrors="this.checkErrors"
v-bind:insideModal="false"
@getCities="getCities"
@getReferenceAddresses="getReferenceAddresses">
@@ -256,8 +260,10 @@ export default {
editPane: false,
datePane: false,
loading: false,
success: false
success: false,
dirty: false
},
errors: [],
defaultz: {
button: {
text: { create: 'add_an_address_title', edit: 'edit_address' },
@@ -529,6 +535,23 @@ export default {
});
},
checkErrors() {
this.errors = [];
if (this.flag.dirty) {
if (this.entity.selected.country === null) {
this.errors.push("Un pays doit être sélectionné.");
}
if (Object.keys(this.entity.selected.city).length === 0) {
this.errors.push("Une ville doit être sélectionnée.");
}
if (!this.entity.selected.isNoAddress) {
if (this.entity.selected.address.street === null || this.entity.selected.address.streetNumber === null) {
this.errors.push("Une adresse doit être sélectionnée.");
}
}
}
},
/*
* Make form ready for new changes
*/
@@ -539,6 +562,7 @@ export default {
this.entity.loaded.cities = [];
this.entity.loaded.countries = [];
this.entity.selected.confidential = this.context.edit ? this.entity.address.confidential : false;
this.entity.selected.isNoAddress = (this.context.edit && this.entity.address.text === '') ? true : false;
this.entity.selected.country = this.context.edit ? this.entity.address.country : {};
@@ -570,6 +594,7 @@ export default {
{
console.log('apply changes');
let newAddress = {
'confidential': this.entity.selected.confidential,
'isNoAddress': this.entity.selected.isNoAddress,
'street': this.entity.selected.isNoAddress ? '' : this.entity.selected.address.street,
'streetNumber': this.entity.selected.isNoAddress ? '' : this.entity.selected.address.streetNumber,

View File

@@ -6,7 +6,6 @@
<input class="form-control"
type="text"
name="floor"
maxlength=16
:placeholder="$t('floor')"
v-model="floor"/>
<label for="floor">{{ $t('floor') }}</label>
@@ -15,7 +14,6 @@
<input class="form-control"
type="text"
name="corridor"
maxlength=16
:placeholder="$t('corridor')"
v-model="corridor"/>
<label for="corridor">{{ $t('corridor') }}</label>
@@ -24,7 +22,6 @@
<input class="form-control"
type="text"
name="steps"
maxlength=16
:placeholder="$t('steps')"
v-model="steps"/>
<label for="steps">{{ $t('steps') }}</label>
@@ -33,7 +30,6 @@
<input class="form-control"
type="text"
name="flat"
maxlength=16
:placeholder="$t('flat')"
v-model="flat"/>
<label for="flat">{{ $t('flat') }}</label>

View File

@@ -10,8 +10,10 @@
:deselect-label="$t('create_address')"
:selected-label="$t('multiselect.selected_label')"
@search-change="listenInputSearch"
:internal-search="false"
ref="addressSelector"
@select="selectAddress"
@remove="remove"
name="field"
track-by="id"
label="value"
@@ -56,7 +58,7 @@ import { searchReferenceAddresses, fetchReferenceAddresses } from '../../api.js'
export default {
name: 'AddressSelection',
components: { VueMultiselect },
props: ['entity', 'context', 'updateMapCenter'],
props: ['entity', 'context', 'updateMapCenter', 'flag', 'checkErrors'],
data() {
return {
value: this.context.edit ? this.entity.address.addressReference : null,
@@ -109,6 +111,13 @@ export default {
this.entity.selected.address.streetNumber = value.streetNumber;
this.entity.selected.writeNew.address = false;
this.updateMapCenter(value.point);
this.flag.dirty = true;
this.checkErrors();
},
remove() {
this.flag.dirty = true;
this.entity.selected.address = {};
this.checkErrors();
},
listenInputSearch(query) {
//console.log('listenInputSearch', query, this.isAddressSelectorOpen);
@@ -149,6 +158,8 @@ export default {
this.entity.selected.address.street = addr.street;
this.entity.selected.address.streetNumber = addr.number;
this.entity.selected.writeNew.address = true;
this.flag.dirty = true;
this.checkErrors();
}
},
splitAddress(address) {

View File

@@ -7,6 +7,7 @@
@search-change="listenInputSearch"
ref="citySelector"
@select="selectCity"
@remove="remove"
name="field"
track-by="id"
label="value"
@@ -17,6 +18,7 @@
:selected-label="$t('multiselect.selected_label')"
:taggable="true"
:multiple="false"
:internal-search="false"
@tag="addPostcode"
:tagPlaceholder="$t('create_postal_code')"
:loading="isLoading"
@@ -55,12 +57,12 @@ import { searchCities, fetchCities } from '../../api.js';
export default {
name: 'CitySelection',
components: { VueMultiselect },
props: ['entity', 'context', 'focusOnAddress', 'updateMapCenter'],
props: ['entity', 'context', 'focusOnAddress', 'updateMapCenter', 'flag', 'checkErrors'],
emits: ['getReferenceAddresses'],
data() {
return {
value: this.context.edit ? this.entity.address.postcode : null,
isLoading: false
isLoading: false,
}
},
computed: {
@@ -123,6 +125,13 @@ export default {
if (value.center) {
this.updateMapCenter(value.center);
}
this.flag.dirty = true;
this.checkErrors();
},
remove() {
this.flag.dirty = true;
this.entity.selected.city = {};
this.checkErrors();
},
listenInputSearch(query) {
if (query.length > 2) {

View File

@@ -12,7 +12,9 @@
:select-label="$t('multiselect.select_label')"
:deselect-label="$t('multiselect.deselect_label')"
:selected-label="$t('multiselect.selected_label')"
@select="selectCountry">
@select="selectCountry"
@remove="remove"
>
</VueMultiselect>
</div>
</template>
@@ -23,7 +25,7 @@ import VueMultiselect from 'vue-multiselect';
export default {
name: 'CountrySelection',
components: { VueMultiselect },
props: ['context', 'entity'],
props: ['context', 'entity', 'flag', 'checkErrors'],
emits: ['getCities'],
data() {
return {
@@ -34,14 +36,13 @@ export default {
},
computed: {
sortedCountries() {
//console.log('sorted countries');
const countries = this.entity.loaded.countries;
let sortedCountries = [];
sortedCountries.push(...countries.filter(c => c.countryCode === 'FR'))
sortedCountries.push(...countries.filter(c => c.countryCode === 'BE'))
sortedCountries.push(...countries.filter(c => c.countryCode !== 'FR').filter(c => c.countryCode !== 'BE'))
return sortedCountries;
}
},
},
mounted() {
this.init();
@@ -50,6 +51,7 @@ export default {
init() {
if (this.value !== undefined) {
this.selectCountry(this.value);
this.flag.dirty = false;
}
},
selectCountryByCode(countryCode) {
@@ -62,7 +64,13 @@ export default {
//console.log('select country', value);
this.entity.selected.country = value;
this.$emit('getCities', value);
}
this.checkErrors();
},
remove() {
this.flag.dirty = true;
this.entity.selected.country = null;
this.checkErrors();
},
}
};

View File

@@ -7,16 +7,32 @@
<span class="sr-only">Loading...</span>
</div>
<div v-if="errors.length" class="alert alert-warning" >
<ul>
<li v-for="(e, i) in errors" :key="i">{{ e }}</li>
</ul>
</div>
<h4 class="h3">{{ $t('select_an_address_title') }}</h4>
<div class="row my-3">
<div class="col-lg-6">
<div class="form-check">
<input type="checkbox"
class="form-check-input"
id="isConfidential"
v-model="isConfidential"
:value="valueConfidential" />
<label class="form-check-label" for="isConfidential">
{{ $t('isConfidential') }}
</label>
</div>
<div class="form-check">
<input type="checkbox"
class="form-check-input"
id="isNoAddress"
v-model="isNoAddress"
v-bind:value="value" />
:value="value" />
<label class="form-check-label" for="isNoAddress">
{{ $t('isNoAddress') }}
</label>
@@ -25,6 +41,8 @@
<country-selection
v-bind:context="context"
v-bind:entity="entity"
v-bind:flag="flag"
v-bind:checkErrors="checkErrors"
@getCities="$emit('getCities', selected.country)">
</country-selection>
@@ -33,13 +51,17 @@
v-bind:context="context"
v-bind:focusOnAddress="focusOnAddress"
v-bind:updateMapCenter="updateMapCenter"
v-bind:flag="flag"
v-bind:checkErrors="checkErrors"
@getReferenceAddresses="$emit('getReferenceAddresses', selected.city)">
</city-selection>
<address-selection v-if="!isNoAddress"
v-bind:entity="entity"
v-bind:context="context"
v-bind:updateMapCenter="updateMapCenter">
v-bind:updateMapCenter="updateMapCenter"
v-bind:flag="flag"
v-bind:checkErrors="checkErrors">
</address-selection>
</div>
@@ -99,12 +121,15 @@ export default {
'flag',
'entity',
'errorMsg',
'insideModal'
'insideModal',
'errors',
'checkErrors',
],
emits: ['getCities', 'getReferenceAddresses'],
data() {
return {
value: false
value: false,
valueConfidential: false,
}
},
computed: {
@@ -120,6 +145,14 @@ export default {
addressMap() {
return this.entity.addressMap;
},
isConfidential: {
set(value) {
this.entity.selected.confidential = value;
},
get() {
return this.entity.selected.confidential;
}
},
isNoAddress: {
set(value) {
console.log('isNoAddress value', value);
@@ -128,7 +161,7 @@ export default {
get() {
return this.entity.selected.isNoAddress;
}
}
},
},
methods: {
focusOnAddress() {

View File

@@ -18,6 +18,7 @@ const addressMessages = {
other_address: 'Autre adresse',
create_address: 'Adresse inconnue. Cliquez ici pour créer une nouvelle adresse',
isNoAddress: 'Pas d\'adresse complète',
isConfidential: 'Adresse confidentielle',
street: 'Nom de rue',
streetNumber: 'Numéro',
floor: 'Étage',

View File

@@ -0,0 +1,140 @@
<template>
<h2>{{ $t('main_title') }}</h2>
<ul class="nav nav-tabs">
<li class="nav-item">
<a class="nav-link"
:class="{'active': activeTab === 'MyCustoms'}"
@click="selectTab('MyCustoms')">
<i class="fa fa-dashboard"></i>
</a>
</li>
<li class="nav-item">
<a class="nav-link"
:class="{'active': activeTab === 'MyNotifications'}"
@click="selectTab('MyNotifications')">
{{ $t('my_notifications.tab') }}
<tab-counter :count="state.notifications.count"></tab-counter>
</a>
</li>
<li class="nav-item">
<a class="nav-link"
:class="{'active': activeTab === 'MyAccompanyingCourses'}"
@click="selectTab('MyAccompanyingCourses')">
{{ $t('my_accompanying_courses.tab') }}
<tab-counter :count="state.accompanyingCourses.count"></tab-counter>
</a>
</li>
<li class="nav-item">
<a class="nav-link"
:class="{'active': activeTab === 'MyWorks'}"
@click="selectTab('MyWorks')">
{{ $t('my_works.tab') }}
<tab-counter :count="state.works.count"></tab-counter>
</a>
</li>
<li class="nav-item">
<a class="nav-link"
:class="{'active': activeTab === 'MyEvaluations'}"
@click="selectTab('MyEvaluations')">
{{ $t('my_evaluations.tab') }}
<tab-counter :count="state.evaluations.count"></tab-counter>
</a>
</li>
<li class="nav-item">
<a class="nav-link"
:class="{'active': activeTab === 'MyTasks'}"
@click="selectTab('MyTasks')">
{{ $t('my_tasks.tab') }}
<tab-counter :count="state.tasks.warning.count"></tab-counter>
<tab-counter :count="state.tasks.alert.count"></tab-counter>
</a>
</li>
<li class="nav-item loading ms-auto py-2" v-if="loading">
<i class="fa fa-circle-o-notch fa-spin fa-lg text-chill-gray" :title="$t('loading')"></i>
</li>
</ul>
<div class="my-4">
<my-customs
v-if="activeTab === 'MyCustoms'">
</my-customs>
<my-works
v-else-if="activeTab === 'MyWorks'">
</my-works>
<my-evaluations
v-else-if="activeTab === 'MyEvaluations'">
</my-evaluations>
<my-tasks
v-else-if="activeTab === 'MyTasks'">
</my-tasks>
<my-accompanying-courses
v-else-if="activeTab === 'MyAccompanyingCourses'">
</my-accompanying-courses>
<my-notifications
v-else-if="activeTab === 'MyNotifications'">
</my-notifications>
</div>
</template>
<script>
import MyCustoms from './MyCustoms';
import MyWorks from './MyWorks';
import MyEvaluations from './MyEvaluations';
import MyTasks from './MyTasks';
import MyAccompanyingCourses from './MyAccompanyingCourses';
import MyNotifications from './MyNotifications';
import TabCounter from './TabCounter';
import { mapState } from "vuex";
export default {
name: "App",
components: {
MyCustoms,
MyWorks,
MyEvaluations,
MyTasks,
MyAccompanyingCourses,
MyNotifications,
TabCounter,
},
data() {
return {
activeTab: 'MyCustoms'
}
},
computed: {
...mapState([
'loading',
]),
// just to see all in devtool :
...mapState({
state: (state) => state,
}),
},
methods: {
selectTab(tab) {
this.$store.dispatch('getByTab', { tab: tab });
this.activeTab = tab;
}
},
mounted() {
for (const m of [
'MyNotifications',
'MyAccompanyingCourses',
'MyWorks',
'MyEvaluations',
'MyTasks',
]) {
this.$store.dispatch('getByTab', { tab: m, param: "countOnly=1" });
}
}
}
</script>
<style scoped>
a.nav-link {
cursor: pointer;
}
</style>

View File

@@ -0,0 +1,84 @@
<template>
<div class="alert alert-light">{{ $t('my_accompanying_courses.description') }}</div>
<span v-if="noResults" class="chill-no-data-statement">{{ $t('no_data') }}</span>
<tab-table v-else>
<template v-slot:thead>
<th scope="col">{{ $t('opening_date') }}</th>
<th scope="col">{{ $t('social_issues') }}</th>
<th scope="col">{{ $t('concerned_persons') }}</th>
<th scope="col"></th>
<th scope="col"></th>
</template>
<template v-slot:tbody>
<tr v-for="(c, i) in accompanyingCourses.results" :key="`course-${i}`">
<td>{{ $d(c.openingDate.datetime, 'short') }}</td>
<td>
<span v-for="i in c.socialIssues"
class="chill-entity entity-social-issue">
<span class="badge bg-chill-l-gray text-dark">
{{ i.title.fr }}
</span>
</span>
</td>
<td>
<span v-for="p in c.participations" class="me-1" :key="p.person.id">
<on-the-fly
:type="p.person.type"
:id="p.person.id"
:buttonText="p.person.textAge"
:displayBadge="'true' === 'true'"
action="show">
</on-the-fly>
</span>
</td>
<td>
<span v-if="c.emergency" class="badge rounded-pill bg-danger">{{ $t('emergency') }}</span>
<span v-if="c.confidential" class="badge rounded-pill bg-danger">{{ $t('confidential') }}</span>
</td>
<td>
<a class="btn btn-sm btn-show" :href="getUrl(c)">
{{ $t('show_entity', { entity: $t('the_course') }) }}
</a>
</td>
</tr>
</template>
</tab-table>
</template>
<script>
import { mapState, mapGetters } from "vuex";
import TabTable from "./TabTable";
import OnTheFly from 'ChillMainAssets/vuejs/OnTheFly/components/OnTheFly';
export default {
name: "MyAccompanyingCourses",
components: {
TabTable,
OnTheFly,
},
computed: {
...mapState([
'accompanyingCourses',
]),
...mapGetters([
'isAccompanyingCoursesLoaded',
]),
noResults() {
if (!this.isAccompanyingCoursesLoaded) {
return false;
} else {
return this.accompanyingCourses.count === 0;
}
},
},
methods: {
getUrl(c) {
return `/fr/parcours/${c.id}`
}
}
}
</script>
<style scoped>
</style>

View File

@@ -0,0 +1,101 @@
<template>
<span v-if="noResults" class="chill-no-data-statement">{{ $t('no_dashboard') }}</span>
<div v-else id="dashboards" class="row g-3" data-masonry='{"percentPosition": true }'>
<div class="mbloc col col-sm-6 col-lg-4">
<div class="custom1">
<ul class="list-unstyled">
<li v-if="counter.notifications > 0">
<i18n-t keypath="counter.unread_notifications" tag="span" :class="counterClass" :plural="counter.notifications">
<template v-slot:n><span>{{ counter.notifications }}</span></template>
</i18n-t>
</li>
<li v-if="counter.accompanyingCourses > 0">
<i18n-t keypath="counter.assignated_courses" tag="span" :class="counterClass" :plural="counter.accompanyingCourses">
<template v-slot:n><span>{{ counter.accompanyingCourses }}</span></template>
</i18n-t>
</li>
<li v-if="counter.works > 0">
<i18n-t keypath="counter.assignated_actions" tag="span" :class="counterClass" :plural="counter.works">
<template v-slot:n><span>{{ counter.works }}</span></template>
</i18n-t>
</li>
<li v-if="counter.evaluations > 0">
<i18n-t keypath="counter.assignated_evaluations" tag="span" :class="counterClass" :plural="counter.evaluations">
<template v-slot:n><span>{{ counter.evaluations }}</span></template>
</i18n-t>
</li>
<li v-if="counter.tasksAlert > 0">
<i18n-t keypath="counter.alert_tasks" tag="span" :class="counterClass" :plural="counter.tasksAlert">
<template v-slot:n><span>{{ counter.tasksAlert }}</span></template>
</i18n-t>
</li>
<li v-if="counter.tasksWarning > 0">
<i18n-t keypath="counter.warning_tasks" tag="span" :class="counterClass" :plural="counter.tasksWarning">
<template v-slot:n><span>{{ counter.tasksWarning }}</span></template>
</i18n-t>
</li>
</ul>
</div>
</div>
<!--
<div class="mbloc col col-sm-6 col-lg-4">
<div class="custom2">
Mon dashboard personnalisé
</div>
</div>
<div class="mbloc col col-sm-6 col-lg-4">
<div class="custom3">
Mon dashboard personnalisé
</div>
</div>
<div class="mbloc col col-sm-6 col-lg-4">
<div class="custom4">
Mon dashboard personnalisé
</div>
</div>
-->
</div>
</template>
<script>
import { mapGetters } from "vuex";
import Masonry from 'masonry-layout/masonry';
export default {
name: "MyCustoms",
data() {
return {
counterClass: {
counter: true //hack to pass class 'counter' in i18n-t
}
}
},
computed: {
...mapGetters(['counter']),
noResults() {
return false
},
},
mounted() {
const elem = document.querySelector('#dashboards');
const masonry = new Masonry(elem, {});
}
}
</script>
<style lang="scss" scoped>
div.custom4,
div.custom3,
div.custom2 {
font-style: italic;
color: var(--bs-chill-gray);
}
span.counter {
& > span {
background-color: unset;
}
}
</style>

View File

@@ -0,0 +1,105 @@
<template>
<div class="accompanying_course_work">
<div class="alert alert-light">{{ $t('my_evaluations.description') }}</div>
<span v-if="noResults" class="chill-no-data-statement">{{ $t('no_data') }}</span>
<tab-table v-else>
<template v-slot:thead>
<th scope="col">{{ $t('max_date') }}</th>
<th scope="col">{{ $t('evaluation') }}</th>
<th scope="col">{{ $t('SocialAction') }}</th>
<th scope="col"></th>
</template>
<template v-slot:tbody>
<tr v-for="(e, i) in evaluations.results" :key="`evaluation-${i}`">
<td>{{ $d(e.maxDate.datetime, 'short') }}</td>
<td>
{{ e.evaluation.title.fr }}
</td>
<td>
<span class="chill-entity entity-social-issue">
<span class="badge bg-chill-l-gray text-dark">
{{ e.accompanyingPeriodWork.socialAction.issue.text }}
</span>
</span>
<h4 class="badge-title">
<span class="title_label"></span>
<span class="title_action">
{{ e.accompanyingPeriodWork.socialAction.text }}
</span>
</h4>
<span v-for="person in e.accompanyingPeriodWork.persons" class="me-1" :key="person.id">
<on-the-fly
:type="person.type"
:id="person.id"
:buttonText="person.textAge"
:displayBadge="'true' === 'true'"
action="show">
</on-the-fly>
</span>
</td>
<td>
<div class="btn-group-vertical" role="group" aria-label="Actions">
<a class="btn btn-sm btn-show" :href="getUrl(e)">
{{ $t('show_entity', { entity: $t('the_evaluation') }) }}
</a>
<a class="btn btn-sm btn-update" :href="getUrl(e.accompanyingPeriodWork)">
{{ $t('show_entity', { entity: $t('the_action') }) }}
</a>
<a class="btn btn-sm btn-show" :href="getUrl(e.accompanyingPeriodWork.accompanyingPeriod)">
{{ $t('show_entity', { entity: $t('the_course') }) }}
</a>
</div>
</td>
</tr>
</template>
</tab-table>
</div>
</template>
<script>
import { mapState, mapGetters } from "vuex";
import TabTable from "./TabTable";
import OnTheFly from 'ChillMainAssets/vuejs/OnTheFly/components/OnTheFly';
export default {
name: "MyEvaluations",
components: {
TabTable,
OnTheFly,
},
computed: {
...mapState([
'evaluations',
]),
...mapGetters([
'isEvaluationsLoaded',
]),
noResults() {
if (!this.isEvaluationsLoaded) {
return false;
} else {
return this.evaluations.count === 0;
}
}
},
methods: {
getUrl(e) {
switch (e.type) {
case 'accompanying_period_work_evaluation':
let anchor = '#evaluations';
return `/fr/person/accompanying-period/work/${e.accompanyingPeriodWork.id}/edit${anchor}`;
case 'accompanying_period_work':
return `/fr/person/accompanying-period/work/${e.id}/edit`
case 'accompanying_period':
return `/fr/parcours/${e.id}`
default:
throw 'entity type unknown';
}
}
},
}
</script>
<style scoped>
</style>

View File

@@ -0,0 +1,96 @@
<template>
<div class="alert alert-light">{{ $t('my_notifications.description') }}</div>
<span v-if="noResults" class="chill-no-data-statement">{{ $t('no_data') }}</span>
<tab-table v-else>
<template v-slot:thead>
<th scope="col">{{ $t('Date') }}</th>
<th scope="col">{{ $t('Subject') }}</th>
<th scope="col">{{ $t('From') }}</th>
<th scope="col"></th>
</template>
<template v-slot:tbody>
<tr v-for="(n, i) in notifications.results" :key="`notify-${i}`">
<td>{{ $d(n.date.datetime, 'long') }}</td>
<td>
<span class="unread">
<i class="fa fa-envelope-o"></i>
<a :href="getNotificationUrl(n)">{{ n.title }}</a>
</span>
</td>
<td>{{ n.sender.text }}</td>
<td>
<a class="btn btn-sm btn-show"
:href="getEntityUrl(n)">
{{ $t('show_entity', { entity: getEntityName(n) }) }}
</a>
</td>
</tr>
</template>
</tab-table>
</template>
<script>
import { mapState, mapGetters } from "vuex";
import TabTable from "./TabTable";
import { appMessages } from 'ChillMainAssets/vuejs/HomepageWidget/js/i18n';
export default {
name: "MyNotifications",
components: {
TabTable
},
computed: {
...mapState([
'notifications',
]),
...mapGetters([
'isNotificationsLoaded',
]),
noResults() {
if (!this.isNotificationsLoaded) {
return false;
} else {
return this.notifications.count === 0;
}
}
},
methods: {
getNotificationUrl(n) {
return `/fr/notification/${n.id}/show`
},
getEntityName(n) {
switch (n.relatedEntityClass) {
case 'Chill\\ActivityBundle\\Entity\\Activity':
return appMessages.fr.the_activity;
case 'Chill\\PersonBundle\\Entity\\AccompanyingPeriod':
return appMessages.fr.the_course;
default:
throw 'notification type unknown';
}
},
getEntityUrl(n) {
switch (n.relatedEntityClass) {
case 'Chill\\ActivityBundle\\Entity\\Activity':
return `/fr/activity/${n.relatedEntityId}/show`
case 'Chill\\PersonBundle\\Entity\\AccompanyingPeriod':
return `/fr/parcours/${n.relatedEntityId}`
default:
throw 'notification type unknown';
}
}
}
}
</script>
<style lang="scss" scoped>
span.unread {
font-weight: bold;
i {
margin-right: 0.5em;
}
a {
text-decoration: unset;
}
}
</style>

View File

@@ -0,0 +1,100 @@
<template>
<div class="alert alert-light">{{ $t('my_tasks.description_warning') }}</div>
<span v-if="noResultsAlert" class="chill-no-data-statement">{{ $t('no_data') }}</span>
<tab-table v-else>
<template v-slot:thead>
<th scope="col">{{ $t('warning_date') }}</th>
<th scope="col">{{ $t('max_date') }}</th>
<th scope="col">{{ $t('task') }}</th>
<th scope="col"></th>
</template>
<template v-slot:tbody>
<tr v-for="(t, i) in tasks.alert.results" :key="`task-alert-${i}`">
<td>{{ $d(t.warningDate.datetime, 'short') }}</td>
<td>
<span class="outdated">{{ $d(t.endDate.datetime, 'short') }}</span>
</td>
<td>{{ t.title }}</td>
<td>
<a class="btn btn-sm btn-show" :href="getUrl(t)">
{{ $t('show_entity', { entity: $t('the_task') }) }}
</a>
</td>
</tr>
</template>
</tab-table>
<div class="alert alert-light">{{ $t('my_tasks.description_alert') }}</div>
<span v-if="noResultsWarning" class="chill-no-data-statement">{{ $t('no_data') }}</span>
<tab-table v-else>
<template v-slot:thead>
<th scope="col">{{ $t('warning_date') }}</th>
<th scope="col">{{ $t('max_date') }}</th>
<th scope="col">{{ $t('task') }}</th>
<th scope="col"></th>
</template>
<template v-slot:tbody>
<tr v-for="(t, i) in tasks.warning.results" :key="`task-warning-${i}`">
<td>
<span class="outdated">{{ $d(t.warningDate.datetime, 'short') }}</span>
</td>
<td>{{ $d(t.endDate.datetime, 'short') }}</td>
<td>{{ t.title }}</td>
<td>
<a class="btn btn-sm btn-show" :href="getUrl(t)">
{{ $t('show_entity', { entity: $t('the_task') }) }}
</a>
</td>
</tr>
</template>
</tab-table>
</template>
<script>
import { mapState, mapGetters } from "vuex";
import TabTable from "./TabTable";
export default {
name: "MyTasks",
components: {
TabTable
},
computed: {
...mapState([
'tasks',
]),
...mapGetters([
'isTasksWarningLoaded',
'isTasksAlertLoaded',
]),
noResultsAlert() {
if (!this.isTasksAlertLoaded) {
return false;
} else {
return this.tasks.alert.count === 0;
}
},
noResultsWarning() {
if (!this.isTasksWarningLoaded) {
return false;
} else {
return this.tasks.warning.count === 0;
}
}
},
methods: {
getUrl(t) {
return `/fr/task/single-task/${t.id}/show`
}
},
}
</script>
<style scoped>
span.outdated {
font-weight: bold;
color: var(--bs-warning);
}
</style>

View File

@@ -0,0 +1,98 @@
<template>
<div class="accompanying_course_work">
<div class="alert alert-light">{{ $t('my_works.description') }}</div>
<span v-if="noResults" class="chill-no-data-statement">{{ $t('no_data') }}</span>
<tab-table v-else>
<template v-slot:thead>
<th scope="col">{{ $t('StartDate') }}</th>
<th scope="col">{{ $t('SocialAction') }}</th>
<th scope="col">{{ $t('concerned_persons') }}</th>
<th scope="col"></th>
</template>
<template v-slot:tbody>
<tr v-for="(w, i) in works.results" :key="`works-${i}`">
<td>{{ $d(w.startDate.datetime, 'short') }}</td>
<td>
<span class="chill-entity entity-social-issue">
<span class="badge bg-chill-l-gray text-dark">
{{ w.socialAction.issue.text }}
</span>
</span>
<h4 class="badge-title">
<span class="title_label"></span>
<span class="title_action">
{{ w.socialAction.text }}
</span>
</h4>
</td>
<td>
<span v-for="person in w.persons" class="me-1" :key="person.id">
<on-the-fly
:type="person.type"
:id="person.id"
:buttonText="person.textAge"
:displayBadge="'true' === 'true'"
action="show">
</on-the-fly>
</span>
</td>
<td>
<div class="btn-group-vertical" role="group" aria-label="Actions">
<a class="btn btn-sm btn-update" :href="getUrl(w)">
{{ $t('show_entity', { entity: $t('the_action') }) }}
</a>
<a class="btn btn-sm btn-show" :href="getUrl(w.accompanyingPeriod)">
{{ $t('show_entity', { entity: $t('the_course') }) }}
</a>
</div>
</td>
</tr>
</template>
</tab-table>
</div>
</template>
<script>
import { mapState, mapGetters } from "vuex";
import TabTable from "./TabTable";
import OnTheFly from 'ChillMainAssets/vuejs/OnTheFly/components/OnTheFly';
export default {
name: "MyWorks",
components: {
TabTable,
OnTheFly,
},
computed: {
...mapState([
'works',
]),
...mapGetters([
'isWorksLoaded',
]),
noResults() {
if (!this.isWorksLoaded) {
return false;
} else {
return this.works.count === 0;
}
}
},
methods: {
getUrl(e) {
switch (e.type) {
case 'accompanying_period_work':
return `/fr/person/accompanying-period/work/${e.id}/edit`
case 'accompanying_period':
return `/fr/parcours/${e.id}`
default:
throw 'entity type unknown';
}
}
},
}
</script>
<style scoped>
</style>

View File

@@ -0,0 +1,18 @@
<template>
<span v-if="isCounterAvailable"
class="badge rounded-pill bg-danger">
{{ count }}
</span>
</template>
<script>
export default {
name: "TabCounter",
props: ['count'],
computed: {
isCounterAvailable() {
return (typeof this.count !== 'undefined' && this.count > 0 )
}
}
}
</script>

View File

@@ -0,0 +1,21 @@
<template>
<table class="table table-striped table-hover">
<thead>
<tr>
<slot name="thead"></slot>
</tr>
</thead>
<tbody>
<slot name="tbody"></slot>
</tbody>
</table>
</template>
<script>
export default {
name: "TabTable",
props: []
}
</script>
<style scoped></style>

View File

@@ -0,0 +1,61 @@
const appMessages = {
fr: {
main_title: "Vue d'ensemble",
my_works: {
tab: "Mes actions",
description: "Liste des actions d'accompagnement dont je suis référent et qui arrivent à échéance.",
},
my_evaluations: {
tab: "Mes évaluations",
description: "Liste des évaluations dont je suis référent et qui arrivent à échéance.",
},
my_tasks: {
tab: "Mes tâches",
description_alert: "Liste des tâches auxquelles je suis assigné et dont la date de rappel est dépassée.",
description_warning: "Liste des tâches auxquelles je suis assigné et dont la date d'échéance est dépassée.",
},
my_accompanying_courses: {
tab: "Mes parcours",
description: "Liste des parcours d'accompagnement que l'on vient de m'attribuer.",
},
my_notifications: {
tab: "Mes notifications",
description: "Liste des notifications reçues et non lues.",
},
opening_date: "Date d'ouverture",
social_issues: "Problématiques sociales",
concerned_persons: "Usagers concernés",
max_date: "Date d'échéance",
warning_date: "Date de rappel",
evaluation: "Évaluation",
task: "Tâche",
Date: "Date",
From: "Expéditeur",
Subject: "Objet",
Entity: "Associé à",
show_entity: "Voir {entity}",
the_activity: "l'échange",
the_course: "le parcours",
the_action: "l'action",
the_evaluation: "l'évaluation",
the_task: "la tâche",
StartDate: "Date d'ouverture",
SocialAction: "Action d'accompagnement",
no_data: "Aucun résultats",
no_dashboard: "Pas de tableaux de bord",
counter: {
unread_notifications: "{n} notification non lue | {n} notifications non lues",
assignated_courses: "{n} parcours récent assigné | {n} parcours récents assignés",
assignated_actions: "{n} action assignée | {n} actions assignées",
assignated_evaluations: "{n} évaluation assignée | {n} évaluations assignées",
alert_tasks: "{n} tâche en rappel | {n} tâches en rappel",
warning_tasks: "{n} tâche à échéance | {n} tâches à échéance",
}
}
};
Object.assign(appMessages.fr);
export {
appMessages
};

View File

@@ -0,0 +1,200 @@
import 'es6-promise/auto';
import { createStore } from 'vuex';
import { makeFetch } from "ChillMainAssets/lib/api/apiMethods";
import MyCustoms from "../MyCustoms";
import MyWorks from "../MyWorks";
import MyEvaluations from "../MyEvaluations";
import MyTasks from "../MyTasks";
import MyAccompanyingCourses from "../MyAccompanyingCourses";
import MyNotifications from "../MyNotifications";
const debug = process.env.NODE_ENV !== 'production';
const isEmpty = (obj) => {
return obj
&& Object.keys(obj).length <= 1
&& Object.getPrototypeOf(obj) === Object.prototype;
};
const store = createStore({
strict: debug,
state: {
works: {},
evaluations: {},
tasks: {
warning: {},
alert: {}
},
accompanyingCourses: {},
notifications: {},
errorMsg: [],
loading: false
},
getters: {
isWorksLoaded(state) {
return !isEmpty(state.works);
},
isEvaluationsLoaded(state) {
return !isEmpty(state.evaluations);
},
isTasksWarningLoaded(state) {
return !isEmpty(state.tasks.warning);
},
isTasksAlertLoaded(state) {
return !isEmpty(state.tasks.alert);
},
isAccompanyingCoursesLoaded(state) {
return !isEmpty(state.accompanyingCourses);
},
isNotificationsLoaded(state) {
return !isEmpty(state.notifications);
},
counter(state) {
return {
works: state.works.count,
evaluations: state.evaluations.count,
tasksWarning: state.tasks.warning.count,
tasksAlert: state.tasks.alert.count,
accompanyingCourses: state.accompanyingCourses.count,
notifications: state.notifications.count,
}
}
},
mutations: {
addWorks(state, works) {
//console.log('addWorks', works);
state.works = works;
},
addEvaluations(state, evaluations) {
//console.log('addEvaluations', evaluations);
state.evaluations = evaluations;
},
addTasksWarning(state, tasks) {
//console.log('addTasksWarning', tasks);
state.tasks.warning = tasks;
},
addTasksAlert(state, tasks) {
//console.log('addTasksAlert', tasks);
state.tasks.alert = tasks;
},
addCourses(state, courses) {
//console.log('addCourses', courses);
state.accompanyingCourses = courses;
},
addNotifications(state, notifications) {
//console.log('addNotifications', notifications);
state.notifications = notifications;
},
setLoading(state, bool) {
state.loading = bool;
},
catchError(state, error) {
state.errorMsg.push(error);
}
},
actions: {
getByTab({ commit, getters }, { tab, param }) {
switch (tab) {
case 'MyCustoms':
break;
case 'MyWorks':
if (!getters.isWorksLoaded) {
commit('setLoading', true);
const url = `/api/1.0/person/accompanying-period/work/my-near-end${'?'+ param}`;
makeFetch('GET', url)
.then((response) => {
commit('addWorks', response);
commit('setLoading', false);
})
.catch((error) => {
commit('catchError', error);
throw error;
})
;
}
break;
case 'MyEvaluations':
if (!getters.isEvaluationsLoaded) {
commit('setLoading', true);
const url = `/api/1.0/person/accompanying-period/work/evaluation/my-near-end${'?'+ param}`;
makeFetch('GET', url)
.then((response) => {
commit('addEvaluations', response);
commit('setLoading', false);
})
.catch((error) => {
commit('catchError', error);
throw error;
})
;
}
break;
case 'MyTasks':
if (!(getters.isTasksWarningLoaded && getters.isTasksAlertLoaded)) {
commit('setLoading', true);
const
urlWarning = `/api/1.0/task/single-task/list/my?f[q]=&f[checkboxes][status][]=warning&f[checkboxes][states][]=new&f[checkboxes][states][]=in_progress${'&'+ param}`,
urlAlert = `/api/1.0/task/single-task/list/my?f[q]=&f[checkboxes][status][]=alert&f[checkboxes][states][]=new&f[checkboxes][states][]=in_progress${'&'+ param}`
;
makeFetch('GET', urlWarning)
.then((response) => {
commit('addTasksWarning', response);
commit('setLoading', false);
})
.catch((error) => {
commit('catchError', error);
throw error;
})
;
makeFetch('GET', urlAlert)
.then((response) => {
commit('addTasksAlert', response);
commit('setLoading', false);
})
.catch((error) => {
commit('catchError', error);
throw error;
})
;
}
break;
case 'MyAccompanyingCourses':
if (!getters.isAccompanyingCoursesLoaded) {
commit('setLoading', true);
const url = `/api/1.0/person/accompanying-course/list/by-recent-attributions${'?'+ param}`;
makeFetch('GET', url)
.then((response) => {
commit('addCourses', response);
commit('setLoading', false);
})
.catch((error) => {
commit('catchError', error);
throw error;
})
;
}
break;
case 'MyNotifications':
if (!getters.isNotificationsLoaded) {
commit('setLoading', true);
const url = `/api/1.0/main/notification/my/unread${'?'+ param}`;
makeFetch('GET', url)
.then((response) => {
commit('addNotifications', response);
commit('setLoading', false);
})
.catch((error) => {
commit('catchError', error);
throw error;
})
;
}
break;
default:
throw 'tab '+ tab;
}
}
},
});
export { store };

View File

@@ -5,6 +5,7 @@
:action="context.action"
:buttonText="options.buttonText"
:displayBadge="options.displayBadge === 'true'"
:isDead="options.isDead"
:parent="options.parent"
@saveFormOnTheFly="saveFormOnTheFly">
</on-the-fly>

View File

@@ -1,15 +1,15 @@
<template>
<a v-if="isDisplayBadge" @click="openModal">
<span class="chill-entity" :class="badgeType">
{{ buttonText }}
</span>
</a>
<a v-else class="btn btn-sm" target="_blank"
<a v-if="isDisplayBadge" @click="openModal">
<span class="chill-entity" :class="badgeType">
{{ buttonText }}<span v-if="isDead"> ()</span>
</span>
</a>
<a v-else class="btn btn-sm" target="_blank"
:class="classAction"
:title="$t(titleAction)"
@click="openModal">
{{ buttonText }}
{{ buttonText }}<span v-if="isDead"> ()</span>
</a>
<teleport to="body">
@@ -101,7 +101,7 @@ export default {
OnTheFlyThirdparty,
OnTheFlyCreate
},
props: ['type', 'id', 'action', 'buttonText', 'displayBadge', 'parent'],
props: ['type', 'id', 'action', 'buttonText', 'displayBadge', 'isDead', 'parent', 'canCloseModal'],
emits: ['saveFormOnTheFly'],
data() {
return {
@@ -179,7 +179,20 @@ export default {
return 'entity-' + this.type + ' badge-' + this.type;
}
},
watch: {
canCloseModal: {
handler: function(val, oldVal) {
if (val) {
this.closeModal();
}
},
deep: true
}
},
methods: {
closeModal() {
this.modal.showModal = false;
},
openModal() {
// console.log('## OPEN ON THE FLY MODAL');
// console.log('## type:', this.type, ', action:', this.action);
@@ -237,8 +250,6 @@ export default {
console.log('type', type, 'data', data)
// pass datas to parent
this.$emit('saveFormOnTheFly', { type: type, data: data });
this.modal.showModal = false;
},
buildLocation(id, type) {
if (type === 'person') {

View File

@@ -22,7 +22,8 @@ containers.forEach((container) => {
options: {
buttonText: container.dataset.buttonText || null,
displayBadge: container.dataset.displayBadge || false,
parent: JSON.parse(container.dataset.parent) || null,
isDead: container.dataset.isDead || false,
parent: container.dataset.parent ? JSON.parse(container.dataset.parent) : null,
}
}
}
@@ -33,4 +34,5 @@ containers.forEach((container) => {
.mount(container);
//console.log('container dataset', container.dataset);
//console.log('data-parent', container.dataset.parent);
});

View File

@@ -1,5 +1,5 @@
<template>
<ul class="list-suggest remove-items">
<ul class="list-suggest remove-items" v-if="picked.length">
<li v-for="p in picked" @click="removeEntity(p)" :key="p.type+p.id">
<span class="chill_denomination">{{ p.text }}</span>
</li>
@@ -79,11 +79,15 @@ export default {
);
this.$refs.addPersons.resetSearch(); // to cast child method
modal.showModal = false;
console.log(this.picked)
},
removeEntity(entity) {
console.log('remove entity', entity);
this.$emit('removeEntity', entity);
}
},
mounted() {
console.log(this.picked);
}
}
</script>

View File

@@ -24,6 +24,11 @@
{{ $t('user')}}
</span>
<span v-if="entity.type === 'household'" class="badge rounded-pill bg-user">
{{ $t('household')}}
</span>
</template>
<script>
@@ -40,7 +45,8 @@ export default {
company: "Personne morale",
contact: "Personne physique",
},
user: 'TMS'
user: 'TMS',
household: 'Ménage',
}
}
}

View File

@@ -1,22 +1,26 @@
<template>
<div class="confidential" v-on:click="toggleBlur">
<div class="confidential-content blur">
<div :class="classes">
<div class="confidential-content" :class="{ 'blur': isBlurred }">
<slot name="confidential-content"></slot>
</div>
<i class="fa fa-eye toggle" aria-hidden="true"></i>
<div>
<i class="fa fa-eye toggle" aria-hidden="true" @click="toggleBlur"></i>
</div>
</div>
</template>
<script>
export default {
name: "Confidential",
data() {
return {
isBlurred: true,
};
},
methods : {
toggleBlur: function(e){
if(e.target.matches('.toggle')){
e.target.previousElementSibling.classList.toggle("blur");
e.target.classList.toggle("fa-eye");
e.target.classList.toggle("fa-eye-slash");
}
toggleBlur() {
console.log('toggle blur');
this.isBlurred = !this.isBlurred;
},
}
}
@@ -39,4 +43,4 @@ export default {
-moz-filter: blur(5px);
filter: blur(5px);
}
</style>
</style>

View File

@@ -1,26 +1,58 @@
<template>
<component :is="component" class="chill-entity entity-address my-3">
<component :is="component" class="address" :class="multiline">
<div v-if="isMultiline === true">
<p v-for="(l, i) in address.lines" :key="`line-${i}`">
{{ l }}
</p>
<div v-if="isConfidential">
<confidential>
<template v-slot:confidential-content>
<div v-if="isMultiline === true">
<p v-for="(l, i) in address.lines" :key="`line-${i}`">
{{ l }}
</p>
</div>
<div v-else>
<p v-if="address.text"
class="street">
{{ address.text }}
</p>
<p v-if="address.postcode"
class="postcode">
{{ address.postcode.code }} {{ address.postcode.name }}
</p>
<p v-if="address.country"
class="country">
{{ address.country.name.fr }}
</p>
</div>
</template>
</confidential>
</div>
<div v-else>
<p v-if="address.text"
class="street">
{{ address.text }}
</p>
<p v-if="address.postcode"
class="postcode">
{{ address.postcode.code }} {{ address.postcode.name }}
</p>
<p v-if="address.country"
class="country">
{{ address.country.name.fr }}
</p>
<div v-if="!isConfidential">
<div v-if="isMultiline === true">
<p v-for="(l, i) in address.lines" :key="`line-${i}`">
{{ l }}
</p>
</div>
<div v-else>
<p v-if="address.text"
class="street">
{{ address.text }}
</p>
<p v-if="address.postcode"
class="postcode">
{{ address.postcode.code }} {{ address.postcode.name }}
</p>
<p v-if="address.country"
class="country">
{{ address.country.name.fr }}
</p>
</div>
</div>
</component>
<!-- <div v-if="isMultiline === true" class="address-more">
@@ -78,8 +110,14 @@
</template>
<script>
import Confidential from 'ChillMainAssets/vuejs/_components/Confidential.vue';
export default {
name: 'AddressRenderBox',
components: {
Confidential
},
props: {
address: {
type: Object
@@ -100,6 +138,9 @@ export default {
multiline() {
//console.log(this.isMultiline, typeof this.isMultiline);
return this.isMultiline === true ? "multiline" : "";
},
isConfidential() {
return this.address.confidential;
}
}
};

View File

@@ -0,0 +1,101 @@
<template>
<div class="d-grid gap-2 my-3">
<button class="btn btn-misc" type="button" v-if="!subscriberFinal" @click="subscribeTo('subscribe', 'final')">
<i class="fa fa-check fa-fw"></i>
{{ $t('subscribe_final') }}
</button>
<button class="btn btn-misc" type="button" v-if="subscriberFinal" @click="subscribeTo('unsubscribe', 'final')">
<i class="fa fa-times fa-fw"></i>
{{ $t('unsubscribe_final') }}
</button>
<button class="btn btn-misc" type="button" v-if="!subscriberStep" @click="subscribeTo('subscribe', 'step')">
<i class="fa fa-check fa-fw"></i>
{{ $t('subscribe_all_steps') }}
</button>
<button class="btn btn-misc" type="button" v-if="subscriberStep" @click="subscribeTo('unsubscribe', 'step')">
<i class="fa fa-times fa-fw"></i>
{{ $t('unsubscribe_all_steps') }}
</button>
</div>
</template>
<script>
import {makeFetch} from 'ChillMainAssets/lib/api/apiMethods.js';
export default {
name: "EntityWorkflowVueSubscriber",
i18n: {
messages: {
fr: {
subscribe_final: "Recevoir une notification à l'étape finale",
unsubscribe_final: "Ne plus recevoir de notification à l'étape finale",
subscribe_all_steps: "Recevoir une notification à chaque étape du suivi",
unsubscribe_all_steps: "Ne plus recevoir de notification à chaque étape du suivi",
}
}
},
props: {
entityWorkflowId: {
type: Number,
required: true,
},
subscriberStep: {
type: Boolean,
required: true,
},
subscriberFinal: {
type: Boolean,
required: true,
},
},
emits: ['subscriptionUpdated'],
methods: {
subscribeTo(step, to) {
let params = new URLSearchParams();
params.set('subscribe', to);
const url = `/api/1.0/main/workflow/${this.entityWorkflowId}/${step}?` + params.toString();
makeFetch('POST', url).then(response => {
this.$emit('subscriptionUpdated', response);
});
}
},
}
/*
* ALTERNATIVES
*
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" role="switch" id="laststep">
<label class="form-check-label" for="laststep">{{ $t('subscribe_final') }}</label>
</div>
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" role="switch" id="allsteps">
<label class="form-check-label" for="allsteps">{{ $t('subscribe_all_steps') }}</label>
</div>
<div class="list-group my-3">
<label class="list-group-item">
<input class="form-check-input me-1" type="checkbox" value="">
{{ $t('subscribe_final') }}
</label>
<label class="list-group-item">
<input class="form-check-input me-1" type="checkbox" value="">
{{ $t('subscribe_all_steps') }}
</label>
</div>
<div class="btn-group-vertical my-3" role="group">
<button type="button" class="btn btn-outline-primary">
<i class="fa fa-check fa-fw"></i>
{{ $t('subscribe_final') }}
</button>
<button type="button" class="btn btn-outline-primary">
<i class="fa fa-check fa-fw"></i>
{{ $t('subscribe_all_steps') }}
</button>
</div>
*/
</script>
<style scoped></style>

View File

@@ -0,0 +1,132 @@
<template>
<div class="flex-table workflow" id="workflow-list">
<div v-for="(w, i) in workflows" :key="`workflow-${i}`"
class="item-bloc">
<div>
<div class="item-row col">
<h2>Workflow</h2>
<div class="flex-grow-1 ms-3 h3">
<div class="visually-hidden">
{{ w.relatedEntityClass }}
{{ w.relatedEntityId }}
</div>
</div>
</div>
<div class="breadcrumb">
<template v-for="(step, j) in w.steps" :key="`step-${j}`">
<span class="mx-2"
tabindex="0"
data-bs-trigger="focus hover"
data-bs-toggle="popover"
data-bs-placement="bottom"
data-bs-custom-class="workflow-transition"
:title="getPopTitle(step)"
:data-bs-content="getPopContent(step)">
<i v-if="step.currentStep.name === 'initial'"
class="fa fa-circle me-1 text-chill-yellow">
</i>
<i v-if="step.isFreezed"
class="fa fa-snowflake-o fa-sm me-1">
</i>
{{ step.currentStep.text }}
</span>
<span v-if="j !== Object.keys(w.steps).length - 1">
</span>
</template>
</div>
</div>
<div class="item-row">
<div class="item-col flex-grow-1">
<p v-if="isUserSubscribedToStep(w)">
<i class="fa fa-check fa-fw"></i>
{{ $t('you_subscribed_to_all_steps') }}
</p>
<p v-if="isUserSubscribedToFinal(w)">
<i class="fa fa-check fa-fw"></i>
{{ $t('you_subscribed_to_final_step') }}
</p>
</div>
<div class="item-col">
<ul class="record_actions">
<li>
<a :href="goToUrl(w)" class="btn btn-sm btn-show" :title="$t('action.show')"></a>
</li>
</ul>
</div>
</div>
</div>
</div>
</template>
<script>
import Popover from 'bootstrap/js/src/popover';
const i18n = {
messages: {
fr: {
you_subscribed_to_all_steps: "Vous recevrez une notification à chaque étape",
you_subscribed_to_final_step: "Vous recevrez une notification à l'étape finale",
by: "Par",
at: "Le"
}
}
}
export default {
name: "ListWorkflow",
i18n: i18n,
props: {
workflows: {
type: Array,
required: true,
}
},
methods: {
goToUrl(w) {
return `/fr/main/workflow/${w.id}/show`;
},
getPopTitle(step) {
if (step.transitionPrevious != null) {
//console.log(step.transitionPrevious.text);
let freezed = step.isFreezed ? `<i class="fa fa-snowflake-o fa-sm me-1"></i>` : ``;
return `${freezed}${step.transitionPrevious.text}`;
}
},
getPopContent(step) {
if (step.transitionPrevious != null) {
return `<ul class="small_in_title">
<li><span class="item-key">${i18n.messages.fr.by} : </span><b>${step.transitionPreviousBy.text}</b></li>
<li><span class="item-key">${i18n.messages.fr.at} : </span><b>${this.formatDate(step.transitionPreviousAt.datetime)}</b></li>
</ul>`
;
}
},
formatDate(datetime) {
return datetime.split('T')[0] +' '+ datetime.split('T')[1].substring(0,5)
},
isUserSubscribedToStep(w) {
// todo
return false;
},
isUserSubscribedToFinal(w) {
// todo
return false;
},
},
mounted() {
const triggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="popover"]'));
const popoverList = triggerList.map(function (el) {
//console.log('popover', el)
return new Popover(el, {
html: true,
});
});
}
}
</script>

View File

@@ -0,0 +1,124 @@
<template>
<button v-if="hasWorkflow"
class="btn btn-primary"
@click="openModal">
<b>{{ countWorkflows }}</b>
<template v-if="countWorkflows > 1">{{ $t('workflows') }}</template>
<template v-else>{{ $t('workflow') }}</template>
</button>
<pick-workflow v-else-if="allowCreate"
:relatedEntityClass="this.relatedEntityClass"
:relatedEntityId="this.relatedEntityId"
:workflowsAvailables="workflowsAvailables"
@go-to-generate-workflow="goToGenerateWorkflow"
></pick-workflow>
<teleport to="body">
<modal v-if="modal.showModal"
:modalDialogClass="modal.modalDialogClass"
@close="modal.showModal = false">
<template v-slot:header>
<h2 class="modal-title">{{ $t('workflow_list') }}</h2>
</template>
<template v-slot:body>
<list-workflow-vue
:workflows="workflows"
></list-workflow-vue>
</template>
<template v-slot:footer>
<pick-workflow v-if="allowCreate"
:relatedEntityClass="this.relatedEntityClass"
:relatedEntityId="this.relatedEntityId"
:workflowsAvailables="workflowsAvailables"
:preventDefaultMoveToGenerate="this.$props.preventDefaultMoveToGenerate"
@go-to-generate-workflow="this.goToGenerateWorkflow"
></pick-workflow>
</template>
</modal>
</teleport>
</template>
<script>
import Modal from 'ChillMainAssets/vuejs/_components/Modal';
import PickWorkflow from 'ChillMainAssets/vuejs/_components/EntityWorkflow/PickWorkflow.vue';
import ListWorkflowVue from 'ChillMainAssets/vuejs/_components/EntityWorkflow/ListWorkflow.vue';
export default {
name: "ListWorkflowModal",
components: {
Modal,
PickWorkflow,
ListWorkflowVue
},
emits: ['goToGenerateWorkflow'],
props: {
workflows: {
type: Array,
required: true,
},
allowCreate: {
type: Boolean,
required: true,
},
relatedEntityClass: {
type: String,
required: true,
},
relatedEntityId: {
type: Number,
required: false,
},
workflowsAvailables: {
type: Array,
required: true,
},
preventDefaultMoveToGenerate: {
type: Boolean,
required: false,
default: false,
},
},
data() {
return {
modal: {
showModal: false,
modalDialogClass: "modal-dialog-scrollable modal-xl"
},
}
},
computed: {
countWorkflows() {
return this.workflows.length;
},
hasWorkflow() {
return this.countWorkflows > 0;
}
},
methods: {
openModal() {
this.modal.showModal = true;
},
goToGenerateWorkflow(data) {
console.log('go to generate workflow intercepted');
this.$emit('goToGenerateWorkflow', data);
}
},
i18n: {
messages: {
fr: {
workflow_list: "Liste des workflows associés",
workflow: " workflow associé",
workflows: " workflows associés",
}
}
}
}
</script>
<style scoped></style>

View File

@@ -0,0 +1,62 @@
<template>
<template v-if="workflowsAvailables.length >= 1">
<div class="dropdown d-grid gap-2">
<button class="btn btn-primary dropdown-toggle" type="button" id="createWorkflowButton" data-bs-toggle="dropdown" aria-expanded="false">
Créer un workflow
</button>
<ul class="dropdown-menu" aria-labelledby="createWorkflowButton">
<li v-for="w in workflowsAvailables" :key="w.name">
<a class="dropdown-item" :href="makeLink(w.name)" @click.prevent="goToGenerateWorkflow($event, w.name)">{{ w.text }}</a>
</li>
</ul>
</div>
</template>
</template>
<script>
import {buildLinkCreate} from 'ChillMainAssets/lib/entity-workflow/api.js';
export default {
name: "PickWorkflow",
props: {
relatedEntityClass: {
type: String,
required: true,
},
relatedEntityId: {
type: Number,
required: false,
},
workflowsAvailables: {
type: Array,
required: true,
},
preventDefaultMoveToGenerate: {
type: Boolean,
required: false,
default: false,
},
},
emits: ['goToGenerateWorkflow'],
methods: {
makeLink(workflowName) {
return buildLinkCreate(workflowName, this.relatedEntityClass, this.relatedEntityId);
},
goToGenerateWorkflow(event, workflowName) {
console.log('goToGenerateWorkflow', event, workflowName);
if (!this.$props.preventDefaultMoveToGenerate) {
console.log('to go generate');
window.location.assign(this.makeLink(workflowName));
}
this.$emit('goToGenerateWorkflow', {event, workflowName, link: this.makeLink(workflowName)});
}
}
}
</script>
<style scoped>
</style>

View File

@@ -15,7 +15,7 @@
<div class="modal-body">
<slot name="body"></slot>
</div>
<div class="modal-footer">
<div class="modal-footer" v-if="!hideFooter">
<button class="btn btn-cancel" @click="$emit('close')">{{ $t('action.close') }}</button>
<slot name="footer"></slot>
</div>
@@ -38,7 +38,16 @@
*/
export default {
name: 'Modal',
props: ['modalDialogClass'],
props: {
modalDialogClass: {
type: String,
required: false
},
hideFooter: {
type: Boolean,
required: false
}
},
emits: ['close']
}
</script>

View File

@@ -34,7 +34,7 @@
:href="showUrl"
:title="$t('action.show')"
>
<i class="fa fa-sm fa-eye"></i>
<i class="fa fa-sm fa-comment-o"></i>
</a>
</div>

View File

@@ -0,0 +1,243 @@
<template>
<a v-if="isOpenDocument"
class="btn change-icon" :class="[isChangeClass ? button.changeClass : 'btn-edit']"
@click="openModal">
<i class="fa me-2" :class="[isChangeIcon ? button.changeIcon : 'fa-pencil']"></i>
<span v-if="!noText">
{{ $t('Update_document') }}
</span>
</a>
<teleport to="body">
<div class="wopi-frame" v-if="isOpenDocument">
<modal v-if="modal.showModal"
:modalDialogClass="modal.modalDialogClass"
:hideFooter=true
@close="modal.showModal = false">
<template v-slot:header>
<img class="logo" :src="logo" height="45"/>
<span class="ms-auto me-3">
{{ this.title }}
</span>
<a class="btn btn-outline-light">
<i class="fa fa-save fa-fw"></i>
{{ $t('save_and_quit') }}
</a>
</template>
<template v-slot:body>
<div v-if="loading" class="loading">
<i class="fa fa-circle-o-notch fa-spin fa-3x" :title="$t('loading')"></i>
</div>
<iframe
:src="this.wopiUrl"
@load="loaded"
></iframe>
</template>
</modal>
</div>
<div v-else>
<modal v-if="modal.showModal"
modalDialogClass="modal-sm"
@close="modal.showModal = false">
<template v-slot:header>
<h3>{{ $t('invalid_title') }}</h3>
</template>
<template v-slot:body>
<div class="alert alert-warning">{{ $t('invalid_message') }}</div>
</template>
</modal>
</div>
</teleport>
</template>
<script>
import Modal from 'ChillMainAssets/vuejs/_components/Modal';
import logo from 'ChillMainAssets/chill/img/logo-chill-sans-slogan_white.png';
export default {
name: "OpenWopiLink",
components: {
Modal
},
props: {
wopiUrl: {
type: String,
required: true
},
title: {
type: String,
required: true
},
type: {
type: String,
required: true
},
button: {
type: Object,
required: false
}
},
data() {
return {
modal: {
showModal: false,
modalDialogClass: "modal-fullscreen" //modal-dialog-scrollable
},
logo: logo,
loading: false,
mime: [
// TODO temporary hardcoded. to be replaced by twig extension or a collabora server query
'application/clarisworks',
'application/coreldraw',
'application/macwriteii',
'application/msword',
'application/pdf',
'application/vnd.lotus-1-2-3',
'application/vnd.ms-excel',
'application/vnd.ms-excel.sheet.binary.macroEnabled.12',
'application/vnd.ms-excel.sheet.macroEnabled.12',
'application/vnd.ms-excel.template.macroEnabled.12',
'application/vnd.ms-powerpoint',
'application/vnd.ms-powerpoint.presentation.macroEnabled.12',
'application/vnd.ms-powerpoint.template.macroEnabled.12',
'application/vnd.ms-visio.drawing',
'application/vnd.ms-word.document.macroEnabled.12',
'application/vnd.ms-word.template.macroEnabled.12',
'application/vnd.ms-works',
'application/vnd.oasis.opendocument.chart',
'application/vnd.oasis.opendocument.formula',
'application/vnd.oasis.opendocument.graphics',
'application/vnd.oasis.opendocument.graphics-flat-xml',
'application/vnd.oasis.opendocument.graphics-template',
'application/vnd.oasis.opendocument.presentation',
'application/vnd.oasis.opendocument.presentation-flat-xml',
'application/vnd.oasis.opendocument.presentation-template',
'application/vnd.oasis.opendocument.spreadsheet',
'application/vnd.oasis.opendocument.spreadsheet-flat-xml',
'application/vnd.oasis.opendocument.spreadsheet-template',
'application/vnd.oasis.opendocument.text',
'application/vnd.oasis.opendocument.text-flat-xml',
'application/vnd.oasis.opendocument.text-master',
'application/vnd.oasis.opendocument.text-master-template',
'application/vnd.oasis.opendocument.text-template',
'application/vnd.oasis.opendocument.text-web',
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'application/vnd.openxmlformats-officedocument.presentationml.slideshow',
'application/vnd.openxmlformats-officedocument.presentationml.template',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/vnd.openxmlformats-officedocument.spreadsheetml.template',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.openxmlformats-officedocument.wordprocessingml.template',
'application/vnd.sun.xml.calc',
'application/vnd.sun.xml.calc.template',
'application/vnd.sun.xml.chart',
'application/vnd.sun.xml.draw',
'application/vnd.sun.xml.draw.template',
'application/vnd.sun.xml.impress',
'application/vnd.sun.xml.impress.template',
'application/vnd.sun.xml.math',
'application/vnd.sun.xml.writer',
'application/vnd.sun.xml.writer.global',
'application/vnd.sun.xml.writer.template',
'application/vnd.visio',
'application/vnd.visio2013',
'application/vnd.wordperfect',
'application/x-abiword',
'application/x-aportisdoc',
'application/x-dbase',
'application/x-dif-document',
'application/x-fictionbook+xml',
'application/x-gnumeric',
'application/x-hwp',
'application/x-iwork-keynote-sffkey',
'application/x-iwork-numbers-sffnumbers',
'application/x-iwork-pages-sffpages',
'application/x-mspublisher',
'application/x-mswrite',
'application/x-pagemaker',
'application/x-sony-bbeb',
'application/x-t602',
]
}
},
computed: {
isOpenDocument() {
if (this.mime.indexOf(this.type) !== -1) {
return true;
}
return false;
},
noText() {
if (typeof this.button.noText !== 'undefined') {
return this.button.noText === true;
}
return false;
},
isChangeIcon() {
if (typeof this.button.changeIcon !== 'undefined') {
return (!(this.button.changeIcon === null || this.button.changeIcon === ''))
}
return false;
},
isChangeClass() {
if (typeof this.button.changeClass !== 'undefined') {
return (!(this.button.changeClass === null || this.button.changeClass === ''))
}
return false;
}
},
methods: {
openModal() {
this.loading = true;
this.modal.showModal = true;
},
loaded() {
this.loading = false;
}
},
i18n: {
messages: {
fr: {
Update_document: "Modifier le document",
save_and_quit: "Enregistrer et quitter",
loading: "Chargement de l'éditeur en ligne",
invalid_title: "Format incompatible",
invalid_message: "Désolé, ce format de document n'est pas éditable en ligne.",
}
}
}
}
</script>
<style lang="scss">
div.wopi-frame {
div.modal-header {
border-bottom: 0;
background-color: var(--bs-primary);
color: white;
}
div.modal-body {
padding: 0;
overflow-y: unset !important;
iframe {
height: 100%;
width: 100%;
}
div.loading {
position: absolute;
color: var(--bs-chill-gray);
top: calc(50% - 30px);
left: calc(50% - 30px);
}
}
}
</style>

View File

@@ -62,14 +62,15 @@ const messages = {
woman: "Née le"
},
deathdate: "Date de décès",
years_old: "ans",
household_without_address: "Le ménage de l'usager est sans adresse",
no_data: "Aucune information renseignée",
type: {
thirdparty: "Tiers",
person: "Usager"
},
holder: "Titulaire"
holder: "Titulaire",
years_old: "an | {n} an | {n} ans",
}
}
};

View File

@@ -3,7 +3,7 @@
{% block navigation_search_bar %}{% endblock %}
{% block navigation_section_menu %}
{{ chill_menu('admin_section', {
'layout': '@ChillMain/Menu/adminSection.html.twig',
'layout': '@ChillMain/Menu/admin.html.twig',
}) }}
{% endblock %}

View File

@@ -23,23 +23,23 @@
{% if options['metadata'] %}
<div class="metadata">
{% if user is not empty %}
{{ 'Last updated by'| trans }}
<span class="user">
{{ user|chill_entity_render_box(options['user']) }}
</span>
{% endif %}
{% if comment.date is not empty %}
{% if user is empty %}
{{ 'Last updated on'|trans ~ ' ' }}
{% else %}
{{ 'on'|trans ~ ' ' }}
{% endif %}
{{ 'Last updated on'|trans ~ ' ' }}
<span class="date">
{{ comment.date|format_datetime("medium", "short") }}
</span>
{% endif %}
{% if user is not empty %}
{% if comment.date is empty %}
{{ 'Last updated by'| trans }}
{% else %}
{{ 'by_user'|trans ~ ' ' }}
{% endif %}
<span class="user">
{{ user|chill_entity_render_box(options['user']) }}
</span>
{% endif %}
</div>
{% endif %}
</blockquote>
{{ closing_box|raw }}
{{ closing_box|raw }}

View File

@@ -59,7 +59,7 @@
must be shown in such list
#}
{%- if render == 'list' -%}
<li class="chill-entity entity-address">
<li class="chill-entity entity-address {% if address.confidential %}confidential{% endif %}">
{% if options['with_picto'] %}
<i class="fa fa-li fa-map-marker"></i>
{% endif %}
@@ -68,7 +68,7 @@
{%- endif -%}
{%- if render == 'inline' -%}
<span class="chill-entity entity-address">
<span class="chill-entity entity-address {% if address.confidential %}confidential{% endif %}">
{% if options['with_picto'] %}
<i class="fa fa-fw fa-map-marker"></i>
{% endif %}
@@ -77,7 +77,7 @@
{%- endif -%}
{%- if render == 'bloc' -%}
<div class="chill-entity entity-address">
<div class="chill-entity entity-address {% if address.confidential %}confidential{% endif %}">
{% if options['has_no_address'] == true and address.isNoAddress == true %}
{% if address.postCode is not empty %}
<div class="address{% if options['multiline'] %} multiline{% endif %}{% if options['with_delimiter'] %} delimiter{% endif %}">

View File

@@ -216,7 +216,7 @@
{% endif %}
{% endblock %}
{% block pick_user_dynamic_widget %}
{% block pick_entity_dynamic_widget %}
<input type="hidden" {{ block('widget_attributes') }} {% if value is not empty %}value="{{ value }}" {% endif %} data-input-uniqid="{{ form.vars['uniqid'] }}"/>
<div data-module="pick-dynamic" data-types="{{ form.vars['types']|json_encode }}" data-multiple="{{ form.vars['multiple'] }}" data-uniqid="{{ form.vars['uniqid'] }}"></div>
{% endblock %}

View File

@@ -0,0 +1,3 @@
<div class="sticky-buttons">
{# Override this file to add fast actions buttons #}
</div>

View File

@@ -0,0 +1,15 @@
<div class="col-10 mt-5">
{# vue component #}
<div id="homepage_widget"></div>
{% include '@ChillMain/Homepage/fast_actions.html.twig' %}
</div>
{% block css %}
{{ encore_entry_link_tags('page_homepage_widget') }}
{% endblock %}
{% block js %}
{{ encore_entry_script_tags('page_homepage_widget') }}
{% endblock %}

View File

@@ -23,7 +23,7 @@
<td>{{ entity.email }}</td>
<td>
{% if entity.address is not null %}
{{ entity.address.street}}, {{ entity.address.streetnumber }}
{{ entity.address| chill_entity_render_box }}
{% endif %}
</td>
<td style="text-align:center;">

View File

@@ -8,6 +8,7 @@
<tr>
<th>{{ 'Title'|trans }}</th>
<th>{{ 'Available for users'|trans }}</th>
<th>{{ 'Editable by users'|trans }}</th>
<th>{{ 'Address required'|trans }}</th>
<th>{{ 'Contact data'|trans }}</th>
<th>{{ 'Active'|trans }}</th>
@@ -25,6 +26,13 @@
<i class="fa fa-square-o"></i>
{%- endif -%}
</td>
<td style="text-align:center;">
{%- if entity.editableByUsers -%}
<i class="fa fa-check-square-o"></i>
{%- else -%}
<i class="fa fa-square-o"></i>
{%- endif -%}
</td>
<td>{{ entity.addressRequired|trans }}</td>
<td>{{ entity.contactData|trans }}</td>
<td style="text-align:center;">

View File

@@ -21,13 +21,13 @@
layout ../layoutWithVerticalMenu.html.twig.
#}
<ul class="tab-nav follow-href-path">
<li class="title">
<div class="list-group vertical-menu {{ 'menu-' ~ menus.name }}">
<a class="list-group-item title">
{% block v_menu_title %}<!-- title of the verticalMenu is empty -->{% endblock %}
</li>
</a>
{% for menu in menus %}
<li class="{% if menu is knp_menu_current %}current {% endif %}">
<a href="{{ menu.uri }}" >{{ menu.label|trans }}</a>
</li>
<a class="list-group-item list-group-item-action" href="{{ menu.uri }}">
{{ menu.label|upper }}
</a>
{% endfor %}
</ul>
</div>

View File

@@ -0,0 +1,77 @@
{% import '@ChillPerson/AccompanyingCourse/Comment/macro_showItem.html.twig' as m %}
{% macro recordAction(comment) %}
{% if is_granted('CHILL_MAIN_NOTIFICATION_COMMENT_EDIT', comment) %}
<li>
<a href="{{ chill_path_forward_return_path('chill_main_notification_show', {
'_fragment': 'comment-' ~ comment.id,
'edit': comment.id,
'id': comment.notification.id
}) }}" class="btn btn-edit" title="{{ 'Edit'|trans }}"
></a>
</li>
{% endif %}
{% endmacro %}
<div class="notification-comment-list my-5">
<h2 class="chill-blue">{{ 'notification.comments_list'|trans }}</h2>
{% if notification.comments|length > 0 %}
<div class="flex-table">
{% for comment in notification.comments %}
{% if editedCommentForm is null or editedCommentId != comment.id %}
{{ m.show_comment(comment, {
'recordAction': _self.recordAction(comment)
}) }}
{% else %}
<div class="item-bloc">
<div class="item-row row">
<a id="comment-{{ comment.id }}"></a>
{{ form_start(editedCommentForm) }}
{{ form_errors(editedCommentForm) }}
{{ form_widget(editedCommentForm.content) }}
<input type="hidden" name="form" value="edit" />
<ul class="record_actions">
<li class="cancel">
<a href="{{ chill_path_forward_return_path('chill_main_notification_show', {
'_fragment': 'comment-' ~ comment.id,
'id': notification.id }) }}" class="btn btn-cancel">
{{ 'cancel'|trans }}
</a>
</li>
<li>
<button class="btn btn-save" type="submit">{{ 'Save'|trans }}</button>
</li>
</ul>
{{ form_end(editedCommentForm) }}
</div>
</div>
{% endif %}
{% endfor %}
</div>
{% else %}
<span class="chill-no-data-statement">{{ 'No comments'|trans }}</span>
{% endif %}
{% if appendCommentForm is not null %}
<div class="new-comment my-5">
<h2 class="chill-blue mb-4">{{ 'Write a new comment'|trans }}</h2>
{{ form_start(appendCommentForm) }}
{{ form_errors(appendCommentForm) }}
{{ form_widget(appendCommentForm.content) }}
<input type="hidden" name="form" value="append" />
<ul class="record_actions">
<li>
<button class="btn btn-create" type="submit">{{ 'notification.append_comment'|trans }}</button>
</li>
</ul>
{{ form_end(appendCommentForm) }}
</div>
{% endif %}
</div>

View File

@@ -1,39 +1,41 @@
<div class="item-bloc {% if not notification.isReadBy(app.user) %}unread{% else %}read{% endif %}">
{% macro title(c) %}
<div class="item-row title">
<h2 class="notification-title">
<a href="{{ chill_path_add_return_path('chill_main_notification_show', {'id': notification.id}) }}">
{{ notification.title }}
<a href="{{ chill_path_add_return_path('chill_main_notification_show', {'id': c.notification.id}) }}">
{{ c.notification.title }}
</a>
</h2>
</div>
<div class="item-row mt-2 header">
{% endmacro %}
{% macro header(c) %}
<div class="item-row notification-header mt-2">
<div class="item-col">
<ul class="small_in_title">
{% if step is not defined or step == 'inbox' %}
{% if c.step is not defined or c.step == 'inbox' %}
<li class="notification-from">
<span class="item-key">
<abbr title="{{ 'notification.received_from'|trans }}">
{{ 'notification.from'|trans }} :
</abbr>
</span>
{% if not notification.isSystem %}
{% if not c.notification.isSystem %}
<span class="badge-user">
{{ notification.sender|chill_entity_render_string }}
</span>
{{ c.notification.sender|chill_entity_render_string }}
</span>
{% else %}
<span class="badge-user system">{{ 'notification.is_system'|trans }}</span>
{% endif %}
</li>
{% endif %}
{% if notification.addressees|length > 0 %}
{% if c.notification.addressees|length > 0 %}
<li class="notification-to">
<span class="item-key">
<abbr title="{{ 'notification.sent_to'|trans }}">
{{ 'notification.to'|trans }} :
</abbr>
</span>
{% for a in notification.addressees %}
{% for a in c.notification.addressees %}
<span class="badge-user">
{{ a|chill_entity_render_string }}
</span>
@@ -42,47 +44,102 @@
{% endif %}
</ul>
</div>
<div class="item-col">
{{ notification.date|format_datetime('long', 'short') }}
{{ c.notification.date|format_datetime('long', 'short') }}
</div>
</div>
{% endmacro %}
</div>
{% macro content(c) %}
<div class="item-row separator">
<div class="mx-3 flex-grow-1">
{% include data.template with data.template_data %}
{% if c.data is defined %}
<div class="mx-3 flex-grow-1">
{% include c.data.template with c.data.template_data %}
</div>
{% endif %}
</div>
<div class="item-row">
<div class="notification-content">
{% if c.full_content is defined and c.full_content == true %}
{{ c.notification.message|chill_markdown_to_html }}
{% else %}
{{ c.notification.message|u.truncate(250, '…', false)|chill_markdown_to_html }}
<p class="read-more"><a href="{{ chill_path_add_return_path('chill_main_notification_show', {'id': c.notification.id}) }}">{{ 'Read more'|trans }}</a></p>
{% endif %}
</div>
</div>
<div class="item-row row">
<div>
<blockquote class="chill-user-quote">
{{ notification.message|u.truncate(250, '…', false)|chill_markdown_to_html }}
</blockquote>
</div>
</div>
{% if action_button is not defined or action_button != 'false' %}
{% if c.action_button is not defined or c.action_button != false %}
<div class="item-row separator">
<ul class="record_actions">
<li>
{# Vue component #}
<span class="notification_toggle_read_status"
data-notification-id="{{ notification.id }}"
data-notification-current-is-read="{{ notification.isReadBy(app.user) }}"
></span>
</li>
{% if is_granted('CHILL_MAIN_NOTIFICATION_UPDATE', notification) %}
<li>
<a href="{{ chill_path_add_return_path('chill_main_notification_edit', {'id': notification.id}) }}"
class="btn btn-edit" title="{{ 'Edit'|trans }}"></a>
</li>
<div class="item-col item-meta">
{% if c.notification.comments|length > 0 %}
<div class="comment-counter">
<span class="counter">
{{ 'notification.counter comments'|trans({'nb': c.notification.comments|length }) }}
</span>
</div>
{% endif %}
{% if is_granted('CHILL_MAIN_NOTIFICATION_SEE', notification) %}
</div>
<div class="item-col">
<ul class="record_actions">
<li>
<a href="{{ chill_path_add_return_path('chill_main_notification_show', {'id': notification.id}) }}"
class="btn btn-show" title="{{ 'Show'|trans }}"></a>
{# Vue component #}
<span class="notification_toggle_read_status"
data-notification-id="{{ c.notification.id }}"
data-notification-current-is-read="{{ c.notification.isReadBy(app.user) }}"
data-container="notification-status"
></span>
</li>
{% endif %}
</ul>
{% if is_granted('CHILL_MAIN_NOTIFICATION_UPDATE', c.notification) %}
<li>
<a href="{{ chill_path_add_return_path('chill_main_notification_edit', {'id': c.notification.id}) }}"
class="btn btn-edit" title="{{ 'Edit'|trans }}"></a>
</li>
{% endif %}
{% if is_granted('CHILL_MAIN_NOTIFICATION_SEE', c.notification) %}
<li>
<a href="{{ chill_path_add_return_path('chill_main_notification_show', {'id': c.notification.id}) }}"
class="btn {% if not c.notification.isSystem %}btn-show change-icon{% else %}btn-misc{% endif %}" title="{{ 'notification.see_comments_thread'|trans }}">
{% if not c.notification.isSystem() %}
<i class="fa fa-comment"></i>
{% else %}
{{ 'Read more'|trans }}
{% endif %}
</a>
</li>
{% endif %}
</ul>
</div>
</div>
{% endif %}
{% endmacro %}
<div class="item-bloc notification-status {% if notification.isReadBy(app.user) %}read{% else %}unread{% endif %}">
{% if fold_item is defined and fold_item != false %}
<div class="accordion-header" id="flush-heading-{{ notification.id }}">
<button type="button" class="accordion-button collapsed"
data-bs-toggle="collapse" data-bs-target="#flush-collapse-{{ notification.id }}"
aria-expanded="false" aria-controls="flush-collapse-{{ notification.id }}">
{{ _self.title(_context) }}
</button>
{{ _self.header(_context) }}
</div>
<div id="flush-collapse-{{ notification.id }}"
class="accordion-collapse collapse"
aria-labelledby="flush-heading-{{ notification.id }}"
data-bs-parent="#notification-fold">
{{ _self.content(_context) }}
</div>
{% else %}
{{ _self.title(_context) }}
{{ _self.header(_context) }}
{{ _self.content(_context) }}
{% endif %}
</div>

View File

@@ -20,7 +20,7 @@
{{ form_row(form.title, { 'label': 'notification.subject'|trans }) }}
{{ form_row(form.addressees, { 'label': 'notification.sent_to'|trans }) }}
{% include handler.template(notification) with handler.templateData(notification) %}
<div class="mb-3 row">

Some files were not shown because too many files have changed in this diff Show More