Compare commits

..

15 Commits

Author SHA1 Message Date
caaed3e759 Add AddressForm component and integrate it into AddressPicker
- Created `AddressForm.vue` for managing address details inputs (e.g., floor, corridor, steps, etc.).
- Replaced placeholder form in `AddressDetailsForm.vue` with the new `AddressForm` component.
- Added bi-directional binding for address fields in `AddressPicker.vue`.
- Updated `package.json` to include `vue-tsc` dependency for improved TypeScript support.
2025-10-06 13:00:43 +02:00
6380fdd9a4 Normalize address search terms by adding UNACCENT and LOWER transformation to AddressReferenceRepository. 2025-10-05 21:04:40 +02:00
fcd5080e6f [wip] Enhance AddressPicker to auto-select position when a single address with one position is found. 2025-10-05 21:04:39 +02:00
086d418aa3 [wip] Refactor AddressDetailsMap to use vue-use-leaflet and enhance AddressPicker layout with dynamic styling adjustments. 2025-10-05 21:04:39 +02:00
4e61821e5b [WIP] refactorization to show details of an address 2025-10-05 21:04:39 +02:00
e3aeab315f [WIP] Create Address button that will open AddressPicker in a modal 2025-10-05 21:04:38 +02:00
8ec1063ef8 [WIP] Add postal code search integration to AddressPicker
Implemented `getPostalCodes` function in `local-search` driver and connected it with `AddressPicker.vue`. Introduced UI changes to display postal codes alongside addresses and ensured search requests are abortable.
2025-10-05 21:04:38 +02:00
aad9c984b1 [WIP] Add postal code search endpoint and controller integration
Introduced a new API endpoint `/api/1.0/main/address-reference/postal-code/search` for searching postal codes matching a query string. Implemented `PostalCodeForAddressReferenceApiController` to handle requests and integrated with `PostalCodeForAddressReferenceRepository`. Enhanced repository to include `country_name` in results by decoding JSON data. Updated API specifications accordingly.
2025-10-05 21:04:38 +02:00
34b3e290e1 [WIP] Add PostalCodeForAddressReferenceRepository and associated tests
Introduced `PostalCodeForAddressReferenceRepository` and its interface to support optimized postal code search using materialized views. Updated `AddressReferenceRepository` to improve query handling. Added test coverage for the new repository functionality.
2025-10-05 21:04:37 +02:00
0987b575ab [WIP] Refactor AddressReferenceRepository to use interface and add tests for AddressReferenceAggregatedApiController 2025-10-05 21:04:37 +02:00
d960578c5f [WIP] Integrate local aggregated address search in AddressPicker
Added a `local-search` driver to support aggregated address fetching. Integrated the `getAddressesAggregated` function with `AddressPicker.vue` for dynamic search suggestions and abortable fetch requests.
2025-10-05 21:04:37 +02:00
176048bce6 [WIP] Add aggregated address search API endpoint
Introduced a new API endpoint `/api/1.0/main/address-reference/aggregated/search` for aggregated address reference search with support for query filtering. Extended repository with `findAggregatedBySearchString` method and updated materialized view `view_chill_main_address_reference`. Added test coverage and API specification details.
2025-10-05 21:04:36 +02:00
be210a6dd6 [WIP] initialize search bar 2025-10-05 21:04:36 +02:00
4323773595 [WIP] initialize an app for address-picker + a demo page 2025-10-05 21:04:32 +02:00
6d432ca2cb Add materialized view and repository methods for address search
Introduced a materialized view `view_chill_main_address_reference` to optimize address search queries and added corresponding repository methods `findBySearchString` and `countBySearchString`. Also included test coverage for the repository to validate the new functionality.
2025-10-05 21:04:15 +02:00
62 changed files with 1765 additions and 411 deletions

View File

@@ -1,6 +0,0 @@
kind: Fixed
body: Fix the rendering of list of StoredObjectVersions, where there are kept version (before converting to pdf) and intermediate versions deleted
time: 2025-10-03T22:40:44.685474863+02:00
custom:
Issue: ""
SchemaChange: No schema change

View File

@@ -1,6 +0,0 @@
kind: Fixed
body: 'Notification: fix editing of sent notification by removing form.addressesEmails, a field that no longer exists'
time: 2025-10-06T12:13:15.45905994+02:00
custom:
Issue: "434"
SchemaChange: No schema change

View File

@@ -1,6 +0,0 @@
kind: UX
body: Change the terms 'cercle' and 'centre' to 'service', and 'territoire' respectively.
time: 2025-10-06T12:39:32.514056818+02:00
custom:
Issue: "425"
SchemaChange: No schema change

View File

@@ -16,5 +16,5 @@
- ajout d'un filtre et regroupement par usager participant sur les échanges
- ajout d'un regroupement: par type d'activité associé au parcours;
- trie les filtre et regroupements par ordre alphabétique dans els exports
- ajout d'un paramètre qui permet de désactiver le filtre par territoire dans les exports
- ajout d'un paramètre qui permet de désactiver le filtre par centre dans les exports
- correction de l'interface de date dans les filtres et regroupements "par statut du parcours à la date"

View File

@@ -29,7 +29,7 @@
- ajout d'un regroupement par métier des intervenants sur un parcours;
- ajout d'un regroupement par service des intervenants sur un parcours;
- ajout d'un regroupement par utilisateur intervenant sur un parcours
- ajout d'un regroupement "par territoire de l'usager";
- ajout d'un regroupement "par centre de l'usager";
- ajout d'un filtre "par métier intervenant sur un parcours";
- ajout d'un filtre "par service intervenant sur un parcours";
- création d'un rôle spécifique pour voir les parcours confidentiels (et séparer de celui de la liste qui permet de ré-assigner les parcours en lot);

View File

@@ -761,7 +761,7 @@ Fix color of Chill footer
- ajout d'un filtre et regroupement par usager participant sur les échanges
- ajout d'un regroupement: par type d'activité associé au parcours;
- trie les filtre et regroupements par ordre alphabétique dans els exports
- ajout d'un paramètre qui permet de désactiver le filtre par territoire dans les exports
- ajout d'un paramètre qui permet de désactiver le filtre par centre dans les exports
- correction de l'interface de date dans les filtres et regroupements "par statut du parcours à la date"
## v2.9.2 - 2023-10-17
@@ -941,7 +941,7 @@ error when trying to reedit a saved export
- ajout d'un regroupement par métier des intervenants sur un parcours;
- ajout d'un regroupement par service des intervenants sur un parcours;
- ajout d'un regroupement par utilisateur intervenant sur un parcours
- ajout d'un regroupement "par territoire de l'usager";
- ajout d'un regroupement "par centre de l'usager";
- ajout d'un filtre "par métier intervenant sur un parcours";
- ajout d'un filtre "par service intervenant sur un parcours";
- création d'un rôle spécifique pour voir les parcours confidentiels (et séparer de celui de la liste qui permet de ré-assigner les parcours en lot);

View File

@@ -17,3 +17,9 @@ when@dev:
defaults:
template: '@ChillMain/Dev/dev.assets.test2.html.twig'
sass_address_picker:
path: /_dev/address-picker
controller: Symfony\Bundle\FrameworkBundle\Controller\TemplateController
defaults:
template: '@ChillMain/Dev/dev.address-picker.html.twig'

View File

@@ -23,8 +23,8 @@ class "Document" {
- text description
- ArrayCollection_DocumentCategory categories
- varchar_150 content #link to openstack
- Territoire territoire
- Service service
- Center center
- Cercle cercle
- User user
- DateTime date # Creation date
}

View File

@@ -38,7 +38,7 @@ Certaines données sont historisées:
- les référents d'un parcours;
- les statuts d'un parcours;
- la liaison entre les territoires et les usagers;
- la liaison entre les centres et les usagers;
- etc.
Dans ces cas-là, Chill crée généralement deux colonnes, qui sont habituellement nommées :code:`startDate` et :code:`endDate`. Lorsque la colonne :code:`endDate` est à :code:`NULL`, cela signifie que la période n'est pas "fermée". La colonne :code:`startDate` n'est pas nullable.

View File

@@ -1,6 +1,6 @@
order,table_schema,table_name,commentaire
1,chill_3party,party_category,Catégorie de tiers
2,chill_3party,party_center,Association entre les tiers et les territoires (déprécié)
2,chill_3party,party_center,Association entre les tiers et les centres (déprécié)
3,chill_3party,party_profession,Profession du tiers (déprécié)
4,chill_3party,third_party,Tiers
5,chill_3party,thirdparty_category,association tiers - catégories
@@ -54,7 +54,7 @@ order,table_schema,table_name,commentaire
53,public,activitytpresence,Présence aux échanges
54,public,activitytype,Types d'échanges
55,public,activitytypecategory,Catégories de types d'échanges
56,public,centers,"Territoires (territoires, agences, etc.)"
56,public,centers,"Centres (territoires, agences, etc.)"
57,public,chill_activity_activity_chill_person_socialaction,
58,public,chill_activity_activity_chill_person_socialissue
59,public,chill_docgen_template,Gabarits de documents
@@ -111,7 +111,7 @@ order,table_schema,table_name,commentaire
110,public,chill_person_marital_status,Etats civils
111,public,chill_person_not_duplicate,
112,public,chill_person_person,Usagers
113,public,chill_person_person_center_history,Historique des territoires d'un usagers
113,public,chill_person_person_center_history,Historique des centres d'un usagers
114,public,chill_person_persons_to_addresses,Déprécié
115,public,chill_person_phone,Numéros d etéléphone supplémentaires d'un usager
116,public,chill_person_relations,Types de relations de filiation
@@ -142,7 +142,7 @@ order,table_schema,table_name,commentaire
141,public,permission_groups
142,public,permissionsgroup_rolescope
143,public,persons_spoken_languages
144,public,regroupment,Regroupement de territoires
144,public,regroupment,Regroupement de centres
145,public,regroupment_center,
146,public,role_scopes,
147,public,scopes,Services
Can't render this file because it has a wrong number of fields in line 28.

View File

@@ -45,6 +45,7 @@
"webpack-cli": "^5.0.1"
},
"dependencies": {
"@fragaria/address-formatter": "^6.6.1",
"@fullcalendar/core": "^6.1.4",
"@fullcalendar/daygrid": "^6.1.4",
"@fullcalendar/interaction": "^6.1.4",
@@ -66,10 +67,12 @@
"mime": "^4.0.0",
"pdfjs-dist": "^4.3.136",
"vis-network": "^9.1.0",
"vue": "^3.5.6",
"vue": "^3.5.x",
"vue-i18n": "^9.1.6",
"vue-multiselect": "3.0.0-alpha.2",
"vue-toast-notification": "^3.1.2",
"vue-tsc": "^3.1.0",
"vue-use-leaflet": "^0.1.7",
"vuex": "^4.0.0"
},
"browserslist": [

View File

@@ -10,7 +10,7 @@ Attendee: Présence de l'usager
attendee: présence de l'usager
list_reasons: liste des sujets
user_username: nom de l'utilisateur
circle_name: nom du service
circle_name: nom du cercle
Remark: Commentaire
No comments: Aucun commentaire
Add a new activity: Ajouter une nouvel échange
@@ -20,7 +20,7 @@ not present: absent
Delete: Supprimer
Update: Mettre à jour
Update activity: Modifier l'échange
Scope: Service
Scope: Cercle
Activity data: Données de l'échange
Activity location: Localisation de l'échange
No reason associated: Aucun sujet
@@ -398,7 +398,7 @@ export:
sent received: Envoyé ou reçu
emergency: Urgence
accompanying course id: Identifiant du parcours
course circles: Services du parcours
course circles: Cercles du parcours
travelTime: Durée de déplacement
durationTime: Durée
id: Identifiant

View File

@@ -177,7 +177,7 @@ export:
agent_id: Utilisateur
creator_id: Créateur
main_scope: Service principal de l'utilisateur
main_center: Territoire principal de l'utilisateur
main_center: Centre principal de l'utilisateur
aside_activity_type: Catégorie d'activité annexe
date: Date
duration: Durée

View File

@@ -59,7 +59,7 @@ final readonly class StoredObjectVersionApiController
return new JsonResponse(
$this->serializer->serialize(
new Collection(array_values($items->toArray()), $paginator),
new Collection($items, $paginator),
'json',
[AbstractNormalizer::GROUPS => ['read', StoredObjectVersionNormalizer::WITH_POINT_IN_TIMES_CONTEXT, StoredObjectVersionNormalizer::WITH_RESTORED_CONTEXT]]
),

View File

@@ -3,9 +3,9 @@ import {
StoredObject,
StoredObjectPointInTime,
StoredObjectVersionWithPointInTime,
} from "ChillDocStoreAssets/types";
} from "./../../../types";
import UserRenderBoxBadge from "ChillMainAssets/vuejs/_components/Entity/UserRenderBoxBadge.vue";
import { ISOToDatetime } from "ChillMainAssets/chill/js/date";
import { ISOToDatetime } from "./../../../../../../ChillMainBundle/Resources/public/chill/js/date";
import FileIcon from "ChillDocStoreAssets/vuejs/FileIcon.vue";
import RestoreVersionButton from "ChillDocStoreAssets/vuejs/StoredObjectButton/HistoryButton/RestoreVersionButton.vue";
import DownloadButton from "ChillDocStoreAssets/vuejs/StoredObjectButton/DownloadButton.vue";

View File

@@ -40,10 +40,6 @@ class StoredObjectVersionApiControllerTest extends \PHPUnit\Framework\TestCase
$storedObject->registerVersion();
}
// remove one version in the history
$v5 = $storedObject->getVersions()->get(5);
$storedObject->removeVersion($v5);
$security = $this->prophesize(Security::class);
$security->isGranted(StoredObjectRoleEnum::SEE->value, $storedObject)
->willReturn(true)
@@ -57,7 +53,6 @@ class StoredObjectVersionApiControllerTest extends \PHPUnit\Framework\TestCase
self::assertEquals($response->getStatusCode(), 200);
self::assertIsArray($body);
self::assertArrayHasKey('results', $body);
self::assertIsList($body['results']);
self::assertCount(10, $body['results']);
}

View File

@@ -246,7 +246,7 @@ final class EventController extends AbstractController
'class' => Center::class,
'choices' => $centers,
'placeholder' => $this->translator->trans('Pick a center'),
'label' => 'To which territory should the event be associated ?',
'label' => 'To which centre should the event be associated ?',
])
->add('submit', SubmitType::class, [
'label' => 'Next step',

View File

@@ -64,7 +64,7 @@ CHILL_EVENT_PARTICIPATION_SEE_DETAILS: Voir le détail d'une participation
# TODO check place to put this
Next step: Étape suivante
To which territory should the event be associated ?: À quel territoire doit être associé l'événement ?
To which centre should the event be associated ?: À quel centre doit être associé l'événement ?
# timeline
past: passé
@@ -151,7 +151,7 @@ event:
filter:
event_types: Par types d'événement
event_dates: Par date d'événement
center: Par territoire
center: Par centre
by_responsable: Par responsable
pick_responsable: Filtrer par responsables
budget:
@@ -188,7 +188,7 @@ event_id: Identifiant
event_name: Nom
event_date: Date
event_type: Type d'évenement
event_center: Territoire
event_center: Centre
event_moderator: Responsable
event_participants_count: Nombre de participants
event_location: Localisation

View File

@@ -0,0 +1,50 @@
<?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\Repository\AddressReferenceRepositoryInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Core\Security;
final readonly class AddressReferenceAggregatedApiController
{
public function __construct(
private Security $security,
private AddressReferenceRepositoryInterface $addressReferenceRepository,
) {}
#[Route(path: '/api/1.0/main/address-reference/aggregated/search')]
public function search(Request $request): JsonResponse
{
if (!$this->security->isGranted('IS_AUTHENTICATED')) {
throw new AccessDeniedHttpException();
}
if (!$request->query->has('q')) {
throw new BadRequestHttpException('Parameter "q" is required.');
}
$q = trim($request->query->get('q'));
if ('' === $q) {
throw new BadRequestHttpException('Parameter "q" is required and cannot be empty.');
}
$result = $this->addressReferenceRepository->findAggregatedBySearchString($q);
return new JsonResponse(iterator_to_array($result));
}
}

View File

@@ -0,0 +1,47 @@
<?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\Repository\PostalCodeForAddressReferenceRepositoryInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Core\Security;
final readonly class PostalCodeForAddressReferenceApiController
{
public function __construct(
private PostalCodeForAddressReferenceRepositoryInterface $postalCodeForAddressReferenceRepository,
private Security $security,
) {}
#[Route('/api/1.0/main/address-reference/postal-code/search')]
public function findPostalCodeBySearch(Request $request): JsonResponse
{
if (!$this->security->isGranted('IS_AUTHENTICATED')) {
throw new AccessDeniedHttpException();
}
$search = $request->query->get('q');
if (null === $search || '' === trim($search)) {
throw new BadRequestHttpException('No search query provided');
}
$postalCodes = iterator_to_array($this->postalCodeForAddressReferenceRepository->findPostalCode($search));
return new JsonResponse($postalCodes, json: false);
}
}

View File

@@ -14,13 +14,14 @@ namespace Chill\MainBundle\Repository;
use Chill\MainBundle\Entity\AddressReference;
use Chill\MainBundle\Entity\PostalCode;
use Chill\MainBundle\Search\SearchApiQuery;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\NativeQuery;
use Doctrine\ORM\Query\ResultSetMapping;
use Doctrine\ORM\Query\ResultSetMappingBuilder;
use Doctrine\Persistence\ObjectRepository;
final readonly class AddressReferenceRepository implements ObjectRepository
final readonly class AddressReferenceRepository implements AddressReferenceRepositoryInterface
{
private EntityManagerInterface $entityManager;
@@ -65,6 +66,121 @@ final readonly class AddressReferenceRepository implements ObjectRepository
return $this->repository->findAll();
}
public function findAggregatedBySearchString(string $search, PostalCode|int|null $postalCode = null, int $firstResult = 0, int $maxResults = 50): iterable
{
$terms = $this->buildTermsFromSearchString($search);
if ([] === $terms) {
return [];
}
$connection = $this->entityManager->getConnection();
$qb = $connection->createQueryBuilder();
$qb->select('row_number() OVER () AS row_number', 'var.street AS street', 'cmpc.id AS postcode_id', 'cmpc.code AS code', 'cmpc.label AS label', 'jsonb_object_agg(var.address_id, var.streetnumber ORDER BY var.row_number) AS positions')
->from('view_chill_main_address_reference', 'var')
->innerJoin('var', 'chill_main_postal_code', 'cmpc', 'cmpc.id = var.postcode_id')
->groupBy('cmpc.id', 'var.street')
->setFirstResult($firstResult)
->setMaxResults($maxResults);
$paramId = 0;
foreach ($terms as $term) {
$qb->andWhere('var.address like UNACCENT(LOWER(?))');
$qb->setParameter(++$paramId, "%{$term}%");
}
if (null !== $postalCode) {
$qb->andWhere('var.postcode_id = ?');
$qb->setParameter(++$paramId, $postalCode instanceof PostalCode ? $postalCode->getId() : $postalCode);
}
$result = $qb->executeQuery();
foreach ($result->iterateAssociative() as $row) {
yield [...$row, 'positions' => json_decode($row['positions'], true, 512, JSON_THROW_ON_ERROR)];
}
}
/**
* @return iterable<AddressReference>
*/
public function findBySearchString(string $search, PostalCode|int|null $postalCode = null, int $firstResult = 0, int $maxResults = 50): iterable
{
$terms = $this->buildTermsFromSearchString($search);
if ([] === $terms) {
return [];
}
$rsm = new ResultSetMappingBuilder($this->entityManager);
$rsm->addRootEntityFromClassMetadata(AddressReference::class, 'ar');
$baseSql = 'SELECT '.$rsm->generateSelectClause(['ar' => 'ar']).' FROM chill_main_address_reference ar JOIN
view_chill_main_address_reference var ON var.address_id = ar.id';
$nql = $this->buildQueryBySearchString($rsm, $baseSql, $terms, $postalCode);
$orderBy = [];
$pertinence = [];
foreach ($terms as $k => $term) {
$pertinence[] =
"(EXISTS (SELECT 1 FROM unnest(string_to_array(address, ' ')) AS t WHERE starts_with(t, UNACCENT(LOWER(:order{$k})))))::int";
$pertinence[] = "(address LIKE UNACCENT(LOWER(:order{$k})))::int";
$nql->setParameter('order'.$k, $term);
}
$orderBy[] = implode(' + ', $pertinence).' ASC';
$orderBy[] = implode('row_number ASC', $orderBy);
$nql->setSQL($nql->getSQL().' ORDER BY '.implode(', ', $orderBy));
return $nql->toIterable();
}
public function countBySearchString(string $search, PostalCode|int|null $postalCode = null): int
{
$terms = $this->buildTermsFromSearchString($search);
if ([] === $terms) {
return 0;
}
$rsm = new ResultSetMappingBuilder($this->entityManager);
$rsm->addScalarResult('c', 'c', Types::INTEGER);
$nql = $this->buildQueryBySearchString($rsm, 'SELECT COUNT(var.*) AS c FROM view_chill_main_address_reference var', $terms, $postalCode);
return $nql->getSingleScalarResult();
}
private function buildTermsFromSearchString(string $search): array
{
return array_filter(
array_map(
static fn (string $term) => trim($term),
explode(' ', $search)
),
static fn (string $term) => '' !== $term
);
}
private function buildQueryBySearchString(ResultSetMapping $rsm, string $select, array $terms, PostalCode|int|null $postalCode = null): NativeQuery
{
$nql = $this->entityManager->createNativeQuery('', $rsm);
$sql = $select.' WHERE ';
$wheres = [];
foreach ($terms as $k => $term) {
$wheres[] = "var.address like :w{$k}";
$nql->setParameter("w{$k}", '%'.$term.'%', Types::STRING);
}
if (null !== $postalCode) {
$wheres[] = 'var.postcode_id = :postalCode';
$nql->setParameter('postalCode', $postalCode instanceof PostalCode ? $postalCode->getId() : $postalCode);
}
$nql->setSQL($sql.implode(' AND ', $wheres));
return $nql;
}
/**
* @param mixed|null $limit
* @param mixed|null $offset

View File

@@ -0,0 +1,20 @@
<?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\Repository;
use Chill\MainBundle\Entity\PostalCode;
use Doctrine\Persistence\ObjectRepository;
interface AddressReferenceRepositoryInterface extends ObjectRepository
{
public function findAggregatedBySearchString(string $search, PostalCode|int|null $postalCode = null, int $firstResult = 0, int $maxResults = 50): iterable;
}

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\Repository;
use Doctrine\DBAL\Connection;
final readonly class PostalCodeForAddressReferenceRepository implements PostalCodeForAddressReferenceRepositoryInterface
{
public function __construct(private Connection $connection) {}
public function findPostalCode(string $search, int $firstResult = 0, int $maxResults = 50): iterable
{
$terms = $this->buildTermsFromSearchString($search);
if ([] === $terms) {
return [];
}
$qb = $this->connection->createQueryBuilder();
$qb->from('chill_main_postal_code', 'cmpc')
->join('cmpc', 'view_chill_main_address_reference', 'vcmar', 'vcmar.postcode_id = cmpc.id')
->join('vcmar', 'country', 'country', condition: 'cmpc.country_id = country.id')
->setFirstResult($firstResult)
->setMaxResults($maxResults)
;
$qb->select(
'DISTINCT ON (cmpc.code, cmpc.label) cmpc.id AS postcode_id',
'cmpc.code AS code',
'cmpc.label AS label',
'country.id AS country_id',
'country.countrycode AS country_code',
'country.name AS country_name'
);
$paramId = 0;
foreach ($terms as $term) {
$qb->andWhere('vcmar.address like ?');
$qb->setParameter(++$paramId, "%{$term}%");
}
$result = $qb->executeQuery();
foreach ($result->iterateAssociative() as $row) {
yield [...$row, 'country_name' => json_decode($row['country_name'], true, 512, JSON_THROW_ON_ERROR)];
}
}
private function buildTermsFromSearchString(string $search): array
{
return array_filter(
array_map(
static fn (string $term) => trim($term),
explode(' ', $search)
),
static fn (string $term) => '' !== $term
);
}
}

View File

@@ -0,0 +1,20 @@
<?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\Repository;
/**
* Search for postal code using optimized materialized view.
*/
interface PostalCodeForAddressReferenceRepositoryInterface
{
public function findPostalCode(string $search, int $firstResult = 0, int $maxResults = 50): iterable;
}

View File

@@ -75,6 +75,7 @@ export interface Postcode {
name: string;
code: string;
center: Point;
country: Country;
}
export interface Point {
@@ -90,6 +91,28 @@ export interface Country {
export type AddressRefStatus = "match" | "to_review" | "reviewed";
/**
* An interface to create an address
*/
export interface AddressCreation {
confidential: boolean;
isNoAddress: boolean;
street: string;
streetNumber: string;
postcode: Postcode;
point: Point; // [number, number]; // [longitude, latitude]
addressReference: AddressReference;
validFrom: DateTime|null;
floor: string;
corridor: string;
steps: string;
flat: string;
buildingName: string;
distribution: string;
extra: string;
}
export interface Address {
type: "address";
address_id: number;
@@ -108,7 +131,7 @@ export interface Address {
confidential: boolean;
lines: string[];
addressReference: AddressReference | null;
validFrom: DateTime;
validFrom: DateTime | null; // TODO there is no null for validFrom
validTo: DateTime | null;
point: Point | null;
refStatus: AddressRefStatus;

View File

@@ -0,0 +1,33 @@
<script setup lang="ts">
import Modal from "ChillMainAssets/vuejs/_components/Modal.vue";
import AddressPicker from "ChillMainAssets/vuejs/AddressPicker/AddressPicker.vue";
import {Ref, ref} from "vue";
const showModal: Ref<boolean> = ref(false);
const modalDialogClasses = {"modal-dialog": true, "modal-dialog-scrollable": true, "modal-xl": true};
const clickButton = () => {
showModal.value = true;
}
const closeModal = () => {
showModal.value = false;
}
</script>
<template>
<modal v-if="showModal" :hide-footer="false" :modal-dialog-class="modalDialogClasses" @close="closeModal">
<template v-slot:header>TODO</template>
<template v-slot:body>
<AddressPicker></AddressPicker>
</template>
</modal>
<button class="btn btn-submit" type="button" @click="clickButton">SEARCH ADDRESS</button>
</template>
<style scoped lang="scss">
</style>

View File

@@ -0,0 +1,170 @@
<script setup lang="ts">
import {Address, AddressReference} from "ChillMainAssets/types";
import SearchBar from "ChillMainAssets/vuejs/AddressPicker/Component/SearchBar.vue";
import {
AddressAggregated,
AssociatedPostalCode, fetchAddressReference,
getAddressesAggregated,
getPostalCodes,
} from "ChillMainAssets/vuejs/AddressPicker/driver/local-search";
import {computed, Ref, ref} from "vue";
import AddressAggregatedList from "ChillMainAssets/vuejs/AddressPicker/Component/AddressAggregatedList.vue";
import AddressDetailsForm from "ChillMainAssets/vuejs/AddressPicker/Component/AddressDetailsForm.vue";
import AddressForm from "ChillMainAssets/vuejs/AddressPicker/Component/AddressForm.vue";
import {trans, SAVE} from "translator";
interface AddressPickerProps {
suggestions?: Address[];
}
const props = withDefaults(defineProps<AddressPickerProps>(), {
suggestions: () => [],
});
const addresses: Ref<AddressAggregated[]> = ref([]);
const postalCodes: Ref<AssociatedPostalCode[]> = ref([]);
const searchTokens: Ref<string[]> = ref([]);
const addressReference: Ref<AddressReference|null> = ref(null);
let abortControllerSearchAddress: null | AbortController = null;
let abortControllerSearchPostalCode: null | AbortController = null;
const searchResultsClasses = computed(() => ({
"mid-size": addressReference !== null,
}));
const floor = ref<string>("");
const corridor = ref<string>("");
const steps = ref<string>("");
const flat = ref<string>("");
const buildingName = ref<string>("");
const extra = ref<string>("");
const distribution = ref<string>("");
const onSearch = async function (search: string): Promise<void> {
performSearchForAddress(search);
performSearchForPostalCode(search);
searchTokens.value = [search];
};
const onPickPosition = async (id: string) => {
console.log('Pick Position', id);
addressReference.value = await fetchAddressReference(id);
}
const performSearchForAddress = async (search: string): Promise<void> => {
if (null !== abortControllerSearchAddress) {
abortControllerSearchAddress.abort();
}
if ("" === search) {
addresses.value = [];
abortControllerSearchAddress = null;
return;
}
abortControllerSearchAddress = new AbortController();
console.log("onSearch", search);
try {
addresses.value = await getAddressesAggregated(
search,
abortControllerSearchAddress,
);
abortControllerSearchAddress = null;
// check if there is only one result
if (addresses.value.length === 1 && Object.keys(addresses.value[0].positions).length === 1) {
onPickPosition(Object.keys(addresses.value[0].positions)[0]);
}
} catch (e: unknown) {
if (e instanceof DOMException && e.name === "AbortError") {
console.log("search aborted for:", search);
return;
}
throw e;
}
};
const performSearchForPostalCode = async (search: string): Promise<void> => {
if (null !== abortControllerSearchPostalCode) {
abortControllerSearchPostalCode.abort();
}
if ("" === search) {
addresses.value = [];
abortControllerSearchPostalCode = null;
return;
}
abortControllerSearchPostalCode = new AbortController();
console.log("onSearch", search);
try {
postalCodes.value = await getPostalCodes(
search,
abortControllerSearchPostalCode,
);
abortControllerSearchPostalCode = null;
} catch (e: unknown) {
if (e instanceof DOMException && e.name === "AbortError") {
console.log("search postal code aborted for:", search);
return;
}
throw e;
}
};
const save = async(): Promise<void> => {
console.log("save");
console.log("content", floor, corridor, steps, flat, buildingName, extra, distribution);
}
</script>
<template>
<search-bar @search="onSearch"></search-bar>
<div class="address-pick-content">
<div class="search-results" :class="searchResultsClasses">
<address-aggregated-list :addresses="addresses" :search-tokens="searchTokens" @pick-position="(id) => onPickPosition(id)"></address-aggregated-list>
</div>
<div v-if="addressReference !== null" class="address-details-form">
<address-details-form :address="addressReference"
v-model:floor="floor"
v-model:corridor="corridor"
v-model:steps="steps"
v-model:flat="flat"
v-model:building-name="buildingName"
v-model:extra="extra"
v-model:distribution="distribution"
/>
</div>
<div>
<ul class="record_actions">
<li><button class="btn btn-save">{{ trans(SAVE) }}</button></li>
</ul>
</div>
</div>
</template>
<style scoped lang="scss">
.address-pick-content {
display: flex;
flex-direction: row;
gap: 1rem;
.search-results {
&.mid-size {
width: 50%;
}
}
.address-details-form {
width: 50%;
}
}
</style>

View File

@@ -0,0 +1,25 @@
<script setup lang="ts">
import {AddressAggregated} from "ChillMainAssets/vuejs/AddressPicker/driver/local-search";
import AddressAggregatedListItem from "ChillMainAssets/vuejs/AddressPicker/Component/AddressAggregatedListItem.vue";
interface AddressAggregatedListProps {
addresses: AddressAggregated[];
searchTokens: string[];
}
const props = defineProps<AddressAggregatedListProps>();
const emit = defineEmits<{
pickPosition: [id: string]
}>();
</script>
<template>
<template v-for="a in props.addresses" :key="a.row_number">
<address-aggregated-list-item :address="a" :search-tokens="props.searchTokens" @pick-position="(id) => emit('pickPosition', id)"></address-aggregated-list-item>
</template>
</template>
<style scoped lang="scss">
</style>

View File

@@ -0,0 +1,82 @@
<script setup lang="ts">
import {AddressAggregated} from "ChillMainAssets/vuejs/AddressPicker/driver/local-search";
import {computed, ref} from "vue";
interface AddressAggregatedListItemProps {
address: AddressAggregated;
searchTokens: string[];
}
const props = defineProps<AddressAggregatedListItemProps>();
const emit = defineEmits<{
pickPosition: [id: string]
}>();
const showAllPositions = ref<boolean>(false);
const positionsToShow = computed((): Record<string, string> => {
const obj: Record<string, any> = {};
let count = 0;
for (const [id, position] of Object.entries(props.address.positions)) {
obj[id] = position;
count++;
if (count >= 10 && !showAllPositions.value) {
break;
}
}
return obj;
})
const needToShowMorePosition = computed(() => {
return Object.keys(props.address.positions).length > 10;
})
const onClickButton = (id: string) => {
console.log('onClickButton', id);
emit('pickPosition', id);
}
const displayAllPositions = () => {
showAllPositions.value = true;
}
</script>
<template>
<div>
<div class="street">
<span>{{ props.address.street }}</span>
</div>
<div class="postcode">
<span>{{ props.address.code }}</span> <span>{{ address.label }}</span>
</div>
<div class="positions">
<ul>
<li v-for="(position, id) in positionsToShow" :key="id" >
<button type="button" @click="onClickButton(id)" >
{{ position }}
</button>
</li>
<li v-if="needToShowMorePosition">
<button @click="displayAllPositions">show all</button>
</li>
</ul>
</div>
</div>
</template>
<style scoped lang="scss">
.street {
font-variant: small-caps;
font-weight: bold;
}
.postcode {
font-variant: small-caps;
}
.positions ul {
list-style-type: none;
li {
display: inline-block;
margin-right: 2px;
}
}
</style>

View File

@@ -0,0 +1,44 @@
<script setup lang="ts">
import {AddressReference} from "ChillMainAssets/types";
import {computed, ref} from "vue";
import {addressReferenceToAddress} from "ChillMainAssets/vuejs/AddressPicker/helper";
import AddressDetailsContent from "ChillMainAssets/vuejs/_components/AddressDetails/AddressDetailsContent.vue";
import AddressForm from "ChillMainAssets/vuejs/AddressPicker/Component/AddressForm.vue";
export interface AddressDetailsFormProps {
address: AddressReference;
}
const props = defineProps<AddressDetailsFormProps>();
const floor = ref<string>("");
const corridor = ref<string>("");
const steps = ref<string>("");
const flat = ref<string>("");
const buildingName = ref<string>("");
const extra = ref<string>("");
const distribution = ref<string>("");
const address = computed(() => addressReferenceToAddress(props.address));
</script>
<template>
<div>
<address-form
@update:floor="val => (floor = val)"
@update:corridor="val => (corridor = val)"
@update:steps="val => (steps = val)"
@update:flat="val => (flat = val)"
@update:building-name="val => (buildingName = val)"
@update:extra="val => (extra = val)"
@update:distribution="val => (distribution = val)"
></address-form>
</div>
<div>
<address-details-content :address="address"></address-details-content>
</div>
</template>
<style scoped lang="scss">
</style>

View File

@@ -0,0 +1,112 @@
<script setup lang="ts">
import {
ADDRESS_STREET,
ADDRESS_STREET_NUMBER,
ADDRESS_FLOOR,
ADDRESS_CORRIDOR,
ADDRESS_STEPS,
ADDRESS_FLAT,
ADDRESS_BUILDING_NAME,
ADDRESS_DISTRIBUTION,
ADDRESS_EXTRA,
ADDRESS_FILL_AN_ADDRESS,
trans,
} from "translator";
import {ref} from "vue";
const isNoAddress = ref(false);
const floor = defineModel("floor");
const corridor = defineModel("corridor");
const steps = defineModel("steps");
const flat = defineModel("flat");
const buildingName = defineModel("buildingName");
const extra = defineModel("extra");
const distribution = defineModel("distribution");
</script>
<template>
<div class="form-floating my-1">
<input
class="form-control"
type="text"
name="floor"
:placeholder="trans(ADDRESS_FLOOR)"
v-model="floor"
/>
<label for="floor">{{ trans(ADDRESS_FLOOR) }}</label>
</div>
<div class="form-floating my-1">
<input
class="form-control"
type="text"
name="corridor"
:placeholder="trans(ADDRESS_CORRIDOR)"
v-model="corridor"
/>
<label for="corridor">{{ trans(ADDRESS_CORRIDOR) }}</label>
</div>
<div class="form-floating my-1">
<input
class="form-control"
type="text"
name="steps"
:placeholder="trans(ADDRESS_STEPS)"
v-model="steps"
/>
<label for="steps">{{ trans(ADDRESS_STEPS) }}</label>
</div>
<div class="form-floating my-1">
<input
class="form-control"
type="text"
name="flat"
:placeholder="trans(ADDRESS_FLAT)"
v-model="flat"
/>
<label for="flat">{{ trans(ADDRESS_FLAT) }}</label>
</div>
<div :class="isNoAddress ? 'col-lg-12' : 'col-lg-6'">
<div class="form-floating my-1" v-if="!isNoAddress">
<input
class="form-control"
type="text"
name="buildingName"
maxlength="255"
:placeholder="trans(ADDRESS_BUILDING_NAME)"
v-model="buildingName"
/>
<label for="buildingName">{{
trans(ADDRESS_BUILDING_NAME)
}}</label>
</div>
<div class="form-floating my-1">
<input
class="form-control"
type="text"
name="extra"
maxlength="255"
:placeholder="trans(ADDRESS_EXTRA)"
v-model="extra"
/>
<label for="extra">{{ trans(ADDRESS_EXTRA) }}</label>
</div>
<div class="form-floating my-1" v-if="!isNoAddress">
<input
class="form-control"
type="text"
name="distribution"
maxlength="255"
:placeholder="trans(ADDRESS_DISTRIBUTION)"
v-model="distribution"
/>
<label for="distribution">{{
trans(ADDRESS_DISTRIBUTION)
}}</label>
</div>
</div>
</template>
<style scoped lang="scss">
</style>

View File

@@ -0,0 +1,39 @@
<script setup lang="ts">
import { ADDRESS_PICKER_SEARCH_FOR_ADDRESSES, trans } from 'translator';
const emits = defineEmits<{
search: [search: string];
}>();
let searchTimer = 0;
let searchString: string;
const onInput = function (event: InputEvent) {
const target = event.target as HTMLInputElement;
const value = target.value;
searchString = value;
if (0 === searchTimer) {
window.clearTimeout(searchTimer);
searchTimer = 0;
}
searchTimer = window.setTimeout(() => {
if (value === searchString) {
emits("search", value);
}
}, 500);
};
</script>
<template>
<div class="input-group mb-3">
<span class="input-group-text">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-search" viewBox="0 0 16 16">
<path d="M11.742 10.344a6.5 6.5 0 1 0-1.397 1.398h-.001q.044.06.098.115l3.85 3.85a1 1 0 0 0 1.415-1.414l-3.85-3.85a1 1 0 0 0-.115-.1zM12 6.5a5.5 5.5 0 1 1-11 0 5.5 5.5 0 0 1 11 0"/>
</svg>
</span>
<input type="search" class="form-control" @input="onInput" :placeholder="trans(ADDRESS_PICKER_SEARCH_FOR_ADDRESSES)" />
</div>
</template>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,69 @@
import {AddressReference, TranslatableString} from "ChillMainAssets/types";
export interface AddressAggregated {
row_number: number;
street: string;
postcode_id: number;
code: string;
label: string;
positions: Record<string, string>;
}
export interface AssociatedPostalCode {
postcode_id: number;
code: string;
label: string;
country_id: number;
country_code: string;
country_name: TranslatableString;
}
/**
* @throws {DOMException} when fetch is aborted, the property name is always equals to 'AbortError'
*/
export const getAddressesAggregated = async (
search: string,
abortController: AbortController,
): Promise<AddressAggregated[]> => {
const params = new URLSearchParams({ q: search.trim() });
let response = null;
response = await fetch(
`/api/1.0/main/address-reference/aggregated/search?${params}`,
{ signal: abortController.signal },
);
if (response.ok) {
return await response.json();
}
throw new Error(response.statusText);
};
export const getPostalCodes = async (
search: string,
abortController: AbortController,
): Promise<AssociatedPostalCode[]> => {
const params = new URLSearchParams({ q: search.trim() });
let response = null;
response = await fetch(
`/api/1.0/main/address-reference/postal-code/search?${params}`,
{ signal: abortController.signal },
);
if (response.ok) {
return await response.json();
}
throw new Error(response.statusText);
};
export const fetchAddressReference = async (id: string): Promise<AddressReference> => {
const response = await fetch(`/api/1.0/main/address-reference/${id}.json`);
if (response.ok) {
return await response.json();
}
throw new Error(response.statusText);
}

View File

@@ -0,0 +1,21 @@
import {Address, AddressCreation, AddressReference} from "ChillMainAssets/types";
export const addressReferenceToAddress = (reference: AddressReference): AddressCreation => {
return {
street: reference.street,
streetNumber: reference.streetNumber,
postcode: reference.postcode,
floor: "",
corridor: "",
steps: "",
flat: "",
buildingName: "",
distribution: "",
extra: "",
confidential: false,
addressReference: reference,
point: reference.point,
isNoAddress: false,
validFrom: null,
}
}

View File

@@ -0,0 +1,12 @@
import { createApp } from "vue";
import AddressButton from "ChillMainAssets/vuejs/AddressPicker/AddressButton.vue";
document.addEventListener("DOMContentLoaded", async () => {
document
.querySelectorAll<HTMLDivElement>("div[data-address-picker]")
.forEach((elem): void => {
const app = createApp(AddressButton);
app.mount(elem);
});
});

View File

@@ -4,24 +4,27 @@
:show-button-details="false"
></address-render-box>
<address-details-ref-matching
v-if="isAddress(props.address)"
:address="props.address"
@update-address="onUpdateAddress"
></address-details-ref-matching>
<address-details-map :address="props.address"></address-details-map>
<address-details-geographical-layers
v-if="isAddress(props.address)"
:address="props.address"
></address-details-geographical-layers>
</template>
<script lang="ts" setup>
import { Address } from "../../../types";
import {Address, AddressCreation} from "../../../types";
import AddressDetailsMap from "./Parts/AddressDetailsMap.vue";
import AddressRenderBox from "../Entity/AddressRenderBox.vue";
import AddressDetailsGeographicalLayers from "./Parts/AddressDetailsGeographicalLayers.vue";
import AddressDetailsRefMatching from "./Parts/AddressDetailsRefMatching.vue";
import {isAddress} from "ChillMainAssets/vuejs/_components/AddressDetails/helper";
interface AddressModalContentProps {
address: Address;
address: Address|AddressCreation;
}
const props = defineProps<AddressModalContentProps>();

View File

@@ -12,90 +12,91 @@
Voir sur
<a :href="makeUrlGoogleMap(props.address)" target="_blank"
>Google Maps</a
>
<a :href="makeUrlOsm(props.address)" target="_blank">OSM</a>
> <a
:href="makeUrlOsm(props.address)" target="_blank"
>OSM</a>
</p>
</template>
<script lang="ts" setup>
import { onMounted, ref } from "vue";
import {computed, onMounted, ref} from "vue";
import "leaflet/dist/leaflet.css";
import markerIconPng from "leaflet/dist/images/marker-icon.png";
import L, { LatLngExpression, LatLngTuple } from "leaflet";
import { Address, Point } from "../../../../types";
import {Address, AddressCreation, Point} from "../../../../types";
import {buildAddressLines, getAddressPoint} from "ChillMainAssets/vuejs/_components/AddressDetails/helper";
import {useLeafletDisplayLayer, useLeafletMap, useLeafletMarker, useLeafletTileLayer} from "vue-use-leaflet";
const lonLatForLeaflet = (point: Point): LatLngTuple => {
return [point.coordinates[1], point.coordinates[0]];
};
export interface MapProps {
address: Address;
address: Address|AddressCreation;
}
const props = defineProps<MapProps>();
const map_div = ref<HTMLDivElement | null>(null);
let map: L.Map | null = null;
let marker: L.Marker | null = null;
onMounted(() => {
if (map_div.value === null) {
// there is no map div when the address does not have any Point
return;
}
if (props.address.point !== null) {
map = L.map(map_div.value);
map.setView(lonLatForLeaflet(props.address.point), 18);
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
attribution:
'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
}).addTo(map);
const markerIcon = L.icon({
const markerIcon = L.icon({
iconUrl: markerIconPng,
iconAnchor: [12, 41],
});
marker = L.marker(lonLatForLeaflet(props.address.point), {
icon: markerIcon,
});
marker.addTo(map);
});
const latLngMarker = computed((): LatLngExpression => {
if (props.address === null || props.address.point === null) {
return [0, 0, 0];
}
return [props.address.point.coordinates[1], props.address.point.coordinates[0], 0]
});
const makeUrlGoogleMap = (address: Address): string => {
const map_div = ref<HTMLDivElement | null>(null);
const map = useLeafletMap(map_div, {zoom: 18, center: latLngMarker});
const tileLayer = useLeafletTileLayer(
"https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
attribution:
'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
}
);
useLeafletDisplayLayer(map, tileLayer);
const marker = useLeafletMarker(latLngMarker, {icon: markerIcon});
useLeafletDisplayLayer(map, marker);
const makeUrlGoogleMap = (address: Address|AddressCreation): string => {
const params = new URLSearchParams();
params.append("api", "1");
if (address.point !== null && address.addressReference !== null) {
const point = getAddressPoint(address);
if (point !== null && address.addressReference !== null) {
params.append(
"query",
`${address.point.coordinates[1]} ${address.point.coordinates[0]}`,
`${point.coordinates[1]} ${point.coordinates[0]}`,
);
} else {
params.append("query", address.lines.join(", "));
params.append("query", buildAddressLines(address).join(", "));
}
return `https://www.google.com/maps/search/?${params.toString()}`;
};
const makeUrlOsm = (address: Address): string => {
if (address.point !== null && address.addressReference !== null) {
const makeUrlOsm = (address: Address|AddressCreation): string => {
const point = getAddressPoint(address);
if (point !== null && address.addressReference !== null) {
const params = new URLSearchParams();
params.append("mlat", `${address.point.coordinates[1]}`);
params.append("mlon", `${address.point.coordinates[0]}`);
params.append("mlat", `${point.coordinates[1]}`);
params.append("mlon", `${point.coordinates[0]}`);
const hashParams = new URLSearchParams();
hashParams.append(
"map",
`18/${address.point.coordinates[1]}/${address.point.coordinates[0]}`,
`18/${point.coordinates[1]}/${point.coordinates[0]}`,
);
return `https://www.openstreetmap.org/?${params.toString()}#${hashParams.toString()}`;
}
const params = new URLSearchParams();
params.append("query", address.lines.join(", "));
params.append("query", buildAddressLines(address).join(", "));
return `https://www.openstreetmap.org/search?${params.toString()}`;
};

View File

@@ -0,0 +1,46 @@
import {Address, AddressCreation, Point} from "ChillMainAssets/types";
import addressFormatter, {Input} from "@fragaria/address-formatter";
/**
* Checks if the given object is of type Address by verifying the existence
* of the `lines` property and confirming that it is an array of strings.
*
* @param {AddressCreation | Address} obj - The object to check.
* @return {boolean} Returns true if the object is of type Address, otherwise false.
*/
export function isAddress(obj: AddressCreation | Address): obj is Address {
return (obj as any).lines !== undefined && Array.isArray((obj as any).lines);
}
function buildAddressFormatterObject(address: AddressCreation): Input {
return {
city: address.postcode.name,
postcode: address.postcode.code,
countryCode: address.postcode.country.code,
street: address.street,
houseNumber: address.streetNumber,
};
}
export const buildAddressLines = (address: AddressCreation|Address): string[] => {
if (isAddress(address)) {
return address.lines;
}
const lines = addressFormatter.format(buildAddressFormatterObject(address), {output: 'array', countryCode: address.addressReference.postcode.country.code });
console.log('lines:', lines);
return lines;
}
export const buildAddressText = (address: AddressCreation|Address): string => {
return buildAddressLines(address).join(' - ');
}
export const getAddressPoint = (address: AddressCreation|Address): Point|null => {
if (isAddress(address)) {
return address.point;
}
return address.addressReference?.point;
}

View File

@@ -4,14 +4,14 @@
<div v-if="isConfidential">
<confidential :position-btn-far="true">
<template #confidential-content>
<div v-if="isMultiline === true">
<div v-if="isMultiline">
<p
v-for="(l, i) in address.lines"
v-for="(l, i) in buildAddressLines(address)"
:key="`line-${i}`"
>
{{ l }}
</p>
<p v-if="showButtonDetails">
<p v-if="showButtonDetails && isAddress(address) ">
<address-details-button
:address_id="address.address_id"
:address_ref_status="address.refStatus"
@@ -19,8 +19,8 @@
</p>
</div>
<div v-else>
<p v-if="'' !== address.text" class="street">
{{ address.text }}
<p v-if="'' !== buildAddressText(address)" class="street">
{{ buildAddressText(address) }}
</p>
<p
v-if="null !== address.postcode"
@@ -29,8 +29,8 @@
{{ address.postcode.code }}
{{ address.postcode.name }}
</p>
<p v-if="null !== address.country" class="country">
{{ localizeString(address.country.name) }}
<p v-if="null !== address.postcode" class="country">
{{ localizeString(address.postcode.country.name) }}
</p>
</div>
</template>
@@ -38,11 +38,11 @@
</div>
<div v-if="!isConfidential">
<div v-if="isMultiline === true">
<p v-for="(l, i) in address.lines" :key="`line-${i}`">
<div v-if="isMultiline">
<p v-for="(l, i) in buildAddressLines(address)" :key="`line-${i}`">
{{ l }}
</p>
<p v-if="showButtonDetails">
<p v-if="showButtonDetails && isAddress(address) ">
<address-details-button
:address_id="address.address_id"
:address_ref_status="address.refStatus"
@@ -50,9 +50,9 @@
</p>
</div>
<div v-else>
<p v-if="address.text" class="street">
{{ address.text }}
<template v-if="showButtonDetails">
<p v-if="'' !== buildAddressText(address)" class="street">
{{ buildAddressText(address)}}
<template v-if="showButtonDetails && isAddress(address) ">
<address-details-button
:address_id="address.address_id"
:address_ref_status="address.refStatus"
@@ -63,68 +63,49 @@
</div>
</component>
<div v-if="useDatePane === true" class="address-more">
<div v-if="useDatePane" class="address-more">
<div v-if="address.validFrom">
<span class="validFrom">
<b>{{ trans(ADDRESS_VALID_FROM) }}</b
>: {{ $d(address.validFrom.date) }}
>: {{ address.validFrom?.datetime8601 }}
</span>
</div>
<div v-if="address.validTo">
<div v-if="isAddress(address) && address.validTo !== null">
<span class="validTo">
<b>{{ trans(ADDRESS_VALID_TO) }}</b
>: {{ $d(address.validTo.date) }}
>: {{ address.validTo?.datetime8601 }}
</span>
</div>
</div>
</component>
</template>
<script>
<script setup lang="ts">
import { computed } from "vue";
import Confidential from "ChillMainAssets/vuejs/_components/Confidential.vue";
import AddressDetailsButton from "ChillMainAssets/vuejs/_components/AddressDetails/AddressDetailsButton.vue";
import { trans, ADDRESS_VALID_FROM, ADDRESS_VALID_TO } from "translator";
import { localizeString } from "ChillMainAssets/lib/localizationHelper/localizationHelper";
import {Address, AddressCreation} from "ChillMainAssets/types";
import {isAddress, buildAddressLines, buildAddressText} from "ChillMainAssets/vuejs/_components/AddressDetails/helper";
export default {
name: "AddressRenderBox",
methods: { localizeString },
components: {
Confidential,
AddressDetailsButton,
},
props: {
address: {
type: Object,
},
isMultiline: {
default: true,
type: Boolean,
},
useDatePane: {
default: false,
type: Boolean,
},
showButtonDetails: {
default: true,
type: Boolean,
},
},
setup() {
return { trans, ADDRESS_VALID_FROM, ADDRESS_VALID_TO };
},
computed: {
component() {
return this.isMultiline === true ? "div" : "span";
},
multiline() {
return this.isMultiline === true ? "multiline" : "";
},
isConfidential() {
return this.address.confidential;
},
},
};
const props = withDefaults(
defineProps<{
address: Address|AddressCreation;
isMultiline?: boolean;
useDatePane?: boolean;
showButtonDetails?: boolean;
}>(),
{
isMultiline: true,
useDatePane: false,
showButtonDetails: true,
}
);
const component = computed(() => (props.isMultiline ? "div" : "span"));
const multiline = computed(() => (props.isMultiline ? "multiline" : ""));
const isConfidential = computed(() => Boolean(props.address?.confidential));
</script>
<style lang="scss" scoped>

View File

@@ -0,0 +1,15 @@
{% extends '@ChillMain/layout.html.twig' %}
{% block css %}
{{ encore_entry_link_tags('address_picker') }}
{% endblock %}
{% block js %}
{{ encore_entry_script_tags('address_picker') }}
{% endblock %}
{% block content %}
<div data-address-picker="data-address-picker"></div>
{% endblock %}

View File

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

View File

@@ -61,7 +61,7 @@
{% endif %}
</li>
<li>
<span class="dt">service/territoire:</span>
<span class="dt">cercle/centre:</span>
{% if entity.mainScope %}
{{ entity.mainScope.name|localize_translatable_string }}
{% endif %}

View File

@@ -49,7 +49,7 @@ class AdminUserMenuBuilder implements LocalMenuBuilderInterface
'route' => 'chill_crud_center_index',
])->setExtras(['order' => 1010]);
$menu->addChild('Regroupements des territoires', [
$menu->addChild('Regroupements des centres', [
'route' => 'chill_crud_regroupment_index',
])->setExtras(['order' => 1015]);

View File

@@ -66,6 +66,7 @@ class AddressNormalizer implements ContextAwareNormalizerInterface, NormalizerAw
'name' => $address->getPostCode()->getName(),
'code' => $address->getPostCode()->getCode(),
'center' => $address->getPostcode()->getCenter(),
'country' => $this->normalizer->normalize($address->getPostCode()->getCountry(), $format, [AbstractNormalizer::GROUPS => ['read']]),
],
'country' => [
'id' => $address->getPostCode()->getCountry()->getId(),

View File

@@ -0,0 +1,118 @@
<?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\Controller;
use Chill\MainBundle\Controller\AddressReferenceAggregatedApiController;
use Chill\MainBundle\Repository\AddressReferenceRepositoryInterface;
use PHPUnit\Framework\TestCase;
use Prophecy\PhpUnit\ProphecyTrait;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\Security\Core\Security;
/**
* @internal
*
* @covers \Chill\MainBundle\Controller\AddressReferenceAggregatedApiController
*/
final class AddressReferenceAggregatedApiControllerTest extends TestCase
{
use ProphecyTrait;
public function testAccessDeniedWhenNotAuthenticated(): void
{
$security = $this->prophesize(Security::class);
$security->isGranted('IS_AUTHENTICATED')->willReturn(false);
$repo = $this->prophesize(AddressReferenceRepositoryInterface::class);
$controller = new AddressReferenceAggregatedApiController($security->reveal(), $repo->reveal());
$request = new Request(query: ['q' => 'anything']);
$this->expectException(AccessDeniedHttpException::class);
$controller->search($request);
}
public function testBadRequestWhenQueryIsMissing(): void
{
$security = $this->prophesize(Security::class);
$security->isGranted('IS_AUTHENTICATED')->willReturn(true);
$repo = $this->prophesize(AddressReferenceRepositoryInterface::class);
$controller = new AddressReferenceAggregatedApiController($security->reveal(), $repo->reveal());
$request = new Request();
$this->expectException(BadRequestHttpException::class);
$this->expectExceptionMessage('Parameter "q" is required.');
$controller->search($request);
}
public function testBadRequestWhenQueryIsEmpty(): void
{
$security = $this->prophesize(Security::class);
$security->isGranted('IS_AUTHENTICATED')->willReturn(true);
$repo = $this->prophesize(AddressReferenceRepositoryInterface::class);
$controller = new AddressReferenceAggregatedApiController($security->reveal(), $repo->reveal());
$request = new Request(query: ['q' => ' ']);
$this->expectException(BadRequestHttpException::class);
$this->expectExceptionMessage('Parameter "q" is required and cannot be empty.');
$controller->search($request);
}
public function testSuccessfulSearchReturnsJsonAndCallsRepositoryWithTrimmedQuery(): void
{
$security = $this->prophesize(Security::class);
$security->isGranted('IS_AUTHENTICATED')->willReturn(true);
$expectedQuery = 'foo';
$iterableResult = new \ArrayIterator([
[
'street' => 'Main Street',
'postcode_id' => 123,
'code' => '1000',
'label' => 'Brussels',
'positions' => ['1' => '12', '2' => '14'],
'row_number' => 1,
],
]);
$repo = $this->prophesize(AddressReferenceRepositoryInterface::class);
$repo->findAggregatedBySearchString($expectedQuery)->willReturn($iterableResult)->shouldBeCalledOnce();
$controller = new AddressReferenceAggregatedApiController($security->reveal(), $repo->reveal());
// Provide spaces around to ensure trimming is applied
$request = new Request(query: ['q' => " {$expectedQuery} "]);
$response = $controller->search($request);
self::assertSame(200, $response->getStatusCode());
$data = json_decode($response->getContent(), true, 512, JSON_THROW_ON_ERROR);
self::assertIsArray($data);
self::assertCount(1, $data);
self::assertSame('Main Street', $data[0]['street']);
self::assertSame(123, $data[0]['postcode_id']);
self::assertSame('1000', $data[0]['code']);
self::assertSame('Brussels', $data[0]['label']);
}
}

View File

@@ -35,7 +35,7 @@ final class ScopeControllerTest extends WebTestCase
$client->getResponse()->getStatusCode(),
'Unexpected HTTP status code for GET /fr/admin/scope/'
);
$crawler = $client->click($crawler->selectLink('Créer un nouveau service')->link());
$crawler = $client->click($crawler->selectLink('Créer un nouveau cercle')->link());
// Fill in the form and submit it
$form = $crawler->selectButton('Créer')->form([
'chill_mainbundle_scope[name][fr]' => 'Test en fr',

View File

@@ -0,0 +1,88 @@
<?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\Repository;
use Chill\MainBundle\Entity\AddressReference;
use Chill\MainBundle\Entity\PostalCode;
use Chill\MainBundle\Repository\AddressReferenceRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
/**
* @internal
*
* @coversNothing
*/
class AddressReferenceRepositoryTest extends KernelTestCase
{
private static AddressReferenceRepository $repository;
public static function setUpBeforeClass(): void
{
static::bootKernel();
static::$repository = static::getContainer()->get(AddressReferenceRepository::class);
}
/**
* @dataProvider generateSearchString
*/
public function testFindBySearchString(string $search, int|PostalCode|null $postalCode, string $text, ?array $expected = null): void
{
$actual = static::$repository->findBySearchString($search, $postalCode);
self::assertIsIterable($actual, $text);
if (null !== $expected) {
self::assertEquals($expected, iterator_to_array($actual));
}
}
/**
* @dataProvider generateSearchString
*/
public function testCountBySearchString(string $search, int|PostalCode|null $postalCode, string $text, ?array $expected = null): void
{
$actual = static::$repository->countBySearchString($search, $postalCode);
self::assertIsInt($actual, $text);
}
/**
* @dataProvider generateSearchString
*/
public function testFindAggreggateBySearchString(string $search, int|PostalCode|null $postalCode, string $text, ?array $expected = null): void
{
$actual = static::$repository->findAggregatedBySearchString($search, $postalCode);
self::assertIsIterable($actual, $text);
if (null !== $expected) {
self::assertEquals($expected, iterator_to_array($actual));
}
}
public static function generateSearchString(): iterable
{
self::bootKernel();
$em = static::getContainer()->get(EntityManagerInterface::class);
/** @var AddressReference $ar */
$ar = $em->createQuery('SELECT ar FROM '.AddressReference::class.' ar')
->setMaxResults(1)
->getSingleResult();
yield ['', null, 'search with empty string', []];
yield [' ', null, 'search with spaces only', []];
yield ['rue des moulins', null, 'search contains an empty string'];
yield ['rue des moulins', $ar->getPostcode()->getId(), 'search with postal code as an id'];
yield ['rue des moulins', $ar->getPostcode(), 'search with postal code instance'];
}
}

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\MainBundle\Tests\Repository;
use Chill\MainBundle\Repository\PostalCodeForAddressReferenceRepository;
use Doctrine\DBAL\Connection;
use PHPUnit\Framework\Attributes\DataProvider;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
/**
* @internal
*
* @coversNothing
*/
final class PostalCodeForAddressReferenceRepositoryTest extends KernelTestCase
{
private Connection $connection;
protected function setUp(): void
{
self::bootKernel();
$this->connection = self::getContainer()->get(Connection::class);
}
/**
* @return iterable<string[]>
*/
public static function provideSearches(): iterable
{
yield [''];
yield [' '];
yield ['hugo'];
yield [' hugo'];
yield ['hugo '];
yield ['rue victor hugo'];
yield ['rue victor hugo'];
}
#[DataProvider('provideSearches')]
public function testFindPostalCodeDoesNotErrorAndIsIterable(string $search): void
{
$repository = new PostalCodeForAddressReferenceRepository($this->connection);
$result = $repository->findPostalCode($search);
self::assertIsIterable($result);
// Ensure it can be converted to an array (and iterate without error)
$rows = \is_array($result) ? $result : iterator_to_array($result, false);
self::assertIsArray($rows);
}
}

View File

@@ -595,6 +595,44 @@ paths:
401:
description: "Unauthorized"
/1.0/main/address-reference/aggregated/search:
get:
tags:
- address
summary: Search for address reference aggregated
parameters:
- name: q
in: query
required: true
description: The search pattern
schema:
type: string
responses:
200:
description: "ok"
401:
description: "Unauthorized"
400:
description: "Bad Request"
/1.0/main/address-reference/postal-code/search:
get:
tags:
- address
summary: Search for postal code that can contains the search query
parameters:
- name: q
in: query
required: true
description: The search pattern
schema:
type: string
responses:
200:
description: "ok"
401:
description: "Unauthorized"
400:
description: "Bad Request"
/1.0/main/postal-code/search.json:
get:
tags:

View File

@@ -120,6 +120,11 @@ module.exports = function (encore, entries) {
"vue_onthefly",
__dirname + "/Resources/public/vuejs/OnTheFly/index.js",
);
encore.addEntry(
'address_picker',
__dirname + "/Resources/public/vuejs/AddressPicker/index.ts",
)
encore.addEntry(
"page_workflow_waiting_post_process",
__dirname + "/Resources/public/vuejs/WaitPostProcessWorkflow/index.ts"

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\Migrations\Main;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20250214154310 extends AbstractMigration
{
public function getDescription(): string
{
return 'Create view for searching address reference';
}
public function up(Schema $schema): void
{
$this->addSql(<<<'SQL'
create materialized view public.view_chill_main_address_reference as
SELECT row_number() OVER () AS row_number,
cmar.street AS street,
cmar.streetnumber AS streetnumber,
cmar.id AS address_id,
lower(unaccent(
(((((cmar.street || ' '::text) || cmar.streetnumber) || ' '::text) || cmpc.code::text) || ' '::text) ||
cmpc.label::text)) AS address,
cmpc.id AS postcode_id
FROM chill_main_address_reference cmar
JOIN chill_main_postal_code cmpc ON cmar.postcode_id = cmpc.id
WHERE cmar.deletedat IS NULL
ORDER BY ((cmpc.code::text || ' '::text) || cmpc.label::text), cmar.street, (lpad(cmar.streetnumber, 10, '0'::text));
SQL);
$this->addSql(<<<'SQL'
create index if not exists view_chill_internal_address_reference_trgm
on view_chill_main_address_reference using gist (postcode_id, address public.gist_trgm_ops);
SQL);
}
public function down(Schema $schema): void
{
$this->addSql('DROP MATERIALIZED VIEW view_chill_main_address_reference');
}
}

View File

@@ -54,7 +54,7 @@ user:
title: Mon profil
Profile successfully updated!: Votre profil a été mis à jour!
no job: Pas de métier assigné
no scope: Pas de service assigné
no scope: Pas de cercle assigné
notification_preferences: Préférences pour mes notifications
user_group:
@@ -102,9 +102,9 @@ createdAt: Créé le
createdBy: Créé par
#elements used in software
centers: territoires
Centers: Territoires
center: territoire
centers: centres
Centers: Centres
center: centre
comment: commentaire
Comment: Commentaire
Comments: Commentaires
@@ -181,6 +181,11 @@ address more:
buildingName: résidence
extra: ""
distribution: cedex
address_picker:
# placeholder
Search for addresses: Chercher des adresses
Create a new address: Créer une nouvelle adresse
Create an address: Créer une adresse
Update address: Modifier l'adresse
@@ -227,12 +232,12 @@ Location Menu: Localisations et types de localisation
Management of location: Gestion des localisations et types de localisation
#admin section for center's administration
Create a new center: Créer une nouveau territoire
Center list: Liste des territoires
Center edit: Édition d'un territoire
Center creation: Création d'un territoire
New center: Nouveau territoire
Center: Territoire
Create a new center: Créer un nouveau centre
Center list: Liste des centres
Center edit: Édition d'un centre
Center creation: Création d'un centre
New center: Nouveau centre
Center: Centre
#admin section for permissions group
Permissions group list: Groupes de permissions
@@ -246,15 +251,15 @@ New permission group: Nouveau groupe de permissions
PermissionsGroup "%name%" edit: Modification du groupe de permission '%name%'
Role: Rôle
Choose amongst roles: Choisir un rôle
Choose amongst scopes: Choisir un service
Choose amongst scopes: Choisir un cercle
Add permission: Ajouter les permissions
This group does not provide any permission: Ce groupe n'attribue aucune permission
The role '%role%' has been removed: Le rôle "%role%" a été enlevé de ce groupe de permission
The role '%role%' on circle '%scope%' has been removed: Le rôle "%role%" sur le service "%scope%" a été enlevé de ce groupe de permission
The role '%role%' on circle '%scope%' has been removed: Le rôle "%role%" sur le cercle "%scope%" a été enlevé de ce groupe de permission
Unclassified: Non classifié
Help to pick role and scope: Certains rôles ne nécessitent pas de service.
The role need scope: Ce rôle nécessite un service.
The role does not need scope: Ce rôle ne nécessite pas de service !
Help to pick role and scope: Certains rôles ne nécessitent pas de cercle.
The role need scope: Ce rôle nécessite un cercle.
The role does not need scope: Ce rôle ne nécessite pas de cercle !
#admin section for users
User configuration: Gestion des utilisateurs
@@ -270,7 +275,7 @@ Grant new permissions: Ajout de permissions
Add a new groupCenter: Ajout de permissions
The permissions have been successfully added to the user: Les permissions ont été accordées à l'utilisateur
The permissions where removed.: Les permissions ont été enlevées.
Center & groups: Territoire et groupes
Center & groups: Centre et groupes
User %username%: Utilisateur %username%
Add a new user: Ajouter un nouvel utilisateur
The permissions have been added: Les permissions ont été ajoutées
@@ -280,13 +285,13 @@ Back to the user edition: Retour au formulaire d'édition
Password successfully updated!: Mot de passe mis à jour
Flags: Drapeaux
Main location: Localisation principale
Main scope: Service
Main center: Territoire
Main scope: Cercle
Main center: Centre
user job: Métier de l'utilisateur
Job: Métier
Jobs: Métiers
Choose a main center: Choisir un territoire
Choose a main scope: Choisir un service
Choose a main center: Choisir un centre
Choose a main scope: Choisir un cercle
choose a job: Choisir un métier
choose a location: Choisir une localisation
@@ -302,12 +307,12 @@ Current location successfully updated: Localisation actuelle mise à jour
Pick a location: Choisir un lieu
#admin section for circles (old: scopes)
List circles: Services
New circle: Nouveau service
Circle: Service
Circle edit: Modification du service
Circle creation: Création d'un service
Create a new circle: Créer un nouveau service
List circles: Cercles
New circle: Nouveau cercle
Circle: Cercle
Circle edit: Modification du cercle
Circle creation: Création d'un cercle
Create a new circle: Créer un nouveau cercle
#admin section for location
Location: Localisation
@@ -347,9 +352,9 @@ Country list: Liste des pays
Country code: Code du pays
# circles / scopes
Choose the circle: Choisir le service
Scope: Service
Scopes: Services
Choose the circle: Choisir le cercle
Scope: Cercle
Scopes: Cercles
#export
@@ -357,14 +362,14 @@ Scopes: Services
Exports list: Liste des exports
Create an export: Créer un export
#export creation step 'center' : pick a center
Pick centers: Choisir les territoires
Pick a center: Choisir un territoire
The export will contains only data from the picked centers.: L'export ne contiendra que les données des territoires choisis.
This will eventually restrict your possibilities in filtering the data.: Les possibilités de filtrages seront adaptées aux droits de consultation pour les territoires choisis.
Pick centers: Choisir les centres
Pick a center: Choisir un centre
The export will contains only data from the picked centers.: L'export ne contiendra que les données des centres choisis.
This will eventually restrict your possibilities in filtering the data.: Les possibilités de filtrages seront adaptées aux droits de consultation pour les centres choisis.
Go to export options: Vers la préparation de l'export
Pick aggregated centers: Regroupement de territoires
uncheck all centers: Désélectionner tous les territoires
check all centers: Sélectionner tous les territoires
Pick aggregated centers: Regroupement de centres
uncheck all centers: Désélectionner tous les centres
check all centers: Sélectionner tous les centres
# export creation step 'export' : choose aggregators, filtering and formatter
Formatter: Mise en forme
Choose the formatter: Choisissez le format d'export voulu.
@@ -510,10 +515,10 @@ crud:
title_edit: Modifier un regroupement
center:
index:
title: Liste des territoires
add_new: Ajouter un territoire
title_new: Nouveau territoire
title_edit: Modifier un territoire
title: Liste des centres
add_new: Ajouter un centre
title_new: Nouveau centre
title_edit: Modifier un centre
news_item:
index:
title: Liste des actualités
@@ -860,7 +865,7 @@ absence:
admin:
users:
export_list_csv: Liste des utilisateurs (format CSV)
export_permissions_csv: Association utilisateurs - groupes de permissions - territoire (format CSV)
export_permissions_csv: Association utilisateurs - groupes de permissions - centre (format CSV)
export:
id: Identifiant
username: Nom d'utilisateur
@@ -870,8 +875,8 @@ admin:
civility_abbreviation: Abbréviation civilité
civility_name: Civilité
label: Label
mainCenter_id: Identifiant territoire principal
mainCenter_name: Territoire principal
mainCenter_id: Identifiant centre principal
mainCenter_name: Centre principal
mainScope_id: Identifiant service principal
mainScope_name: Service principal
userJob_id: Identifiant métier
@@ -881,8 +886,8 @@ admin:
mainLocation_id: Identifiant localisation principale
mainLocation_name: Localisation principale
absenceStart: Absent à partir du
center_id: Identifiant du territoire
center_name: Territoire
center_id: Identifiant du centre
center_name: Centre
permissionsGroup_id: Identifiant du groupe de permissions
permissionsGroup_name: Groupe de permissions
job_scope_histories:

View File

@@ -1,15 +1,15 @@
# role_scope constraint
# scope presence
The role "%role%" require to be associated with a scope.: Le rôle "%role%" doit être associé à un service.
The role "%role%" should not be associated with a scope.: Le rôle "%role%" ne doit pas être associé à un service.
The role "%role%" require to be associated with a scope.: Le rôle "%role%" doit être associé à un cercle.
The role "%role%" should not be associated with a scope.: Le rôle "%role%" ne doit pas être associé à un cercle.
"The password must contains one letter, one capitalized letter, one number and one special character as *[@#$%!,;:+\"'-/{}~=µ()£]). Other characters are allowed.": "Le mot de passe doit contenir une majuscule, une minuscule, et au moins un caractère spécial parmi *[@#$%!,;:+\"'-/{}~=µ()£]). Les autres caractères sont autorisés."
The password fields must match: Les mots de passe doivent correspondre
The password must be greater than {{ limit }} characters: "[1,Inf] Le mot de passe doit contenir au moins {{ limit }} caractères"
A permission is already present for the same role and scope: Une permission est déjà présente pour le même rôle et service.
A permission is already present for the same role and scope: Une permission est déjà présente pour le même rôle et cercle.
#UserCircleConsistency
"{{ username }} is not allowed to see entities published in this circle": "{{ username }} n'est pas autorisé à voir l'élément publié dans ce service."
"{{ username }} is not allowed to see entities published in this circle": "{{ username }} n'est pas autorisé à voir l'élément publié dans ce cercle."
The user in cc cannot be a dest user in the same workflow step: Un utilisateur en Cc ne peut pas être un utilisateur qui valide.

View File

@@ -80,7 +80,7 @@ const appMessages = {
firstName: "Prénom",
lastName: "Nom",
birthdate: "Date de naissance",
center: "Territoire",
center: "Centre",
phonenumber: "Téléphone",
mobilenumber: "Mobile",
altNames: "Autres noms",

View File

@@ -50,8 +50,8 @@ const visMessages = {
return "Né·e le";
}
},
center_id: "Identifiant du territoire",
center_type: "Type de territoire",
center_id: "Identifiant du centre",
center_type: "Type de centre",
center_name: "Territoire", // vendée
phonenumber: "Téléphone",
mobilenumber: "Mobile",

View File

@@ -464,7 +464,7 @@ export default {
this.errors.push("Le genre doit être renseigné");
}
if (this.showCenters && this.person.center === null) {
this.errors.push("Le territoire doit être renseigné");
this.errors.push("Le centre doit être renseigné");
}
},
loadData() {

View File

@@ -25,8 +25,8 @@ const personMessages = {
return "Né·e le";
}
},
center_id: "Identifiant du territoire",
center_type: "Type de territoire",
center_id: "Identifiant du centre",
center_type: "Type de centre",
center_name: "Territoire", // vendée
phonenumber: "Téléphone",
mobilenumber: "Mobile",
@@ -53,8 +53,8 @@ const personMessages = {
"Un nouveau ménage va être créé. L'usager sera membre de ce ménage.",
},
center: {
placeholder: "Choisissez un territoire",
title: "territoire",
placeholder: "Choisissez un centre",
title: "Centre",
},
},
error_only_one_person: "Une seule personne peut être sélectionnée !",

View File

@@ -376,7 +376,7 @@ Create a list of people according to various filters.: Crée une liste d'usagers
Fields to include in export: Champs à inclure dans l'export
Address valid at this date: Addresse valide à cette date
Data valid at this date: Données valides à cette date
Data regarding center, addresses, and so on will be computed at this date: Les données concernant le territoire, l'adresse, le ménage, sera calculé à cette date.
Data regarding center, addresses, and so on will be computed at this date: Les données concernant le centre, l'adresse, le ménage, sera calculé à cette date.
List duplicates: Liste des doublons
Create a list of duplicate people: Créer la liste des usagers détectés comme doublons.
Count people participating in an accompanying course: Nombre d'usagers concernés par un parcours
@@ -1110,9 +1110,9 @@ export:
Group course by household composition: Grouper les usagers par composition familiale
Calc date: Date de calcul de la composition du ménage
by_center:
title: Grouper les usagers par territoire
at_date: Date de calcul du territoire
center: Territoire de l'usager
title: Grouper les usagers par centre
at_date: Date de calcul du centre
center: Centre de l'usager
by_postal_code:
title: Grouper les usagers par code postal de l'adresse
at_date: Date de calcul de l'adresse
@@ -1437,7 +1437,7 @@ export:
acpParticipantPersons: Usagers concernés
acpParticipantPersonsIds: Usagers concernés (identifiants)
duration: Durée du parcours (en jours)
centers: Territoires des usagers
centers: Centres des usagers
eval:
List of evaluations: Liste des évaluations

View File

@@ -23,7 +23,7 @@ The gender must be set: Le genre doit être renseigné
You are not allowed to perform this action: Vous n'avez pas le droit de changer cette valeur.
Sorry, but someone else has already changed this entity. Please refresh the page and apply the changes again: Désolé, mais quelqu'un d'autre a déjà modifié cette entité. Veuillez actualiser la page et appliquer à nouveau les modifications
A center is required: Un territoire est requis
A center is required: Un centre est requis
#export list
You must select at least one element: Vous devez sélectionner au moins un élément

View File

@@ -9,7 +9,7 @@
'Report list': 'Liste des rapports'
Details: Détails
Person: Usager
Scope: Service
Scope: Cercle
Date: Date
User: Utilisateur
'Report type': 'Type de rapport'

View File

@@ -4,7 +4,7 @@ Tasks: "Tâches"
Title: Titre
Description: Description
Assignee: "Personne assignée"
Scope: Service
Scope: Cercle
"Start date": "Date de début"
"End date": "Date d'échéance"
"Warning date": "Date d'avertissement"
@@ -106,7 +106,7 @@ My tasks over deadline: Mes tâches à échéance dépassée
#transition page
Apply transition on task <em>%title%</em>: Appliquer la transition sur la tâche <em>%title%</em>
All centers: Tous les territoires
All centers: Tous les centres
# ROLES
CHILL_TASK_TASK_CREATE: Ajouter une tâche

View File

@@ -73,8 +73,8 @@ No acronym given: Aucun sigle renseigné
No phone given: Aucun téléphone renseigné
No email given: Aucune adresse courriel renseignée
The party is visible in those centers: Le tiers est visible dans ces territoires
The party is not visible in any center: Le tiers n'est associé à aucun territoire
The party is visible in those centers: Le tiers est visible dans ces centres
The party is not visible in any center: Le tiers n'est associé à aucun centre
No third parties: Aucun tiers
Any third party selected: Aucun tiers sélectionné