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
57 changed files with 1521 additions and 622 deletions

View File

@@ -1,14 +0,0 @@
## v4.6.0 - 2025-10-15
### Feature
* ([#423](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/423)) Create environment banner that can be activated and configured depending on the image deployed
* ([#394](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/394)) Only show active workflow on the page "my tracked workflow"
### Fixed
* Fix loading of classLists in SocialIssuesAcc.vue, ensure elements are present
* Fix the rendering of list of StoredObjectVersions, where there are kept version (before converting to pdf) and intermediate versions deleted
* ([#434](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/434)) Notification: fix editing of sent notification by removing form.addressesEmails, a field that no longer exists
* Fix loading of social issues and social actions within vue component
* ([#446](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/446)) Add unique condition on stored object filename, with cleaning step on existing duplicate filenames
**Schema Change**: Drop or rename table or columns, or enforce new constraint that must be manually fixed
* [workflow] take permissions into account to delete the workflow attachment
* ([#448](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/448)) Fix the execution of daily cronjob notification, when the previous last execution storage was invalid

View File

@@ -240,6 +240,9 @@ The tests are run from the project's root (not from the bundle's root).
# Run all tests # Run all tests
vendor/bin/phpunit vendor/bin/phpunit
# Run tests for a specific bundle
vendor/bin/phpunit --testsuite NameBundle
# Run a specific test file # Run a specific test file
vendor/bin/phpunit path/to/TestFile.php vendor/bin/phpunit path/to/TestFile.php
@@ -247,9 +250,6 @@ vendor/bin/phpunit path/to/TestFile.php
vendor/bin/phpunit --filter methodName path/to/TestFile.php vendor/bin/phpunit --filter methodName path/to/TestFile.php
``` ```
When writing tests, only test specific files. Do not run all tests or the full
test suite.
#### Test Structure #### Test Structure
Tests are organized by bundle and follow the same structure as the bundle itself: Tests are organized by bundle and follow the same structure as the bundle itself:

View File

@@ -6,21 +6,6 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html),
and is generated by [Changie](https://github.com/miniscruff/changie). and is generated by [Changie](https://github.com/miniscruff/changie).
## v4.6.0 - 2025-10-15
### Feature
* ([#423](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/423)) Create environment banner that can be activated and configured depending on the image deployed
* ([#394](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/394)) Only show active workflow on the page "my tracked workflow"
### Fixed
* Fix loading of classLists in SocialIssuesAcc.vue, ensure elements are present
* Fix the rendering of list of StoredObjectVersions, where there are kept version (before converting to pdf) and intermediate versions deleted
* ([#434](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/434)) Notification: fix editing of sent notification by removing form.addressesEmails, a field that no longer exists
* Fix loading of social issues and social actions within vue component
* ([#446](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/446)) Add unique condition on stored object filename, with cleaning step on existing duplicate filenames
**Schema Change**: Drop or rename table or columns, or enforce new constraint that must be manually fixed
* [workflow] take permissions into account to delete the workflow attachment
* ([#448](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/448)) Fix the execution of daily cronjob notification, when the previous last execution storage was invalid
## v4.5.1 - 2025-10-03 ## v4.5.1 - 2025-10-03
### Fixed ### Fixed
* Add missing javascript dependency * Add missing javascript dependency

View File

@@ -1,13 +1,6 @@
chill_main: chill_main:
available_languages: [ '%env(resolve:LOCALE)%', 'en' ] available_languages: [ '%env(resolve:LOCALE)%', 'en' ]
available_countries: ['BE', 'FR'] available_countries: ['BE', 'FR']
top_banner:
visible: false
text:
fr: 'Vous travaillez actuellement avec la version de PRÉ-PRODUCTION.'
nl: 'Je werkt momenteel in de PRE-PRODUCTIE versie'
color: '#353535'
background_color: '#d8bb48'
notifications: notifications:
from_email: '%env(resolve:NOTIFICATION_FROM_EMAIL)%' from_email: '%env(resolve:NOTIFICATION_FROM_EMAIL)%'
from_name: '%env(resolve:NOTIFICATION_FROM_NAME)%' from_name: '%env(resolve:NOTIFICATION_FROM_NAME)%'

View File

@@ -17,3 +17,9 @@ 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'

View File

@@ -45,6 +45,7 @@
"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",
@@ -66,10 +67,12 @@
"mime": "^4.0.0", "mime": "^4.0.0",
"pdfjs-dist": "^4.3.136", "pdfjs-dist": "^4.3.136",
"vis-network": "^9.1.0", "vis-network": "^9.1.0",
"vue": "^3.5.6", "vue": "^3.5.x",
"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-tsc": "^3.1.0",
"vue-use-leaflet": "^0.1.7",
"vuex": "^4.0.0" "vuex": "^4.0.0"
}, },
"browserslist": [ "browserslist": [

View File

@@ -136,14 +136,8 @@ export default {
issueIsLoading: false, issueIsLoading: false,
actionIsLoading: false, actionIsLoading: false,
actionAreLoaded: false, actionAreLoaded: false,
socialIssuesClassList: { socialIssuesClassList: `col-form-label ${document.querySelector("input#chill_activitybundle_activity_socialIssues").getAttribute("required") ? "required" : ""}`,
"col-form-label": true, socialActionsClassList: `col-form-label ${document.querySelector("input#chill_activitybundle_activity_socialActions").getAttribute("required") ? "required" : ""}`,
required: false,
},
socialActionsClassList: {
"col-form-label": true,
required: false,
},
}; };
}, },
computed: { computed: {
@@ -164,21 +158,6 @@ export default {
}, },
}, },
mounted() { mounted() {
/* Load classNames after element is present */
const socialActionsEl = document.querySelector(
"input#chill_activitybundle_activity_socialActions",
);
if (socialActionsEl && socialActionsEl.hasAttribute("required")) {
this.socialActionsClassList.required = true;
}
const socialIssuesEl = document.querySelector(
"input#chill_activitybundle_activity_socialIssues",
);
if (socialIssuesEl && socialIssuesEl.hasAttribute("required")) {
this.socialIssuesClassList.required = true;
}
/* Load other issues in multiselect */ /* Load other issues in multiselect */
this.issueIsLoading = true; this.issueIsLoading = true;
this.actionAreLoaded = false; this.actionAreLoaded = false;

View File

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

View File

@@ -23,14 +23,10 @@ use Random\RandomException;
* Store each version of StoredObject's. * Store each version of StoredObject's.
* *
* A version should not be created manually: use the method @see{StoredObject::registerVersion} instead. * A version should not be created manually: use the method @see{StoredObject::registerVersion} instead.
*
* Each filename must be unique within the same StoredObject. We add a condition on id to apply this condition only for
* newly created versions when this new index is applied.
*/ */
#[ORM\Entity] #[ORM\Entity]
#[ORM\Table('chill_doc.stored_object_version')] #[ORM\Table('chill_doc.stored_object_version')]
#[ORM\UniqueConstraint(name: 'chill_doc_stored_object_version_unique_by_object', columns: ['stored_object_id', 'version'])] #[ORM\UniqueConstraint(name: 'chill_doc_stored_object_version_unique_by_object', columns: ['stored_object_id', 'version'])]
#[ORM\UniqueConstraint(name: 'chill_doc_stored_object_version_unique_by_filename', columns: ['filename'], options: ['where' => '(id > 0)'])]
class StoredObjectVersion implements TrackCreationInterface class StoredObjectVersion implements TrackCreationInterface
{ {
use TrackCreationTrait; use TrackCreationTrait;

View File

@@ -36,18 +36,6 @@ export interface GenericDocForAccompanyingPeriod extends GenericDoc {
context: "accompanying-period"; context: "accompanying-period";
} }
export function isGenericDocForAccompanyingPeriod(
doc: GenericDoc,
): doc is GenericDocForAccompanyingPeriod {
return doc.context === "accompanying-period";
}
export function isGenericDocWithStoredObject(
doc: GenericDoc,
): doc is GenericDoc & { storedObject: StoredObject } {
return doc.storedObject !== null;
}
interface BaseMetadataWithHtml extends BaseMetadata { interface BaseMetadataWithHtml extends BaseMetadata {
html: string; html: string;
} }
@@ -56,33 +44,28 @@ export interface GenericDocForAccompanyingCourseDocument
extends GenericDocForAccompanyingPeriod { extends GenericDocForAccompanyingPeriod {
key: "accompanying_course_document"; key: "accompanying_course_document";
metadata: BaseMetadataWithHtml; metadata: BaseMetadataWithHtml;
storedObject: StoredObject;
} }
export interface GenericDocForAccompanyingCourseActivityDocument export interface GenericDocForAccompanyingCourseActivityDocument
extends GenericDocForAccompanyingPeriod { extends GenericDocForAccompanyingPeriod {
key: "accompanying_course_activity_document"; key: "accompanying_course_activity_document";
metadata: BaseMetadataWithHtml; metadata: BaseMetadataWithHtml;
storedObject: StoredObject;
} }
export interface GenericDocForAccompanyingCourseCalendarDocument export interface GenericDocForAccompanyingCourseCalendarDocument
extends GenericDocForAccompanyingPeriod { extends GenericDocForAccompanyingPeriod {
key: "accompanying_course_calendar_document"; key: "accompanying_course_calendar_document";
metadata: BaseMetadataWithHtml; metadata: BaseMetadataWithHtml;
storedObject: StoredObject;
} }
export interface GenericDocForAccompanyingCoursePersonDocument export interface GenericDocForAccompanyingCoursePersonDocument
extends GenericDocForAccompanyingPeriod { extends GenericDocForAccompanyingPeriod {
key: "person_document"; key: "person_document";
metadata: BaseMetadataWithHtml; metadata: BaseMetadataWithHtml;
storedObject: StoredObject;
} }
export interface GenericDocForAccompanyingCourseWorkEvaluationDocument export interface GenericDocForAccompanyingCourseWorkEvaluationDocument
extends GenericDocForAccompanyingPeriod { extends GenericDocForAccompanyingPeriod {
key: "accompanying_period_work_evaluation_document"; key: "accompanying_period_work_evaluation_document";
metadata: BaseMetadataWithHtml; metadata: BaseMetadataWithHtml;
storedObject: StoredObject;
} }

View File

@@ -3,9 +3,9 @@ import {
StoredObject, StoredObject,
StoredObjectPointInTime, StoredObjectPointInTime,
StoredObjectVersionWithPointInTime, StoredObjectVersionWithPointInTime,
} from "ChillDocStoreAssets/types"; } from "./../../../types";
import UserRenderBoxBadge from "ChillMainAssets/vuejs/_components/Entity/UserRenderBoxBadge.vue"; 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 FileIcon from "ChillDocStoreAssets/vuejs/FileIcon.vue";
import RestoreVersionButton from "ChillDocStoreAssets/vuejs/StoredObjectButton/HistoryButton/RestoreVersionButton.vue"; import RestoreVersionButton from "ChillDocStoreAssets/vuejs/StoredObjectButton/HistoryButton/RestoreVersionButton.vue";
import DownloadButton from "ChillDocStoreAssets/vuejs/StoredObjectButton/DownloadButton.vue"; import DownloadButton from "ChillDocStoreAssets/vuejs/StoredObjectButton/DownloadButton.vue";

View File

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

View File

@@ -1,63 +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\DocStore;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20251013094414 extends AbstractMigration
{
public function getDescription(): string
{
return 'DocStore: Enforce filename uniqueness on chill_doc.stored_object_version; clean duplicates and add partial unique index on filename (for new rows only).';
}
public function up(Schema $schema): void
{
// 1) Clean duplicates: for each (stored_object_id, filename, key, iv), keep only the last inserted row
// and delete all others. Use ROW_NUMBER over id DESC to define the last one.
$this->addSql(<<<'SQL'
WITH ranked AS (
SELECT id,
rank() OVER (
PARTITION BY stored_object_id, filename, "key"::jsonb, iv::jsonb
ORDER BY id DESC
) AS rn
FROM chill_doc.stored_object_version
)
DELETE FROM chill_doc.stored_object_version sov
USING ranked r
WHERE sov.id = r.id
AND r.rn > 1
SQL);
// 2) Create a partial unique index on filename that applies only to subsequently inserted rows.
// Per user's instruction, compute the cutoff using the stored_object_id sequence value.
$nextVal = (int) $this->connection->fetchOne("SELECT nextval('chill_doc.stored_object_version_id_seq')");
// Safety: if somehow sequence is not available, fallback to current max id from the table
if ($nextVal <= 0) {
$nextVal = (int) $this->connection->fetchOne('SELECT COALESCE(MAX(id), 0) FROM chill_doc.stored_object_version');
}
$this->addSql(sprintf(
'CREATE UNIQUE INDEX chill_doc_stored_object_version_unique_by_filename ON chill_doc.stored_object_version (filename) WHERE id > %d',
$nextVal
));
}
public function down(Schema $schema): void
{
// Drop the partial unique index; data cleanup is irreversible.
$this->addSql('DROP INDEX IF EXISTS chill_doc_stored_object_version_unique_by_filename');
}
}

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

@@ -264,12 +264,11 @@ class WorkflowController extends AbstractController
{ {
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_REMEMBERED'); $this->denyAccessUnlessGranted('IS_AUTHENTICATED_REMEMBERED');
$total = $this->entityWorkflowRepository->countBySubscriber($this->security->getUser(), false); $total = $this->entityWorkflowRepository->countBySubscriber($this->security->getUser());
$paginator = $this->paginatorFactory->create($total); $paginator = $this->paginatorFactory->create($total);
$workflows = $this->entityWorkflowRepository->findBySubscriber( $workflows = $this->entityWorkflowRepository->findBySubscriber(
$this->security->getUser(), $this->security->getUser(),
false,
['createdAt' => 'DESC'], ['createdAt' => 'DESC'],
$paginator->getItemsPerPage(), $paginator->getItemsPerPage(),
$paginator->getCurrentPageFirstItemNumber() $paginator->getCurrentPageFirstItemNumber()

View File

@@ -205,11 +205,6 @@ class ChillMainExtension extends Extension implements
[] []
); );
$container->setParameter(
'chill_main.top_banner',
$config['top_banner'] ?? []
);
$loader = new Loader\YamlFileLoader($container, new FileLocator(__DIR__.'/../config')); $loader = new Loader\YamlFileLoader($container, new FileLocator(__DIR__.'/../config'));
$loader->load('services.yaml'); $loader->load('services.yaml');
$loader->load('services/doctrine.yaml'); $loader->load('services/doctrine.yaml');
@@ -255,7 +250,6 @@ class ChillMainExtension extends Extension implements
'name' => $config['installation_name'], ], 'name' => $config['installation_name'], ],
'available_languages' => $config['available_languages'], 'available_languages' => $config['available_languages'],
'add_address' => $config['add_address'], 'add_address' => $config['add_address'],
'chill_main_config' => $config,
], ],
'form_themes' => ['@ChillMain/Form/fields.html.twig'], 'form_themes' => ['@ChillMain/Form/fields.html.twig'],
]; ];

View File

@@ -168,20 +168,6 @@ class Configuration implements ConfigurationInterface
->end() ->end()
->end() ->end()
->end() ->end()
->arrayNode('top_banner')
->canBeUnset()
->children()
->booleanNode('visible')
->defaultFalse()
->end()
->arrayNode('text')
->useAttributeAsKey('lang')
->scalarPrototype()->end()
->end() // end of text
->scalarNode('color')->defaultNull()->end()
->scalarNode('background_color')->defaultNull()->end()
->end() // end of top_banner children
->end() // end of top_banner
->arrayNode('widgets') ->arrayNode('widgets')
->canBeEnabled() ->canBeEnabled()
->canBeUnset() ->canBeUnset()

View File

@@ -53,16 +53,11 @@ readonly class DailyNotificationDigestCronjob implements CronJobInterface
public function run(array $lastExecutionData): ?array public function run(array $lastExecutionData): ?array
{ {
$now = $this->clock->now(); $now = $this->clock->now();
if (isset($lastExecutionData['last_execution'])) { if (isset($lastExecutionData['last_execution'])) {
$lastExecution = \DateTimeImmutable::createFromFormat( $lastExecution = \DateTimeImmutable::createFromFormat(
\DateTimeImmutable::ATOM, \DateTimeImmutable::ATOM,
$lastExecutionData['last_execution'] $lastExecutionData['last_execution']
); );
if (false === $lastExecution) {
$lastExecution = $now->sub(new \DateInterval('P1D'));
}
} else { } else {
$lastExecution = $now->sub(new \DateInterval('P1D')); $lastExecution = $now->sub(new \DateInterval('P1D'));
} }
@@ -101,7 +96,7 @@ readonly class DailyNotificationDigestCronjob implements CronJobInterface
]); ]);
return [ return [
'last_execution' => $now->format(\DateTimeInterface::ATOM), 'last_execution' => $now->format('Y-m-d-H:i:s.u e'),
]; ];
} }
} }

View File

@@ -14,13 +14,14 @@ 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 ObjectRepository final readonly class AddressReferenceRepository implements AddressReferenceRepositoryInterface
{ {
private EntityManagerInterface $entityManager; private EntityManagerInterface $entityManager;
@@ -65,6 +66,121 @@ final readonly class AddressReferenceRepository implements ObjectRepository
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

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

@@ -57,15 +57,9 @@ class EntityWorkflowRepository implements ObjectRepository
return (int) $qb->getQuery()->getSingleScalarResult(); return (int) $qb->getQuery()->getSingleScalarResult();
} }
/** public function countBySubscriber(User $user): int
* @param bool|null $isFinal true to get only the entityWorkflow which is finalized, false to get the workflows that are not finalized, and null to ignore
*
* @throws \Doctrine\ORM\NoResultException
* @throws \Doctrine\ORM\NonUniqueResultException
*/
public function countBySubscriber(User $user, ?bool $isFinal = null): int
{ {
$qb = $this->buildQueryBySubscriber($user, $isFinal)->select('count(ew)'); $qb = $this->buildQueryBySubscriber($user)->select('count(ew)');
return (int) $qb->getQuery()->getSingleScalarResult(); return (int) $qb->getQuery()->getSingleScalarResult();
} }
@@ -188,14 +182,9 @@ class EntityWorkflowRepository implements ObjectRepository
return $qb->getQuery()->getResult(); return $qb->getQuery()->getResult();
} }
/** public function findBySubscriber(User $user, ?array $orderBy = null, $limit = null, $offset = null): array
* @param bool|null $isFinal true to get only the entityWorkflow which is finalized, false to get the workflows that are not finalized, and null to ignore
* @param mixed|null $limit
* @param mixed|null $offset
*/
public function findBySubscriber(User $user, ?bool $isFinal = null, ?array $orderBy = null, $limit = null, $offset = null): array
{ {
$qb = $this->buildQueryBySubscriber($user, $isFinal)->select('ew'); $qb = $this->buildQueryBySubscriber($user)->select('ew');
foreach ($orderBy as $key => $sort) { foreach ($orderBy as $key => $sort) {
$qb->addOrderBy('ew.'.$key, $sort); $qb->addOrderBy('ew.'.$key, $sort);
@@ -323,7 +312,7 @@ class EntityWorkflowRepository implements ObjectRepository
return $qb; return $qb;
} }
private function buildQueryBySubscriber(User $user, ?bool $isFinal): QueryBuilder private function buildQueryBySubscriber(User $user): QueryBuilder
{ {
$qb = $this->repository->createQueryBuilder('ew'); $qb = $this->repository->createQueryBuilder('ew');
@@ -336,14 +325,6 @@ class EntityWorkflowRepository implements ObjectRepository
$qb->setParameter('user', $user); $qb->setParameter('user', $user);
if (null !== $isFinal) {
if ($isFinal) {
$qb->andWhere(sprintf('EXISTS (SELECT 1 FROM %s step WHERE step.isFinal = true AND ew = step.entityWorkflow)', EntityWorkflowStep::class));
} else {
$qb->andWhere(sprintf('NOT EXISTS (SELECT 1 FROM %s step WHERE step.isFinal = true AND ew = step.entityWorkflow)', EntityWorkflowStep::class));
}
}
return $qb; return $qb;
} }
} }

View File

@@ -1,7 +1,4 @@
import { import { GenericDoc } from "ChillDocStoreAssets/types/generic_doc";
GenericDoc,
isGenericDocWithStoredObject,
} from "ChillDocStoreAssets/types/generic_doc";
import { StoredObject, StoredObjectStatus } from "ChillDocStoreAssets/types"; import { StoredObject, StoredObjectStatus } from "ChillDocStoreAssets/types";
import { Person } from "../../../ChillPersonBundle/Resources/public/types"; import { Person } from "../../../ChillPersonBundle/Resources/public/types";
@@ -78,6 +75,7 @@ export interface Postcode {
name: string; name: string;
code: string; code: string;
center: Point; center: Point;
country: Country;
} }
export interface Point { export interface Point {
@@ -93,6 +91,28 @@ 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;
@@ -111,7 +131,7 @@ export interface Address {
confidential: boolean; confidential: boolean;
lines: string[]; lines: string[];
addressReference: AddressReference | null; addressReference: AddressReference | null;
validFrom: DateTime; validFrom: DateTime | null; // TODO there is no null for validFrom
validTo: DateTime | null; validTo: DateTime | null;
point: Point | null; point: Point | null;
refStatus: AddressRefStatus; refStatus: AddressRefStatus;
@@ -206,25 +226,6 @@ export interface WorkflowAttachment {
genericDoc: null | GenericDoc; genericDoc: null | GenericDoc;
} }
export type AttachmentWithDocAndStored = WorkflowAttachment & {
genericDoc: GenericDoc & { storedObject: StoredObject };
};
export function isAttachmentWithDocAndStored(
a: WorkflowAttachment,
): a is AttachmentWithDocAndStored {
return (
isWorkflowAttachmentWithGenericDoc(a) &&
isGenericDocWithStoredObject(a.genericDoc)
);
}
export function isWorkflowAttachmentWithGenericDoc(
attachment: WorkflowAttachment,
): attachment is WorkflowAttachment & { genericDoc: GenericDoc } {
return attachment.genericDoc !== null;
}
export interface Workflow { export interface Workflow {
name: string; name: string;
text: string; text: string;

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

@@ -6,7 +6,6 @@ import { GenericDocForAccompanyingPeriod } from "ChillDocStoreAssets/types/gener
import AttachmentList from "ChillMainAssets/vuejs/WorkflowAttachment/Component/AttachmentList.vue"; import AttachmentList from "ChillMainAssets/vuejs/WorkflowAttachment/Component/AttachmentList.vue";
import { GenericDoc } from "ChillDocStoreAssets/types"; import { GenericDoc } from "ChillDocStoreAssets/types";
import { fetchWorkflow } from "ChillMainAssets/lib/workflow/api"; import { fetchWorkflow } from "ChillMainAssets/lib/workflow/api";
import { trans, WORKFLOW_ATTACHMENTS_ADD_AN_ATTACHMENT } from "translator";
interface AppConfig { interface AppConfig {
workflowId: number; workflowId: number;
@@ -84,7 +83,7 @@ const canEditAttachement = computed<boolean>(() => {
<ul v-if="canEditAttachement" class="record_actions"> <ul v-if="canEditAttachement" class="record_actions">
<li> <li>
<button type="button" class="btn btn-create" @click="openModal"> <button type="button" class="btn btn-create" @click="openModal">
{{ trans(WORKFLOW_ATTACHMENTS_ADD_AN_ATTACHMENT) }} Ajouter une pièce jointe
</button> </button>
</li> </li>
</ul> </ul>

View File

@@ -1,14 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { import { EntityWorkflow, WorkflowAttachment } from "ChillMainAssets/types";
AttachmentWithDocAndStored,
EntityWorkflow,
isAttachmentWithDocAndStored,
WorkflowAttachment,
} from "ChillMainAssets/types";
import GenericDocItemBox from "ChillMainAssets/vuejs/WorkflowAttachment/Component/GenericDocItemBox.vue"; import GenericDocItemBox from "ChillMainAssets/vuejs/WorkflowAttachment/Component/GenericDocItemBox.vue";
import DocumentActionButtonsGroup from "ChillDocStoreAssets/vuejs/DocumentActionButtonsGroup.vue"; import DocumentActionButtonsGroup from "ChillDocStoreAssets/vuejs/DocumentActionButtonsGroup.vue";
import { computed } from "vue";
import { trans, WORKFLOW_ATTACHMENTS_NO_ATTACHMENT } from "translator";
interface AttachmentListProps { interface AttachmentListProps {
attachments: WorkflowAttachment[]; attachments: WorkflowAttachment[];
@@ -21,43 +14,35 @@ const emit = defineEmits<{
}>(); }>();
const props = defineProps<AttachmentListProps>(); const props = defineProps<AttachmentListProps>();
const notNullAttachments = computed<AttachmentWithDocAndStored[]>(() =>
props.attachments.filter(
(a: WorkflowAttachment): a is AttachmentWithDocAndStored =>
isAttachmentWithDocAndStored(a),
),
);
const canRemove = computed<boolean>((): boolean => {
if (null === props.workflow) {
return false;
}
return props.workflow._permissions.CHILL_MAIN_WORKFLOW_ATTACHMENT_EDIT;
});
</script> </script>
<template> <template>
<p <p
v-if="notNullAttachments.length === 0" v-if="props.attachments.length === 0"
class="chill-no-data-statement text-center" class="chill-no-data-statement text-center"
> >
{{ trans(WORKFLOW_ATTACHMENTS_NO_ATTACHMENT) }} Aucune pièce jointe
</p> </p>
<div v-else class="flex-table"> <!-- TODO translate -->
<div v-for="a in notNullAttachments" :key="a.id" class="item-bloc"> <div else class="flex-table">
<div v-for="a in props.attachments" :key="a.id" class="item-bloc">
<generic-doc-item-box <generic-doc-item-box
v-if="a.genericDoc !== null"
:generic-doc="a.genericDoc" :generic-doc="a.genericDoc"
></generic-doc-item-box> ></generic-doc-item-box>
<div class="item-row separator"> <div class="item-row separator">
<ul class="record_actions"> <ul class="record_actions">
<li> <li v-if="a.genericDoc?.storedObject !== null">
<document-action-buttons-group <document-action-buttons-group
:stored-object="a.genericDoc.storedObject" :stored-object="a.genericDoc.storedObject"
></document-action-buttons-group> ></document-action-buttons-group>
</li> </li>
<li v-if="canRemove"> <li
v-if="
!workflow?._permissions
.CHILL_MAIN_WORKFLOW_ATTACHMENT_EDIT
"
>
<button <button
type="button" type="button"
class="btn btn-delete" class="btn btn-delete"

View File

@@ -1,8 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { GenericDoc } from "ChillDocStoreAssets/types/generic_doc"; import { GenericDocForAccompanyingPeriod } from "ChillDocStoreAssets/types/generic_doc";
interface GenericDocItemBoxProps { interface GenericDocItemBoxProps {
genericDoc: GenericDoc; genericDoc: GenericDocForAccompanyingPeriod;
} }
const props = defineProps<GenericDocItemBoxProps>(); const props = defineProps<GenericDocItemBoxProps>();

View File

@@ -4,24 +4,27 @@
: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 } from "../../../types"; import {Address, AddressCreation} 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; address: Address|AddressCreation;
} }
const props = defineProps<AddressModalContentProps>(); const props = defineProps<AddressModalContentProps>();

View File

@@ -12,90 +12,91 @@
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
<a :href="makeUrlOsm(props.address)" target="_blank">OSM</a> :href="makeUrlOsm(props.address)" target="_blank"
>OSM</a>
</p> </p>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { onMounted, ref } from "vue"; import {computed, 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, 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 => { 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; address: Address|AddressCreation;
} }
const props = defineProps<MapProps>(); const props = defineProps<MapProps>();
const map_div = ref<HTMLDivElement | null>(null); const markerIcon = L.icon({
let map: L.Map | null = null; iconUrl: markerIconPng,
let marker: L.Marker | null = null; iconAnchor: [12, 41],
});
onMounted(() => { const latLngMarker = computed((): LatLngExpression => {
if (map_div.value === null) { if (props.address === null || props.address.point === null) {
// there is no map div when the address does not have any Point return [0, 0, 0];
return;
} }
if (props.address.point !== null) { return [props.address.point.coordinates[1], props.address.point.coordinates[0], 0]
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({
iconUrl: markerIconPng,
iconAnchor: [12, 41],
});
marker = L.marker(lonLatForLeaflet(props.address.point), {
icon: markerIcon,
});
marker.addTo(map);
}
}); });
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(); const params = new URLSearchParams();
params.append("api", "1"); params.append("api", "1");
if (address.point !== null && address.addressReference !== null) { const point = getAddressPoint(address);
if (point !== null && address.addressReference !== null) {
params.append( params.append(
"query", "query",
`${address.point.coordinates[1]} ${address.point.coordinates[0]}`, `${point.coordinates[1]} ${point.coordinates[0]}`,
); );
} else { } else {
params.append("query", address.lines.join(", ")); params.append("query", buildAddressLines(address).join(", "));
} }
return `https://www.google.com/maps/search/?${params.toString()}`; return `https://www.google.com/maps/search/?${params.toString()}`;
}; };
const makeUrlOsm = (address: Address): string => { const makeUrlOsm = (address: Address|AddressCreation): string => {
if (address.point !== null && address.addressReference !== null) { const point = getAddressPoint(address);
if (point !== null && address.addressReference !== null) {
const params = new URLSearchParams(); const params = new URLSearchParams();
params.append("mlat", `${address.point.coordinates[1]}`); params.append("mlat", `${point.coordinates[1]}`);
params.append("mlon", `${address.point.coordinates[0]}`); params.append("mlon", `${point.coordinates[0]}`);
const hashParams = new URLSearchParams(); const hashParams = new URLSearchParams();
hashParams.append( hashParams.append(
"map", "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()}`; return `https://www.openstreetmap.org/?${params.toString()}#${hashParams.toString()}`;
} }
const params = new URLSearchParams(); const params = new URLSearchParams();
params.append("query", address.lines.join(", ")); params.append("query", buildAddressLines(address).join(", "));
return `https://www.openstreetmap.org/search?${params.toString()}`; 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"> <div v-if="isConfidential">
<confidential :position-btn-far="true"> <confidential :position-btn-far="true">
<template #confidential-content> <template #confidential-content>
<div v-if="isMultiline === true"> <div v-if="isMultiline">
<p <p
v-for="(l, i) in address.lines" v-for="(l, i) in buildAddressLines(address)"
:key="`line-${i}`" :key="`line-${i}`"
> >
{{ l }} {{ l }}
</p> </p>
<p v-if="showButtonDetails"> <p v-if="showButtonDetails && isAddress(address) ">
<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="'' !== address.text" class="street"> <p v-if="'' !== buildAddressText(address)" class="street">
{{ address.text }} {{ buildAddressText(address) }}
</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.country" class="country"> <p v-if="null !== address.postcode" class="country">
{{ localizeString(address.country.name) }} {{ localizeString(address.postcode.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 === true"> <div v-if="isMultiline">
<p v-for="(l, i) in address.lines" :key="`line-${i}`"> <p v-for="(l, i) in buildAddressLines(address)" :key="`line-${i}`">
{{ l }} {{ l }}
</p> </p>
<p v-if="showButtonDetails"> <p v-if="showButtonDetails && isAddress(address) ">
<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="address.text" class="street"> <p v-if="'' !== buildAddressText(address)" class="street">
{{ address.text }} {{ buildAddressText(address)}}
<template v-if="showButtonDetails"> <template v-if="showButtonDetails && isAddress(address) ">
<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,68 +63,49 @@
</div> </div>
</component> </component>
<div v-if="useDatePane === true" class="address-more"> <div v-if="useDatePane" 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
>: {{ $d(address.validFrom.date) }} >: {{ address.validFrom?.datetime8601 }}
</span> </span>
</div> </div>
<div v-if="address.validTo"> <div v-if="isAddress(address) && address.validTo !== null">
<span class="validTo"> <span class="validTo">
<b>{{ trans(ADDRESS_VALID_TO) }}</b <b>{{ trans(ADDRESS_VALID_TO) }}</b
>: {{ $d(address.validTo.date) }} >: {{ address.validTo?.datetime8601 }}
</span> </span>
</div> </div>
</div> </div>
</component> </component>
</template> </template>
<script> <script setup lang="ts">
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";
export default { const props = withDefaults(
name: "AddressRenderBox", defineProps<{
methods: { localizeString }, address: Address|AddressCreation;
components: { isMultiline?: boolean;
Confidential, useDatePane?: boolean;
AddressDetailsButton, showButtonDetails?: boolean;
}, }>(),
props: { {
address: { isMultiline: true,
type: Object, useDatePane: false,
}, showButtonDetails: true,
isMultiline: { }
default: true, );
type: Boolean,
}, const component = computed(() => (props.isMultiline ? "div" : "span"));
useDatePane: { const multiline = computed(() => (props.isMultiline ? "multiline" : ""));
default: false, const isConfidential = computed(() => Boolean(props.address?.confidential));
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>

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

@@ -1,17 +0,0 @@
{% if chill_main_config.top_banner is defined and chill_main_config.top_banner.text is defined %}
{% set banner_text = '' %}
{% set current_locale = app.request.locale %}
{% if chill_main_config.top_banner.text[current_locale] is defined %}
{% set banner_text = chill_main_config.top_banner.text[current_locale] %}
{% else %}
{% set banner_text = chill_main_config.top_banner.text|first %}
{% endif %}
{% if banner_text %}
<div class="top-banner w-100 text-center py-2"
style="{% if chill_main_config.top_banner.color is defined %}color: {{ chill_main_config.top_banner.color }};{% endif %}{% if chill_main_config.top_banner.background_color is defined %}background-color: {{ chill_main_config.top_banner.background_color }};{% endif %}">
{{ banner_text }}
</div>
{% endif %}
{% endif %}

View File

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

View File

@@ -26,10 +26,6 @@
</head> </head>
<body> <body>
{% if chill_main_config.top_banner is defined and chill_main_config.top_banner.visible is true %}
{{ include('@ChillMain/Layout/_top_banner.html.twig') }}
{% endif %}
{% if responsive_debug is defined and responsive_debug == 1 %} {% if responsive_debug is defined and responsive_debug == 1 %}
{{ include('@ChillMain/Layout/_debug.html.twig') }} {{ include('@ChillMain/Layout/_debug.html.twig') }}
{% endif %} {% endif %}

View File

@@ -66,6 +66,7 @@ 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(),

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

@@ -1,98 +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\DependencyInjection;
use Chill\MainBundle\DependencyInjection\Configuration;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Config\Definition\Processor;
use Symfony\Component\DependencyInjection\ContainerBuilder;
/**
* @internal
*
* @coversNothing
*/
class ConfigurationTest extends TestCase
{
public function testTopBannerConfiguration(): void
{
$containerBuilder = new ContainerBuilder();
$configuration = new Configuration([], $containerBuilder);
$processor = new Processor();
// Test with top_banner configuration
$config = [
'chill_main' => [
'top_banner' => [
'text' => [
'fr' => 'Vous travaillez actuellement avec la version de pré-production de Chill.',
'nl' => 'Je werkte momenteel in de pré-productie versie van Chill.',
],
'color' => 'white',
'background-color' => 'red',
],
],
];
$processedConfig = $processor->processConfiguration($configuration, $config);
self::assertArrayHasKey('top_banner', $processedConfig);
self::assertArrayHasKey('text', $processedConfig['top_banner']);
self::assertArrayHasKey('fr', $processedConfig['top_banner']['text']);
self::assertArrayHasKey('nl', $processedConfig['top_banner']['text']);
self::assertSame('white', $processedConfig['top_banner']['color']);
self::assertSame('red', $processedConfig['top_banner']['background_color']);
}
public function testTopBannerConfigurationOptional(): void
{
$containerBuilder = new ContainerBuilder();
$configuration = new Configuration([], $containerBuilder);
$processor = new Processor();
// Test without top_banner configuration
$config = [
'chill_main' => [],
];
$processedConfig = $processor->processConfiguration($configuration, $config);
// top_banner should not be present when not configured
self::assertArrayNotHasKey('top_banner', $processedConfig);
}
public function testTopBannerWithMinimalConfiguration(): void
{
$containerBuilder = new ContainerBuilder();
$configuration = new Configuration([], $containerBuilder);
$processor = new Processor();
// Test with minimal top_banner configuration (only text)
$config = [
'chill_main' => [
'top_banner' => [
'text' => [
'fr' => 'Test message',
],
],
],
];
$processedConfig = $processor->processConfiguration($configuration, $config);
self::assertArrayHasKey('top_banner', $processedConfig);
self::assertArrayHasKey('text', $processedConfig['top_banner']);
self::assertSame('Test message', $processedConfig['top_banner']['text']['fr']);
self::assertNull($processedConfig['top_banner']['color']);
self::assertNull($processedConfig['top_banner']['background_color']);
}
}

View File

@@ -37,5 +37,10 @@ class DailyNotificationDigestCronJobFunctionalTest extends KernelTestCase
$actual = $this->dailyNotificationDigestCronjob->run([]); $actual = $this->dailyNotificationDigestCronjob->run([]);
self::assertArrayHasKey('last_execution', $actual); self::assertArrayHasKey('last_execution', $actual);
self::assertInstanceOf(
\DateTimeImmutable::class,
\DateTimeImmutable::createFromFormat('Y-m-d-H:i:s.u e', $actual['last_execution']),
'test that the string can be converted to a date'
);
} }
} }

View File

@@ -12,21 +12,16 @@ declare(strict_types=1);
namespace Chill\MainBundle\Tests\Notification\Email; namespace Chill\MainBundle\Tests\Notification\Email;
use Chill\MainBundle\Notification\Email\DailyNotificationDigestCronjob; use Chill\MainBundle\Notification\Email\DailyNotificationDigestCronjob;
use Chill\MainBundle\Notification\Email\NotificationEmailMessages\ScheduleDailyNotificationDigestMessage;
use Doctrine\DBAL\Connection; use Doctrine\DBAL\Connection;
use Doctrine\DBAL\Result;
use Doctrine\DBAL\Statement;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
use Symfony\Component\Clock\ClockInterface; use Symfony\Component\Clock\ClockInterface;
use Symfony\Component\Clock\MockClock;
use Symfony\Component\Messenger\Envelope;
use Symfony\Component\Messenger\MessageBusInterface; use Symfony\Component\Messenger\MessageBusInterface;
/** /**
* @internal * @internal
* *
* @covers \DailyNotificationDigestCronjob * @coversNothing
*/ */
class DailyNotificationDigestCronJobTest extends TestCase class DailyNotificationDigestCronJobTest extends TestCase
{ {
@@ -35,7 +30,6 @@ class DailyNotificationDigestCronJobTest extends TestCase
private MessageBusInterface $messageBus; private MessageBusInterface $messageBus;
private LoggerInterface $logger; private LoggerInterface $logger;
private DailyNotificationDigestCronjob $cronjob; private DailyNotificationDigestCronjob $cronjob;
private \DateTimeImmutable $firstNow;
protected function setUp(): void protected function setUp(): void
{ {
@@ -44,8 +38,6 @@ class DailyNotificationDigestCronJobTest extends TestCase
$this->messageBus = $this->createMock(MessageBusInterface::class); $this->messageBus = $this->createMock(MessageBusInterface::class);
$this->logger = $this->createMock(LoggerInterface::class); $this->logger = $this->createMock(LoggerInterface::class);
$this->firstNow = new \DateTimeImmutable('2024-01-02T07:15:00+00:00');
$this->cronjob = new DailyNotificationDigestCronjob( $this->cronjob = new DailyNotificationDigestCronjob(
$this->clock, $this->clock,
$this->connection, $this->connection,
@@ -86,129 +78,4 @@ class DailyNotificationDigestCronJobTest extends TestCase
'hour 23 - should not run' => [23, false], 'hour 23 - should not run' => [23, false],
]; ];
} }
public function testRunFirstExecutionReturnsStateAndDispatches(): array
{
// Use MockClock for deterministic time
$firstNow = $this->firstNow;
$clock = new MockClock($firstNow);
// Mock DBAL statement/result
$statement = $this->createMock(Statement::class);
$result = $this->createMock(Result::class);
$this->connection->method('prepare')->willReturn($statement);
$statement->method('bindValue')->willReturnSelf();
$statement->method('executeQuery')->willReturn($result);
$rows = [
['user_id' => 10],
['user_id' => 42],
];
$result->method('fetchAllAssociative')->willReturn($rows);
$dispatched = [];
$this->messageBus->method('dispatch')->willReturnCallback(function ($message) use (&$dispatched) {
$dispatched[] = $message;
return new Envelope($message);
});
$cron = new DailyNotificationDigestCronjob($clock, $this->connection, $this->messageBus, $this->logger);
$state = $cron->run([]);
// Assert dispatch count and message contents
self::assertCount(2, $dispatched);
$expectedLast = $firstNow->sub(new \DateInterval('P1D'));
foreach ($dispatched as $i => $msg) {
self::assertInstanceOf(ScheduleDailyNotificationDigestMessage::class, $msg);
self::assertTrue(in_array($msg->getUserId(), [10, 42], true));
self::assertEquals($firstNow, $msg->getCurrentDateTime(), 'compare the current date');
self::assertEquals($expectedLast, $msg->getLastExecutionDateTime(), 'compare the last execution date');
}
// Assert returned state
self::assertIsArray($state);
self::assertArrayHasKey('last_execution', $state);
self::assertSame($firstNow->format(\DateTimeInterface::ATOM), $state['last_execution']);
return $state;
}
/**
* @depends testRunFirstExecutionReturnsStateAndDispatches
*/
public function testRunSecondExecutionUsesPreviousState(array $previousState): void
{
$firstNow = $this->firstNow;
$secondNow = $firstNow->add(new \DateInterval('P1D'));
$clock = new MockClock($secondNow);
// Mock DBAL for a single user this time
$statement = $this->createMock(Statement::class);
$result = $this->createMock(Result::class);
$this->connection->method('prepare')->willReturn($statement);
$statement->method('bindValue')->willReturnSelf();
$statement->method('executeQuery')->willReturn($result);
$rows = [
['user_id' => 7],
];
$result->method('fetchAllAssociative')->willReturn($rows);
$captured = [];
$this->messageBus->method('dispatch')->willReturnCallback(function ($message) use (&$captured) {
$captured[] = $message;
return new Envelope($message);
});
$cron = new DailyNotificationDigestCronjob($clock, $this->connection, $this->messageBus, $this->logger);
$cron->run($previousState);
self::assertCount(1, $captured);
$msg = $captured[0];
self::assertInstanceOf(ScheduleDailyNotificationDigestMessage::class, $msg);
self::assertEquals(7, $msg->getUserId());
self::assertEquals($secondNow, $msg->getCurrentDateTime(), 'compare the current date');
self::assertEquals($firstNow, $msg->getLastExecutionDateTime(), 'compare the last execution date');
}
public function testRunWithInvalidExecutionState(): void
{
$firstNow = new \DateTimeImmutable('2025-10-14T10:30:00 Europe/Brussels');
$previousExpected = $firstNow->sub(new \DateInterval('P1D'));
$clock = new MockClock($firstNow);
// Mock DBAL for a single user this time
$statement = $this->createMock(Statement::class);
$result = $this->createMock(Result::class);
$this->connection->method('prepare')->willReturn($statement);
$statement->method('bindValue')->willReturnSelf();
$statement->method('executeQuery')->willReturn($result);
$rows = [
['user_id' => 7],
];
$result->method('fetchAllAssociative')->willReturn($rows);
$captured = [];
$this->messageBus->method('dispatch')->willReturnCallback(function ($message) use (&$captured) {
$captured[] = $message;
return new Envelope($message);
});
$cron = new DailyNotificationDigestCronjob($clock, $this->connection, $this->messageBus, $this->logger);
$cron->run(['last_execution' => 'invalid data']);
self::assertCount(1, $captured);
$msg = $captured[0];
self::assertInstanceOf(ScheduleDailyNotificationDigestMessage::class, $msg);
self::assertEquals(7, $msg->getUserId());
self::assertEquals($firstNow, $msg->getCurrentDateTime(), 'compare the current date');
self::assertEquals($previousExpected, $msg->getLastExecutionDateTime(), 'compare the last execution date');
}
} }

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: 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:

View File

@@ -120,6 +120,11 @@ 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",
)
encore.addEntry( encore.addEntry(
"page_workflow_waiting_post_process", "page_workflow_waiting_post_process",
__dirname + "/Resources/public/vuejs/WaitPostProcessWorkflow/index.ts" __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

@@ -181,6 +181,11 @@ 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
@@ -670,8 +675,6 @@ workflow:
attachments: attachments:
title: Pièces jointes title: Pièces jointes
no_attachment: Aucune pièce jointe
Add_an_attachment: Ajouter une pièce jointe
wait: wait:
title: En attente de traitement title: En attente de traitement