Compare commits

...

91 Commits

Author SHA1 Message Date
e00ece4200 Update form builder parameter in SearchController
Changed the first argument in the `createNamedBuilder` method from `null` to an empty string. This adjustment ensures the form factory correctly creates the builder in the SearchController.
2024-05-28 15:58:17 +02:00
640fd71402 merge ticket-app-master and fix rector / cs 2024-05-28 15:54:52 +02:00
aae50ca290 Merge branch 'ticket-app-master' into chill-bundles-ticket-app-adaptations 2024-05-28 15:08:59 +02:00
1fa483598b Merge branch 'upgrade-sf5' into ticket-app-master 2024-05-28 14:59:25 +02:00
e4b6a468f8 adding fixtures for ticket in every environment 2024-05-28 13:47:58 +02:00
Boris Waaub
66c7758023 Adapt module name 2024-05-22 11:17:07 +02:00
Boris Waaub
4750d2c24e Adapt module name 2024-05-22 11:16:18 +02:00
Boris Waaub
ca05e3d979 Layout adaptation 2024-05-22 11:12:22 +02:00
Boris Waaub
a20f9b4f86 Generalize ticket actions 2024-05-22 00:38:47 +02:00
Boris Waaub
c73c1eb8d5 Rename "appelant" by "patient" 2024-05-21 22:24:30 +02:00
Boris Waaub
8778bb0731 Use colors and badges for history and banner 2024-05-21 22:22:33 +02:00
Boris Waaub
c7d20eebc5 chore: Remove unused code in AddresseeSelectorComponent.vue 2024-05-21 20:53:15 +02:00
Boris Waaub
b9e130c159 Use suggestion for user asignee 2024-05-21 20:44:23 +02:00
Boris Waaub
3e8bc94af3 Remove user object display 2024-05-21 18:14:11 +02:00
Boris Waaub
0c914c9f9f Remove "remove_addressee" history line 2024-05-21 17:32:40 +02:00
Boris Waaub
580a60c939 Add user_group for returning type 2024-05-21 17:32:05 +02:00
Boris Waaub
4996ac3b7c Adapt layout action toolbar 2024-05-21 15:22:13 +02:00
Boris Waaub
2a23bf19cb use record_actions sticky-form-buttons 2024-05-21 10:53:25 +02:00
Boris Waaub
650d2596d9 Update ticket display to use ticket ID instead of external reference 2024-05-21 09:54:06 +02:00
Boris Waaub
2bdd5a329e Merge branch 'ticket-app-master' of gitlab.com:boriswa/chill-bundles into ticket-app-master 2024-05-21 09:53:32 +02:00
78d1776733 Add functionality to find a caller by phone number
Added a new method in PersonRepository to allow querying people by phone number. Also, a new REST API endpoint "/public/api/1.0/ticket/find-caller" was introduced and it can find a caller by their phone number. Accompanied this feature addition with corresponding test cases.
2024-05-17 13:14:26 +02:00
66dc603c85 fix cs with new version of php-cs-fixer 2024-05-17 12:20:33 +02:00
3a8154ecce Replace PhoneNumberUtil with PhonenumberHelper
The PhoneNumberUtil has been replaced with PhonenumberHelper in AssociateByPhonenumberCommandHandler and its test class. The purpose of this change is to improve phone number parsing which is now delegated to the PhonenumberHelper class in the Chill\MainBundle\Phonenumber namespace. As a consequence, the related dependencies in both the service and the test class have been updated accordingly.
2024-05-17 12:17:00 +02:00
c81828e04f Add phone number parsing functionality
Added a new method 'parse' in the PhonenumberHelper class in ChillMainBundle to sanitize and parse phone numbers. This method specifically handles phone numbers that start with '00', '+' or '0'. Associated unit tests for this new method were also added in PhonenumberHelperTest.php.
2024-05-17 12:16:28 +02:00
Boris Waaub
ec17dd7de2 Merge branch 'master' of https://gitlab.com/Chill-Projet/chill-bundles into ticket-app-master 2024-05-13 16:08:19 +02:00
76c076a5f3 Merge branch 'ticket-app-create-template' into 'ticket-app-master'
Mise à jour des messages de l'interface utilisateur pour inclure les...

See merge request Chill-Projet/chill-bundles!689
2024-05-13 13:34:43 +00:00
Boris Waaub
f0045edd6c FIX: Ouvert depuis 2024-05-13 12:33:11 +02:00
Boris Waaub
d00b76ffcd $tc n'est plus supporté pour i18n composition api, il faut utiliser $t.
FIX: Person PersonRenderBox
2024-05-13 12:16:07 +02:00
Boris Waaub
8991f0ef3f Modification i18n 2024-05-13 12:00:11 +02:00
Boris Waaub
d6f5eae0c9 Rendre les commentaire markdown 2024-05-13 11:59:50 +02:00
Boris Waaub
821fce3dd8 $tc n'est plus supporté pour i18n composiontion api, il faut utiliser $t.
Source : https://github.com/intlify/vue-cli-plugin-i18n/issues/214
i18n composion api : https://vue-i18n.intlify.dev/api/composition
2024-05-13 11:38:28 +02:00
Boris Waaub
1d33ae1e39 use ckeditor 2024-05-08 18:03:50 +02:00
Boris Waaub
19af0feb57 Use PersonRenderBox 2024-05-08 17:54:03 +02:00
Boris Waaub
1c09e9a692 Merge branch 'ticket-app-master' into ticket-app-create-template 2024-05-08 16:05:35 +02:00
Boris Waaub
d72e748388 Merge branch 'ticket-app-master' of https://gitlab.com/boriswa/chill-bundles into ticket-app-master 2024-05-08 16:02:09 +02:00
Boris Waaub
ab850b7b70 Fusionner les utilisateurs/goupes en une "Card" 2024-05-06 20:07:15 +02:00
Boris Waaub
3f9745d8cf Use teleport for banner 2024-05-06 18:03:04 +02:00
Boris Waaub
473765366a Add tranfert with AddPerson 2024-05-06 16:38:56 +02:00
Boris Waaub
6500c24a7f Déplacer le répertoire translation dans source 2024-05-02 14:10:22 +02:00
Boris Waaub
1d00457141 Ajouter les propriétés createdAt et updatedBy à l'interface Ticket 2024-05-02 14:09:52 +02:00
Boris Waaub
eb0bf56cff Add user group addressee 2024-05-02 13:18:45 +02:00
Boris Waaub
7b8cd90cf1 Add user store 2024-05-02 12:03:10 +02:00
Boris Waaub
a27d92aba0 Add comment and motive 2024-05-02 00:50:33 +02:00
Boris Waaub
85bdfb9e21 Remove banner component 2024-05-01 22:04:07 +02:00
Boris Waaub
4cffcf4de1 Use translate in setup 2024-05-01 22:03:36 +02:00
Boris Waaub
b2587a688f Déplacer le composant banner dans twig 2024-05-01 15:51:12 +02:00
Boris Waaub
c9f0e9843b Déplacer le composant banner dans twig 2024-05-01 15:49:32 +02:00
Boris Waaub
b40ad9e445 Mise à jour des messages de l'interface utilisateur pour inclure les fonctionnalités de commentaire, de motif et de transfert 2024-04-25 11:16:08 +02:00
Boris Waaub
3e10e47e29 Merge branch 'ticket-app-master' into ticket-app-create-template 2024-04-25 10:37:42 +02:00
Boris Waaub
2a1963e993 Mise à jour de l'interface utilisateur pour le composant ActionToolbarComponent 2024-04-25 10:36:45 +02:00
34c171659b Merge branch 'ticket-app/backend-3' into 'ticket-app-master'
Add functionality to set addressees for a ticket

See merge request Chill-Projet/chill-bundles!683
2024-04-24 16:50:29 +00:00
2d8b960d9e Re-open the same ticket if a ticket already exists with the same externalRef, instead of creating a new one 2024-04-24 18:48:00 +02:00
831ae03431 Merge branch 'ticket-app/backend-2' into 'ticket-app-master'
Add functionality to add comments to tickets

See merge request Chill-Projet/chill-bundles!681
2024-04-23 21:42:07 +00:00
45828174d1 Add addressee history to ticket serialization
This update extends the tickets serialization and normalisation process to include addressee history. With the changes, AddresseeHistory class now also keeps track of who removed an addressee. Additional types, tests and interfaces have been introduced to support this change.
2024-04-23 23:39:01 +02:00
ed45f14a45 Add tracking of addressee history in ticket system
The updates introduce tracking for the history of addressees in the ticket system, both when added and when removed. The user who removed an addressee is now recorded. The changes also ensure these updated aspects are correctly normalized and users can see them in the ticket history. A new database migration file was created for the changes.
2024-04-23 23:38:34 +02:00
fa67835690 Add functionality to add single addressee to tickets
This update introduces a new feature allowing end-users to add a single addressee to a ticket without removing the existing ones. This was achieved by adding a new API endpoint and updating the SetAddresseesController to handle the addition of a single addressee. Accompanying tests have also been provided to ensure the new feature works as expected.
2024-04-23 23:00:12 +02:00
b434d38091 Add functionality to set addressees for a ticket
This update includes the implementation of methods to add and retrieve addressee history in the Ticket entity, a handler for addressee setting command, denormalizer for transforming request data to SetAddresseesCommand, and corresponding tests. Additionally, it adds a SetAddresseesController for handling addressee related requests and updates the API specifications.
2024-04-23 22:50:51 +02:00
Boris Waaub
800a952532 Add base template 2024-04-23 20:41:32 +02:00
9f355032a8 Create a "do not exclude" validation constraint for user groups 2024-04-22 12:41:43 +02:00
0bc6e62d4d Add fixtures for UserGroup 2024-04-22 12:01:49 +02:00
46fb1c04b5 Add color and exclusion fields to UserGroup
This commit introduces new fields to the UserGroup entity, specifically background color, foreground color, and an exclusion key. These have been implemented both in the PHP entity and TypeScript interface definitions. Additionally, a Doctrine migration has been created to reflect these changes on the database side.
2024-04-22 12:01:28 +02:00
3b2c3d1464 Merge branch 'ticket-app-create-store' into 'ticket-app-master'
Create vuex store

See merge request Chill-Projet/chill-bundles!678
2024-04-22 08:29:56 +00:00
Boris Waaub
0bd6038160 Merge branch chill-bundles:master into ticket-app-master 2024-04-19 15:54:24 +00:00
Boris Waaub
baab8e94ce Add ticket to storeand catch error with toast in component 2024-04-19 17:46:12 +02:00
e2deb55fdb Create api endpoint for listing user-group 2024-04-19 15:34:43 +02:00
Boris Waaub
2cdfb50058 Mise en œuvre de la fonctionnalité de remplacement du motif du ticket
La validation introduit plusieurs fonctionnalités liées à la gestion du motif du ticket dans le bundle Chill-TicketBundle :
- Ajoute la possibilité de remplacer le motif d'un ticket par un nouveau.
- Fournit des fonctionnalités de gestion de l'historique des motifs du ticket.
- Implémente les modifications pertinentes au niveau du contrôleur, du gestionnaire d'actions et de l'entité.
- Intègre de nouvelles points d'API et met à jour le fichier de spécification de l'API pour la nouvelle fonctionnalité.
- Inclut des tests pour garantir le bon fonctionnement de la nouvelle fonctionnalité.
2024-04-19 14:12:09 +02:00
39d701feb2 Serialize ticket's Comment 2024-04-18 22:10:56 +02:00
613ee8b186 Add functionality to add comments to tickets
A new controller, 'AddCommentController', has been added. This controller implements the 'AddCommentCommandHandler', allowing users to add comments to tickets. Additionally, corresponding test cases were implemented. The Ticket entity was also updated to accept and manage comments. API endpoint specs were updated to reflect these changes.
2024-04-18 21:57:55 +02:00
56a1a488de Return the content of the ticket on replace motive POST request 2024-04-18 15:44:05 +02:00
3f789ad0f4 Merge branch 'ticket-app/create-entities' into 'ticket-app-master'
Add phone number search function to PersonACLAwareRepository

See merge request Chill-Projet/chill-bundles!677
2024-04-18 11:21:46 +00:00
467bea7cde Serialization of tickets with history 2024-04-18 13:13:09 +02:00
670b8eb82b Implement functionality to replace ticket's motive
The commit introduces several features related to ticket motive management in the Chill-TicketBundle:
- Adds capability to replace a ticket's motive with a new one.
- Provides ticket motive history management features.
- Implements relevant changes in Controller, Action Handler, and Entity levels.
- Incorporates new API endpoints and updates the API specification file for the new feature.
- Includes tests to ensure the new functionality works as expected.
2024-04-18 13:13:08 +02:00
a9760b323f Add ChillTicketBundle to configuration and autoload-dev
The commit includes the ChillTicketBundle in the bundles configuration file for testing. Additionally, the autoload-dev directive in the composer.json file was updated to include the "App" namespace for testing purposes. This ensures that the tests related to the "App" namespace are correctly autoloaded.
2024-04-18 13:13:08 +02:00
71a3a1924a Add Motive API and related fixtures to ChillTicketBundle
This update introduces the Motive API Controller to the ChillTicket bundle with its corresponding service configuration. Also included are related data fixtures for loading motive information. The motive entity has been updated to improve its serialization properties and new types were added to the TypeScript definitions of the bundle.
2024-04-18 13:13:07 +02:00
ecdc1e25bf Layout of banner for ticket 2024-04-18 13:13:07 +02:00
dd37427be1 Bootstrap ticket layout and vue app to edit ticket 2024-04-18 13:13:07 +02:00
c8467df1b1 fixup! Rename Command directory to Action to avoid confusion with symfony commands 2024-04-18 13:13:06 +02:00
4c89a954fa Refactor test, fixing the constructor 2024-04-18 13:13:05 +02:00
7c1f3b114d Rename Command directory to Action to avoid confusion with symfony commands 2024-04-18 13:13:05 +02:00
36bc4dab24 Configure a testsuite for TicketBundle 2024-04-18 13:13:04 +02:00
4b30d92282 Add ticket creation and associating by phone number functionality
This update introduces new features allowing the creation of tickets and associating them with a phone number. Specifically, relevant commands and their handlers have been created along with corresponding tests. An endpoint for ticket creation has also been set up, and the ViewTicketController has been renamed and refactored to EditTicketController to better reflect its function.
2024-04-18 13:13:04 +02:00
75fbec5489 Create entities and doctrine mapping for ticket 2024-04-18 13:13:03 +02:00
912fdd6349 Add phone number search function to PersonACLAwareRepository
A new function, findByPhone, has been added to the PersonACLAwareRepository. This function allows searching for people based on their phone numbers. Changes also reflect in the PersonACLAwareRepositoryInterface, and new test cases have been added to the PersonACLAwareRepositoryTest.
2024-04-16 14:41:55 +02:00
5832542978 load also tests for ticket bundle 2024-04-16 14:41:39 +02:00
5c3585a1ed Fix loading of environment variable in bootstrap process 2024-04-16 14:41:29 +02:00
a2f1e20ddf Fix cs 2024-04-15 15:49:47 +02:00
4d67702a76 Bootstrap loading of controllers and routes for ticket bundle 2024-04-15 15:48:25 +02:00
18e442db29 Merge branch 'ticket-app-init' into 'ticket-app-master'
Add ChillTicketBundle webpack configuration

See merge request Chill-Projet/chill-bundles!673
2024-04-15 12:44:21 +00:00
Boris Waaub
deb3d92189 Add ChillTicketBundle webpack configuration 2024-04-15 14:34:09 +02:00
a59ea7db31 Compiles with ticket bundle 2024-04-15 13:48:49 +02:00
a738b0cac9 Initialize ChillTicketBundle 2024-04-15 13:22:36 +02:00
109 changed files with 6287 additions and 157 deletions

View File

@@ -55,7 +55,7 @@ Arborescence:
- person
- personvendee
- household_edit_metadata
- index.js
- index.ts
```
## Organisation des feuilles de styles

View File

@@ -119,6 +119,7 @@
"Chill\\PersonBundle\\": "src/Bundle/ChillPersonBundle",
"Chill\\ReportBundle\\": "src/Bundle/ChillReportBundle",
"Chill\\TaskBundle\\": "src/Bundle/ChillTaskBundle",
"Chill\\TicketBundle\\": "src/Bundle/ChillTicketBundle/src",
"Chill\\ThirdPartyBundle\\": "src/Bundle/ChillThirdPartyBundle",
"Chill\\WopiBundle\\": "src/Bundle/ChillWopiBundle/src",
"Chill\\Utils\\Rector\\": "utils/rector/src"
@@ -126,8 +127,9 @@
},
"autoload-dev": {
"psr-4": {
"App\\": "tests/",
"App\\": "tests",
"Chill\\DocGeneratorBundle\\Tests\\": "src/Bundle/ChillDocGeneratorBundle/tests",
"Chill\\TicketBundle\\Tests\\": "src/Bundle/ChillTicketBundle/tests",
"Chill\\WopiBundle\\Tests\\": "src/Bundle/ChillDocGeneratorBundle/tests",
"Chill\\Utils\\Rector\\Tests\\": "utils/rector/tests"
}

View File

@@ -49,6 +49,10 @@
<!-- temporarily removed, the time to find a fix -->
<exclude>src/Bundle/ChillPersonBundle/Tests/Controller/PersonDuplicateControllerViewTest.php</exclude>
</testsuite>
<testsuite name="TicketBundle">
<directory suffix="Test.php">src/Bundle/ChillTicketBundle/tests/</directory>
</testsuite>
<!--
<testsuite name="ReportBundle">
<directory suffix="Test.php">src/Bundle/ChillReportBundle/Tests/</directory>

View File

@@ -211,7 +211,7 @@ class SearchController extends AbstractController
$builder = $this
->get('form.factory')
->createNamedBuilder(
null,
'',
FormType::class,
$data,
['method' => Request::METHOD_POST]

View File

@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
/*
* 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.
*/
namespace Chill\MainBundle\Controller;
use Chill\MainBundle\CRUD\Controller\ApiController;
class UserGroupApiController extends ApiController {}

View File

@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
/*
* 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.
*/
namespace Chill\MainBundle\DataFixtures\ORM;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Entity\UserGroup;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Bundle\FixturesBundle\FixtureGroupInterface;
use Doctrine\Persistence\ObjectManager;
class LoadUserGroup extends Fixture implements FixtureGroupInterface
{
public static function getGroups(): array
{
return ['user-group'];
}
public function load(ObjectManager $manager)
{
$centerASocial = $manager->getRepository(User::class)->findOneBy(['username' => 'center a_social']);
$centerBSocial = $manager->getRepository(User::class)->findOneBy(['username' => 'center b_social']);
$multiCenter = $manager->getRepository(User::class)->findOneBy(['username' => 'multi_center']);
$administrativeA = $manager->getRepository(User::class)->findOneBy(['username' => 'center a_administrative']);
$administrativeB = $manager->getRepository(User::class)->findOneBy(['username' => 'center b_administrative']);
$level1 = $this->generateLevelGroup('Niveau 1', '#eec84aff', '#000000ff', 'level');
$level1->addUser($centerASocial)->addUser($centerBSocial);
$manager->persist($level1);
$level2 = $this->generateLevelGroup('Niveau 2', ' #e2793dff', '#000000ff', 'level');
$level2->addUser($multiCenter);
$manager->persist($level2);
$level3 = $this->generateLevelGroup('Niveau 3', ' #df4949ff', '#000000ff', 'level');
$level3->addUser($multiCenter);
$manager->persist($level3);
$tss = $this->generateLevelGroup('Travailleur sociaux', '#43b29dff', '#000000ff', '');
$tss->addUser($multiCenter)->addUser($centerASocial)->addUser($centerBSocial);
$manager->persist($tss);
$admins = $this->generateLevelGroup('Administratif', '#334d5cff', '#000000ff', '');
$admins->addUser($administrativeA)->addUser($administrativeB);
$manager->persist($admins);
$manager->flush();
}
private function generateLevelGroup(string $title, string $backgroundColor, string $foregroundColor, string $excludeKey): UserGroup
{
$userGroup = new UserGroup();
return $userGroup
->setLabel(['fr' => $title])
->setBackgroundColor($backgroundColor)
->setForegroundColor($foregroundColor)
->setExcludeKey($excludeKey)
;
}
}

View File

@@ -24,6 +24,7 @@ use Chill\MainBundle\Controller\LocationTypeController;
use Chill\MainBundle\Controller\NewsItemController;
use Chill\MainBundle\Controller\RegroupmentController;
use Chill\MainBundle\Controller\UserController;
use Chill\MainBundle\Controller\UserGroupApiController;
use Chill\MainBundle\Controller\UserJobApiController;
use Chill\MainBundle\Controller\UserJobController;
use Chill\MainBundle\DependencyInjection\Widget\Factory\WidgetFactoryInterface;
@@ -59,6 +60,7 @@ use Chill\MainBundle\Entity\LocationType;
use Chill\MainBundle\Entity\NewsItem;
use Chill\MainBundle\Entity\Regroupment;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Entity\UserGroup;
use Chill\MainBundle\Entity\UserJob;
use Chill\MainBundle\Form\CenterType;
use Chill\MainBundle\Form\CivilityType;
@@ -803,6 +805,21 @@ class ChillMainExtension extends Extension implements
],
],
],
[
'class' => UserGroup::class,
'controller' => UserGroupApiController::class,
'name' => 'user-group',
'base_path' => '/api/1.0/main/user-group',
'base_role' => 'ROLE_USER',
'actions' => [
'_index' => [
'methods' => [
Request::METHOD_GET => true,
Request::METHOD_HEAD => true,
],
],
],
],
],
]);
}

View File

@@ -0,0 +1,139 @@
<?php
declare(strict_types=1);
/*
* 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.
*/
namespace Chill\MainBundle\Entity;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Annotation as Serializer;
#[ORM\Entity]
#[ORM\Table(name: 'chill_main_user_group')]
#[Serializer\DiscriminatorMap(typeProperty: 'type', mapping: ['user_group' => UserGroup::class])]
class UserGroup
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::INTEGER, nullable: false)]
#[Serializer\Groups(['read'])]
private ?int $id = null;
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::JSON, nullable: false, options: ['default' => '[]'])]
#[Serializer\Groups(['read'])]
private array $label = [];
/**
* @var \Doctrine\Common\Collections\Collection<int, \Chill\MainBundle\Entity\User>
*/
#[ORM\ManyToMany(targetEntity: User::class)]
#[ORM\JoinTable(name: 'chill_main_user_group_user')]
private Collection $users;
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::TEXT, nullable: false, options: ['default' => '#ffffffff'])]
#[Serializer\Groups(['read'])]
private string $backgroundColor = '#ffffffff';
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::TEXT, nullable: false, options: ['default' => '#000000ff'])]
#[Serializer\Groups(['read'])]
private string $foregroundColor = '#000000ff';
/**
* Groups with same exclude key are mutually exclusive: adding one in a many-to-one relationship
* will exclude others.
*
* An empty string means "no exclusion"
*/
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::TEXT, nullable: false, options: ['default' => ''])]
#[Serializer\Groups(['read'])]
private string $excludeKey = '';
public function __construct()
{
$this->users = new ArrayCollection();
}
public function addUser(User $user): self
{
if (!$this->users->contains($user)) {
$this->users[] = $user;
}
return $this;
}
public function removeUser(User $user): self
{
if ($this->users->contains($user)) {
$this->users->removeElement($user);
}
return $this;
}
public function getId(): ?int
{
return $this->id;
}
public function getLabel(): array
{
return $this->label;
}
public function getUsers(): Collection
{
return $this->users;
}
public function getForegroundColor(): string
{
return $this->foregroundColor;
}
public function getExcludeKey(): string
{
return $this->excludeKey;
}
public function getBackgroundColor(): string
{
return $this->backgroundColor;
}
public function setForegroundColor(string $foregroundColor): self
{
$this->foregroundColor = $foregroundColor;
return $this;
}
public function setBackgroundColor(string $backgroundColor): self
{
$this->backgroundColor = $backgroundColor;
return $this;
}
public function setExcludeKey(string $excludeKey): self
{
$this->excludeKey = $excludeKey;
return $this;
}
public function setLabel(array $label): self
{
$this->label = $label;
return $this;
}
}

View File

@@ -76,6 +76,24 @@ final class PhonenumberHelper implements PhoneNumberHelperInterface
->formatOutOfCountryCallingNumber($phoneNumber, $this->config['default_carrier_code']);
}
/**
* @throws NumberParseException
*/
public function parse(string $phoneNumber): PhoneNumber
{
$sanitizedPhoneNumber = $phoneNumber;
if (str_starts_with($sanitizedPhoneNumber, '00')) {
$sanitizedPhoneNumber = '+'.substr($sanitizedPhoneNumber, 2, null);
}
if (!str_starts_with($sanitizedPhoneNumber, '+') && !str_starts_with($sanitizedPhoneNumber, '0')) {
$sanitizedPhoneNumber = '+'.$sanitizedPhoneNumber;
}
return $this->phoneNumberUtil->parse($sanitizedPhoneNumber, $this->config['default_carrier_code']);
}
/**
* Get type (mobile, landline, ...) for phone number.
*/

View File

@@ -1,164 +1,175 @@
export interface DateTime {
datetime: string;
datetime8601: string
datetime: string;
datetime8601: string;
}
export interface Civility {
id: number;
// TODO
id: number;
// TODO
}
export interface Job {
id: number;
type: "user_job";
label: {
"fr": string; // could have other key. How to do that in ts ?
}
id: number;
type: "user_job";
label: {
fr: string; // could have other key. How to do that in ts ?
};
}
export interface Center {
id: number;
type: "center";
name: string;
id: number;
type: "center";
name: string;
}
export interface Scope {
id: number;
type: "scope";
name: {
"fr": string
}
id: number;
type: "scope";
name: {
fr: string;
};
}
export interface User {
type: "user";
id: number;
username: string;
text: string;
text_without_absence: string;
email: string;
user_job: Job;
label: string;
// todo: mainCenter; mainJob; etc..
type: "user";
id: number;
username: string;
text: string;
text_without_absence: string;
email: string;
user_job: Job;
label: string;
// todo: mainCenter; mainJob; etc..
}
export interface UserGroup {
type: "chill_main_user_group" | "user_group";
id: number;
label: TranslatableString;
backgroundColor: string;
foregroundColor: string;
excludeKey: string;
}
export type UserGroupOrUser = User | UserGroup;
export interface UserAssociatedInterface {
type: "user";
id: number;
};
export type TranslatableString = {
fr?: string;
nl?: string;
type: "user";
id: number;
}
export type TranslatableString = {
fr?: string;
nl?: string;
};
export interface Postcode {
id: number;
name: string;
code: string;
center: Point;
id: number;
name: string;
code: string;
center: Point;
}
export type Point = {
type: "Point";
coordinates: [lat: number, lon: number];
}
type: "Point";
coordinates: [lat: number, lon: number];
};
export interface Country {
id: number;
name: TranslatableString;
code: string;
id: number;
name: TranslatableString;
code: string;
}
export type AddressRefStatus = 'match'|'to_review'|'reviewed';
export type AddressRefStatus = "match" | "to_review" | "reviewed";
export interface Address {
type: "address";
address_id: number;
text: string;
street: string;
streetNumber: string;
postcode: Postcode;
country: Country;
floor: string | null;
corridor: string | null;
steps: string | null;
flat: string | null;
buildingName: string | null;
distribution: string | null;
extra: string | null;
confidential: boolean;
lines: string[];
addressReference: AddressReference | null;
validFrom: DateTime;
validTo: DateTime | null;
point: Point | null;
refStatus: AddressRefStatus;
isNoAddress: boolean;
type: "address";
address_id: number;
text: string;
street: string;
streetNumber: string;
postcode: Postcode;
country: Country;
floor: string | null;
corridor: string | null;
steps: string | null;
flat: string | null;
buildingName: string | null;
distribution: string | null;
extra: string | null;
confidential: boolean;
lines: string[];
addressReference: AddressReference | null;
validFrom: DateTime;
validTo: DateTime | null;
point: Point | null;
refStatus: AddressRefStatus;
isNoAddress: boolean;
}
export interface AddressWithPoint extends Address {
point: Point
point: Point;
}
export interface AddressReference {
id: number;
createdAt: DateTime | null;
deletedAt: DateTime | null;
municipalityCode: string;
point: Point;
postcode: Postcode;
refId: string;
source: string;
street: string;
streetNumber: string;
updatedAt: DateTime | null;
id: number;
createdAt: DateTime | null;
deletedAt: DateTime | null;
municipalityCode: string;
point: Point;
postcode: Postcode;
refId: string;
source: string;
street: string;
streetNumber: string;
updatedAt: DateTime | null;
}
export interface SimpleGeographicalUnit {
id: number;
layerId: number;
unitName: string;
unitRefId: string;
id: number;
layerId: number;
unitName: string;
unitRefId: string;
}
export interface GeographicalUnitLayer {
id: number;
name: TranslatableString;
refId: string;
id: number;
name: TranslatableString;
refId: string;
}
export interface Location {
type: "location";
id: number;
active: boolean;
address: Address | null;
availableForUsers: boolean;
createdAt: DateTime | null;
createdBy: User | null;
updatedAt: DateTime | null;
updatedBy: User | null;
email: string | null
name: string;
phonenumber1: string | null;
phonenumber2: string | null;
locationType: LocationType;
type: "location";
id: number;
active: boolean;
address: Address | null;
availableForUsers: boolean;
createdAt: DateTime | null;
createdBy: User | null;
updatedAt: DateTime | null;
updatedBy: User | null;
email: string | null;
name: string;
phonenumber1: string | null;
phonenumber2: string | null;
locationType: LocationType;
}
export interface LocationAssociated {
type: "location";
id: number;
type: "location";
id: number;
}
export interface LocationType {
type: "location-type";
id: number;
active: boolean;
addressRequired: "optional" | "required";
availableForUsers: boolean;
editableByUsers: boolean;
contactData: "optional" | "required";
title: TranslatableString;
type: "location-type";
id: number;
active: boolean;
addressRequired: "optional" | "required";
availableForUsers: boolean;
editableByUsers: boolean;
contactData: "optional" | "required";
title: TranslatableString;
}
export interface NewsItemType {

View File

@@ -1,13 +1,24 @@
<template>
<span class="chill-entity entity-user">
{{ user.label }}
<span class="user-job" v-if="user.user_job !== null">({{ user.user_job.label.fr }})</span> <span class="main-scope" v-if="user.main_scope !== null">({{ user.main_scope.name.fr }})</span> <span v-if="user.isAbsent" class="badge bg-danger rounded-pill" :title="Absent">A</span>
</span>
<span class="chill-entity entity-user">
{{ user.label }}
<span class="user-job" v-if="user.user_job !== null"
>({{ user.user_job.label.fr }})</span
>
<span class="main-scope" v-if="user.main_scope !== null"
>({{ user.main_scope.name.fr }})</span
>
<span
v-if="user.isAbsent"
class="badge bg-danger rounded-pill"
:title="Absent"
>A</span
>
</span>
</template>
<script>
export default {
name: "UserRenderBoxBadge",
props: ['user'],
}
props: ["user"],
};
</script>

View File

@@ -69,35 +69,37 @@
</div>
{% endif %}
{% block content %}
<div class="col-8 main_search">
{% if app.user.isAbsent %}
<div class="d-flex flex-row mb-5 alert alert-warning" role="alert">
<p class="m-2">{{'absence.You are marked as being absent'|trans }}</p>
<span class="ms-auto">
<a class="btn btn-remove" title="Modifier" href="{{ path('chill_main_user_absence_index') }}">{{ 'absence.Unset absence'|trans }}</a>
</span>
</div>
{% endif %}
<h2>{{ 'Search'|trans }}</h2>
{% block wrapping_content %}
{% block content %}
<div class="col-8 main_search">
{% if app.user.isAbsent %}
<div class="d-flex flex-row mb-5 alert alert-warning" role="alert">
<p class="m-2">{{'absence.You are marked as being absent'|trans }}</p>
<span class="ms-auto">
<a class="btn btn-remove" title="Modifier" href="{{ path('chill_main_user_absence_index') }}">{{ 'absence.Unset absence'|trans }}</a>
</span>
</div>
{% endif %}
<h2>{{ 'Search'|trans }}</h2>
<form action="{{ path('chill_main_search') }}" method="get">
<input class="form-control form-control-lg" name="q" type="search" placeholder="{{ 'Search persons, ...'|trans }}" />
<div class="text-center">
<button type="submit" class="btn btn-lg btn-warning mt-3">
<i class="fa fa-fw fa-search"></i> {{ 'Search'|trans }}
</button>
<a class="btn btn-lg btn-misc mt-3" href="{{ path('chill_main_advanced_search_list') }}">
<i class="fa fa-fw fa-search"></i> {{ 'Advanced search'|trans }}
</a>
</div>
</form>
</div>
<form action="{{ path('chill_main_search') }}" method="get">
<input class="form-control form-control-lg" name="q" type="search" placeholder="{{ 'Search persons, ...'|trans }}" />
<div class="text-center">
<button type="submit" class="btn btn-lg btn-warning mt-3">
<i class="fa fa-fw fa-search"></i> {{ 'Search'|trans }}
</button>
<a class="btn btn-lg btn-misc mt-3" href="{{ path('chill_main_advanced_search_list') }}">
<i class="fa fa-fw fa-search"></i> {{ 'Advanced search'|trans }}
</a>
</div>
</form>
</div>
{# DISABLED {{ chill_widget('homepage', {} ) }} #}
{# DISABLED {{ chill_widget('homepage', {} ) }} #}
{% include '@ChillMain/Homepage/index.html.twig' %}
{% include '@ChillMain/Homepage/index.html.twig' %}
{% endblock %}
{% endblock %}
</div>

View File

@@ -12,6 +12,7 @@ declare(strict_types=1);
namespace Chill\MainBundle\Tests\Phonenumber;
use Chill\MainBundle\Phonenumber\PhonenumberHelper;
use libphonenumber\PhoneNumber;
use libphonenumber\PhoneNumberUtil;
use Psr\Log\NullLogger;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
@@ -52,12 +53,36 @@ final class PhonenumberHelperTest extends KernelTestCase
];
}
public static function providePhoneNumbersToParse(): iterable
{
$util = PhoneNumberUtil::getInstance();
yield [
'FR',
'+32486544999',
$util->parse('+32486544999', 'FR'),
];
yield [
'FR',
'32486544999',
$util->parse('+32486544999', 'FR'),
];
yield [
'FR',
'0228858040',
$util->parse('+33228858040', 'FR'),
];
}
/**
* @dataProvider formatPhonenumbers
*/
public function testFormatPhonenumbers(string $defaultCarrierCode, string $phoneNumber, string $expected)
{
$util = PhoneNumberUtil::getInstance();
$subject = new PhonenumberHelper(
new ArrayAdapter(),
new ParameterBag([
@@ -70,4 +95,24 @@ final class PhonenumberHelperTest extends KernelTestCase
$this->assertEquals($expected, $subject->format($util->parse($phoneNumber)));
}
/**
* @dataProvider providePhoneNumbersToParse
*/
public function testParsePhonenumbers(string $defaultCarrierCode, string $phoneNumber, PhoneNumber $expected): void
{
$subject = new PhonenumberHelper(
new ArrayAdapter(),
new ParameterBag([
'chill_main.phone_helper' => [
'default_carrier_code' => $defaultCarrierCode,
],
]),
new NullLogger()
);
$actual = $subject->parse($phoneNumber);
self::assertTrue($expected->equals($actual));
}
}

View File

@@ -0,0 +1,91 @@
<?php
declare(strict_types=1);
/*
* 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.
*/
namespace Chill\MainBundle\Tests\Validation\Validator;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Entity\UserGroup;
use Chill\MainBundle\Templating\TranslatableStringHelperInterface;
use Chill\MainBundle\Validation\Validator\UserGroupDoNotExclude;
use Symfony\Component\Validator\Test\ConstraintValidatorTestCase;
/**
* @internal
*
* @coversNothing
*/
class UserGroupDoNotExcludeTest extends ConstraintValidatorTestCase
{
protected function createValidator()
{
return new UserGroupDoNotExclude(
new class () implements TranslatableStringHelperInterface {
public function localize(array $translatableStrings): ?string
{
return $translatableStrings['fr'];
}
}
);
}
public function testEmptyArrayIsValid(): void
{
$this->validator->validate([], new \Chill\MainBundle\Validation\Constraint\UserGroupDoNotExclude());
$this->assertNoViolation();
}
public function testMixedUserGroupAndUsersIsValid(): void
{
$this->validator->validate(
[new User(), new UserGroup()],
new \Chill\MainBundle\Validation\Constraint\UserGroupDoNotExclude()
);
$this->assertNoViolation();
}
public function testDifferentExcludeKeysIsValid(): void
{
$this->validator->validate(
[(new UserGroup())->setExcludeKey('A'), (new UserGroup())->setExcludeKey('B')],
new \Chill\MainBundle\Validation\Constraint\UserGroupDoNotExclude()
);
$this->assertNoViolation();
}
public function testMultipleGroupsWithEmptyExcludeKeyIsValid(): void
{
$this->validator->validate(
[(new UserGroup())->setExcludeKey(''), (new UserGroup())->setExcludeKey('')],
new \Chill\MainBundle\Validation\Constraint\UserGroupDoNotExclude()
);
$this->assertNoViolation();
}
public function testSameExclusionKeyWillRaiseError(): void
{
$this->validator->validate(
[
(new UserGroup())->setExcludeKey('A')->setLabel(['fr' => 'Group 1']),
(new UserGroup())->setExcludeKey('A')->setLabel(['fr' => 'Group 2']),
],
new \Chill\MainBundle\Validation\Constraint\UserGroupDoNotExclude()
);
$this->buildViolation('The groups {{ excluded_groups }} do exclude themselves. Please choose one between them')
->setParameter('excluded_groups', 'Group 1, Group 2')
->setCode('e16c8226-0090-11ef-8560-f7239594db09')
->assertRaised();
}
}

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
/*
* 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.
*/
namespace Chill\MainBundle\Validation\Constraint;
use Symfony\Component\Validator\Constraint;
#[\Attribute(\Attribute::TARGET_PROPERTY)]
class UserGroupDoNotExclude extends Constraint
{
public string $message = 'The groups {{ excluded_groups }} do exclude themselves. Please choose one between them';
public string $code = 'e16c8226-0090-11ef-8560-f7239594db09';
public function getTargets()
{
return [self::PROPERTY_CONSTRAINT];
}
public function validatedBy()
{
return \Chill\MainBundle\Validation\Validator\UserGroupDoNotExclude::class;
}
}

View File

@@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
/*
* 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.
*/
namespace Chill\MainBundle\Validation\Validator;
use Chill\MainBundle\Entity\UserGroup;
use Chill\MainBundle\Templating\TranslatableStringHelperInterface;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
use Symfony\Component\Validator\Exception\UnexpectedValueException;
final class UserGroupDoNotExclude extends ConstraintValidator
{
public function __construct(private readonly TranslatableStringHelperInterface $translatableStringHelper) {}
public function validate($value, Constraint $constraint)
{
if (!$constraint instanceof \Chill\MainBundle\Validation\Constraint\UserGroupDoNotExclude) {
throw new UnexpectedTypeException($constraint, UserGroupDoNotExclude::class);
}
if (null === $value) {
return;
}
if (!is_iterable($value)) {
throw new UnexpectedValueException($value, 'iterable');
}
$groups = [];
foreach ($value as $gr) {
if ($gr instanceof UserGroup) {
$groups[$gr->getExcludeKey()][] = $gr;
}
}
foreach ($groups as $excludeKey => $groupByKey) {
if ('' === $excludeKey) {
continue;
}
if (1 < count($groupByKey)) {
$excludedGroups = implode(
', ',
array_map(
fn (UserGroup $group) => $this->translatableStringHelper->localize($group->getLabel()),
$groupByKey
)
);
$this->context
->buildViolation($constraint->message)
->setCode($constraint->code)
->setParameters(['excluded_groups' => $excludedGroups])
->addViolation();
}
}
}
}

View File

@@ -29,6 +29,42 @@ components:
type: string
text:
type: string
UserById:
type: object
properties:
id:
type: integer
type:
type: string
enum:
- user
UserGroup:
type: object
properties:
id:
type: integer
type:
type: string
enum:
- user_group
label:
type: object
additionalProperties: true
backgroundColor:
type: string
foregroundColor:
type: string
exclusionKey:
type: string
UserGroupById:
type: object
properties:
id:
type: integer
type:
type: string
enum:
- user_group
Center:
type: object
properties:
@@ -908,3 +944,19 @@ paths:
$ref: '#/components/schemas/NewsItem'
403:
description: "Unauthorized"
/1.0/main/user-group.json:
get:
tags:
- user-group
summary: Return a list of users-groups
responses:
200:
description: "ok"
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/UserGroup'
403:
description: "Unauthorized"

View File

@@ -3,6 +3,9 @@ services:
autowire: true
autoconfigure: true
Chill\MainBundle\Validation\:
resource: '../../Validation'
chill_main.validator_user_circle_consistency:
class: Chill\MainBundle\Validator\Constraints\Entity\UserCircleConsistencyValidator
arguments:

View File

@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
/*
* 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.
*/
namespace Chill\Migrations\Main;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20240416145021 extends AbstractMigration
{
public function getDescription(): string
{
return 'Create tables for user_group';
}
public function up(Schema $schema): void
{
$this->addSql('CREATE SEQUENCE chill_main_user_group_id_seq INCREMENT BY 1 MINVALUE 1 START 1');
$this->addSql('CREATE TABLE chill_main_user_group (id INT NOT NULL, label JSON DEFAULT \'[]\' NOT NULL, PRIMARY KEY(id))');
$this->addSql('CREATE TABLE chill_main_user_group_user (usergroup_id INT NOT NULL, user_id INT NOT NULL, PRIMARY KEY(usergroup_id, user_id))');
$this->addSql('CREATE INDEX IDX_1E07F044D2112630 ON chill_main_user_group_user (usergroup_id)');
$this->addSql('CREATE INDEX IDX_1E07F044A76ED395 ON chill_main_user_group_user (user_id)');
$this->addSql('ALTER TABLE chill_main_user_group_user ADD CONSTRAINT FK_1E07F044D2112630 FOREIGN KEY (usergroup_id) REFERENCES chill_main_user_group (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('ALTER TABLE chill_main_user_group_user ADD CONSTRAINT FK_1E07F044A76ED395 FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');
}
public function down(Schema $schema): void
{
$this->addSql('DROP SEQUENCE chill_main_user_group_id_seq');
$this->addSql('DROP TABLE chill_main_user_group_user');
$this->addSql('DROP TABLE chill_main_user_group');
}
}

View File

@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
/*
* 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.
*/
namespace Chill\Migrations\Main;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20240422091752 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add colors and exclude string to user groups';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE chill_main_user_group ADD backgroundColor TEXT DEFAULT \'#ffffffff\' NOT NULL');
$this->addSql('ALTER TABLE chill_main_user_group ADD foregroundColor TEXT DEFAULT \'#000000ff\' NOT NULL');
$this->addSql('ALTER TABLE chill_main_user_group ADD excludeKey TEXT DEFAULT \'\' NOT NULL');
$this->addSql('ALTER INDEX idx_1e07f044d2112630 RENAME TO IDX_738BC82BD2112630');
$this->addSql('ALTER INDEX idx_1e07f044a76ed395 RENAME TO IDX_738BC82BA76ED395');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE chill_main_user_group DROP backgroundColor');
$this->addSql('ALTER TABLE chill_main_user_group DROP foregroundColor');
$this->addSql('ALTER TABLE chill_main_user_group DROP excludeKey');
$this->addSql('ALTER INDEX idx_738bc82bd2112630 RENAME TO idx_1e07f044d2112630');
$this->addSql('ALTER INDEX idx_738bc82ba76ed395 RENAME TO idx_1e07f044a76ed395');
}
}

View File

@@ -28,7 +28,11 @@ use Symfony\Component\Form\FormBuilderInterface;
final readonly class GeographicalUnitStatAggregator implements AggregatorInterface
{
public function __construct(private GeographicalUnitLayerRepositoryInterface $geographicalUnitLayerRepository, private TranslatableStringHelperInterface $translatableStringHelper, private RollingDateConverterInterface $rollingDateConverter) {}
public function __construct(
private GeographicalUnitLayerRepositoryInterface $geographicalUnitLayerRepository,
private TranslatableStringHelperInterface $translatableStringHelper,
private RollingDateConverterInterface $rollingDateConverter
) {}
public function addRole(): ?string
{

View File

@@ -21,6 +21,8 @@ use Chill\PersonBundle\Security\Authorization\PersonVoter;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\NonUniqueResultException;
use Doctrine\ORM\Query;
use libphonenumber\PhoneNumber;
use libphonenumber\PhoneNumberFormat;
use Symfony\Component\Security\Core\Security;
final readonly class PersonACLAwareRepository implements PersonACLAwareRepositoryInterface
@@ -298,4 +300,27 @@ final readonly class PersonACLAwareRepository implements PersonACLAwareRepositor
\array_map(static fn (Center $c) => $c->getId(), $authorizedCenters)
);
}
public function findByPhone(PhoneNumber $phoneNumber, int $start = 0, int $limit = 20): array
{
$authorizedCenters = $this->authorizationHelper
->getReachableCenters($this->security->getUser(), PersonVoter::SEE);
if ([] === $authorizedCenters) {
return [];
}
$util = \libphonenumber\PhoneNumberUtil::getInstance();
return $this->em->createQuery(
'SELECT p FROM '.Person::class.' p LEFT JOIN p.otherPhoneNumbers opn JOIN p.centerCurrent pcc '.
'WHERE (p.phonenumber LIKE :phone OR p.mobilenumber LIKE :phone OR opn.phonenumber LIKE :phone) '.
'AND pcc.center IN (:centers)'
)
->setMaxResults($limit)
->setFirstResult($start)
->setParameter('phone', $util->format($phoneNumber, PhoneNumberFormat::E164))
->setParameter('centers', $authorizedCenters)
->getResult();
}
}

View File

@@ -13,6 +13,7 @@ namespace Chill\PersonBundle\Repository;
use Chill\MainBundle\Search\SearchApiQuery;
use Chill\PersonBundle\Entity\Person;
use libphonenumber\PhoneNumber;
interface PersonACLAwareRepositoryInterface
{
@@ -60,4 +61,13 @@ interface PersonACLAwareRepositoryInterface
?string $phonenumber = null,
?string $city = null
): array;
/**
* @return list<Person>
*/
public function findByPhone(
PhoneNumber $phoneNumber,
int $start = 0,
int $limit = 20
): array;
}

View File

@@ -12,10 +12,12 @@ declare(strict_types=1);
namespace Chill\PersonBundle\Repository;
use Chill\PersonBundle\Entity\Person;
use Chill\PersonBundle\Entity\PersonPhone;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\QueryBuilder;
use Doctrine\Persistence\ObjectRepository;
use libphonenumber\PhoneNumber;
class PersonRepository implements ObjectRepository
{
@@ -29,6 +31,8 @@ class PersonRepository implements ObjectRepository
/**
* @throws \Doctrine\ORM\NoResultException
* @throws \Doctrine\ORM\NonUniqueResultException
*
* @deprecated
*/
public function countByPhone(
string $phonenumber,
@@ -71,6 +75,8 @@ class PersonRepository implements ObjectRepository
/**
* @throws \Exception
*
* @deprecated Use @see{self::findByPhoneNumber} or use a dedicated method in PersonACLAwareRepository
*/
public function findByPhone(
string $phonenumber,
@@ -91,6 +97,25 @@ class PersonRepository implements ObjectRepository
return $qb->getQuery()->getResult();
}
/**
* Find a person which is associated to the given phonenumber, without restrictions
* on any.
*
* @return list<Person>
*/
public function findByPhoneNumber(PhoneNumber $phoneNumber, int $firstResult = 0, int $maxResults = 50): array
{
$qb = $this->repository->createQueryBuilder('p');
$qb->select('p');
$this->searchByPhoneNumbers($qb, $phoneNumber);
$qb->setFirstResult($firstResult)
->setMaxResults($maxResults);
return $qb->getQuery()->getResult();
}
public function findOneBy(array $criteria)
{
return $this->repository->findOneBy($criteria);
@@ -109,6 +134,20 @@ class PersonRepository implements ObjectRepository
}
}
private function searchByPhoneNumbers(QueryBuilder $qb, PhoneNumber $phoneNumber): void
{
$qb->setParameter('number', $phoneNumber, 'phone_number');
$orX = $qb->expr()->orX();
$orX->add($qb->expr()->eq('p.mobilenumber', ':number'));
$orX->add($qb->expr()->eq('p.phonenumber', ':number'));
$orX->add(
$qb->expr()->exists('SELECT 1 FROM '.PersonPhone::class.' k WHERE k.phonenumber = :number AND k.person = p')
);
$qb->andWhere($orX);
}
/**
* @throws \Exception
*/

View File

@@ -3,10 +3,10 @@
<h2><a id="section-10"></a>{{ $t('persons_associated.title')}}</h2>
<div v-if="currentParticipations.length > 0">
<label class="col-form-label">{{ $tc('persons_associated.counter', counter) }}</label>
<label class="col-form-label">{{ $t('persons_associated.counter', { count: counter }) }}</label>
</div>
<div v-else>
<label class="chill-no-data-statement">{{ $tc('persons_associated.counter', counter) }}</label>
<label class="chill-no-data-statement">{{ $t('persons_associated.counter', { count: counter }) }}</label>
</div>
<div v-if="participationWithoutHousehold.length > 0" class="alert alert-warning no-household">

View File

@@ -4,10 +4,10 @@
<h2><a id="section-90"></a>{{ $t('resources.title')}}</h2>
<div v-if="resources.length > 0">
<label class="col-form-label">{{ $tc('resources.counter', counter) }}</label>
<label class="col-form-label">{{ $t('resources.counter', { count: counter }) }}</label>
</div>
<div v-else>
<label class="chill-no-data-statement">{{ $tc('resources.counter', counter) }}</label>
<label class="chill-no-data-statement">{{ $t('resources.counter', { count: counter }) }}</label>
</div>
<div class="flex-table mb-3">

View File

@@ -23,7 +23,7 @@
data-bs-toggle="collapse"
aria-expanded="false"
@click="toggleHouseholdSuggestion">
{{ $tc('household_members_editor.show_household_suggestion', countHouseholdSuggestion) }}
{{ $t('household_members_editor.show_household_suggestion', { count: countHouseholdSuggestion }) }}
</button>
<button v-if="showHouseholdSuggestion"
class="accordion-button"

View File

@@ -17,7 +17,7 @@
<div class="search">
<label class="col-form-label" style="float: right;">
{{ $tc('add_persons.suggested_counter', suggestedCounter) }}
{{ $t('add_persons.suggested_counter', { count: suggestedCounter }) }}
</label>
<input id="search-persons"
@@ -42,7 +42,7 @@
</a>
</span>
<span v-if="selectedCounter > 0">
{{ $tc('add_persons.selected_counter', selectedCounter) }}
{{ $t('add_persons.selected_counter', { count: selectedCounter }) }}
</span>
</div>
</div>

View File

@@ -52,9 +52,7 @@
{{ $t('renderbox.deathdate') + ' ' + deathdate }}
</time>
<span v-if="options.addAge && person.birthdate" class="age">{{
$tc('renderbox.years_old', person.age)
}}</span>
<span v-if="options.addAge && person.birthdate" class="age">{{ $t('renderbox.years_old', { n: person.age }) }}</span>
</p>
</div>
</div>

View File

@@ -7,7 +7,7 @@
<span :class="'altname altname-' + altNameKey"> ({{ altNameLabel }})</span>
</span>
<span v-if="person.suffixText" class="suffixtext">&nbsp;{{ person.suffixText }}</span>
<span class="age" v-if="this.addAge && person.birthdate !== null && person.deathdate === null">{{ $tc('renderbox.years_old', person.age) }}</span>
<span class="age" v-if="this.addAge && person.birthdate !== null && person.deathdate === null">{{ $t('renderbox.years_old', { n: person.age }) }}</span>
<span v-else-if="this.addAge && person.deathdate !== null">&nbsp;()</span>
</span>
</template>

View File

@@ -11,14 +11,17 @@ declare(strict_types=1);
namespace Chill\PersonBundle\Tests\Repository;
use Chill\MainBundle\Entity\Center;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Repository\CenterRepositoryInterface;
use Chill\MainBundle\Repository\CountryRepository;
use Chill\MainBundle\Security\Authorization\AuthorizationHelperInterface;
use Chill\PersonBundle\Entity\Person;
use Chill\PersonBundle\Entity\PersonPhone;
use Chill\PersonBundle\Repository\PersonACLAwareRepository;
use Chill\PersonBundle\Security\Authorization\PersonVoter;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\Attributes\DataProvider;
use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
@@ -98,4 +101,67 @@ final class PersonACLAwareRepositoryTest extends KernelTestCase
$this->assertStringContainsString('diallo', strtolower($person->getFirstName().' '.$person->getLastName()));
}
}
/**
* @dataProvider providePersonsWithPhoneNumbers
*/
public function testFindByPhonenumber(\libphonenumber\PhoneNumber $phoneNumber, ?int $expectedId): void
{
$user = new User();
$authorizationHelper = $this->prophesize(AuthorizationHelperInterface::class);
$authorizationHelper->getReachableCenters(Argument::exact($user), Argument::exact(PersonVoter::SEE))
->willReturn($this->centerRepository->findAll());
$security = $this->prophesize(Security::class);
$security->getUser()->willReturn($user);
$repository = new PersonACLAwareRepository(
$security->reveal(),
$this->entityManager,
$this->countryRepository,
$authorizationHelper->reveal()
);
$actual = $repository->findByPhone($phoneNumber, 0, 10);
if (null === $expectedId) {
self::assertCount(0, $actual);
} else {
$actualIds = array_map(fn (Person $person) => $person->getId(), $actual);
self::assertContains($expectedId, $actualIds);
}
}
public static function providePersonsWithPhoneNumbers(): iterable
{
self::bootKernel();
$em = self::getContainer()->get(EntityManagerInterface::class);
$center = $em->createQuery('SELECT c FROM '.Center::class.' c ')->setMaxResults(1)
->getSingleResult();
$util = \libphonenumber\PhoneNumberUtil::getInstance();
$mobile = $util->parse('+32486123456');
$fixed = $util->parse('+3281136917');
$anotherMobile = $util->parse('+32486123478');
$person = (new Person())->setFirstName('diallo')->setLastName('diallo')->setCenter($center);
$person->setMobilenumber($mobile)->setPhonenumber($fixed);
$otherPhone = new PersonPhone();
$otherPhone->setPerson($person);
$otherPhone->setPhonenumber($anotherMobile);
$otherPhone->setType('mobile');
$em->persist($person);
$em->persist($otherPhone);
$em->flush();
self::ensureKernelShutdown();
yield [$mobile, $person->getId()];
yield [$anotherMobile, $person->getId()];
yield [$fixed, $person->getId()];
yield [$util->parse('+331234567890'), null];
}
}

View File

@@ -0,0 +1,163 @@
components:
schemas:
Motive:
type: object
properties:
id:
type: integer
label:
type: object
additionalProperties:
type: string
example:
fr: Retard de livraison
active:
type: boolean
MotiveById:
type: object
properties:
id:
type: integer
type:
type: string
enum:
- ticket_motive
required:
- id
- type
paths:
/1.0/ticket/motive.json:
get:
tags:
- ticket
summary: A list of available ticket's motive
responses:
200:
description: "OK"
/1.0/ticket/{id}/motive/set:
post:
tags:
- ticket
summary: Replace the existing ticket's motive by a new one
parameters:
- name: id
in: path
required: true
description: The ticket id
schema:
type: integer
format: integer
minimum: 1
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
motive:
$ref: "#/components/schemas/MotiveById"
responses:
201:
description: "ACCEPTED"
422:
description: "UNPROCESSABLE ENTITY"
/1.0/ticket/{id}/comment/add:
post:
tags:
- ticket
summary: Add a comment to an existing ticket
parameters:
- name: id
in: path
required: true
description: The ticket id
schema:
type: integer
format: integer
minimum: 1
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
content:
type: string
responses:
201:
description: "ACCEPTED"
422:
description: "UNPROCESSABLE ENTITY"
/1.0/ticket/{id}/addressees/set:
post:
tags:
- ticket
summary: Set the addresses for an existing ticket (will replace all the existing addresses)
parameters:
- name: id
in: path
required: true
description: The ticket id
schema:
type: integer
format: integer
minimum: 1
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
addressees:
type: array
items:
oneOf:
- $ref: '#/components/schemas/UserGroupById'
- $ref: '#/components/schemas/UserById'
responses:
201:
description: "ACCEPTED"
422:
description: "UNPROCESSABLE ENTITY"
/1.0/ticket/{id}/addressee/add:
post:
tags:
- ticket
summary: Add an addressee to a ticket, without removing existing ones.
parameters:
- name: id
in: path
required: true
description: The ticket id
schema:
type: integer
format: integer
minimum: 1
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
addressee:
oneOf:
- $ref: '#/components/schemas/UserGroupById'
- $ref: '#/components/schemas/UserById'
responses:
201:
description: "ACCEPTED"
422:
description: "UNPROCESSABLE ENTITY"

View File

@@ -0,0 +1,4 @@
module.exports = function(encore, entries) {
encore.addEntry('page_ticket', __dirname + '/src/Resources/public/page/ticket/index.ts');
encore.addEntry('vue_ticket_app', __dirname + '/src/Resources/public/vuejs/TicketApp/index.ts');
};

View File

@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
/*
* 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.
*/
namespace Chill\TicketBundle\Action\Ticket;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Entity\UserGroup;
use Symfony\Component\Serializer\Annotation\Groups;
/**
* Add a single addressee to the ticket.
*
* This command is converted into an "SetAddresseesCommand" for handling
*/
final readonly class AddAddresseeCommand
{
public function __construct(
#[Groups(['read'])]
public User|UserGroup $addressee
) {}
}

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
/*
* 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.
*/
namespace Chill\TicketBundle\Action\Ticket;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Serializer\Annotation as Serializer;
final readonly class AddCommentCommand
{
public function __construct(
#[Assert\NotBlank()]
#[Assert\NotNull]
#[Serializer\Groups(['write'])]
public ?string $content = null,
) {}
}

View File

@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
/*
* 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.
*/
namespace Chill\TicketBundle\Action\Ticket;
class AssociateByPhonenumberCommand
{
public function __construct(
public string $phonenumber,
) {}
}

View File

@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
/*
* 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.
*/
namespace Chill\TicketBundle\Action\Ticket;
final readonly class CreateTicketCommand
{
public function __construct(
public string $externalReference = '',
) {}
}

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
/*
* 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.
*/
namespace Chill\TicketBundle\Action\Ticket\Handler;
use Chill\TicketBundle\Action\Ticket\AddCommentCommand;
use Chill\TicketBundle\Entity\Comment;
use Chill\TicketBundle\Entity\Ticket;
use Doctrine\ORM\EntityManagerInterface;
final readonly class AddCommentCommandHandler
{
public function __construct(
private EntityManagerInterface $entityManager,
) {}
public function handle(Ticket $ticket, AddCommentCommand $command): void
{
$comment = new Comment($command->content, $ticket);
$this->entityManager->persist($comment);
}
}

View File

@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
/*
* 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.
*/
namespace Chill\TicketBundle\Action\Ticket\Handler;
use Chill\MainBundle\Phonenumber\PhonenumberHelper;
use Chill\PersonBundle\Repository\PersonACLAwareRepositoryInterface;
use Chill\TicketBundle\Action\Ticket\AssociateByPhonenumberCommand;
use Chill\TicketBundle\Entity\PersonHistory;
use Chill\TicketBundle\Entity\Ticket;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Clock\ClockInterface;
class AssociateByPhonenumberCommandHandler
{
public function __construct(
private PersonACLAwareRepositoryInterface $personRepository,
private PhonenumberHelper $phonenumberHelper,
private ClockInterface $clock,
private EntityManagerInterface $entityManager,
) {}
public function __invoke(Ticket $ticket, AssociateByPhonenumberCommand $command): void
{
$phone = $this->phonenumberHelper->parse($command->phonenumber);
$persons = $this->personRepository->findByPhone($phone);
foreach ($persons as $person) {
$history = new PersonHistory($person, $ticket, $this->clock->now());
$this->entityManager->persist($history);
}
}
}

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
/*
* 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.
*/
namespace Chill\TicketBundle\Action\Ticket\Handler;
use Chill\TicketBundle\Action\Ticket\CreateTicketCommand;
use Chill\TicketBundle\Entity\Ticket;
class CreateTicketCommandHandler
{
public function __invoke(CreateTicketCommand $command): Ticket
{
$ticket = new Ticket();
$ticket->setExternalRef($command->externalReference);
return $ticket;
}
}

View File

@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
/*
* 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.
*/
namespace Chill\TicketBundle\Action\Ticket\Handler;
use Chill\TicketBundle\Action\Ticket\ReplaceMotiveCommand;
use Chill\TicketBundle\Entity\MotiveHistory;
use Chill\TicketBundle\Entity\Ticket;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Clock\ClockInterface;
final readonly class ReplaceMotiveCommandHandler
{
public function __construct(
private ClockInterface $clock,
private EntityManagerInterface $entityManager,
) {}
public function handle(Ticket $ticket, ReplaceMotiveCommand $command): void
{
if (null === $command->motive) {
throw new \InvalidArgumentException('The new motive cannot be null');
}
// will add if there are no existing motive
$readyToAdd = 0 === count($ticket->getMotiveHistories());
foreach ($ticket->getMotiveHistories() as $history) {
if (null !== $history->getEndDate()) {
continue;
}
if ($history->getMotive() === $command->motive) {
// we apply the same motive, we do nothing
continue;
}
$history->setEndDate($this->clock->now());
$readyToAdd = true;
}
if ($readyToAdd) {
$history = new MotiveHistory($command->motive, $ticket, $this->clock->now());
$this->entityManager->persist($history);
}
}
}

View File

@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
/*
* 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.
*/
namespace Chill\TicketBundle\Action\Ticket\Handler;
use Chill\MainBundle\Entity\User;
use Chill\TicketBundle\Action\Ticket\SetAddresseesCommand;
use Chill\TicketBundle\Entity\AddresseeHistory;
use Chill\TicketBundle\Entity\Ticket;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Clock\ClockInterface;
use Symfony\Component\Security\Core\Security;
final readonly class SetAddresseesCommandHandler
{
public function __construct(
private ClockInterface $clock,
private EntityManagerInterface $entityManager,
private Security $security,
) {}
public function handle(Ticket $ticket, SetAddresseesCommand $command): void
{
// remove existing addresses which are not in the new addresses
foreach ($ticket->getAddresseeHistories() as $addressHistory) {
if (null !== $addressHistory->getEndDate()) {
continue;
}
if (!in_array($addressHistory->getAddressee(), $command->addressees, true)) {
$addressHistory->setEndDate($this->clock->now());
if (($user = $this->security->getUser()) instanceof User) {
$addressHistory->setRemovedBy($user);
}
}
}
// add new addresses
foreach ($command->addressees as $address) {
if (in_array($address, $ticket->getCurrentAddressee(), true)) {
continue;
}
$history = new AddresseeHistory($address, $this->clock->now(), $ticket);
$this->entityManager->persist($history);
}
}
}

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
/*
* 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.
*/
namespace Chill\TicketBundle\Action\Ticket;
use Chill\TicketBundle\Entity\Motive;
use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Validator\Constraints as Assert;
final readonly class ReplaceMotiveCommand
{
public function __construct(
#[Assert\NotNull]
#[Groups(['write'])]
public ?Motive $motive,
) {}
}

View File

@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
/*
* 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.
*/
namespace Chill\TicketBundle\Action\Ticket;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Entity\UserGroup;
use Chill\MainBundle\Validation\Constraint\UserGroupDoNotExclude;
use Chill\TicketBundle\Entity\Ticket;
use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Validator\Constraints\GreaterThan;
final readonly class SetAddresseesCommand
{
public function __construct(
/**
* @var list<UserGroup|User>
*/
#[UserGroupDoNotExclude]
#[GreaterThan(0)]
#[Groups(['read'])]
public array $addressees
) {}
public static function fromAddAddresseeCommand(AddAddresseeCommand $command, Ticket $ticket): self
{
return new self([
$command->addressee,
...$ticket->getCurrentAddressee(),
]);
}
}

View File

@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
/*
* 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.
*/
namespace Chill\TicketBundle;
use Symfony\Component\HttpKernel\Bundle\Bundle;
class ChillTicketBundle extends Bundle {}

View File

@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
/*
* 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.
*/
namespace Chill\TicketBundle\Controller;
use Chill\TicketBundle\Action\Ticket\AddCommentCommand;
use Chill\TicketBundle\Action\Ticket\Handler\AddCommentCommandHandler;
use Chill\TicketBundle\Entity\Ticket;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Core\Security;
use Symfony\Component\Serializer\SerializerInterface;
use Symfony\Component\Validator\Validator\ValidatorInterface;
final readonly class AddCommentController
{
public function __construct(
private Security $security,
private SerializerInterface $serializer,
private ValidatorInterface $validator,
private AddCommentCommandHandler $addCommentCommandHandler,
private EntityManagerInterface $entityManager,
) {}
#[Route('/api/1.0/ticket/{id}/comment/add', name: 'chill_ticket_comment_add', methods: ['POST'])]
public function __invoke(Ticket $ticket, Request $request): Response
{
if (!$this->security->isGranted('ROLE_USER')) {
throw new AccessDeniedHttpException('Only user can add ticket comments.');
}
$command = $this->serializer->deserialize($request->getContent(), AddCommentCommand::class, 'json', ['groups' => 'write']);
$errors = $this->validator->validate($command);
if (count($errors) > 0) {
return new JsonResponse(
$this->serializer->serialize($errors, 'json'),
Response::HTTP_UNPROCESSABLE_ENTITY,
[],
true
);
}
$this->addCommentCommandHandler->handle($ticket, $command);
$this->entityManager->flush();
return new JsonResponse(
$this->serializer->serialize($ticket, 'json', ['groups' => 'read']),
Response::HTTP_CREATED,
[],
true
);
}
}

View File

@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
/*
* 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.
*/
namespace Chill\TicketBundle\Controller;
use Chill\TicketBundle\Action\Ticket\AssociateByPhonenumberCommand;
use Chill\TicketBundle\Action\Ticket\Handler\AssociateByPhonenumberCommandHandler;
use Chill\TicketBundle\Action\Ticket\CreateTicketCommand;
use Chill\TicketBundle\Action\Ticket\Handler\CreateTicketCommandHandler;
use Chill\TicketBundle\Repository\TicketRepositoryInterface;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Security\Core\Security;
final readonly class CreateTicketController
{
public function __construct(
private CreateTicketCommandHandler $createTicketCommandHandler,
private AssociateByPhonenumberCommandHandler $associateByPhonenumberCommandHandler,
private Security $security,
private UrlGeneratorInterface $urlGenerator,
private EntityManagerInterface $entityManager,
private TicketRepositoryInterface $ticketRepository,
) {}
#[Route('{_locale}/ticket/ticket/create')]
public function __invoke(Request $request): Response
{
if (!$this->security->isGranted('ROLE_USER')) {
throw new AccessDeniedHttpException('Only users are allowed to create tickets.');
}
if ('' !== $extId = $request->query->get('extId', '')) {
if (null !== $ticket = $this->ticketRepository->findOneByExternalRef($extId)) {
return new RedirectResponse(
$this->urlGenerator->generate('chill_ticket_ticket_edit', ['id' => $ticket->getId()])
);
}
}
$createCommand = new CreateTicketCommand($request->query->get('extId', ''));
$ticket = $this->createTicketCommandHandler->__invoke($createCommand);
$this->entityManager->persist($ticket);
if ($request->query->has('caller')) {
$associateByPhonenumberCommand = new AssociateByPhonenumberCommand($request->query->get('caller'));
$this->associateByPhonenumberCommandHandler->__invoke($ticket, $associateByPhonenumberCommand);
}
$this->entityManager->flush();
return new RedirectResponse(
$this->urlGenerator->generate('chill_ticket_ticket_edit', ['id' => $ticket->getId()])
);
}
}

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
/*
* 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.
*/
namespace Chill\TicketBundle\Controller;
use Chill\TicketBundle\Entity\Ticket;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Twig\Environment;
class EditTicketController
{
public function __construct(
private Environment $templating
) {}
#[Route('/{_locale}/ticket/ticket/{id}/edit', name: 'chill_ticket_ticket_edit')]
public function __invoke(
Ticket $ticket
): Response {
return new Response(
$this->templating->render(
'@ChillTicket/Ticket/edit.html.twig',
[
'ticket' => $ticket,
]
)
);
}
}

View File

@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
/*
* 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.
*/
namespace Chill\TicketBundle\Controller;
use Chill\MainBundle\Phonenumber\PhonenumberHelper;
use Chill\PersonBundle\Repository\PersonRepository;
use Chill\PersonBundle\Templating\Entity\PersonRenderInterface;
use libphonenumber\NumberParseException;
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;
/**
* Controller for a rest api to find a caller for a given phonenumber.
*
* TODO: currently, this rest api is not secured
*/
class FindCallerController
{
public function __construct(private PhonenumberHelper $phonenumberHelper, private PersonRepository $personRepository, private PersonRenderInterface $personRender) {}
#[Route('/public/api/1.0/ticket/find-caller', name: 'find-caller', methods: ['GET'])]
public function findCaller(Request $request): Response
{
$caller = $request->query->get('caller', '');
if ('' === $caller) {
throw new BadRequestHttpException('Missing "caller" query parameter');
}
try {
$phoneNumber = $this->phonenumberHelper->parse($caller);
} catch (NumberParseException $e) {
throw new BadRequestHttpException('Unable to parse number', $e);
}
$persons = $this->personRepository->findByPhoneNumber($phoneNumber, 0, 2);
$asArray = match (count($persons)) {
0 => ['found' => false, 'name' => null],
1 => ['found' => true, 'name' => $this->personRender->renderString($persons[0], ['addAge' => false])],
default => ['found' => true, 'name' => 'multiple'],
};
return new JsonResponse($asArray);
}
}

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
/*
* 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.
*/
namespace Chill\TicketBundle\Controller;
use Chill\MainBundle\CRUD\Controller\ApiController;
use Doctrine\ORM\QueryBuilder;
use Symfony\Component\HttpFoundation\Request;
final class MotiveApiController extends ApiController
{
protected function customizeQuery(string $action, Request $request, $query): void
{
/* @var $query QueryBuilder */
$query->andWhere('e.active = TRUE');
}
}

View File

@@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
/*
* 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.
*/
namespace Chill\TicketBundle\Controller;
use Chill\TicketBundle\Action\Ticket\Handler\ReplaceMotiveCommandHandler;
use Chill\TicketBundle\Action\Ticket\ReplaceMotiveCommand;
use Chill\TicketBundle\Entity\Ticket;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Core\Security;
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
use Symfony\Component\Serializer\SerializerInterface;
use Symfony\Component\Validator\Validator\ValidatorInterface;
final readonly class ReplaceMotiveController
{
public function __construct(
private Security $security,
private ReplaceMotiveCommandHandler $replaceMotiveCommandHandler,
private SerializerInterface $serializer,
private ValidatorInterface $validator,
private EntityManagerInterface $entityManager,
) {}
#[Route('/api/1.0/ticket/{id}/motive/set', name: 'chill_ticket_motive_set', methods: ['POST'])]
public function __invoke(Ticket $ticket, Request $request): Response
{
if (!$this->security->isGranted('ROLE_USER')) {
throw new AccessDeniedHttpException('');
}
$command = $this->serializer->deserialize($request->getContent(), ReplaceMotiveCommand::class, 'json', [
AbstractNormalizer::GROUPS => ['write'],
]);
$errors = $this->validator->validate($command);
if (0 < $errors->count()) {
return new JsonResponse(
$this->serializer->serialize($errors, 'json'),
Response::HTTP_UNPROCESSABLE_ENTITY,
);
}
$this->replaceMotiveCommandHandler->handle($ticket, $command);
$this->entityManager->flush();
return new JsonResponse(
$this->serializer->serialize($ticket, 'json', ['groups' => 'read']),
Response::HTTP_CREATED,
[],
true
);
}
}

View File

@@ -0,0 +1,85 @@
<?php
declare(strict_types=1);
/*
* 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.
*/
namespace Chill\TicketBundle\Controller;
use Chill\TicketBundle\Action\Ticket\AddAddresseeCommand;
use Chill\TicketBundle\Action\Ticket\Handler\SetAddresseesCommandHandler;
use Chill\TicketBundle\Action\Ticket\SetAddresseesCommand;
use Chill\TicketBundle\Entity\Ticket;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Core\Security;
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
use Symfony\Component\Serializer\SerializerInterface;
use Symfony\Component\Validator\Validator\ValidatorInterface;
final readonly class SetAddresseesController
{
public function __construct(
private Security $security,
private EntityManagerInterface $entityManager,
private SerializerInterface $serializer,
private SetAddresseesCommandHandler $addressesCommandHandler,
private ValidatorInterface $validator,
) {}
#[Route('/api/1.0/ticket/{id}/addressees/set', methods: ['POST'])]
public function setAddressees(Ticket $ticket, Request $request): Response
{
if (!$this->security->isGranted('ROLE_USER')) {
throw new AccessDeniedHttpException('Only users can set addressees.');
}
$command = $this->serializer->deserialize($request->getContent(), SetAddresseesCommand::class, 'json', [AbstractNormalizer::GROUPS => ['read']]);
return $this->registerSetAddressees($command, $ticket);
}
#[Route('/api/1.0/ticket/{id}/addressee/add', methods: ['POST'])]
public function addAddressee(Ticket $ticket, Request $request): Response
{
if (!$this->security->isGranted('ROLE_USER')) {
throw new AccessDeniedHttpException('Only users can add addressees.');
}
$command = $this->serializer->deserialize($request->getContent(), AddAddresseeCommand::class, 'json', [AbstractNormalizer::GROUPS => ['read']]);
return $this->registerSetAddressees(SetAddresseesCommand::fromAddAddresseeCommand($command, $ticket), $ticket);
}
private function registerSetAddressees(SetAddresseesCommand $command, Ticket $ticket): Response
{
if (0 < count($errors = $this->validator->validate($command))) {
return new JsonResponse(
$this->serializer->serialize($errors, 'json'),
Response::HTTP_UNPROCESSABLE_ENTITY,
[],
true
);
}
$this->addressesCommandHandler->handle($ticket, $command);
$this->entityManager->flush();
return new JsonResponse(
$this->serializer->serialize($ticket, 'json', ['groups' => 'read']),
Response::HTTP_OK,
[],
true,
);
}
}

View File

@@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
/*
* 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.
*/
namespace Chill\TicketBundle\DataFixtures\ORM;
use Chill\TicketBundle\Entity\Motive;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Bundle\FixturesBundle\FixtureGroupInterface;
use Doctrine\Persistence\ObjectManager;
final class LoadMotives extends Fixture implements FixtureGroupInterface
{
public static function getGroups(): array
{
return ['ticket'];
}
public function load(ObjectManager $manager)
{
foreach (explode("\n", self::MOTIVES) as $label) {
if ('' === trim($label)) {
continue;
}
$motive = new Motive();
$motive->setLabel(['fr' => trim($label)]);
$manager->persist($motive);
}
$manager->flush();
}
private const MOTIVES = <<<'TXT'
Coordonnées
Horaire de passage
Retard de livraison
Erreur de livraison
Colis incomplet
MATLOC
Retard DASRI
Planning d'astreintes
Planning des tournées
Contrôle pompe
Changement de rendez-vous
Renseignement facturation/prestation
Décès patient
Demande de prise en charge
Information absence
Demande bulletin de situation
Difficultés accès logement
Déplacement inutile
Problème de prélèvement/de commande
Parc auto
Demande d'admission
Retrait de matériel au domicile
Comptes-rendus
Démarchage commercial
Demande de transport
Demande laboratoire
Demande admission
Suivi de prise en charge
Mauvaise adresse
Patient absent
Annulation
Colis perdu
Changement de rendez-vous
Coordination interservices
Problème de substitution produits
Problème ordonnance
Réclamations facture
Préparation urgente
TXT;
}

View File

@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
/*
* 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.
*/
namespace Chill\TicketBundle\DependencyInjection;
use Chill\TicketBundle\Controller\MotiveApiController;
use Chill\TicketBundle\Entity\Motive;
use Symfony\Component\Config\FileLocator;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Extension\PrependExtensionInterface;
use Symfony\Component\DependencyInjection\Loader;
use Symfony\Component\DependencyInjection\Extension\Extension;
use Symfony\Component\HttpFoundation\Request;
class ChillTicketExtension extends Extension implements PrependExtensionInterface
{
public function load(array $configs, ContainerBuilder $container)
{
$loader = new Loader\YamlFileLoader($container, new FileLocator(__DIR__.'/../config'));
$loader->load('services.yaml');
}
public function prepend(ContainerBuilder $container)
{
$this->prependApi($container);
}
private function prependApi(ContainerBuilder $container): void
{
$container->prependExtensionConfig('chill_main', [
'apis' => [
[
'class' => Motive::class,
'name' => 'motive',
'base_path' => '/api/1.0/ticket/motive',
'controller' => MotiveApiController::class,
'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,
],
],
],
],
],
]);
}
}

View File

@@ -0,0 +1,130 @@
<?php
declare(strict_types=1);
/*
* 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.
*/
namespace Chill\TicketBundle\Entity;
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\Entity\UserGroup;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Annotation as Serializer;
#[ORM\Entity()]
#[ORM\Table(name: 'addressee_history', schema: 'chill_ticket')]
#[Serializer\DiscriminatorMap(typeProperty: 'type', mapping: ['ticket_addressee_history' => AddresseeHistory::class])]
class AddresseeHistory implements TrackUpdateInterface, TrackCreationInterface
{
use TrackCreationTrait;
use TrackUpdateTrait;
#[ORM\Id]
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::INTEGER, nullable: false)]
#[ORM\GeneratedValue(strategy: 'AUTO')]
#[Serializer\Groups(['read'])]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: User::class)]
#[ORM\JoinColumn(nullable: true)]
private ?User $addresseeUser = null;
#[ORM\ManyToOne(targetEntity: UserGroup::class)]
#[ORM\JoinColumn(nullable: true)]
private ?UserGroup $addresseeGroup = null;
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::DATETIME_IMMUTABLE, nullable: true, options: ['default' => 'null'])]
#[Serializer\Groups(['read'])]
private ?\DateTimeImmutable $endDate = null;
#[ORM\ManyToOne(targetEntity: User::class)]
#[ORM\JoinColumn(nullable: true)]
#[Serializer\Groups(['read'])]
private ?User $removedBy = null;
public function __construct(
User|UserGroup $addressee,
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::DATETIME_IMMUTABLE, nullable: false)]
#[Serializer\Groups(['read'])]
private \DateTimeImmutable $startDate,
#[ORM\ManyToOne(targetEntity: Ticket::class)]
#[ORM\JoinColumn(nullable: false)]
private Ticket $ticket,
) {
if ($addressee instanceof User) {
$this->addresseeUser = $addressee;
} else {
$this->addresseeGroup = $addressee;
}
$this->ticket->addAddresseeHistory($this);
}
#[Serializer\Groups(['read'])]
public function getAddressee(): UserGroup|User
{
if (null !== $this->addresseeGroup) {
return $this->addresseeGroup;
}
return $this->addresseeUser;
}
public function getAddresseeGroup(): ?UserGroup
{
return $this->addresseeGroup;
}
public function getAddresseeUser(): ?User
{
return $this->addresseeUser;
}
public function getEndDate(): ?\DateTimeImmutable
{
return $this->endDate;
}
public function getId(): ?int
{
return $this->id;
}
public function getStartDate(): \DateTimeImmutable
{
return $this->startDate;
}
public function getTicket(): Ticket
{
return $this->ticket;
}
public function getRemovedBy(): ?User
{
return $this->removedBy;
}
public function setRemovedBy(?User $removedBy): self
{
$this->removedBy = $removedBy;
return $this;
}
public function setEndDate(?\DateTimeImmutable $endDate): self
{
$this->endDate = $endDate;
return $this;
}
}

View File

@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
/*
* 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.
*/
namespace Chill\TicketBundle\Entity;
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;
use Doctrine\ORM\Mapping\JoinColumn;
use Symfony\Component\Serializer\Annotation as Serializer;
#[ORM\Entity()]
#[ORM\Table(name: 'comment', schema: 'chill_ticket')]
#[Serializer\DiscriminatorMap(typeProperty: 'type', mapping: ['ticket_comment' => Comment::class])]
class Comment implements TrackCreationInterface, TrackUpdateInterface
{
use TrackCreationTrait;
use TrackUpdateTrait;
#[ORM\Id]
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::INTEGER, nullable: false)]
#[ORM\GeneratedValue(strategy: 'AUTO')]
#[Serializer\Groups(['read'])]
private ?int $id = null;
public function __construct(
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::TEXT, nullable: false, options: ['default' => ''])]
#[Serializer\Groups(['read'])]
private string $content,
#[ORM\ManyToOne(targetEntity: Ticket::class, inversedBy: 'comments')]
#[JoinColumn(nullable: false)]
private Ticket $ticket,
) {
$ticket->addComment($this);
}
public function getId(): ?int
{
return $this->id;
}
public function getContent(): string
{
return $this->content;
}
public function getTicket(): Ticket
{
return $this->ticket;
}
}

View File

@@ -0,0 +1,91 @@
<?php
declare(strict_types=1);
/*
* 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.
*/
namespace Chill\TicketBundle\Entity;
use Chill\MainBundle\Entity\User;
use Chill\PersonBundle\Entity\Person;
use Chill\ThirdPartyBundle\Entity\ThirdParty;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity()]
#[ORM\Table(name: 'input_history', schema: 'chill_ticket')]
class InputHistory
{
#[ORM\Id]
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::INTEGER, nullable: false)]
#[ORM\GeneratedValue(strategy: 'AUTO')]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: Person::class)]
#[ORM\JoinColumn(nullable: true)]
private ?Person $person = null;
#[ORM\ManyToOne(targetEntity: ThirdParty::class)]
#[ORM\JoinColumn(nullable: true)]
private ?ThirdParty $thirdParty = null;
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::DATETIME_IMMUTABLE, nullable: true, options: ['default' => null])]
private ?\DateTimeImmutable $endDate = null;
#[ORM\ManyToOne(targetEntity: User::class)]
#[ORM\JoinColumn(nullable: true)]
private ?User $removedBy = null;
public function __construct(
Person|ThirdParty $input,
#[ORM\ManyToOne(targetEntity: Ticket::class)]
#[ORM\JoinColumn(nullable: false)]
private Ticket $ticket,
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::DATETIME_IMMUTABLE, nullable: false)]
private \DateTimeImmutable $startDate,
) {
if ($input instanceof Person) {
$this->person = $input;
} else {
$this->thirdParty = $input;
}
}
public function getId(): ?int
{
return $this->id;
}
public function getEndDate(): ?\DateTimeImmutable
{
return $this->endDate;
}
public function getRemovedBy(): ?User
{
return $this->removedBy;
}
public function getStartDate(): \DateTimeImmutable
{
return $this->startDate;
}
public function getTicket(): Ticket
{
return $this->ticket;
}
public function getInput(): Person|ThirdParty
{
if (null !== $this->person) {
return $this->person;
}
return $this->thirdParty;
}
}

View File

@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
/*
* 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.
*/
namespace Chill\TicketBundle\Entity;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Annotation as Serializer;
#[ORM\Entity()]
#[ORM\Table(name: 'motive', schema: 'chill_ticket')]
#[Serializer\DiscriminatorMap(typeProperty: 'type', mapping: ['ticket_motive' => Motive::class])]
class Motive
{
#[ORM\Id]
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::INTEGER, nullable: false)]
#[ORM\GeneratedValue(strategy: 'AUTO')]
#[Serializer\Groups(['read'])]
private ?int $id = null;
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::JSON, nullable: false, options: ['default' => '[]'])]
#[Serializer\Groups(['read'])]
private array $label = [];
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::BOOLEAN, nullable: false, options: ['default' => true])]
#[Serializer\Groups(['read'])]
private bool $active = true;
public function isActive(): bool
{
return $this->active;
}
public function setActive(bool $active): void
{
$this->active = $active;
}
public function getId(): ?int
{
return $this->id;
}
public function getLabel(): array
{
return $this->label;
}
public function setLabel(array $label): void
{
$this->label = $label;
}
}

View File

@@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
/*
* 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.
*/
namespace Chill\TicketBundle\Entity;
use Chill\MainBundle\Doctrine\Model\TrackCreationInterface;
use Chill\MainBundle\Doctrine\Model\TrackCreationTrait;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Annotation as Serializer;
#[ORM\Entity]
#[ORM\Table(name: 'motives_history', schema: 'chill_ticket')]
#[Serializer\DiscriminatorMap(typeProperty: 'type', mapping: ['ticket_motive_history' => MotiveHistory::class])]
class MotiveHistory implements TrackCreationInterface
{
use TrackCreationTrait;
#[ORM\Id]
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::INTEGER, nullable: false)]
#[ORM\GeneratedValue(strategy: 'AUTO')]
#[Serializer\Groups(['read'])]
private ?int $id = null;
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::DATETIME_IMMUTABLE, nullable: true, options: ['default' => null])]
#[Serializer\Groups(['read'])]
private ?\DateTimeImmutable $endDate = null;
public function __construct(
#[ORM\ManyToOne(targetEntity: Motive::class)]
#[ORM\JoinColumn(nullable: false)]
#[Serializer\Groups(['read'])]
private Motive $motive,
#[ORM\ManyToOne(targetEntity: Ticket::class)]
#[ORM\JoinColumn(nullable: false)]
private Ticket $ticket,
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::DATETIME_IMMUTABLE, nullable: false)]
#[Serializer\Groups(['read'])]
private \DateTimeImmutable $startDate = new \DateTimeImmutable('now')
) {
$ticket->addMotiveHistory($this);
}
public function getEndDate(): ?\DateTimeImmutable
{
return $this->endDate;
}
public function getId(): ?int
{
return $this->id;
}
public function getMotive(): Motive
{
return $this->motive;
}
public function getStartDate(): \DateTimeImmutable
{
return $this->startDate;
}
public function getTicket(): Ticket
{
return $this->ticket;
}
public function setEndDate(?\DateTimeImmutable $endDate): void
{
$this->endDate = $endDate;
}
}

View File

@@ -0,0 +1,94 @@
<?php
declare(strict_types=1);
/*
* 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.
*/
namespace Chill\TicketBundle\Entity;
use Chill\MainBundle\Doctrine\Model\TrackCreationInterface;
use Chill\MainBundle\Doctrine\Model\TrackCreationTrait;
use Chill\MainBundle\Entity\User;
use Chill\PersonBundle\Entity\Person;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Annotation as Serializer;
#[ORM\Entity]
#[ORM\Table(name: 'person_history', schema: 'chill_ticket')]
#[Serializer\DiscriminatorMap(typeProperty: 'type', mapping: ['ticket_person_history' => PersonHistory::class])]
class PersonHistory implements TrackCreationInterface
{
use TrackCreationTrait;
#[ORM\Id]
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::INTEGER, nullable: false)]
#[ORM\GeneratedValue(strategy: 'AUTO')]
#[Serializer\Groups(['read'])]
private ?int $id = null;
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::DATETIME_IMMUTABLE, nullable: true, options: ['default' => null])]
#[Serializer\Groups(['read'])]
private ?\DateTimeImmutable $endDate = null;
#[ORM\ManyToOne(targetEntity: User::class)]
#[ORM\JoinColumn(nullable: true)]
#[Serializer\Groups(['read'])]
private ?User $removedBy = null;
public function __construct(
#[ORM\ManyToOne(targetEntity: Person::class, fetch: 'EAGER')]
#[Serializer\Groups(['read'])]
private Person $person,
#[ORM\ManyToOne(targetEntity: Ticket::class)]
#[ORM\JoinColumn(nullable: false)]
private Ticket $ticket,
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::DATETIME_IMMUTABLE, nullable: false)]
#[Serializer\Groups(['read'])]
private \DateTimeImmutable $startDate,
) {
// keep ticket instance in sync with this
$this->ticket->addPersonHistory($this);
}
public function getId(): ?int
{
return $this->id;
}
public function getPerson(): Person
{
return $this->person;
}
public function getEndDate(): ?\DateTimeImmutable
{
return $this->endDate;
}
public function getTicket(): Ticket
{
return $this->ticket;
}
public function getStartDate(): \DateTimeImmutable
{
return $this->startDate;
}
public function getRemovedBy(): ?User
{
return $this->removedBy;
}
public function setEndDate(?\DateTimeImmutable $endDate): self
{
$this->endDate = $endDate;
return $this;
}
}

View File

@@ -0,0 +1,231 @@
<?php
declare(strict_types=1);
/*
* 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.
*/
namespace Chill\TicketBundle\Entity;
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\Entity\UserGroup;
use Chill\PersonBundle\Entity\Person;
use Chill\ThirdPartyBundle\Entity\ThirdParty;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\Common\Collections\ReadableCollection;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity]
#[ORM\Table(name: 'ticket', schema: 'chill_ticket')]
class Ticket implements TrackCreationInterface, TrackUpdateInterface
{
use TrackCreationTrait;
use TrackUpdateTrait;
#[ORM\Id]
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::INTEGER, nullable: false)]
#[ORM\GeneratedValue(strategy: 'AUTO')]
private ?int $id = null;
/**
* @var Collection<int, AddresseeHistory>
*/
#[ORM\OneToMany(targetEntity: AddresseeHistory::class, mappedBy: 'ticket')]
private Collection $addresseeHistory;
/**
* @var Collection<int, Comment>
*/
#[ORM\OneToMany(targetEntity: Comment::class, mappedBy: 'ticket')]
private Collection $comments;
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::TEXT, nullable: false, options: ['default' => ''])]
private string $externalRef = '';
/**
* @var Collection<int, InputHistory>
*/
#[ORM\OneToMany(targetEntity: InputHistory::class, mappedBy: 'ticket')]
private Collection $inputHistories;
/**
* @var Collection<int, MotiveHistory>
*/
#[ORM\OneToMany(targetEntity: MotiveHistory::class, mappedBy: 'ticket')]
private Collection $motiveHistories;
/**
* @var Collection<int, PersonHistory>
*/
#[ORM\OneToMany(targetEntity: PersonHistory::class, mappedBy: 'ticket')]
private Collection $personHistories;
#[ORM\ManyToOne(targetEntity: User::class)]
#[ORM\JoinColumn(nullable: true)]
private ?User $updatedBy = null;
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::DATETIME_IMMUTABLE, nullable: true)]
private ?\DateTimeImmutable $createdAt = null;
public function __construct()
{
$this->addresseeHistory = new ArrayCollection();
$this->comments = new ArrayCollection();
$this->motiveHistories = new ArrayCollection();
$this->personHistories = new ArrayCollection();
$this->inputHistories = new ArrayCollection();
}
public function getId(): ?int
{
return $this->id;
}
public function getExternalRef(): string
{
return $this->externalRef;
}
public function setExternalRef(string $externalRef): void
{
$this->externalRef = $externalRef;
}
/**
* @return list<Person>
*/
public function getPersons(): array
{
return $this->personHistories
->filter(fn (PersonHistory $personHistory) => null === $personHistory->getEndDate())
->map(fn (PersonHistory $personHistory) => $personHistory->getPerson())
->getValues();
}
/**
* @internal use @see{Comment::__construct} instead
*/
public function addComment(Comment $comment): void
{
$this->comments->add($comment);
}
/**
* Add a PersonHistory.
*
* @internal use @see{PersonHistory::__construct} instead
*/
public function addPersonHistory(PersonHistory $personHistory): void
{
$this->personHistories->add($personHistory);
}
/**
* @internal use @see{MotiveHistory::__construct} instead
*/
public function addMotiveHistory(MotiveHistory $motiveHistory): void
{
$this->motiveHistories->add($motiveHistory);
}
/**
* @internal use @see{AddresseHistory::__construct} instead
*/
public function addAddresseeHistory(AddresseeHistory $addresseeHistory): void
{
$this->addresseeHistory->add($addresseeHistory);
}
/**
* @return list<UserGroup|User>
*/
public function getCurrentAddressee(): array
{
$addresses = [];
foreach ($this->addresseeHistory
->filter(fn (AddresseeHistory $addresseeHistory) => null === $addresseeHistory->getEndDate()) as $addressHistory) {
$addresses[] = $addressHistory->getAddressee();
}
return $addresses;
}
/**
* @return ReadableCollection<int, Comment>
*/
public function getComments(): ReadableCollection
{
return $this->comments;
}
/**
* @return list<ThirdParty|Person>
*/
public function getCurrentInputs(): array
{
$inputs = [];
foreach ($this->inputHistories
->filter(fn (InputHistory $inputHistory) => null === $inputHistory->getEndDate()) as $inputHistory
) {
$inputs[] = $inputHistory->getInput();
}
return $inputs;
}
public function getMotive(): ?Motive
{
foreach ($this->motiveHistories as $motiveHistory) {
if (null === $motiveHistory->getEndDate()) {
return $motiveHistory->getMotive();
}
}
return null;
}
/**
* @return ReadableCollection<int, MotiveHistory>
*/
public function getMotiveHistories(): ReadableCollection
{
return $this->motiveHistories;
}
/**
* @return ReadableCollection<int, PersonHistory>
*/
public function getPersonHistories(): ReadableCollection
{
return $this->personHistories;
}
/**
* @return ReadableCollection<int, AddresseeHistory>
*/
public function getAddresseeHistories(): ReadableCollection
{
return $this->addresseeHistory;
}
public function getCreatedAt(): \DateTimeImmutable
{
return $this->createdAt;
}
public function getUpdatedBy(): ?User
{
return $this->updatedBy;
}
}

View File

@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
/*
* 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.
*/
namespace Chill\TicketBundle\Repository;
use Chill\TicketBundle\Entity\Ticket;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\Persistence\ObjectRepository;
final readonly class TicketRepository implements TicketRepositoryInterface
{
private ObjectRepository $repository;
public function __construct(EntityManagerInterface $objectManager)
{
$this->repository = $objectManager->getRepository($this->getClassName());
}
public function find($id): ?Ticket
{
return $this->repository->find($id);
}
public function findAll(): array
{
return $this->repository->findAll();
}
public function findBy(array $criteria, ?array $orderBy = null, ?int $limit = null, ?int $offset = null): array
{
return $this->repository->findBy($criteria, $orderBy, $limit, $offset);
}
public function findOneBy(array $criteria): ?Ticket
{
return $this->repository->findOneBy($criteria);
}
public function getClassName()
{
return Ticket::class;
}
public function findOneByExternalRef(string $extId): ?Ticket
{
return $this->repository->findOneBy(['externalRef' => $extId]);
}
}

View File

@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
/*
* 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.
*/
namespace Chill\TicketBundle\Repository;
use Chill\TicketBundle\Entity\Ticket;
use Doctrine\Persistence\ObjectRepository;
/**
* @extends ObjectRepository<Ticket>
*/
interface TicketRepositoryInterface extends ObjectRepository
{
public function findOneByExternalRef(string $extId): ?Ticket;
}

View File

@@ -0,0 +1,24 @@
@import '~ChillMainAssets/module/bootstrap/shared';
div.banner {
div#header-ticket-main {
background: none repeat scroll 0 0 #ae986fFF;
color: $white;
padding-top: 1em;
padding-bottom: 1em;
}
div#header-ticket-details {
background: none repeat scroll 0 0 #d3c7b1FF;
color: $white;
padding-top: 1em;
padding-bottom: 1em;
div.contact {
display: flex;
align-content: center;
& > * {
margin-right: 1em;
}
}
}
}

View File

@@ -0,0 +1 @@
import './banner.scss';

View File

@@ -0,0 +1,86 @@
import {
DateTime,
TranslatableString,
User,
UserGroupOrUser
} from "../../../../ChillMainBundle/Resources/public/types";
import { Person } from "../../../../ChillPersonBundle/Resources/public/types";
export interface Motive {
type: "ticket_motive"
id: number,
active: boolean,
label: TranslatableString
}
interface TicketHistory<T extends string, D extends object> {
event_type: T,
at: DateTime,
by: User,
data: D
}
export interface PersonHistory {
type: "ticket_person_history",
id: number,
startDate: DateTime,
endDate: null|DateTime,
person: Person,
removedBy: null,
createdBy: User|null,
createdAt: DateTime|null
}
export interface MotiveHistory {
type: "ticket_motive_history",
id: number,
startDate: null,
endDate: null|DateTime,
motive: Motive,
createdBy: User|null,
createdAt: DateTime|null,
}
export interface Comment {
type: "ticket_comment",
id: number,
content: string,
createdBy: User|null,
createdAt: DateTime|null,
updatedBy: User|null,
updatedAt: DateTime|null,
}
export interface AddresseeHistory {
type: "ticket_addressee_history",
id: number,
startDate: DateTime|null,
addressee: UserGroupOrUser,
endDate: DateTime|null,
removedBy: User|null,
createdBy: User|null,
createdAt: DateTime|null,
updatedBy: User|null,
updatedAt: DateTime|null,
}
interface AddPersonEvent extends TicketHistory<"add_person", PersonHistory> {};
interface AddCommentEvent extends TicketHistory<"add_comment", Comment> {};
interface SetMotiveEvent extends TicketHistory<"set_motive", MotiveHistory> {};
interface AddAddressee extends TicketHistory<"add_addressee", AddresseeHistory> {};
interface RemoveAddressee extends TicketHistory<"remove_addressee", AddresseeHistory> {};
type TicketHistoryLine = AddPersonEvent | AddCommentEvent | SetMotiveEvent | AddAddressee | RemoveAddressee;
export interface Ticket {
type: "ticket_ticket",
id: number,
externalRef: string,
currentAddressees: UserGroupOrUser[],
currentPersons: Person[],
currentMotive: null|Motive,
history: TicketHistoryLine[],
createdAt: DateTime|null,
updatedBy: User|null,
}

View File

@@ -0,0 +1,63 @@
<template>
<banner-component :ticket="ticket" />
<div class="container-xxl pt-1" style="padding-bottom: 55px">
<ticket-selector-component :tickets="[]" />
<ticket-history-list-component :history="ticketHistory" />
</div>
<action-toolbar-component />
</template>
<script lang="ts">
import { computed, defineComponent, inject, onMounted, ref } from "vue";
import { useStore } from "vuex";
// Types
import { Motive, Ticket } from "../../types";
// Components
import TicketSelectorComponent from "./components/TicketSelectorComponent.vue";
import TicketHistoryListComponent from "./components/TicketHistoryListComponent.vue";
import ActionToolbarComponent from "./components/ActionToolbarComponent.vue";
import BannerComponent from "./components/BannerComponent.vue";
export default defineComponent({
name: "App",
components: {
TicketSelectorComponent,
TicketHistoryListComponent,
ActionToolbarComponent,
BannerComponent,
},
setup() {
const store = useStore();
const toast = inject("toast") as any;
store.commit("setTicket", JSON.parse(window.initialTicket) as Ticket);
const motives = computed(() => store.getters.getMotives as Motive[]);
const ticket = computed(() => store.getters.getTicket as Ticket);
const ticketHistory = computed(
() => store.getters.getDistinctAddressesHistory
);
onMounted(async () => {
try {
await store.dispatch("fetchMotives");
await store.dispatch("fetchUserGroups");
await store.dispatch("fetchUsers");
} catch (error) {
toast.error(error);
}
});
return {
ticketHistory,
motives,
ticket,
};
},
});
</script>
<style lang="scss" scoped></style>

View File

@@ -0,0 +1,254 @@
<template>
<div class="fixed-bottom">
<div class="footer-ticket-details" v-if="activeTab">
<div class="tab-content p-2">
<div>
<label class="col-form-label">
{{ $t(`${activeTab}.title`) }}
</label>
</div>
<form @submit.prevent="submitAction">
<add-comment-component
v-model="content"
v-if="activeTab === 'add_comment'"
/>
<addressee-selector-component
v-model="addressees"
:user-groups="userGroups"
:users="users"
v-if="activeTab === 'add_addressee'"
/>
<motive-selector-component
v-model="motive"
:motives="motives"
v-if="activeTab === 'set_motive'"
/>
<ul class="record_actions sticky-form-buttons">
<li class="cancel">
<button
@click="activeTab = ''"
class="btn btn-cancel"
>
{{ $t("ticket.cancel") }}
</button>
</li>
<li>
<button class="btn btn-create" type="submit">
{{ $t("ticket.save") }}
</button>
</li>
</ul>
</form>
</div>
</div>
<div class="footer-ticket-main">
<ul class="nav nav-tabs justify-content-end">
<li class="nav-item p-2">
<button
type="button"
:class="`btn ${
activeTab === 'set_motive'
? 'btn-primary'
: 'btn-light'
}`"
@click="
activeTab === 'set_motive'
? (activeTab = '')
: (activeTab = 'set_motive')
"
>
<i :class="actionIcons['set_motive']"></i>
{{ $t("set_motive.title") }}
</button>
</li>
<li class="nav-item p-2">
<button
type="button"
:class="`btn ${
activeTab === 'add_comment'
? 'btn-primary'
: 'btn-light'
}`"
@click="
activeTab === 'add_comment'
? (activeTab = '')
: (activeTab = 'add_comment')
"
>
<i :class="actionIcons['add_comment']"></i>
{{ $t("add_comment.title") }}
</button>
</li>
<li class="nav-item p-2">
<button
type="button"
:class="`btn ${
activeTab === 'add_addressee'
? 'btn-primary'
: 'btn-light'
}`"
@click="
activeTab === 'add_addressee'
? (activeTab = '')
: (activeTab = 'add_addressee')
"
>
<i :class="actionIcons['add_addressee']"></i>
{{ $t("add_addressee.title") }}
</button>
</li>
<li class="nav-item p-2">
<button
type="button"
class="btn btn-light"
@click="handleClick()"
>
<i class="fa fa-bolt"></i>
{{ $t("ticket.close") }}
</button>
</li>
</ul>
</div>
</div>
</template>
<script lang="ts">
import { computed, defineComponent, inject, ref } from "vue";
import { useI18n } from "vue-i18n";
import { useStore } from "vuex";
// Types
import {
User,
UserGroup,
UserGroupOrUser,
} from "../../../../../../../ChillMainBundle/Resources/public/types";
import { Comment, Motive, Ticket } from "../../../types";
// Component
import MotiveSelectorComponent from "./MotiveSelectorComponent.vue";
import AddresseeSelectorComponent from "./AddresseeSelectorComponent.vue";
import AddCommentComponent from "./AddCommentComponent.vue";
export default defineComponent({
name: "ActionToolbarComponent",
components: {
AddCommentComponent,
MotiveSelectorComponent,
AddresseeSelectorComponent,
},
setup() {
const store = useStore();
const { t } = useI18n();
const toast = inject("toast") as any;
const activeTab = ref(
"" as "" | "add_comment" | "set_motive" | "add_addressee"
);
const ticket = computed(() => store.getters.getTicket as Ticket);
const motives = computed(() => store.getters.getMotives as Motive[]);
const userGroups = computed(
() => store.getters.getUserGroups as UserGroup[]
);
const users = computed(() => store.getters.getUsers as User[]);
const motive = ref(
ticket.value.currentMotive
? ticket.value.currentMotive
: ({} as Motive)
);
const content = ref("" as Comment["content"]);
const addressees = ref(
ticket.value.currentAddressees as Array<UserGroupOrUser>
);
async function submitAction() {
try {
switch (activeTab.value) {
case "add_comment":
if (!content.value) {
toast.error(t("add_comment.error"));
} else {
await store.dispatch("createComment", {
ticketId: ticket.value.id,
content: content.value,
});
content.value = "";
activeTab.value = "";
toast.success(t("add_comment.success"));
}
break;
case "set_motive":
if (!motive.value.id) {
toast.error(t("set_motive.error"));
} else {
await store.dispatch("createMotive", {
ticketId: ticket.value.id,
motive: motive.value,
});
activeTab.value = "";
toast.success(t("set_motive.success"));
}
break;
case "add_addressee":
if (!addressees.value.length) {
toast.error(t("add_addressee.error"));
} else {
await store.dispatch("setAdressees", {
ticketId: ticket.value.id,
addressees: addressees.value,
});
activeTab.value = "";
toast.success(t("add_addressee.success"));
}
break;
}
} catch (error) {
toast.error(error);
}
}
function handleClick() {
alert("Sera disponible plus tard");
}
return {
actionIcons: ref(store.getters.getActionIcons),
activeTab,
ticket,
motives,
motive,
userGroups,
addressees,
users,
content,
submitAction,
handleClick,
};
},
});
</script>
<style lang="scss" scoped>
.sticky-form-buttons {
margin-top: 0px;
background: none;
}
div.footer-ticket-main {
background: none repeat scroll 0 0 #cabb9f;
}
div.footer-ticket-details {
background: none repeat scroll 0 0 #efe2ca;
}
.fixed-bottom {
max-width: 1272px;
margin: 0 auto;
}
</style>

View File

@@ -0,0 +1,49 @@
<template>
<div class="row">
<div class="col-12">
<ckeditor
name="content"
:placeholder="$t('add_comment.content')"
:editor="editor"
v-model="content"
tag-name="textarea"
/>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent, ref, watch } from "vue";
import CKEditor from "@ckeditor/ckeditor5-vue";
import ClassicEditor from "../../../../../../../ChillMainBundle/Resources/public/module/ckeditor5";
export default defineComponent({
name: "AddCommentComponent",
props: {
modelValue: {
type: String,
required: false,
},
},
components: {
ckeditor: CKEditor.component,
},
emits: ["update:modelValue"],
setup(props, ctx) {
const content = ref(props.modelValue);
watch(content, (content) => {
ctx.emit("update:modelValue", content);
});
return {
content,
editor: ClassicEditor,
};
},
});
</script>
<style lang="scss" scoped></style>

View File

@@ -0,0 +1,77 @@
<template>
<h3>
<div class="col-12">
<span
class="badge m-1"
:style="`background-color: ${userGroup.backgroundColor}; color: white;`"
v-for="userGroup in userGroupLevels"
:key="userGroup.id"
>
{{ userGroup.label.fr }}
</span>
</div>
<div class="col-12">
<span
class="badge m-1"
:style="`background-color: ${userGroup.backgroundColor}; color: white;`"
v-for="userGroup in userGroups"
:key="userGroup.id"
>
{{ userGroup.label.fr }}
</span>
</div>
</h3>
<div class="col-12">
<span class="badge bg-primary m-1" v-for="user in users" :key="user.id">
{{ user.label }}
</span>
</div>
</template>
<script lang="ts">
import { PropType, computed, defineComponent, ref } from "vue";
// Types
import {
User,
UserGroup,
UserGroupOrUser,
} from "../../../../../../../ChillMainBundle/Resources/public/types";
export default defineComponent({
name: "AddresseeComponent",
props: {
addressees: {
type: Array as PropType<UserGroupOrUser[]>,
required: true,
},
},
setup(props, ctx) {
const userGroups = computed(
() =>
props.addressees.filter(
(addressee) =>
addressee.type == "user_group" &&
addressee.excludeKey == ""
) as UserGroup[]
);
const userGroupLevels = computed(
() =>
props.addressees.filter(
(addressee) =>
addressee.type == "user_group" &&
addressee.excludeKey == "level"
) as UserGroup[]
);
const users = computed(
() =>
props.addressees.filter(
(addressee) => addressee.type == "user"
) as User[]
);
return { userGroups, users, userGroupLevels };
},
});
</script>
<style lang="scss" scoped></style>

View File

@@ -0,0 +1,241 @@
<template>
<div class="row">
<div class="col-12 col-lg-6 col-md-6 text-center">
<div class="mb-2">
<span
v-for="userGroupItem in userGroups.filter(
(userGroup) => userGroup.excludeKey == 'level'
)"
:key="userGroupItem.id"
class="m-1"
>
<input
type="radio"
class="btn-check"
name="options-outlined"
:id="`level-${userGroupItem.id}`"
autocomplete="off"
:value="userGroupItem"
v-model="userGroupLevel"
@click="
Object.values(userGroupLevel).includes(
userGroupItem.id
)
? (userGroupLevel = {})
: (userGroupLevel = userGroupItem)
"
/>
<label
:class="`btn btn-${userGroupItem.id}`"
:for="`level-${userGroupItem.id}`"
:style="getUserGroupBtnColor(userGroupItem)"
>
{{ userGroupItem.label.fr }}
</label>
</span>
</div>
<div class="mb-2">
<span
v-for="userGroupItem in userGroups.filter(
(userGroup) => userGroup.excludeKey == ''
)"
:key="userGroupItem.id"
class="m-1"
>
<input
type="checkbox"
class="btn-check"
name="options-outlined"
:id="`user-group-${userGroupItem.id}`"
autocomplete="off"
:value="userGroupItem"
v-model="userGroup"
/>
<label
:class="`btn btn-${userGroupItem.id}`"
:for="`user-group-${userGroupItem.id}`"
:style="getUserGroupBtnColor(userGroupItem)"
>
{{ userGroupItem.label.fr }}
</label>
</span>
</div>
</div>
<div class="col-12 col-lg-6 col-md-6 mb-2 mb-2 text-center">
<add-persons
:options="addPersonsOptions"
key="add-person-ticket"
buttonTitle="add_addressee.user_label"
modalTitle="add_addressee.user_label"
ref="addPersons"
@addNewPersons="addNewEntity"
/>
<div class="p-2">
<ul class="list-suggest inline remove-items">
<li v-for="user in users" :key="user.id">
<span :title="user.username" @click="removeUser(user)">
{{ user.username }}
</span>
</li>
</ul>
</div>
</div>
</div>
</template>
<script lang="ts">
import { PropType, computed, defineComponent, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
// Types
import {
User,
UserGroup,
UserGroupOrUser,
} from "../../../../../../../ChillMainBundle/Resources/public/types";
// Components
import AddPersons from "../../../../../../../ChillPersonBundle/Resources/public/vuejs/_components/AddPersons.vue";
export default defineComponent({
name: "AddresseeSelectorComponent",
props: {
modelValue: {
type: Array as PropType<UserGroupOrUser[]>,
default: [],
required: false,
},
userGroups: {
type: Array as PropType<UserGroup[]>,
required: true,
},
users: {
type: Array as PropType<User[]>,
required: true,
},
},
components: {
AddPersons,
},
emits: ["update:modelValue"],
setup(props, ctx) {
const addressees = ref([...props.modelValue] as UserGroupOrUser[]);
const userGroups = [
...props.modelValue.filter(
(addressee) => addressee.type == "user_group"
),
] as UserGroup[];
const userGroupLevel = ref(
userGroups.filter(
(userGroup) => userGroup.excludeKey == "level"
)[0] as UserGroup | {}
);
const userGroup = ref(
userGroups.filter((userGroup) => userGroup.excludeKey == "") as
| UserGroup[]
);
const users = ref([
...props.modelValue.filter((addressee) => addressee.type == "user"),
] as User[]);
const addPersons = ref();
const { t } = useI18n();
function getUserGroupBtnColor(userGroup: UserGroup) {
return [
`.btn-check:checked + .btn-${userGroup.id} {
color: ${userGroup.foregroundColor};
background-color: ${userGroup.backgroundColor};
}`,
];
}
function addNewEntity(datas: any) {
const { selected, modal } = datas;
users.value = selected.map((selected: any) => selected.result);
addressees.value = addressees.value.filter(
(addressee) => addressee.type === "user_group"
);
addressees.value = [...addressees.value, ...users.value];
ctx.emit("update:modelValue", addressees.value);
addPersons.value.resetSearch();
modal.showModal = false;
}
const addPersonsOptions = computed(() => {
return {
uniq: false,
type: ["user"],
priority: null,
button: {
size: "btn-sm",
class: "btn-submit",
},
};
});
function removeUser(user: User) {
users.value.splice(users.value.indexOf(user), 1);
addressees.value = addressees.value.filter(
(addressee) => addressee.id !== user.id
);
ctx.emit("update:modelValue", addressees.value);
}
watch(userGroupLevel, (userGroupLevelAdd, userGroupLevelRem) => {
const index = addressees.value.indexOf(
userGroupLevelRem as UserGroup
);
if (index !== -1) {
addressees.value.splice(index, 1);
}
addressees.value.push(userGroupLevelAdd as UserGroup);
ctx.emit("update:modelValue", addressees.value);
});
watch(userGroup, (userGroupAdd) => {
const userGroupLevel = addressees.value.filter(
(addressee) =>
addressee.type == "user_group" &&
addressee.excludeKey == "level"
) as UserGroup[];
const users = addressees.value.filter(
(addressee) => addressee.type == "user"
) as UserGroup[];
addressees.value = [...users, ...userGroupLevel, ...userGroupAdd];
ctx.emit("update:modelValue", addressees.value);
});
return {
addressees,
userGroupLevel,
userGroup,
users,
addPersons,
addPersonsOptions,
addNewEntity,
removeUser,
getUserGroupBtnColor,
customUserGroupLabel(selectedUserGroup: UserGroup) {
return selectedUserGroup.label
? selectedUserGroup.label.fr
: t("add_addresseeuser_group_label");
},
};
},
});
</script>
<style lang="scss" scoped>
.btn-check:checked + .btn,
:not(.btn-check) + .btn:active,
.btn:first-child:active,
.btn.active,
.btn.show {
color: white;
box-shadow: 0 0 0 0.2rem var(--bs-chill-green);
outline: 0;
}
</style>

View File

@@ -0,0 +1,138 @@
<template>
<Teleport to="#header-ticket-main">
<div class="container-xxl text-primary">
<div class="row">
<div class="col-md-6 col-sm-12 ps-md-5 ps-xxl-0">
<h2>#{{ ticket.id }}</h2>
<h1 v-if="ticket.currentMotive">
{{ ticket.currentMotive.label.fr }}
</h1>
<p class="chill-no-data-statement" v-else>
{{ $t("banner.no_motive") }}
</p>
</div>
<div class="col-md-6 col-sm-12">
<div class="d-flex justify-content-end">
<h1>
<span class="badge text-bg-chill-green text-white">
{{ $t("banner.open") }}
</span>
</h1>
</div>
<div class="d-flex justify-content-end">
<h3 class="fst-italic" v-if="ticket.createdAt">
{{
$t("banner.since", {
time: since,
})
}}
</h3>
</div>
</div>
</div>
</div>
</Teleport>
<Teleport to="#header-ticket-details">
<div class="container-xxl">
<div class="row justify-content-between">
<div class="col-md-6 col-sm-12 ps-md-5 ps-xxl-0">
<h3 class="text-primary">
{{ $t("banner.concerned_patient") }}
</h3>
<person-render-box
render="badge"
v-for="person in ticket.currentPersons"
:key="person.id"
:person="person"
:options="{
addLink: true,
addId: false,
addAltNames: false,
addEntity: true,
addInfo: true,
hLevel: 3,
isMultiline: true,
isConfidential: false,
}"
/>
</div>
<div class="col-md-6 col-sm-12">
<h3 class="text-primary">{{ $t("banner.speaker") }}</h3>
<addressee-component
:addressees="ticket.currentAddressees"
/>
</div>
</div>
</div>
</Teleport>
</template>
<script lang="ts">
import { PropType, computed, defineComponent, ref } from "vue";
import { useI18n } from "vue-i18n";
// Components
import PersonRenderBox from "../../../../../../../ChillPersonBundle/Resources/public/vuejs/_components/Entity/PersonRenderBox.vue";
import AddresseeComponent from "./AddresseeComponent.vue";
// Types
import { Ticket } from "../../../types";
export default defineComponent({
name: "BannerComponent",
props: {
ticket: {
type: Object as PropType<Ticket>,
required: true,
},
},
components: {
PersonRenderBox,
AddresseeComponent,
},
setup(props) {
const { t } = useI18n();
const today = ref(new Date());
const createdAt = ref(props.ticket.createdAt as any);
setInterval(function () {
today.value = new Date();
}, 1000);
const since = computed(() => {
const date = new Date(createdAt.value.date);
const timeDiff = Math.abs(today.value.getTime() - date.getTime());
const daysDiff = Math.floor(timeDiff / (1000 * 3600 * 24));
const hoursDiff = Math.floor(
(timeDiff % (1000 * 3600 * 24)) / (1000 * 3600)
);
const minutesDiff = Math.floor(
(timeDiff % (1000 * 3600)) / (1000 * 60)
);
const secondsDiff = Math.floor((timeDiff % (1000 * 60)) / 1000);
if (daysDiff < 1 && hoursDiff < 1 && minutesDiff < 1) {
return `${t("banner.seconds", { count: secondsDiff })}`;
} else if (daysDiff < 1) {
return `${t("banner.hours", { count: hoursDiff })}
${t("banner.minutes", { count: minutesDiff })}
${t("banner.seconds", { count: secondsDiff })}`;
} else {
return `${t("banner.days", { count: daysDiff })}, ${t(
"banner.hours",
{
count: hoursDiff,
}
)} ${t("banner.minutes", {
count: minutesDiff,
})}`;
}
});
return { since };
},
});
</script>

View File

@@ -0,0 +1,72 @@
<template>
<div class="row">
<div class="col-12">
<vue-multiselect
name="selectMotive"
id="selectMotive"
label="label"
:custom-label="customLabel"
track-by="id"
open-direction="top"
:multiple="false"
:searchable="true"
:placeholder="$t('set_motive.label')"
:select-label="$t('multiselect.select_label')"
:deselect-label="$t('multiselect.deselect_label')"
:selected-label="$t('multiselect.selected_label')"
:options="motives"
v-model="motive"
class="mb-4"
/>
</div>
</div>
</template>
<script lang="ts">
import { PropType, defineComponent, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import VueMultiselect from "vue-multiselect";
// Types
import { Motive } from "../../../types";
export default defineComponent({
name: "MotiveSelectorComponent",
props: {
modelValue: {
type: Object as PropType<Motive>,
required: false,
},
motives: {
type: Object as PropType<Motive[]>,
required: true,
},
},
components: {
VueMultiselect,
},
emits: ["update:modelValue"],
setup(props, ctx) {
const motive = ref(props.modelValue);
const { t } = useI18n();
watch(motive, (motive) => {
ctx.emit("update:modelValue", motive);
});
return {
motive,
customLabel(motive: Motive) {
return motive.label ? motive.label.fr : t("set_motive.label");
},
};
},
});
</script>
<style lang="scss" scoped>
#selectMotive {
margin-bottom: 1.5em;
}
</style>

View File

@@ -0,0 +1,33 @@
<template>
<div class="col-12">
<addressee-component :addressees="addressees" />
</div>
</template>
<script lang="ts">
import { PropType, defineComponent } from "vue";
// Types
import { UserGroupOrUser } from "../../../../../../../ChillMainBundle/Resources/public/types";
// Components
import AddresseeComponent from "./AddresseeComponent.vue";
export default defineComponent({
name: "TicketHistoryAddresseeComponenvt",
props: {
addressees: {
type: Array as PropType<UserGroupOrUser[]>,
required: true,
},
},
components: {
AddresseeComponent,
},
setup() {
return {};
},
});
</script>
<style lang="scss" scoped></style>

View File

@@ -0,0 +1,60 @@
<template>
<div class="col-12">
<blockquote class="chill-user-quote">
<p v-html="convertMarkdownToHtml(commentHistory.content)"></p>
</blockquote>
</div>
</template>
<script lang="ts">
import { PropType, defineComponent } from "vue";
import { marked } from "marked";
import DOMPurify from "dompurify";
// Types
import { Comment } from "../../../types";
export default defineComponent({
name: "TicketHistoryCommentComponent",
props: {
commentHistory: {
type: Object as PropType<Comment>,
required: true,
},
},
setup() {
const preprocess = (markdown: string): string => {
return markdown;
};
const postprocess = (html: string): string => {
DOMPurify.addHook("afterSanitizeAttributes", (node: any) => {
if ("target" in node) {
node.setAttribute("target", "_blank");
node.setAttribute("rel", "noopener noreferrer");
}
if (
!node.hasAttribute("target") &&
(node.hasAttribute("xlink:href") ||
node.hasAttribute("href"))
) {
node.setAttribute("xlink:show", "new");
}
});
return DOMPurify.sanitize(html);
};
const convertMarkdownToHtml = (markdown: string): string => {
marked.use({ hooks: { postprocess, preprocess } });
const rawHtml = marked(markdown) as string;
return rawHtml;
};
return {
convertMarkdownToHtml,
};
},
});
</script>
<style lang="scss" scoped></style>

View File

@@ -0,0 +1,98 @@
<template>
<div
class="card my-2 bg-light"
v-for="history_line in history"
:key="history.indexOf(history_line)"
>
<template v-if="!Array.isArray(history_line)">
<div class="card-header">
<i :class="`${actionIcons[history_line.event_type]} me-1`"></i>
<span class="fw-bold fst-italic mx-1">
{{ formatDate(history_line.at) }}
</span>
<span class="badge bg-white text-black mx-1">
{{ history_line.by.username }}
</span>
</div>
<div class="card-body row">
<ticket-history-person-component
:personHistory="history_line.data"
v-if="history_line.event_type == 'add_person'"
/>
<ticket-history-motive-component
:motiveHistory="history_line.data"
v-else-if="history_line.event_type == 'set_motive'"
/>
<ticket-history-comment-component
:commentHistory="history_line.data"
v-else-if="history_line.event_type == 'add_comment'"
/>
</div>
</template>
<template v-else>
<div class="card-header">
<i class="fa fa-paper-plane me-1"></i>
<span class="fw-bold fst-italic mx-1">
{{ formatDate(history_line[0].at) }}
</span>
<span class="badge bg-white text-black mx-1">
{{ history_line[0].by.username }}
</span>
</div>
<div class="card-body row">
<ticket-history-addressee-component
:addressees="
history_line
.map((line) => line.data)
.map((data) => data.addressee)
"
/>
</div>
</template>
</div>
</template>
<script lang="ts">
import { PropType, defineComponent, ref } from "vue";
import { useStore } from "vuex";
// Types
import { DateTime } from "../../../../../../../ChillMainBundle/Resources/public/types";
import { Ticket } from "../../../types";
// Components
import TicketHistoryPersonComponent from "./TicketHistoryPersonComponent.vue";
import TicketHistoryMotiveComponent from "./TicketHistoryMotiveComponent.vue";
import TicketHistoryCommentComponent from "./TicketHistoryCommentComponent.vue";
import TicketHistoryAddresseeComponent from "./TicketHistoryAddresseeComponent.vue";
export default defineComponent({
name: "TicketHistoryListComponent",
components: {
TicketHistoryPersonComponent,
TicketHistoryMotiveComponent,
TicketHistoryCommentComponent,
TicketHistoryAddresseeComponent,
},
props: {
history: {
type: Array as PropType<Ticket["history"]>,
required: true,
},
},
setup() {
const store = useStore();
function formatDate(d: DateTime) {
const date = new Date(d.datetime);
const month = date.toLocaleString("default", { month: "long" });
return `${date.getDate()} ${month} ${date.getFullYear()}, ${date.toLocaleTimeString()}`;
}
return { actionIcons: ref(store.getters.getActionIcons), formatDate };
},
});
</script>
<style lang="scss" scoped></style>

View File

@@ -0,0 +1,26 @@
<template>
<div class="col-12 fw-bolder">
{{ motiveHistory.motive.label.fr }}
</div>
</template>
<script lang="ts">
import { PropType, defineComponent } from "vue";
// Types
import { MotiveHistory } from "../../../types";
export default defineComponent({
name: "TicketHistoryMotiveComponent",
props: {
motiveHistory: {
type: Object as PropType<MotiveHistory>,
required: true,
},
},
setup() {},
});
</script>
<style lang="scss" scoped></style>

View File

@@ -0,0 +1,57 @@
<template>
<div class="col-12" v-if="personHistory.createdBy">
<span class="mx-1">
{{ $t("history.user") }}
<span class="badge bg-primary m-1">
{{ personHistory.createdBy.username }}
</span>
</span>
</div>
<div class="col-12">
<span class="mx-1">
{{ $t("history.person") }}
</span>
<person-render-box
render="badge"
:key="personHistory.person.id"
:person="personHistory.person"
:options="{
addLink: true,
addId: false,
addAltNames: false,
addEntity: true,
addInfo: true,
hLevel: 3,
isMultiline: true,
isConfidential: false,
}"
/>
</div>
</template>
<script lang="ts">
import { PropType, defineComponent } from "vue";
// Components
import PersonRenderBox from "../../../../../../../ChillPersonBundle/Resources/public/vuejs/_components/Entity/PersonRenderBox.vue";
// Type
import { PersonHistory } from "../../../types";
export default defineComponent({
name: "TicketHistoryPersonComponent",
props: {
personHistory: {
type: Object as PropType<PersonHistory>,
required: true,
},
},
components: {
PersonRenderBox,
},
setup() {},
});
</script>
<style lang="scss" scoped></style>

View File

@@ -0,0 +1,41 @@
<template>
<div class="d-flex justify-content-end">
<div class="btn-group" @click="handleClick">
<button type="button" class="btn btn-light dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">
{{ $t('ticket.previous_tickets') }}
<span class="position-absolute top-0 start-100 translate-middle badge rounded-pill bg-chill-green">
{{ tickets.length }}
<span class="visually-hidden">Tickets</span>
</span>
</button>
</div>
</div>
</template>
<script lang="ts">
import { PropType, defineComponent } from 'vue';
// Types
import { Ticket } from '../../../types';
export default defineComponent({
name: 'TicketSelectorComponent',
props: {
tickets: {
type: Object as PropType<Ticket[]>,
required: true,
},
},
setup() {
function handleClick() {
alert('Sera disponible plus tard')
}
return { handleClick }
}
});
</script>
<style lang="scss" scoped></style>

View File

@@ -0,0 +1,52 @@
import { multiSelectMessages } from "../../../../../../../ChillMainBundle/Resources/public/vuejs/_js/i18n";
import { personMessages } from "../../../../../../../ChillPersonBundle/Resources/public/vuejs/_js/i18n";
const messages = {
fr: {
ticket: {
previous_tickets: "Précédents tickets",
cancel: "Annuler",
save: "Enregistrer",
close: "Fermer",
},
history: {
person: "Ouverture par appel téléphonique de ",
user: "Prise en charge par ",
},
add_comment: {
title: "Commentaire",
label: "Ajouter un commentaire",
success: "Commentaire enregistré",
content: "Ajouter un commentaire",
error: "Aucun commentaire ajouté",
},
set_motive: {
title: "Motif",
label: "Choisir un motif",
success: "Motif enregistré",
error: "Aucun motif sélectionné",
},
add_addressee: {
title: "Transfert",
user_group_label: "Transferer vers un groupe",
user_label: "Transferer vers un ou plusieurs utilisateurs",
success: "Transfert effectué",
error: "Aucun destinataire sélectionné",
},
banner: {
concerned_patient: "Patient concerné",
speaker: "Destinataire(s)",
open: "Ouvert",
since: "Depuis {time}",
and: "et",
days: "|1 jour|{count} jours",
hours: "|1 heure et|{count} heures",
minutes: "|1 minute|{count} minutes",
seconds: "|1 seconde|{count} secondes",
no_motive: "Pas de motif",
},
},
};
Object.assign(messages.fr, multiSelectMessages.fr);
Object.assign(messages.fr, personMessages.fr);
export default messages;

View File

@@ -0,0 +1,32 @@
import App from './App.vue';
import {createApp} from "vue";
import { _createI18n } from "../../../../../../ChillMainBundle/Resources/public/vuejs/_js/i18n";
import VueToast from 'vue-toast-notification';
import 'vue-toast-notification/dist/theme-sugar.css';
import { store } from "./store";
import messages from './i18n/messages';
declare global {
interface Window {
initialTicket: string
}
}
const i18n = _createI18n(messages, false);
const _app = createApp({
template: '<app></app>',
});
_app
.use(store)
.use(i18n)
// Cant use this.$toast in components in composition API so we need to provide it
// Fix: with vue-toast-notification@^3
.use(VueToast).provide('toast', _app.config.globalProperties.$toast)
.component('app', App)
.mount('#ticketRoot');

View File

@@ -0,0 +1,21 @@
import { createStore } from "vuex";
import { State as MotiveStates, moduleMotive } from "./modules/motive";
import { State as TicketStates, moduleTicket } from "./modules/ticket";
import { State as CommentStates, moduleComment } from "./modules/comment";
import { State as AddresseeStates, moduleAddressee } from "./modules/addressee";
export type RootState = {
motive: MotiveStates;
ticket: TicketStates;
comment: CommentStates;
addressee: AddresseeStates;
};
export const store = createStore<RootState>({
modules: {
motive: moduleMotive,
ticket: moduleTicket,
comment: moduleComment,
addressee: moduleAddressee,
},
});

View File

@@ -0,0 +1,84 @@
import {
fetchResults,
makeFetch,
} from "../../../../../../../../ChillMainBundle/Resources/public/lib/api/apiMethods";
import { Module } from "vuex";
import { RootState } from "..";
import {
User,
UserGroup,
UserGroupOrUser,
} from "../../../../../../../../ChillMainBundle/Resources/public/types";
export interface State {
userGroups: Array<UserGroup>;
users: Array<User>;
}
export const moduleAddressee: Module<State, RootState> = {
state: () => ({
userGroups: [] as Array<UserGroup>,
users: [] as Array<User>,
}),
getters: {
getUserGroups(state) {
return state.userGroups;
},
getUsers(state) {
return state.users;
},
},
mutations: {
setUserGroups(state, userGroups) {
state.userGroups = userGroups;
},
setUsers(state, users) {
state.users = users;
},
},
actions: {
fetchUserGroups({ commit }) {
try {
fetchResults("/api/1.0/main/user-group.json").then(
(results) => {
commit("setUserGroups", results);
}
);
} catch (e: any) {
throw e.name;
}
},
fetchUsers({ commit }) {
try {
fetchResults("/api/1.0/main/user.json").then((results) => {
commit("setUsers", results);
});
} catch (e: any) {
throw e.name;
}
},
async setAdressees(
{ commit },
datas: { ticketId: number; addressees: Array<UserGroupOrUser> }
) {
const { ticketId, addressees } = datas;
try {
const result = await makeFetch(
"POST",
`/api/1.0/ticket/${ticketId}/addressees/set`,
{
addressees: addressees.map((addressee) => {
return { id: addressee.id, type: addressee.type };
}),
}
);
commit("setTicket", result);
} catch (e: any) {
throw e.name;
}
},
},
};

View File

@@ -0,0 +1,40 @@
import {
fetchResults,
makeFetch,
} from "../../../../../../../../ChillMainBundle/Resources/public/lib/api/apiMethods";
import { Module } from "vuex";
import { RootState } from "..";
import { Comment } from "../../../../types";
export interface State {
comments: Array<Comment>;
}
export const moduleComment: Module<State, RootState> = {
state: () => ({
comments: [] as Array<Comment>,
}),
getters: {},
mutations: {},
actions: {
async createComment(
{ commit },
datas: { ticketId: number; content: Comment["content"] }
) {
const { ticketId, content } = datas;
try {
const result = await makeFetch(
"POST",
`/api/1.0/ticket/${ticketId}/comment/add`,
{ content }
);
commit("setTicket", result);
}
catch(e: any) {
throw e.name;
}
},
},
};

View File

@@ -0,0 +1,63 @@
import {
fetchResults,
makeFetch,
} from "../../../../../../../../ChillMainBundle/Resources/public/lib/api/apiMethods";
import { Module } from "vuex";
import { RootState } from "..";
import { Motive } from "../../../../types";
export interface State {
motives: Array<Motive>;
}
export const moduleMotive: Module<State, RootState> = {
state: () => ({
motives: [] as Array<Motive>,
}),
getters: {
getMotives(state) {
return state.motives;
},
},
mutations: {
setMotives(state, motives) {
state.motives = motives;
},
},
actions: {
async fetchMotives({ commit }) {
try {
const results = (await fetchResults(
"/api/1.0/ticket/motive.json"
)) as Motive[];
commit("setMotives", results);
} catch (e: any) {
throw e.name;
}
},
async createMotive(
{ commit },
datas: { ticketId: number; motive: Motive }
) {
const { ticketId, motive } = datas;
try {
const result = await makeFetch(
"POST",
`/api/1.0/ticket/${ticketId}/motive/set`,
{
motive: {
id: motive.id,
type: motive.type,
},
}
);
commit("setTicket", result);
} catch (e: any) {
throw e.name;
}
},
},
};

View File

@@ -0,0 +1,66 @@
import { Module } from "vuex";
import { RootState } from "..";
import { Ticket } from "../../../../types";
export interface State {
ticket: Ticket;
action_icons: Object;
}
export const moduleTicket: Module<State, RootState> = {
state: () => ({
ticket: {} as Ticket,
action_icons: {
add_person: "fa fa-eyedropper",
add_comment: "fa fa-comment",
set_motive: "fa fa-paint-brush",
add_addressee: "fa fa-paper-plane",
},
toto: "toto",
}),
getters: {
getTicket(state) {
state.ticket.history = state.ticket.history.sort((a, b) =>
b.at.datetime.localeCompare(a.at.datetime)
);
return state.ticket;
},
getActionIcons(state) {
return state.action_icons;
},
getDistinctAddressesHistory(state) {
const addresseeHistory = state.ticket.history.reduce(
(result, item) => {
const { datetime } = item.at;
if (
!["add_addressee", "remove_addressee"].includes(
item.event_type
)
) {
result[datetime] = item;
return result;
}
if (!result[datetime]) {
result[datetime] = [];
}
if (item.event_type === "add_addressee") {
result[datetime].push(item);
}
return result;
},
{} as any
);
return Object.values(addresseeHistory) as Array<Ticket["history"]>;
},
},
mutations: {
setTicket(state, ticket) {
state.ticket = ticket;
},
},
actions: {},
};

View File

@@ -0,0 +1,8 @@
<div class="banner banner-ticket ">
<div id="header-ticket-main" class="header-name">
</div>
<div id="header-ticket-details" class="header-details">
</div>
</div>

View File

@@ -0,0 +1,18 @@
{% extends '@ChillTicket/layout.html.twig' %}
{% block css %}
{{ parent() }}
{{ encore_entry_link_tags('vue_ticket_app') }}
{% endblock %}
{% block js %}
{{ parent() }}
<script type="text/javascript">
window.initialTicket = "{{ ticket|serialize('json', {'groups': 'read'})|escape('js') }}";
</script>
{{ encore_entry_script_tags('vue_ticket_app') }}
{% endblock %}
{% block content %}
<div id="ticketRoot"></div>
{% endblock %}

View File

@@ -0,0 +1,17 @@
{% extends '@ChillMain/layout.html.twig' %}
{% block css %}
{{ encore_entry_link_tags('page_ticket') }}
{% endblock %}
{% block js %}
{{ encore_entry_script_tags('page_ticket') }}
{% endblock %}
{% block top_banner %}
{{ include('@ChillTicket/Banner/banner.html.twig') }}
{% endblock %}
{% block wrapping_content %}
{% block content %}{% endblock %}
{% endblock %}

View File

@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
/*
* 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.
*/
namespace Chill\TicketBundle\Serializer\Normalizer;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Entity\UserGroup;
use Chill\TicketBundle\Action\Ticket\SetAddresseesCommand;
use Symfony\Component\Serializer\Exception\UnexpectedValueException;
use Symfony\Component\Serializer\Normalizer\DenormalizerAwareInterface;
use Symfony\Component\Serializer\Normalizer\DenormalizerAwareTrait;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
final class SetAddresseesCommandDenormalizer implements DenormalizerInterface, DenormalizerAwareInterface
{
use DenormalizerAwareTrait;
public function denormalize($data, string $type, ?string $format = null, array $context = [])
{
if (null === $data) {
return null;
}
if (!array_key_exists('addressees', $data)) {
throw new UnexpectedValueException("key 'addressees' does exists");
}
if (!is_array($data['addressees'])) {
throw new UnexpectedValueException("key 'addressees' must be an array");
}
$addresses = [];
foreach ($data['addressees'] as $address) {
$addresses[] = match ($address['type'] ?? '') {
'user_group' => $this->denormalizer->denormalize($address, UserGroup::class, $format, $context),
'user' => $this->denormalizer->denormalize($address, User::class, $format, $context),
default => throw new UnexpectedValueException('the type is not set or not supported')
};
}
return new SetAddresseesCommand($addresses);
}
public function supportsDenormalization($data, string $type, ?string $format = null)
{
return SetAddresseesCommand::class === $type && 'json' === $format;
}
}

View File

@@ -0,0 +1,122 @@
<?php
declare(strict_types=1);
/*
* 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.
*/
namespace Chill\TicketBundle\Serializer\Normalizer;
use Chill\TicketBundle\Entity\AddresseeHistory;
use Chill\TicketBundle\Entity\Comment;
use Chill\TicketBundle\Entity\MotiveHistory;
use Chill\TicketBundle\Entity\PersonHistory;
use Chill\TicketBundle\Entity\Ticket;
use Symfony\Component\Serializer\Exception\UnexpectedValueException;
use Symfony\Component\Serializer\Normalizer\NormalizerAwareInterface;
use Symfony\Component\Serializer\Normalizer\NormalizerAwareTrait;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
final class TicketNormalizer implements NormalizerInterface, NormalizerAwareInterface
{
use NormalizerAwareTrait;
public function normalize($object, ?string $format = null, array $context = [])
{
if (!$object instanceof Ticket) {
throw new UnexpectedValueException();
}
return [
'type' => 'ticket_ticket',
'id' => $object->getId(),
'externalRef' => $object->getExternalRef(),
'currentPersons' => $this->normalizer->normalize($object->getPersons(), $format, [
'groups' => 'read',
]),
'currentAddressees' => $this->normalizer->normalize($object->getCurrentAddressee(), $format, ['groups' => 'read']),
'currentInputs' => $this->normalizer->normalize($object->getCurrentInputs(), $format, ['groups' => 'read']),
'currentMotive' => $this->normalizer->normalize($object->getMotive(), $format, ['groups' => 'read']),
'history' => array_values($this->serializeHistory($object, $format, ['groups' => 'read'])),
'createdAt' => $object->getCreatedAt(),
'updatedBy' => $object->getUpdatedBy(),
];
}
public function supportsNormalization($data, ?string $format = null)
{
return 'json' === $format && $data instanceof Ticket;
}
private function serializeHistory(Ticket $ticket, string $format, array $context): array
{
$events = [
...array_map(
fn (MotiveHistory $motiveHistory) => [
'event_type' => 'set_motive',
'at' => $motiveHistory->getStartDate(),
'by' => $motiveHistory->getCreatedBy(),
'data' => $motiveHistory,
],
$ticket->getMotiveHistories()->toArray()
),
...array_map(
fn (PersonHistory $personHistory) => [
'event_type' => 'add_person',
'at' => $personHistory->getStartDate(),
'by' => $personHistory->getCreatedBy(),
'data' => $personHistory,
],
$ticket->getPersonHistories()->toArray(),
),
...array_map(
fn (Comment $comment) => [
'event_type' => 'add_comment',
'at' => $comment->getCreatedAt(),
'by' => $comment->getCreatedBy(),
'data' => $comment,
],
$ticket->getComments()->toArray(),
),
...array_map(
fn (AddresseeHistory $history) => [
'event_type' => 'add_addressee',
'at' => $history->getStartDate(),
'by' => $history->getCreatedBy(),
'data' => $history,
],
$ticket->getAddresseeHistories()->toArray(),
),
...array_map(
fn (AddresseeHistory $history) => [
'event_type' => 'remove_addressee',
'at' => $history->getStartDate(),
'by' => $history->getRemovedBy(),
'data' => $history,
],
$ticket->getAddresseeHistories()->filter(fn (AddresseeHistory $history) => null !== $history->getEndDate())->toArray()
),
];
usort(
$events,
static function (array $a, array $b): int {
return $a['at'] <=> $b['at'];
}
);
return array_map(
fn ($data) => [
'event_type' => $data['event_type'],
'at' => $this->normalizer->normalize($data['at'], $format, $context),
'by' => $this->normalizer->normalize($data['by'], $format, $context),
'data' => $this->normalizer->normalize($data['data'], $format, $context),
],
$events
);
}
}

View File

@@ -0,0 +1,21 @@
services:
_defaults:
autoconfigure: true
autowire: true
Chill\TicketBundle\Action\Ticket\Handler\:
resource: '../Action/Ticket/Handler/'
Chill\TicketBundle\Controller\:
resource: '../Controller/'
tags:
- controller.service_arguments
Chill\TicketBundle\Repository\:
resource: '../Repository/'
Chill\TicketBundle\Serializer\:
resource: '../Serializer/'
Chill\TicketBundle\DataFixtures\:
resource: '../DataFixtures/'

View File

@@ -0,0 +1,139 @@
<?php
declare(strict_types=1);
/*
* 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.
*/
namespace Chill\Migrations\Ticket;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20240416145919 extends AbstractMigration
{
public function getDescription(): string
{
return 'Create schema and tables for chill ticket';
}
public function up(Schema $schema): void
{
$this->addSql('CREATE SCHEMA chill_ticket');
$this->addSql('CREATE SEQUENCE chill_ticket.addressee_history_id_seq INCREMENT BY 1 MINVALUE 1 START 1');
$this->addSql('CREATE SEQUENCE chill_ticket.comment_id_seq INCREMENT BY 1 MINVALUE 1 START 1');
$this->addSql('CREATE SEQUENCE chill_ticket.input_history_id_seq INCREMENT BY 1 MINVALUE 1 START 1');
$this->addSql('CREATE SEQUENCE chill_ticket.motive_id_seq INCREMENT BY 1 MINVALUE 1 START 1000');
$this->addSql('CREATE SEQUENCE chill_ticket.motives_history_id_seq INCREMENT BY 1 MINVALUE 1 START 1');
$this->addSql('CREATE SEQUENCE chill_ticket.person_history_id_seq INCREMENT BY 1 MINVALUE 1 START 1');
$this->addSql('CREATE SEQUENCE chill_ticket.ticket_id_seq INCREMENT BY 1 MINVALUE 1 START 1');
$this->addSql('CREATE TABLE chill_ticket.addressee_history (id INT NOT NULL, ticket_id INT NOT NULL, startDate TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, createdAt TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, updatedAt TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, addresseeUser_id INT DEFAULT NULL, addresseeGroup_id INT DEFAULT NULL, createdBy_id INT DEFAULT NULL, updatedBy_id INT DEFAULT NULL, PRIMARY KEY(id))');
$this->addSql('CREATE INDEX IDX_434EBDBD4D06F00C ON chill_ticket.addressee_history (addresseeUser_id)');
$this->addSql('CREATE INDEX IDX_434EBDBD776D9A84 ON chill_ticket.addressee_history (addresseeGroup_id)');
$this->addSql('CREATE INDEX IDX_434EBDBD700047D2 ON chill_ticket.addressee_history (ticket_id)');
$this->addSql('CREATE INDEX IDX_434EBDBD3174800F ON chill_ticket.addressee_history (createdBy_id)');
$this->addSql('CREATE INDEX IDX_434EBDBD65FF1AEC ON chill_ticket.addressee_history (updatedBy_id)');
$this->addSql('COMMENT ON COLUMN chill_ticket.addressee_history.startDate IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('COMMENT ON COLUMN chill_ticket.addressee_history.createdAt IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('COMMENT ON COLUMN chill_ticket.addressee_history.updatedAt IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('CREATE TABLE chill_ticket.comment (id INT NOT NULL, ticket_id INT NOT NULL, content TEXT DEFAULT \'\' NOT NULL, createdAt TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, updatedAt TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, createdBy_id INT DEFAULT NULL, updatedBy_id INT DEFAULT NULL, PRIMARY KEY(id))');
$this->addSql('CREATE INDEX IDX_79EBD416700047D2 ON chill_ticket.comment (ticket_id)');
$this->addSql('CREATE INDEX IDX_79EBD4163174800F ON chill_ticket.comment (createdBy_id)');
$this->addSql('CREATE INDEX IDX_79EBD41665FF1AEC ON chill_ticket.comment (updatedBy_id)');
$this->addSql('COMMENT ON COLUMN chill_ticket.comment.createdAt IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('COMMENT ON COLUMN chill_ticket.comment.updatedAt IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('CREATE TABLE chill_ticket.input_history (id INT NOT NULL, person_id INT DEFAULT NULL, ticket_id INT NOT NULL, endDate TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, startDate TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, thirdParty_id INT DEFAULT NULL, removedBy_id INT DEFAULT NULL, PRIMARY KEY(id))');
$this->addSql('CREATE INDEX IDX_E2AA301F217BBB47 ON chill_ticket.input_history (person_id)');
$this->addSql('CREATE INDEX IDX_E2AA301F3EA5CAB0 ON chill_ticket.input_history (thirdParty_id)');
$this->addSql('CREATE INDEX IDX_E2AA301FB8346CCF ON chill_ticket.input_history (removedBy_id)');
$this->addSql('CREATE INDEX IDX_E2AA301F700047D2 ON chill_ticket.input_history (ticket_id)');
$this->addSql('COMMENT ON COLUMN chill_ticket.input_history.endDate IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('COMMENT ON COLUMN chill_ticket.input_history.startDate IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('CREATE TABLE chill_ticket.motive (id INT NOT NULL, label JSON DEFAULT \'[]\' NOT NULL, active BOOLEAN DEFAULT true NOT NULL, PRIMARY KEY(id))');
$this->addSql('CREATE TABLE chill_ticket.motives_history (id INT NOT NULL, motive_id INT NOT NULL, ticket_id INT NOT NULL, endDate TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, startDate TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, createdAt TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, createdBy_id INT DEFAULT NULL, PRIMARY KEY(id))');
$this->addSql('CREATE INDEX IDX_48995CFF9658649C ON chill_ticket.motives_history (motive_id)');
$this->addSql('CREATE INDEX IDX_48995CFF700047D2 ON chill_ticket.motives_history (ticket_id)');
$this->addSql('CREATE INDEX IDX_48995CFF3174800F ON chill_ticket.motives_history (createdBy_id)');
$this->addSql('COMMENT ON COLUMN chill_ticket.motives_history.endDate IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('COMMENT ON COLUMN chill_ticket.motives_history.startDate IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('COMMENT ON COLUMN chill_ticket.motives_history.createdAt IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('CREATE TABLE chill_ticket.person_history (id INT NOT NULL, person_id INT DEFAULT NULL, ticket_id INT NOT NULL, endDate TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, startDate TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, createdAt TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, removedBy_id INT DEFAULT NULL, createdBy_id INT DEFAULT NULL, PRIMARY KEY(id))');
$this->addSql('CREATE INDEX IDX_F2969246B8346CCF ON chill_ticket.person_history (removedBy_id)');
$this->addSql('CREATE INDEX IDX_F2969246217BBB47 ON chill_ticket.person_history (person_id)');
$this->addSql('CREATE INDEX IDX_F2969246700047D2 ON chill_ticket.person_history (ticket_id)');
$this->addSql('CREATE INDEX IDX_F29692463174800F ON chill_ticket.person_history (createdBy_id)');
$this->addSql('COMMENT ON COLUMN chill_ticket.person_history.endDate IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('COMMENT ON COLUMN chill_ticket.person_history.startDate IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('COMMENT ON COLUMN chill_ticket.person_history.createdAt IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('CREATE TABLE chill_ticket.ticket (id INT NOT NULL, externalRef TEXT DEFAULT \'\' NOT NULL, createdAt TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, updatedAt TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, createdBy_id INT DEFAULT NULL, updatedBy_id INT DEFAULT NULL, PRIMARY KEY(id))');
$this->addSql('CREATE INDEX IDX_B0A5F7233174800F ON chill_ticket.ticket (createdBy_id)');
$this->addSql('CREATE INDEX IDX_B0A5F72365FF1AEC ON chill_ticket.ticket (updatedBy_id)');
$this->addSql('COMMENT ON COLUMN chill_ticket.ticket.createdAt IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('COMMENT ON COLUMN chill_ticket.ticket.updatedAt IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('ALTER TABLE chill_ticket.addressee_history ADD CONSTRAINT FK_434EBDBD4D06F00C FOREIGN KEY (addresseeUser_id) REFERENCES users (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('ALTER TABLE chill_ticket.addressee_history ADD CONSTRAINT FK_434EBDBD776D9A84 FOREIGN KEY (addresseeGroup_id) REFERENCES chill_main_user_group (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('ALTER TABLE chill_ticket.addressee_history ADD CONSTRAINT FK_434EBDBD700047D2 FOREIGN KEY (ticket_id) REFERENCES chill_ticket.ticket (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('ALTER TABLE chill_ticket.addressee_history ADD CONSTRAINT FK_434EBDBD3174800F FOREIGN KEY (createdBy_id) REFERENCES users (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('ALTER TABLE chill_ticket.addressee_history ADD CONSTRAINT FK_434EBDBD65FF1AEC FOREIGN KEY (updatedBy_id) REFERENCES users (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('ALTER TABLE chill_ticket.comment ADD CONSTRAINT FK_79EBD416700047D2 FOREIGN KEY (ticket_id) REFERENCES chill_ticket.ticket (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('ALTER TABLE chill_ticket.comment ADD CONSTRAINT FK_79EBD4163174800F FOREIGN KEY (createdBy_id) REFERENCES users (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('ALTER TABLE chill_ticket.comment ADD CONSTRAINT FK_79EBD41665FF1AEC FOREIGN KEY (updatedBy_id) REFERENCES users (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('ALTER TABLE chill_ticket.input_history ADD CONSTRAINT FK_E2AA301F217BBB47 FOREIGN KEY (person_id) REFERENCES chill_person_person (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('ALTER TABLE chill_ticket.input_history ADD CONSTRAINT FK_E2AA301F3EA5CAB0 FOREIGN KEY (thirdParty_id) REFERENCES chill_3party.third_party (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('ALTER TABLE chill_ticket.input_history ADD CONSTRAINT FK_E2AA301FB8346CCF FOREIGN KEY (removedBy_id) REFERENCES users (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('ALTER TABLE chill_ticket.input_history ADD CONSTRAINT FK_E2AA301F700047D2 FOREIGN KEY (ticket_id) REFERENCES chill_ticket.ticket (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('ALTER TABLE chill_ticket.motives_history ADD CONSTRAINT FK_48995CFF9658649C FOREIGN KEY (motive_id) REFERENCES chill_ticket.motive (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('ALTER TABLE chill_ticket.motives_history ADD CONSTRAINT FK_48995CFF700047D2 FOREIGN KEY (ticket_id) REFERENCES chill_ticket.ticket (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('ALTER TABLE chill_ticket.motives_history ADD CONSTRAINT FK_48995CFF3174800F FOREIGN KEY (createdBy_id) REFERENCES users (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('ALTER TABLE chill_ticket.person_history ADD CONSTRAINT FK_F2969246B8346CCF FOREIGN KEY (removedBy_id) REFERENCES users (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('ALTER TABLE chill_ticket.person_history ADD CONSTRAINT FK_F2969246217BBB47 FOREIGN KEY (person_id) REFERENCES chill_person_person (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('ALTER TABLE chill_ticket.person_history ADD CONSTRAINT FK_F2969246700047D2 FOREIGN KEY (ticket_id) REFERENCES chill_ticket.ticket (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('ALTER TABLE chill_ticket.person_history ADD CONSTRAINT FK_F29692463174800F FOREIGN KEY (createdBy_id) REFERENCES users (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('ALTER TABLE chill_ticket.ticket ADD CONSTRAINT FK_B0A5F7233174800F FOREIGN KEY (createdBy_id) REFERENCES users (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('ALTER TABLE chill_ticket.ticket ADD CONSTRAINT FK_B0A5F72365FF1AEC FOREIGN KEY (updatedBy_id) REFERENCES users (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
}
public function down(Schema $schema): void
{
$this->addSql('DROP SEQUENCE chill_ticket.addressee_history_id_seq CASCADE');
$this->addSql('DROP SEQUENCE chill_ticket.comment_id_seq CASCADE');
$this->addSql('DROP SEQUENCE chill_ticket.input_history_id_seq CASCADE');
$this->addSql('DROP SEQUENCE chill_ticket.motive_id_seq CASCADE');
$this->addSql('DROP SEQUENCE chill_ticket.motives_history_id_seq CASCADE');
$this->addSql('DROP SEQUENCE chill_ticket.person_history_id_seq CASCADE');
$this->addSql('DROP SEQUENCE chill_ticket.ticket_id_seq CASCADE');
$this->addSql('ALTER TABLE chill_ticket.addressee_history DROP CONSTRAINT FK_434EBDBD4D06F00C');
$this->addSql('ALTER TABLE chill_ticket.addressee_history DROP CONSTRAINT FK_434EBDBD776D9A84');
$this->addSql('ALTER TABLE chill_ticket.addressee_history DROP CONSTRAINT FK_434EBDBD700047D2');
$this->addSql('ALTER TABLE chill_ticket.addressee_history DROP CONSTRAINT FK_434EBDBD3174800F');
$this->addSql('ALTER TABLE chill_ticket.addressee_history DROP CONSTRAINT FK_434EBDBD65FF1AEC');
$this->addSql('ALTER TABLE chill_ticket.comment DROP CONSTRAINT FK_79EBD416700047D2');
$this->addSql('ALTER TABLE chill_ticket.comment DROP CONSTRAINT FK_79EBD4163174800F');
$this->addSql('ALTER TABLE chill_ticket.comment DROP CONSTRAINT FK_79EBD41665FF1AEC');
$this->addSql('ALTER TABLE chill_ticket.input_history DROP CONSTRAINT FK_E2AA301F217BBB47');
$this->addSql('ALTER TABLE chill_ticket.input_history DROP CONSTRAINT FK_E2AA301F3EA5CAB0');
$this->addSql('ALTER TABLE chill_ticket.input_history DROP CONSTRAINT FK_E2AA301FB8346CCF');
$this->addSql('ALTER TABLE chill_ticket.input_history DROP CONSTRAINT FK_E2AA301F700047D2');
$this->addSql('ALTER TABLE chill_ticket.motives_history DROP CONSTRAINT FK_48995CFF9658649C');
$this->addSql('ALTER TABLE chill_ticket.motives_history DROP CONSTRAINT FK_48995CFF700047D2');
$this->addSql('ALTER TABLE chill_ticket.motives_history DROP CONSTRAINT FK_48995CFF3174800F');
$this->addSql('ALTER TABLE chill_ticket.person_history DROP CONSTRAINT FK_F2969246B8346CCF');
$this->addSql('ALTER TABLE chill_ticket.person_history DROP CONSTRAINT FK_F2969246217BBB47');
$this->addSql('ALTER TABLE chill_ticket.person_history DROP CONSTRAINT FK_F2969246700047D2');
$this->addSql('ALTER TABLE chill_ticket.person_history DROP CONSTRAINT FK_F29692463174800F');
$this->addSql('ALTER TABLE chill_ticket.ticket DROP CONSTRAINT FK_B0A5F7233174800F');
$this->addSql('ALTER TABLE chill_ticket.ticket DROP CONSTRAINT FK_B0A5F72365FF1AEC');
$this->addSql('DROP TABLE chill_ticket.addressee_history');
$this->addSql('DROP TABLE chill_ticket.comment');
$this->addSql('DROP TABLE chill_ticket.input_history');
$this->addSql('DROP TABLE chill_ticket.motive');
$this->addSql('DROP TABLE chill_ticket.motives_history');
$this->addSql('DROP TABLE chill_ticket.person_history');
$this->addSql('DROP TABLE chill_ticket.ticket');
$this->addSql('DROP SCHEMA chill_ticket CASCADE');
}
}

View File

@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
/*
* 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.
*/
namespace Chill\Migrations\Ticket;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20240423212824 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add endDate and removedBy columns on addressee history (ticket)';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE chill_ticket.addressee_history ADD endDate TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT null');
$this->addSql('ALTER TABLE chill_ticket.addressee_history ADD removedBy_id INT DEFAULT NULL');
$this->addSql('COMMENT ON COLUMN chill_ticket.addressee_history.endDate IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('ALTER TABLE chill_ticket.addressee_history ADD CONSTRAINT FK_434EBDBDB8346CCF FOREIGN KEY (removedBy_id) REFERENCES users (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('CREATE INDEX IDX_434EBDBDB8346CCF ON chill_ticket.addressee_history (removedBy_id)');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE chill_ticket.addressee_history DROP CONSTRAINT FK_434EBDBDB8346CCF');
$this->addSql('DROP INDEX chill_ticket.IDX_434EBDBDB8346CCF');
$this->addSql('ALTER TABLE chill_ticket.addressee_history DROP endDate');
$this->addSql('ALTER TABLE chill_ticket.addressee_history DROP removedBy_id');
}
}

View File

@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
/*
* 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.
*/
namespace Chill\TicketBundle\Tests\Action\Ticket\Handler;
use Chill\TicketBundle\Action\Ticket\AddCommentCommand;
use Chill\TicketBundle\Action\Ticket\Handler\AddCommentCommandHandler;
use Chill\TicketBundle\Entity\Comment;
use Chill\TicketBundle\Entity\Ticket;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
/**
* @internal
*
* @coversNothing
*/
class AddCommentCommandHandlerTest extends TestCase
{
use ProphecyTrait;
public function testAddComment(): void
{
$handler = $this->buildCommand();
$ticket = new Ticket();
$command = new AddCommentCommand(content: 'test');
$handler->handle($ticket, $command);
self::assertCount(1, $ticket->getComments());
self::assertEquals('test', $ticket->getComments()[0]->getContent());
}
private function buildCommand(): AddCommentCommandHandler
{
$entityManager = $this->prophesize(EntityManagerInterface::class);
$entityManager->persist(Argument::type(Comment::class))->shouldBeCalled();
return new AddCommentCommandHandler($entityManager->reveal());
}
}

View File

@@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
/*
* 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.
*/
namespace Chill\TicketBundle\Tests\Action\Ticket\Handler;
use Chill\MainBundle\Phonenumber\PhonenumberHelper;
use Chill\PersonBundle\Entity\Person;
use Chill\PersonBundle\Repository\PersonACLAwareRepositoryInterface;
use Chill\TicketBundle\Action\Ticket\AssociateByPhonenumberCommand;
use Chill\TicketBundle\Action\Ticket\Handler\AssociateByPhonenumberCommandHandler;
use Chill\TicketBundle\Entity\Ticket;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
use Psr\Log\NullLogger;
use Symfony\Component\Cache\Adapter\ArrayAdapter;
use Symfony\Component\Clock\MockClock;
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBag;
/**
* @internal
*
* @coversNothing
*/
class AssociateByPhonenumberCommandHandlerTest extends TestCase
{
use ProphecyTrait;
private function getHandler(
PersonACLAwareRepositoryInterface $personACLAwareRepository,
): AssociateByPhonenumberCommandHandler {
$entityManager = $this->prophesize(EntityManagerInterface::class);
$phonenumberHelper = new PhonenumberHelper(
new ArrayAdapter(),
new ParameterBag([
'chill_main.phone_helper' => [
'default_carrier_code' => 'BE',
],
]),
new NullLogger()
);
return new AssociateByPhonenumberCommandHandler(
$personACLAwareRepository,
$phonenumberHelper,
new MockClock(),
$entityManager->reveal()
);
}
public function testHandleWithPersonFoundByPhonenumber(): void
{
$person = new Person();
$personAclAwareRepository = $this->prophesize(PersonACLAwareRepositoryInterface::class);
$personAclAwareRepository->findByPhone(Argument::any())->willReturn([$person]);
$handler = $this->getHandler($personAclAwareRepository->reveal());
$ticket = new Ticket();
$handler($ticket, new AssociateByPhonenumberCommand('+3281136917'));
self::assertSame($person, $ticket->getPersons()[0]);
}
}

View File

@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
/*
* 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.
*/
namespace Chill\TicketBundle\Tests\Action\Ticket\Handler;
use Chill\TicketBundle\Action\Ticket\CreateTicketCommand;
use Chill\TicketBundle\Action\Ticket\Handler\CreateTicketCommandHandler;
use Chill\TicketBundle\Entity\Ticket;
use PHPUnit\Framework\TestCase;
/**
* @internal
*
* @coversNothing
*/
class CreateTicketCommandHandlerTest extends TestCase
{
private function getHandler(): CreateTicketCommandHandler
{
return new CreateTicketCommandHandler();
}
public function testHandleWithoutReference(): void
{
$command = new CreateTicketCommand();
$actual = ($this->getHandler())($command);
self::assertInstanceOf(Ticket::class, $actual);
self::assertEquals('', $actual->getExternalRef());
}
public function testHandleWithReference(): void
{
$command = new CreateTicketCommand($ref = 'external-ref');
$actual = ($this->getHandler())($command);
self::assertInstanceOf(Ticket::class, $actual);
self::assertEquals($ref, $actual->getExternalRef());
}
}

View File

@@ -0,0 +1,108 @@
<?php
declare(strict_types=1);
/*
* 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.
*/
namespace Chill\TicketBundle\Tests\Action\Ticket\Handler;
use Chill\TicketBundle\Action\Ticket\Handler\ReplaceMotiveCommandHandler;
use Chill\TicketBundle\Action\Ticket\ReplaceMotiveCommand;
use Chill\TicketBundle\Entity\Motive;
use Chill\TicketBundle\Entity\MotiveHistory;
use Chill\TicketBundle\Entity\Ticket;
use Doctrine\ORM\EntityManagerInterface;
use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\Clock\MockClock;
/**
* @internal
*
* @coversNothing
*/
final class ReplaceMotiveCommandHandlerTest extends KernelTestCase
{
use ProphecyTrait;
private function buildHandler(
EntityManagerInterface $entityManager,
): ReplaceMotiveCommandHandler {
$clock = new MockClock();
return new ReplaceMotiveCommandHandler($clock, $entityManager);
}
public function testHandleOnTicketWithoutMotive(): void
{
$motive = new Motive();
$ticket = new Ticket();
$entityManager = $this->prophesize(EntityManagerInterface::class);
$entityManager->persist(Argument::that(static function ($arg) use ($motive): bool {
if (!$arg instanceof MotiveHistory) {
return false;
}
return $arg->getMotive() === $motive;
}))->shouldBeCalled();
$handler = $this->buildHandler($entityManager->reveal());
$handler->handle($ticket, new ReplaceMotiveCommand($motive));
self::assertSame($motive, $ticket->getMotive());
}
public function testHandleReplaceMotiveOnTicketWithExistingMotive(): void
{
$motive = new Motive();
$ticket = new Ticket();
$history = new MotiveHistory(new Motive(), $ticket);
$entityManager = $this->prophesize(EntityManagerInterface::class);
$entityManager->persist(Argument::that(static function ($arg) use ($motive): bool {
if (!$arg instanceof MotiveHistory) {
return false;
}
return $arg->getMotive() === $motive;
}))->shouldBeCalled();
$handler = $this->buildHandler($entityManager->reveal());
$handler->handle($ticket, new ReplaceMotiveCommand($motive));
self::assertSame($motive, $ticket->getMotive());
self::assertCount(2, $ticket->getMotiveHistories());
}
public function testHandleReplaceMotiveOnTicketWithSameMotive(): void
{
$motive = new Motive();
$ticket = new Ticket();
$history = new MotiveHistory($motive, $ticket);
$entityManager = $this->prophesize(EntityManagerInterface::class);
$entityManager->persist(Argument::that(static function ($arg) use ($motive): bool {
if (!$arg instanceof MotiveHistory) {
return false;
}
return $arg->getMotive() === $motive;
}))->shouldNotBeCalled();
$handler = $this->buildHandler($entityManager->reveal());
$handler->handle($ticket, new ReplaceMotiveCommand($motive));
self::assertSame($motive, $ticket->getMotive());
self::assertCount(1, $ticket->getMotiveHistories());
}
}

View File

@@ -0,0 +1,122 @@
<?php
declare(strict_types=1);
/*
* 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.
*/
namespace Chill\TicketBundle\Tests\Action\Ticket\Handler;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Entity\UserGroup;
use Chill\TicketBundle\Action\Ticket\Handler\SetAddresseesCommandHandler;
use Chill\TicketBundle\Action\Ticket\SetAddresseesCommand;
use Chill\TicketBundle\Entity\AddresseeHistory;
use Chill\TicketBundle\Entity\Ticket;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
use Symfony\Component\Clock\MockClock;
use Symfony\Component\Security\Core\Security;
/**
* @internal
*
* @coversNothing
*/
final class SetAddressesCommandHandlerTest extends TestCase
{
use ProphecyTrait;
public function testHandleOnEmptyAddresses(): void
{
$ticket = new Ticket();
$command = new SetAddresseesCommand([$user1 = new User(), $group1 = new UserGroup()]);
$entityManager = $this->prophesize(EntityManagerInterface::class);
$entityManager->persist(Argument::that(function ($arg) use ($user1) {
return $arg instanceof AddresseeHistory && $arg->getAddressee() === $user1;
}))->shouldBeCalledOnce();
$entityManager->persist(Argument::that(function ($arg) use ($group1) {
return $arg instanceof AddresseeHistory && $arg->getAddressee() === $group1;
}))->shouldBeCalledOnce();
$handler = $this->buildHandler($entityManager->reveal());
$handler->handle($ticket, $command);
self::assertCount(2, $ticket->getCurrentAddressee());
}
public function testHandleExistingUserIsNotRemovedNorCreatingDouble(): void
{
$ticket = new Ticket();
$user = new User();
$history = new AddresseeHistory($user, new \DateTimeImmutable('1 month ago'), $ticket);
$command = new SetAddresseesCommand([$user]);
$entityManager = $this->prophesize(EntityManagerInterface::class);
$entityManager->persist(Argument::that(function ($arg) use ($user) {
return $arg instanceof AddresseeHistory && $arg->getAddressee() === $user;
}))->shouldNotBeCalled();
$handler = $this->buildHandler($entityManager->reveal());
$handler->handle($ticket, $command);
self::assertNull($history->getEndDate());
self::assertCount(1, $ticket->getCurrentAddressee());
}
public function testHandleRemoveExistingAddressee(): void
{
$ticket = new Ticket();
$user = new User();
$group = new UserGroup();
$history = new AddresseeHistory($user, new \DateTimeImmutable('1 month ago'), $ticket);
$command = new SetAddresseesCommand([$group]);
$entityManager = $this->prophesize(EntityManagerInterface::class);
$entityManager->persist(Argument::that(function ($arg) use ($group) {
return $arg instanceof AddresseeHistory && $arg->getAddressee() === $group;
}))->shouldBeCalled();
$handler = $this->buildHandler($entityManager->reveal());
$handler->handle($ticket, $command);
self::assertNotNull($history->getEndDate());
self::assertContains($group, $ticket->getCurrentAddressee());
}
public function testAddingDoublingAddresseeDoesNotCreateDoubleHistories(): void
{
$ticket = new Ticket();
$group = new UserGroup();
$command = new SetAddresseesCommand([$group, $group]);
$entityManager = $this->prophesize(EntityManagerInterface::class);
$entityManager->persist(Argument::that(function ($arg) use ($group) {
return $arg instanceof AddresseeHistory && $arg->getAddressee() === $group;
}))->shouldBeCalledOnce();
$handler = $this->buildHandler($entityManager->reveal());
$handler->handle($ticket, $command);
self::assertCount(1, $ticket->getCurrentAddressee());
}
private function buildHandler(EntityManagerInterface $entityManager): SetAddresseesCommandHandler
{
$security = $this->prophesize(Security::class);
$security->getUser()->willReturn(new User());
return new SetAddresseesCommandHandler(new MockClock(), $entityManager, $security->reveal());
}
}

View File

@@ -0,0 +1,104 @@
<?php
declare(strict_types=1);
/*
* 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.
*/
namespace Chill\TicketBundle\Tests\Controller;
use Chill\TicketBundle\Action\Ticket\Handler\AddCommentCommandHandler;
use Chill\TicketBundle\Controller\AddCommentController;
use Chill\TicketBundle\Entity\Comment;
use Chill\TicketBundle\Entity\Ticket;
use Doctrine\ORM\EntityManagerInterface;
use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Security\Core\Security;
use Symfony\Component\Serializer\SerializerInterface;
use Symfony\Component\Validator\Validator\ValidatorInterface;
/**
* @internal
*
* @coversNothing
*/
class AddCommentControllerTest extends KernelTestCase
{
use ProphecyTrait;
private SerializerInterface $serializer;
private ValidatorInterface $validator;
protected function setUp(): void
{
self::bootKernel();
$this->validator = self::getContainer()->get(ValidatorInterface::class);
$this->serializer = self::getContainer()->get(SerializerInterface::class);
}
public function testAddComment(): void
{
$controller = $this->buildController(willFlush: true);
$ticket = new Ticket();
$request = new Request(content: <<<'JSON'
{"content": "test"}
JSON);
$response = $controller->__invoke($ticket, $request);
self::assertEquals(201, $response->getStatusCode());
}
public function testAddCommentWithBlankContent(): void
{
$controller = $this->buildController(willFlush: false);
$ticket = new Ticket();
$request = new Request(content: <<<'JSON'
{"content": ""}
JSON);
$response = $controller->__invoke($ticket, $request);
self::assertEquals(422, $response->getStatusCode());
$request = new Request(content: <<<'JSON'
{"content": null}
JSON);
$response = $controller->__invoke($ticket, $request);
self::assertEquals(422, $response->getStatusCode());
}
private function buildController(bool $willFlush): AddCommentController
{
$security = $this->prophesize(Security::class);
$security->isGranted('ROLE_USER')->willReturn(true);
$entityManager = $this->prophesize(EntityManagerInterface::class);
if ($willFlush) {
$entityManager->persist(Argument::type(Comment::class))->shouldBeCalled();
$entityManager->flush()->shouldBeCalled();
}
$commandHandler = new AddCommentCommandHandler($entityManager->reveal());
return new AddCommentController(
$security->reveal(),
$this->serializer,
$this->validator,
$commandHandler,
$entityManager->reveal(),
);
}
}

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