mirror of
https://gitlab.com/Chill-Projet/chill-bundles.git
synced 2025-09-26 00:24:59 +00:00
Compare commits
9 Commits
#361-impro
...
fix_calend
Author | SHA1 | Date | |
---|---|---|---|
90d3bf32e6 | |||
ebc2921696 | |||
aa085a1562 | |||
2754251fdc | |||
2f6cef4238 | |||
2309636eae | |||
56ec8fb516 | |||
fe6e6e54c1 | |||
2a09594b4a |
6
.changes/unreleased/Feature-20250722-155039.yaml
Normal file
6
.changes/unreleased/Feature-20250722-155039.yaml
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
kind: Feature
|
||||||
|
body: Show filters on list pages unfolded by default
|
||||||
|
time: 2025-07-22T15:50:39.338057044+02:00
|
||||||
|
custom:
|
||||||
|
Issue: "399"
|
||||||
|
SchemaChange: No schema change
|
6
.changes/unreleased/Feature-20250811-152154.yaml
Normal file
6
.changes/unreleased/Feature-20250811-152154.yaml
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
kind: Feature
|
||||||
|
body: Add 45 and 60 min calendar ranges
|
||||||
|
time: 2025-08-11T15:21:54.209009751+02:00
|
||||||
|
custom:
|
||||||
|
Issue: "409"
|
||||||
|
SchemaChange: No schema change
|
6
.changes/unreleased/Fixed-20250806-134609.yaml
Normal file
6
.changes/unreleased/Fixed-20250806-134609.yaml
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
kind: Fixed
|
||||||
|
body: adjust display logic for accompanying period dates, include closing date if period is closed.
|
||||||
|
time: 2025-08-06T13:46:09.241584292+02:00
|
||||||
|
custom:
|
||||||
|
Issue: "382"
|
||||||
|
SchemaChange: No schema change
|
6
.changes/unreleased/Fixed-20250806-173527.yaml
Normal file
6
.changes/unreleased/Fixed-20250806-173527.yaml
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
kind: Fixed
|
||||||
|
body: add min and step attributes to integer field in DateIntervalType
|
||||||
|
time: 2025-08-06T17:35:27.413787704+02:00
|
||||||
|
custom:
|
||||||
|
Issue: "384"
|
||||||
|
SchemaChange: No schema change
|
6
.changes/unreleased/Fixed-20250811-155212.yaml
Normal file
6
.changes/unreleased/Fixed-20250811-155212.yaml
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
kind: Fixed
|
||||||
|
body: fix date formatting in calendar range display
|
||||||
|
time: 2025-08-11T15:52:12.949078671+02:00
|
||||||
|
custom:
|
||||||
|
Issue: ""
|
||||||
|
SchemaChange: No schema change
|
6
.changes/unreleased/UX-20250722-132637.yaml
Normal file
6
.changes/unreleased/UX-20250722-132637.yaml
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
kind: UX
|
||||||
|
body: Limit display of participations in event list
|
||||||
|
time: 2025-07-22T13:26:37.500656935+02:00
|
||||||
|
custom:
|
||||||
|
Issue: ""
|
||||||
|
SchemaChange: No schema change
|
@@ -17,9 +17,3 @@ when@dev:
|
|||||||
defaults:
|
defaults:
|
||||||
template: '@ChillMain/Dev/dev.assets.test2.html.twig'
|
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'
|
|
||||||
|
@@ -45,7 +45,6 @@
|
|||||||
"webpack-cli": "^5.0.1"
|
"webpack-cli": "^5.0.1"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fragaria/address-formatter": "^6.6.1",
|
|
||||||
"@fullcalendar/core": "^6.1.4",
|
"@fullcalendar/core": "^6.1.4",
|
||||||
"@fullcalendar/daygrid": "^6.1.4",
|
"@fullcalendar/daygrid": "^6.1.4",
|
||||||
"@fullcalendar/interaction": "^6.1.4",
|
"@fullcalendar/interaction": "^6.1.4",
|
||||||
@@ -70,7 +69,6 @@
|
|||||||
"vue-i18n": "^9.1.6",
|
"vue-i18n": "^9.1.6",
|
||||||
"vue-multiselect": "3.0.0-alpha.2",
|
"vue-multiselect": "3.0.0-alpha.2",
|
||||||
"vue-toast-notification": "^3.1.2",
|
"vue-toast-notification": "^3.1.2",
|
||||||
"vue-use-leaflet": "^0.1.7",
|
|
||||||
"vuex": "^4.0.0"
|
"vuex": "^4.0.0"
|
||||||
},
|
},
|
||||||
"browserslist": [
|
"browserslist": [
|
||||||
|
@@ -70,6 +70,8 @@
|
|||||||
<option value="00:10:00">10 minutes</option>
|
<option value="00:10:00">10 minutes</option>
|
||||||
<option value="00:15:00">15 minutes</option>
|
<option value="00:15:00">15 minutes</option>
|
||||||
<option value="00:30:00">30 minutes</option>
|
<option value="00:30:00">30 minutes</option>
|
||||||
|
<option value="00:45:00">45 minutes</option>
|
||||||
|
<option value="00:60:00">60 minutes</option>
|
||||||
</select>
|
</select>
|
||||||
<label class="input-group-text" for="slotMinTime">De</label>
|
<label class="input-group-text" for="slotMinTime">De</label>
|
||||||
<select
|
<select
|
||||||
|
@@ -32,6 +32,8 @@
|
|||||||
<option value="00:10:00">10 minutes</option>
|
<option value="00:10:00">10 minutes</option>
|
||||||
<option value="00:15:00">15 minutes</option>
|
<option value="00:15:00">15 minutes</option>
|
||||||
<option value="00:30:00">30 minutes</option>
|
<option value="00:30:00">30 minutes</option>
|
||||||
|
<option value="00:45:00">45 minutes</option>
|
||||||
|
<option value="00:60:00">60 minutes</option>
|
||||||
</select>
|
</select>
|
||||||
<label class="input-group-text" for="slotMinTime">De</label>
|
<label class="input-group-text" for="slotMinTime">De</label>
|
||||||
<select
|
<select
|
||||||
@@ -102,7 +104,7 @@
|
|||||||
event.title
|
event.title
|
||||||
}}</b>
|
}}</b>
|
||||||
<b v-else-if="event.extendedProps.is === 'range'"
|
<b v-else-if="event.extendedProps.is === 'range'"
|
||||||
>{{ formatDate(event.startStr) }} -
|
>{{ formatDate(event.startStr) }} - {{ formatDate(event.endStr, 'time') }}:
|
||||||
{{ event.extendedProps.locationName }}</b
|
{{ event.extendedProps.locationName }}</b
|
||||||
>
|
>
|
||||||
<b v-else-if="event.extendedProps.is === 'local'">{{
|
<b v-else-if="event.extendedProps.is === 'local'">{{
|
||||||
@@ -294,9 +296,26 @@ const nextWeeks = computed((): Weeks[] =>
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const formatDate = (datetime: string) => {
|
const formatDate = (datetime: string, format: null | 'time' = null) => {
|
||||||
console.log(typeof datetime);
|
const date = ISOToDate(datetime);
|
||||||
return ISOToDate(datetime);
|
if (!date) return '';
|
||||||
|
|
||||||
|
if (format === 'time') {
|
||||||
|
return date.toLocaleTimeString('fr-FR', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// French date formatting
|
||||||
|
return date.toLocaleDateString('fr-FR', {
|
||||||
|
weekday: 'short',
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const baseOptions = ref<CalendarOptions>({
|
const baseOptions = ref<CalendarOptions>({
|
||||||
|
@@ -54,14 +54,14 @@ block js %}
|
|||||||
{% if e.participations|length > 0 %}
|
{% if e.participations|length > 0 %}
|
||||||
<div class="item-row separator">
|
<div class="item-row separator">
|
||||||
<strong>{{ "Participations" | trans }} : </strong>
|
<strong>{{ "Participations" | trans }} : </strong>
|
||||||
{% for part in e.participations|slice(0, 20) %} {% include
|
{% for part in e.participations|slice(0, 5) %} {% include
|
||||||
'@ChillMain/OnTheFly/_insert_vue_onthefly.html.twig' with {
|
'@ChillMain/OnTheFly/_insert_vue_onthefly.html.twig' with {
|
||||||
targetEntity: { name: 'person', id: part.person.id }, action:
|
targetEntity: { name: 'person', id: part.person.id }, action:
|
||||||
'show', displayBadge: true, buttonText:
|
'show', displayBadge: true, buttonText:
|
||||||
part.person|chill_entity_render_string, isDead:
|
part.person|chill_entity_render_string, isDead:
|
||||||
part.person.deathdate is not null } %} {% endfor %} {% if
|
part.person.deathdate is not null } %} {% endfor %}
|
||||||
e.participations|length > 20 %}
|
{% if e.participations|length > 5 %}
|
||||||
{{ 'events.and_other_count_participants'|trans({'count': e.participations|length - 20}) }}
|
{{ 'events.and_other_count_participants'|trans({'count': e.participations|length - 5}) }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
@@ -1,50 +0,0 @@
|
|||||||
<?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));
|
|
||||||
}
|
|
||||||
}
|
|
@@ -1,47 +0,0 @@
|
|||||||
<?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);
|
|
||||||
}
|
|
||||||
}
|
|
@@ -55,6 +55,10 @@ class DateIntervalType extends AbstractType
|
|||||||
{
|
{
|
||||||
$builder
|
$builder
|
||||||
->add('n', IntegerType::class, [
|
->add('n', IntegerType::class, [
|
||||||
|
'attr' => [
|
||||||
|
'min' => 0,
|
||||||
|
'step' => 1,
|
||||||
|
],
|
||||||
'constraints' => [
|
'constraints' => [
|
||||||
new GreaterThan([
|
new GreaterThan([
|
||||||
'value' => 0,
|
'value' => 0,
|
||||||
|
@@ -14,14 +14,13 @@ namespace Chill\MainBundle\Repository;
|
|||||||
use Chill\MainBundle\Entity\AddressReference;
|
use Chill\MainBundle\Entity\AddressReference;
|
||||||
use Chill\MainBundle\Entity\PostalCode;
|
use Chill\MainBundle\Entity\PostalCode;
|
||||||
use Chill\MainBundle\Search\SearchApiQuery;
|
use Chill\MainBundle\Search\SearchApiQuery;
|
||||||
use Doctrine\DBAL\Types\Types;
|
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
use Doctrine\ORM\EntityRepository;
|
use Doctrine\ORM\EntityRepository;
|
||||||
use Doctrine\ORM\NativeQuery;
|
|
||||||
use Doctrine\ORM\Query\ResultSetMapping;
|
use Doctrine\ORM\Query\ResultSetMapping;
|
||||||
use Doctrine\ORM\Query\ResultSetMappingBuilder;
|
use Doctrine\ORM\Query\ResultSetMappingBuilder;
|
||||||
|
use Doctrine\Persistence\ObjectRepository;
|
||||||
|
|
||||||
final readonly class AddressReferenceRepository implements AddressReferenceRepositoryInterface
|
final readonly class AddressReferenceRepository implements ObjectRepository
|
||||||
{
|
{
|
||||||
private EntityManagerInterface $entityManager;
|
private EntityManagerInterface $entityManager;
|
||||||
|
|
||||||
@@ -66,121 +65,6 @@ final readonly class AddressReferenceRepository implements AddressReferenceRepos
|
|||||||
return $this->repository->findAll();
|
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 $limit
|
||||||
* @param mixed|null $offset
|
* @param mixed|null $offset
|
||||||
|
@@ -1,20 +0,0 @@
|
|||||||
<?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;
|
|
||||||
}
|
|
@@ -1,69 +0,0 @@
|
|||||||
<?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
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
@@ -1,20 +0,0 @@
|
|||||||
<?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;
|
|
||||||
}
|
|
@@ -37,8 +37,13 @@ export const ISOToDate = (str: string | null): Date | null => {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const [year, month, day] = str.split("-").map((p) => parseInt(p));
|
// If the string already contains time info, use it directly
|
||||||
|
if (str.includes('T') || str.includes(' ')) {
|
||||||
|
return new Date(str);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, parse date only
|
||||||
|
const [year, month, day] = str.split("-").map((p) => parseInt(p));
|
||||||
return new Date(year, month - 1, day, 0, 0, 0, 0);
|
return new Date(year, month - 1, day, 0, 0, 0, 0);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@@ -74,7 +74,6 @@ export interface Postcode {
|
|||||||
name: string;
|
name: string;
|
||||||
code: string;
|
code: string;
|
||||||
center: Point;
|
center: Point;
|
||||||
country: Country;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Point {
|
export interface Point {
|
||||||
@@ -90,28 +89,6 @@ export interface Country {
|
|||||||
|
|
||||||
export type AddressRefStatus = "match" | "to_review" | "reviewed";
|
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 {
|
export interface Address {
|
||||||
type: "address";
|
type: "address";
|
||||||
address_id: number;
|
address_id: number;
|
||||||
@@ -130,7 +107,7 @@ export interface Address {
|
|||||||
confidential: boolean;
|
confidential: boolean;
|
||||||
lines: string[];
|
lines: string[];
|
||||||
addressReference: AddressReference | null;
|
addressReference: AddressReference | null;
|
||||||
validFrom: DateTime | null; // TODO there is no null for validFrom
|
validFrom: DateTime;
|
||||||
validTo: DateTime | null;
|
validTo: DateTime | null;
|
||||||
point: Point | null;
|
point: Point | null;
|
||||||
refStatus: AddressRefStatus;
|
refStatus: AddressRefStatus;
|
||||||
|
@@ -1,33 +0,0 @@
|
|||||||
<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>
|
|
@@ -1,142 +0,0 @@
|
|||||||
<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";
|
|
||||||
|
|
||||||
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 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;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
</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"></address-details-form>
|
|
||||||
</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>
|
|
@@ -1,25 +0,0 @@
|
|||||||
<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>
|
|
@@ -1,82 +0,0 @@
|
|||||||
<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>
|
|
@@ -1,27 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import {AddressReference} from "ChillMainAssets/types";
|
|
||||||
import {computed} from "vue";
|
|
||||||
import {addressReferenceToAddress} from "ChillMainAssets/vuejs/AddressPicker/helper";
|
|
||||||
import AddressDetailsContent from "ChillMainAssets/vuejs/_components/AddressDetails/AddressDetailsContent.vue";
|
|
||||||
|
|
||||||
export interface AddressDetailsFormProps {
|
|
||||||
address: AddressReference;
|
|
||||||
}
|
|
||||||
|
|
||||||
const props = defineProps<AddressDetailsFormProps>();
|
|
||||||
|
|
||||||
const address = computed(() => addressReferenceToAddress(props.address));
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div>
|
|
||||||
FORM
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<address-details-content :address="address"></address-details-content>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped lang="scss">
|
|
||||||
|
|
||||||
</style>
|
|
@@ -1,39 +0,0 @@
|
|||||||
<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>
|
|
@@ -1,69 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
@@ -1,21 +0,0 @@
|
|||||||
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,
|
|
||||||
}
|
|
||||||
}
|
|
@@ -1,12 +0,0 @@
|
|||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
@@ -4,27 +4,24 @@
|
|||||||
:show-button-details="false"
|
:show-button-details="false"
|
||||||
></address-render-box>
|
></address-render-box>
|
||||||
<address-details-ref-matching
|
<address-details-ref-matching
|
||||||
v-if="isAddress(props.address)"
|
|
||||||
:address="props.address"
|
:address="props.address"
|
||||||
@update-address="onUpdateAddress"
|
@update-address="onUpdateAddress"
|
||||||
></address-details-ref-matching>
|
></address-details-ref-matching>
|
||||||
<address-details-map :address="props.address"></address-details-map>
|
<address-details-map :address="props.address"></address-details-map>
|
||||||
<address-details-geographical-layers
|
<address-details-geographical-layers
|
||||||
v-if="isAddress(props.address)"
|
|
||||||
:address="props.address"
|
:address="props.address"
|
||||||
></address-details-geographical-layers>
|
></address-details-geographical-layers>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import {Address, AddressCreation} from "../../../types";
|
import { Address } from "../../../types";
|
||||||
import AddressDetailsMap from "./Parts/AddressDetailsMap.vue";
|
import AddressDetailsMap from "./Parts/AddressDetailsMap.vue";
|
||||||
import AddressRenderBox from "../Entity/AddressRenderBox.vue";
|
import AddressRenderBox from "../Entity/AddressRenderBox.vue";
|
||||||
import AddressDetailsGeographicalLayers from "./Parts/AddressDetailsGeographicalLayers.vue";
|
import AddressDetailsGeographicalLayers from "./Parts/AddressDetailsGeographicalLayers.vue";
|
||||||
import AddressDetailsRefMatching from "./Parts/AddressDetailsRefMatching.vue";
|
import AddressDetailsRefMatching from "./Parts/AddressDetailsRefMatching.vue";
|
||||||
import {isAddress} from "ChillMainAssets/vuejs/_components/AddressDetails/helper";
|
|
||||||
|
|
||||||
interface AddressModalContentProps {
|
interface AddressModalContentProps {
|
||||||
address: Address|AddressCreation;
|
address: Address;
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<AddressModalContentProps>();
|
const props = defineProps<AddressModalContentProps>();
|
||||||
|
@@ -12,91 +12,90 @@
|
|||||||
Voir sur
|
Voir sur
|
||||||
<a :href="makeUrlGoogleMap(props.address)" target="_blank"
|
<a :href="makeUrlGoogleMap(props.address)" target="_blank"
|
||||||
>Google Maps</a
|
>Google Maps</a
|
||||||
> <a
|
>
|
||||||
:href="makeUrlOsm(props.address)" target="_blank"
|
<a :href="makeUrlOsm(props.address)" target="_blank">OSM</a>
|
||||||
>OSM</a>
|
|
||||||
</p>
|
</p>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import {computed, onMounted, ref} from "vue";
|
import { onMounted, ref } from "vue";
|
||||||
import "leaflet/dist/leaflet.css";
|
import "leaflet/dist/leaflet.css";
|
||||||
import markerIconPng from "leaflet/dist/images/marker-icon.png";
|
import markerIconPng from "leaflet/dist/images/marker-icon.png";
|
||||||
import L, { LatLngExpression, LatLngTuple } from "leaflet";
|
import L, { LatLngExpression, LatLngTuple } from "leaflet";
|
||||||
import {Address, AddressCreation, Point} from "../../../../types";
|
import { Address, 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 => {
|
const lonLatForLeaflet = (point: Point): LatLngTuple => {
|
||||||
return [point.coordinates[1], point.coordinates[0]];
|
return [point.coordinates[1], point.coordinates[0]];
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface MapProps {
|
export interface MapProps {
|
||||||
address: Address|AddressCreation;
|
address: Address;
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<MapProps>();
|
const props = defineProps<MapProps>();
|
||||||
|
|
||||||
const markerIcon = L.icon({
|
|
||||||
iconUrl: markerIconPng,
|
|
||||||
iconAnchor: [12, 41],
|
|
||||||
});
|
|
||||||
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 map_div = ref<HTMLDivElement | null>(null);
|
const map_div = ref<HTMLDivElement | null>(null);
|
||||||
const map = useLeafletMap(map_div, {zoom: 18, center: latLngMarker});
|
let map: L.Map | null = null;
|
||||||
const tileLayer = useLeafletTileLayer(
|
let marker: L.Marker | null = null;
|
||||||
"https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
|
|
||||||
attribution:
|
onMounted(() => {
|
||||||
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
|
if (map_div.value === null) {
|
||||||
|
// there is no map div when the address does not have any Point
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
);
|
|
||||||
useLeafletDisplayLayer(map, tileLayer);
|
|
||||||
|
|
||||||
|
if (props.address.point !== null) {
|
||||||
|
map = L.map(map_div.value);
|
||||||
|
map.setView(lonLatForLeaflet(props.address.point), 18);
|
||||||
|
|
||||||
const marker = useLeafletMarker(latLngMarker, {icon: markerIcon});
|
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
|
||||||
useLeafletDisplayLayer(map, marker);
|
attribution:
|
||||||
|
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
|
||||||
|
}).addTo(map);
|
||||||
|
|
||||||
|
const markerIcon = L.icon({
|
||||||
|
iconUrl: markerIconPng,
|
||||||
|
iconAnchor: [12, 41],
|
||||||
|
});
|
||||||
|
|
||||||
const makeUrlGoogleMap = (address: Address|AddressCreation): string => {
|
marker = L.marker(lonLatForLeaflet(props.address.point), {
|
||||||
|
icon: markerIcon,
|
||||||
|
});
|
||||||
|
marker.addTo(map);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const makeUrlGoogleMap = (address: Address): string => {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
params.append("api", "1");
|
params.append("api", "1");
|
||||||
const point = getAddressPoint(address);
|
if (address.point !== null && address.addressReference !== null) {
|
||||||
if (point !== null && address.addressReference !== null) {
|
|
||||||
params.append(
|
params.append(
|
||||||
"query",
|
"query",
|
||||||
`${point.coordinates[1]} ${point.coordinates[0]}`,
|
`${address.point.coordinates[1]} ${address.point.coordinates[0]}`,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
params.append("query", buildAddressLines(address).join(", "));
|
params.append("query", address.lines.join(", "));
|
||||||
}
|
}
|
||||||
|
|
||||||
return `https://www.google.com/maps/search/?${params.toString()}`;
|
return `https://www.google.com/maps/search/?${params.toString()}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const makeUrlOsm = (address: Address|AddressCreation): string => {
|
const makeUrlOsm = (address: Address): string => {
|
||||||
const point = getAddressPoint(address);
|
if (address.point !== null && address.addressReference !== null) {
|
||||||
if (point !== null && address.addressReference !== null) {
|
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
params.append("mlat", `${point.coordinates[1]}`);
|
params.append("mlat", `${address.point.coordinates[1]}`);
|
||||||
params.append("mlon", `${point.coordinates[0]}`);
|
params.append("mlon", `${address.point.coordinates[0]}`);
|
||||||
const hashParams = new URLSearchParams();
|
const hashParams = new URLSearchParams();
|
||||||
hashParams.append(
|
hashParams.append(
|
||||||
"map",
|
"map",
|
||||||
`18/${point.coordinates[1]}/${point.coordinates[0]}`,
|
`18/${address.point.coordinates[1]}/${address.point.coordinates[0]}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
return `https://www.openstreetmap.org/?${params.toString()}#${hashParams.toString()}`;
|
return `https://www.openstreetmap.org/?${params.toString()}#${hashParams.toString()}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
params.append("query", buildAddressLines(address).join(", "));
|
params.append("query", address.lines.join(", "));
|
||||||
|
|
||||||
return `https://www.openstreetmap.org/search?${params.toString()}`;
|
return `https://www.openstreetmap.org/search?${params.toString()}`;
|
||||||
};
|
};
|
||||||
|
@@ -1,46 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
@@ -4,14 +4,14 @@
|
|||||||
<div v-if="isConfidential">
|
<div v-if="isConfidential">
|
||||||
<confidential :position-btn-far="true">
|
<confidential :position-btn-far="true">
|
||||||
<template #confidential-content>
|
<template #confidential-content>
|
||||||
<div v-if="isMultiline">
|
<div v-if="isMultiline === true">
|
||||||
<p
|
<p
|
||||||
v-for="(l, i) in buildAddressLines(address)"
|
v-for="(l, i) in address.lines"
|
||||||
:key="`line-${i}`"
|
:key="`line-${i}`"
|
||||||
>
|
>
|
||||||
{{ l }}
|
{{ l }}
|
||||||
</p>
|
</p>
|
||||||
<p v-if="showButtonDetails && isAddress(address) ">
|
<p v-if="showButtonDetails">
|
||||||
<address-details-button
|
<address-details-button
|
||||||
:address_id="address.address_id"
|
:address_id="address.address_id"
|
||||||
:address_ref_status="address.refStatus"
|
:address_ref_status="address.refStatus"
|
||||||
@@ -19,8 +19,8 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div v-else>
|
<div v-else>
|
||||||
<p v-if="'' !== buildAddressText(address)" class="street">
|
<p v-if="'' !== address.text" class="street">
|
||||||
{{ buildAddressText(address) }}
|
{{ address.text }}
|
||||||
</p>
|
</p>
|
||||||
<p
|
<p
|
||||||
v-if="null !== address.postcode"
|
v-if="null !== address.postcode"
|
||||||
@@ -29,8 +29,8 @@
|
|||||||
{{ address.postcode.code }}
|
{{ address.postcode.code }}
|
||||||
{{ address.postcode.name }}
|
{{ address.postcode.name }}
|
||||||
</p>
|
</p>
|
||||||
<p v-if="null !== address.postcode" class="country">
|
<p v-if="null !== address.country" class="country">
|
||||||
{{ localizeString(address.postcode.country.name) }}
|
{{ localizeString(address.country.name) }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -38,11 +38,11 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="!isConfidential">
|
<div v-if="!isConfidential">
|
||||||
<div v-if="isMultiline">
|
<div v-if="isMultiline === true">
|
||||||
<p v-for="(l, i) in buildAddressLines(address)" :key="`line-${i}`">
|
<p v-for="(l, i) in address.lines" :key="`line-${i}`">
|
||||||
{{ l }}
|
{{ l }}
|
||||||
</p>
|
</p>
|
||||||
<p v-if="showButtonDetails && isAddress(address) ">
|
<p v-if="showButtonDetails">
|
||||||
<address-details-button
|
<address-details-button
|
||||||
:address_id="address.address_id"
|
:address_id="address.address_id"
|
||||||
:address_ref_status="address.refStatus"
|
:address_ref_status="address.refStatus"
|
||||||
@@ -50,9 +50,9 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div v-else>
|
<div v-else>
|
||||||
<p v-if="'' !== buildAddressText(address)" class="street">
|
<p v-if="address.text" class="street">
|
||||||
{{ buildAddressText(address)}}
|
{{ address.text }}
|
||||||
<template v-if="showButtonDetails && isAddress(address) ">
|
<template v-if="showButtonDetails">
|
||||||
<address-details-button
|
<address-details-button
|
||||||
:address_id="address.address_id"
|
:address_id="address.address_id"
|
||||||
:address_ref_status="address.refStatus"
|
:address_ref_status="address.refStatus"
|
||||||
@@ -63,49 +63,68 @@
|
|||||||
</div>
|
</div>
|
||||||
</component>
|
</component>
|
||||||
|
|
||||||
<div v-if="useDatePane" class="address-more">
|
<div v-if="useDatePane === true" class="address-more">
|
||||||
<div v-if="address.validFrom">
|
<div v-if="address.validFrom">
|
||||||
<span class="validFrom">
|
<span class="validFrom">
|
||||||
<b>{{ trans(ADDRESS_VALID_FROM) }}</b
|
<b>{{ trans(ADDRESS_VALID_FROM) }}</b
|
||||||
>: {{ address.validFrom?.datetime8601 }}
|
>: {{ $d(address.validFrom.date) }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="isAddress(address) && address.validTo !== null">
|
<div v-if="address.validTo">
|
||||||
<span class="validTo">
|
<span class="validTo">
|
||||||
<b>{{ trans(ADDRESS_VALID_TO) }}</b
|
<b>{{ trans(ADDRESS_VALID_TO) }}</b
|
||||||
>: {{ address.validTo?.datetime8601 }}
|
>: {{ $d(address.validTo.date) }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</component>
|
</component>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script>
|
||||||
import { computed } from "vue";
|
|
||||||
import Confidential from "ChillMainAssets/vuejs/_components/Confidential.vue";
|
import Confidential from "ChillMainAssets/vuejs/_components/Confidential.vue";
|
||||||
import AddressDetailsButton from "ChillMainAssets/vuejs/_components/AddressDetails/AddressDetailsButton.vue";
|
import AddressDetailsButton from "ChillMainAssets/vuejs/_components/AddressDetails/AddressDetailsButton.vue";
|
||||||
import { trans, ADDRESS_VALID_FROM, ADDRESS_VALID_TO } from "translator";
|
import { trans, ADDRESS_VALID_FROM, ADDRESS_VALID_TO } from "translator";
|
||||||
import { localizeString } from "ChillMainAssets/lib/localizationHelper/localizationHelper";
|
import { localizeString } from "ChillMainAssets/lib/localizationHelper/localizationHelper";
|
||||||
import {Address, AddressCreation} from "ChillMainAssets/types";
|
|
||||||
import {isAddress, buildAddressLines, buildAddressText} from "ChillMainAssets/vuejs/_components/AddressDetails/helper";
|
|
||||||
|
|
||||||
const props = withDefaults(
|
export default {
|
||||||
defineProps<{
|
name: "AddressRenderBox",
|
||||||
address: Address|AddressCreation;
|
methods: { localizeString },
|
||||||
isMultiline?: boolean;
|
components: {
|
||||||
useDatePane?: boolean;
|
Confidential,
|
||||||
showButtonDetails?: boolean;
|
AddressDetailsButton,
|
||||||
}>(),
|
},
|
||||||
{
|
props: {
|
||||||
isMultiline: true,
|
address: {
|
||||||
useDatePane: false,
|
type: Object,
|
||||||
showButtonDetails: true,
|
},
|
||||||
}
|
isMultiline: {
|
||||||
);
|
default: true,
|
||||||
|
type: Boolean,
|
||||||
const component = computed(() => (props.isMultiline ? "div" : "span"));
|
},
|
||||||
const multiline = computed(() => (props.isMultiline ? "multiline" : ""));
|
useDatePane: {
|
||||||
const isConfidential = computed(() => Boolean(props.address?.confidential));
|
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;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
|
@@ -1,15 +0,0 @@
|
|||||||
{% 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 %}
|
|
@@ -5,7 +5,7 @@
|
|||||||
<strong><i class="fa fa-fw fa-filter"></i>Filtrer la liste</strong>
|
<strong><i class="fa fa-fw fa-filter"></i>Filtrer la liste</strong>
|
||||||
</button>
|
</button>
|
||||||
</h2>
|
</h2>
|
||||||
<div class="accordion-collapse collapse" id="filterOrderCollapse" aria-labelledby="filterOrderHeading" data-bs-parent="#filterOrderAccordion">
|
<div id="filterOrderCollapse" aria-labelledby="filterOrderHeading" data-bs-parent="#filterOrderAccordion">
|
||||||
{% set btnSubmit = 0 %}
|
{% set btnSubmit = 0 %}
|
||||||
<div class="accordion-body chill_filter_order container-xxl p-5 py-2">
|
<div class="accordion-body chill_filter_order container-xxl p-5 py-2">
|
||||||
<div class="row my-2">
|
<div class="row my-2">
|
||||||
|
@@ -66,7 +66,6 @@ class AddressNormalizer implements ContextAwareNormalizerInterface, NormalizerAw
|
|||||||
'name' => $address->getPostCode()->getName(),
|
'name' => $address->getPostCode()->getName(),
|
||||||
'code' => $address->getPostCode()->getCode(),
|
'code' => $address->getPostCode()->getCode(),
|
||||||
'center' => $address->getPostcode()->getCenter(),
|
'center' => $address->getPostcode()->getCenter(),
|
||||||
'country' => $this->normalizer->normalize($address->getPostCode()->getCountry(), $format, [AbstractNormalizer::GROUPS => ['read']]),
|
|
||||||
],
|
],
|
||||||
'country' => [
|
'country' => [
|
||||||
'id' => $address->getPostCode()->getCountry()->getId(),
|
'id' => $address->getPostCode()->getCountry()->getId(),
|
||||||
|
@@ -1,118 +0,0 @@
|
|||||||
<?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']);
|
|
||||||
}
|
|
||||||
}
|
|
@@ -1,88 +0,0 @@
|
|||||||
<?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'];
|
|
||||||
}
|
|
||||||
}
|
|
@@ -1,61 +0,0 @@
|
|||||||
<?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);
|
|
||||||
}
|
|
||||||
}
|
|
@@ -595,44 +595,6 @@ paths:
|
|||||||
401:
|
401:
|
||||||
description: "Unauthorized"
|
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:
|
/1.0/main/postal-code/search.json:
|
||||||
get:
|
get:
|
||||||
tags:
|
tags:
|
||||||
|
@@ -120,9 +120,5 @@ module.exports = function (encore, entries) {
|
|||||||
"vue_onthefly",
|
"vue_onthefly",
|
||||||
__dirname + "/Resources/public/vuejs/OnTheFly/index.js",
|
__dirname + "/Resources/public/vuejs/OnTheFly/index.js",
|
||||||
);
|
);
|
||||||
encore.addEntry(
|
|
||||||
'address_picker',
|
|
||||||
__dirname + "/Resources/public/vuejs/AddressPicker/index.ts",
|
|
||||||
)
|
|
||||||
|
|
||||||
};
|
};
|
||||||
|
@@ -1,52 +0,0 @@
|
|||||||
<?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');
|
|
||||||
}
|
|
||||||
}
|
|
@@ -181,11 +181,6 @@ address more:
|
|||||||
buildingName: résidence
|
buildingName: résidence
|
||||||
extra: ""
|
extra: ""
|
||||||
distribution: cedex
|
distribution: cedex
|
||||||
|
|
||||||
address_picker:
|
|
||||||
# placeholder
|
|
||||||
Search for addresses: Chercher des adresses
|
|
||||||
|
|
||||||
Create a new address: Créer une nouvelle adresse
|
Create a new address: Créer une nouvelle adresse
|
||||||
Create an address: Créer une adresse
|
Create an address: Créer une adresse
|
||||||
Update address: Modifier l'adresse
|
Update address: Modifier l'adresse
|
||||||
|
@@ -32,9 +32,16 @@
|
|||||||
<div class="wl-col list">
|
<div class="wl-col list">
|
||||||
<div class="d-flex flex-column justify-content-center">
|
<div class="d-flex flex-column justify-content-center">
|
||||||
{% if app != null %}
|
{% if app != null %}
|
||||||
<div class="date">
|
{% if acp.closingDate != null %}
|
||||||
{{ 'Since %date%'|trans({'%date%': app.startDate|format_date('medium') }) }}
|
{{ 'accompanying_period.dates_from_%opening_date%_to_%closing_date%'|trans({
|
||||||
</div>
|
'%opening_date%': acp.openingDate|format_date('long'),
|
||||||
|
'%closing_date%': acp.closingDate|format_date('long')}
|
||||||
|
) }}
|
||||||
|
{% else %}
|
||||||
|
<div class="date">
|
||||||
|
{{ 'Since %date%'|trans({'%date%': app.startDate|format_date('medium') }) }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% set notif_counter = chill_count_notifications('Chill\\PersonBundle\\Entity\\AccompanyingPeriod', acp.id) %}
|
{% set notif_counter = chill_count_notifications('Chill\\PersonBundle\\Entity\\AccompanyingPeriod', acp.id) %}
|
||||||
@@ -70,6 +77,20 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% if acp.step == 'CLOSED' and acp.closingMotive is not null %}
|
||||||
|
<div class="wl-row">
|
||||||
|
<div class="wl-col title">
|
||||||
|
<h3 class="closingMotive">{{ 'Closing motive'|trans }}</h3>
|
||||||
|
</div>
|
||||||
|
<div class="wl-col list">
|
||||||
|
<div>
|
||||||
|
{{ acp.closingMotive.name|localize_translatable_string }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
|
||||||
{% if acp.user is not null %}
|
{% if acp.user is not null %}
|
||||||
<div class="wl-row">
|
<div class="wl-row">
|
||||||
<div class="wl-col title">
|
<div class="wl-col title">
|
||||||
|
@@ -624,8 +624,7 @@ final class SingleTaskController extends AbstractController
|
|||||||
->addCheckbox('status', $statuses, $statuses, $statusTrans);
|
->addCheckbox('status', $statuses, $statuses, $statusTrans);
|
||||||
|
|
||||||
$states = $this->singleTaskStateRepository->findAllExistingStates();
|
$states = $this->singleTaskStateRepository->findAllExistingStates();
|
||||||
$checked = array_values(array_filter($states, fn (string $state) => !in_array($state, ['in_progress', 'closed', 'canceled', 'validated'], true)));
|
$checked = array_values(array_filter($states, fn (string $state) => !in_array($state, ['to_validate', 'in_progress', 'closed', 'canceled', 'validated'], true)));
|
||||||
|
|
||||||
if ([] !== $states) {
|
if ([] !== $states) {
|
||||||
$filterBuilder
|
$filterBuilder
|
||||||
->addCheckbox('states', $states, $checked);
|
->addCheckbox('states', $states, $checked);
|
||||||
|
Reference in New Issue
Block a user