Compare commits

..

14 Commits

Author SHA1 Message Date
e55a7f2993 Normalize address search terms by adding UNACCENT and LOWER transformation to AddressReferenceRepository. 2025-08-22 23:14:11 +02:00
8cade9ac77 [wip] Enhance AddressPicker to auto-select position when a single address with one position is found. 2025-08-22 23:09:38 +02:00
eb25f1408e [wip] Refactor AddressDetailsMap to use vue-use-leaflet and enhance AddressPicker layout with dynamic styling adjustments. 2025-08-22 23:05:44 +02:00
279901ac93 [WIP] refactorization to show details of an address 2025-08-18 13:14:10 +02:00
b1a7680b5e [WIP] Create Address button that will open AddressPicker in a modal 2025-08-16 00:29:43 +02:00
b1703a4187 [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-08-16 00:08:03 +02:00
b16e6c4517 [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-08-15 23:38:12 +02:00
d4eddaf671 [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-08-15 23:17:10 +02:00
a126f2f06d [WIP] Refactor AddressReferenceRepository to use interface and add tests for AddressReferenceAggregatedApiController 2025-08-15 01:19:49 +02:00
845aa040cc [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-08-15 01:08:56 +02:00
5a284fe6cf [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-08-07 01:44:15 +02:00
a2b8e0e6ae [WIP] initialize search bar 2025-07-21 00:53:26 +02:00
189a26e0e8 [WIP] initialize an app for address-picker + a demo page 2025-07-21 00:20:27 +02:00
2e32b8946b 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-07-20 23:32:57 +02:00
89 changed files with 2091 additions and 2159 deletions

View File

@@ -1,6 +0,0 @@
kind: Feature
body: Allow the merge of two accompanying period works
time: 2025-02-11T14:22:43.134106669+01:00
custom:
Issue: "359"
SchemaChange: No schema change

View File

@@ -1,6 +0,0 @@
kind: Feature
body: Duplication of a document to another accompanying period work evaluation
time: 2025-04-03T10:03:11.796736107+02:00
custom:
Issue: "369"
SchemaChange: No schema change

View File

@@ -1,6 +0,0 @@
kind: Feature
body: Fusion of two accompanying period works
time: 2025-04-03T10:08:57.25079018+02:00
custom:
Issue: "359"
SchemaChange: No schema change

View File

@@ -1,6 +0,0 @@
kind: Feature
body: Add filter to social actions list to filter out actions where current user intervenes
time: 2025-07-17T11:08:50.128269232+02:00
custom:
Issue: "400"
SchemaChange: No schema change

View File

@@ -1,6 +0,0 @@
kind: Feature
body: Show filters on list pages unfolded by default
time: 2025-07-22T15:50:39.338057044+02:00
custom:
Issue: "399"
SchemaChange: No schema change

View File

@@ -1,6 +0,0 @@
kind: Fixed
body: adjust display logic for accompanying period dates, include closing date if period is closed.
time: 2025-08-06T13:46:09.241584292+02:00
custom:
Issue: "382"
SchemaChange: No schema change

View File

@@ -1,6 +0,0 @@
kind: Fixed
body: add min and step attributes to integer field in DateIntervalType
time: 2025-08-06T17:35:27.413787704+02:00
custom:
Issue: "384"
SchemaChange: No schema change

View File

@@ -1,6 +0,0 @@
kind: UX
body: Limit display of participations in event list
time: 2025-07-22T13:26:37.500656935+02:00
custom:
Issue: ""
SchemaChange: No schema change

View File

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

View File

@@ -45,6 +45,7 @@
"webpack-cli": "^5.0.1"
},
"dependencies": {
"@fragaria/address-formatter": "^6.6.1",
"@fullcalendar/core": "^6.1.4",
"@fullcalendar/daygrid": "^6.1.4",
"@fullcalendar/interaction": "^6.1.4",
@@ -69,6 +70,7 @@
"vue-i18n": "^9.1.6",
"vue-multiselect": "3.0.0-alpha.2",
"vue-toast-notification": "^3.1.2",
"vue-use-leaflet": "^0.1.7",
"vuex": "^4.0.0"
},
"browserslist": [

View File

@@ -4,7 +4,6 @@ import { StoredObject, StoredObjectVersion } from "../../types";
import DropFileWidget from "ChillDocStoreAssets/vuejs/DropFileWidget/DropFileWidget.vue";
import { computed, reactive } from "vue";
import { useToast } from "vue-toast-notification";
import { DOCUMENT_REPLACE, DOCUMENT_ADD, trans } from "translator";
interface DropFileConfig {
allowRemove: boolean;
@@ -76,10 +75,10 @@ function closeModal(): void {
@click="openModal"
class="btn btn-create"
>
{{ trans(DOCUMENT_ADD) }}
Ajouter un document
</button>
<button v-else @click="openModal" class="dropdown-item">
{{ trans(DOCUMENT_REPLACE) }}
<button v-else @click="openModal" class="btn btn-edit">
Remplacer le document
</button>
<modal
v-if="state.showModal"

View File

@@ -23,8 +23,6 @@ See the document: Voir le document
document:
Any title: Aucun titre
replace: Remplacer
Add: Ajouter un document
generic_doc:
filter:

View File

@@ -54,14 +54,14 @@ block js %}
{% if e.participations|length > 0 %}
<div class="item-row separator">
<strong>{{ "Participations" | trans }}&nbsp;: </strong>
{% for part in e.participations|slice(0, 5) %} {% include
{% for part in e.participations|slice(0, 20) %} {% include
'@ChillMain/OnTheFly/_insert_vue_onthefly.html.twig' with {
targetEntity: { name: 'person', id: part.person.id }, action:
'show', displayBadge: true, buttonText:
part.person|chill_entity_render_string, isDead:
part.person.deathdate is not null } %} {% endfor %}
{% if e.participations|length > 5 %}
{{ 'events.and_other_count_participants'|trans({'count': e.participations|length - 5}) }}
part.person.deathdate is not null } %} {% endfor %} {% if
e.participations|length > 20 %}
{{ 'events.and_other_count_participants'|trans({'count': e.participations|length - 20}) }}
{% endif %}
</div>
{% endif %}

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

@@ -55,10 +55,6 @@ class DateIntervalType extends AbstractType
{
$builder
->add('n', IntegerType::class, [
'attr' => [
'min' => 0,
'step' => 1,
],
'constraints' => [
new GreaterThan([
'value' => 0,

View File

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

View File

@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Repository;
use Chill\MainBundle\Entity\PostalCode;
use Doctrine\Persistence\ObjectRepository;
interface AddressReferenceRepositoryInterface extends ObjectRepository
{
public function findAggregatedBySearchString(string $search, PostalCode|int|null $postalCode = null, int $firstResult = 0, int $maxResults = 50): iterable;
}

View File

@@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Repository;
use Doctrine\DBAL\Connection;
final readonly class PostalCodeForAddressReferenceRepository implements PostalCodeForAddressReferenceRepositoryInterface
{
public function __construct(private Connection $connection) {}
public function findPostalCode(string $search, int $firstResult = 0, int $maxResults = 50): iterable
{
$terms = $this->buildTermsFromSearchString($search);
if ([] === $terms) {
return [];
}
$qb = $this->connection->createQueryBuilder();
$qb->from('chill_main_postal_code', 'cmpc')
->join('cmpc', 'view_chill_main_address_reference', 'vcmar', 'vcmar.postcode_id = cmpc.id')
->join('vcmar', 'country', 'country', condition: 'cmpc.country_id = country.id')
->setFirstResult($firstResult)
->setMaxResults($maxResults)
;
$qb->select(
'DISTINCT ON (cmpc.code, cmpc.label) cmpc.id AS postcode_id',
'cmpc.code AS code',
'cmpc.label AS label',
'country.id AS country_id',
'country.countrycode AS country_code',
'country.name AS country_name'
);
$paramId = 0;
foreach ($terms as $term) {
$qb->andWhere('vcmar.address like ?');
$qb->setParameter(++$paramId, "%{$term}%");
}
$result = $qb->executeQuery();
foreach ($result->iterateAssociative() as $row) {
yield [...$row, 'country_name' => json_decode($row['country_name'], true, 512, JSON_THROW_ON_ERROR)];
}
}
private function buildTermsFromSearchString(string $search): array
{
return array_filter(
array_map(
static fn (string $term) => trim($term),
explode(' ', $search)
),
static fn (string $term) => '' !== $term
);
}
}

View File

@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Repository;
/**
* Search for postal code using optimized materialized view.
*/
interface PostalCodeForAddressReferenceRepositoryInterface
{
public function findPostalCode(string $search, int $firstResult = 0, int $maxResults = 50): iterable;
}

View File

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

View File

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

View File

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

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,27 @@
<script setup lang="ts">
import {AddressReference} from "ChillMainAssets/types";
import {computed} from "vue";
import {addressReferenceToAddress} from "ChillMainAssets/vuejs/AddressPicker/helper";
import AddressDetailsContent from "ChillMainAssets/vuejs/_components/AddressDetails/AddressDetailsContent.vue";
export interface AddressDetailsFormProps {
address: AddressReference;
}
const props = defineProps<AddressDetailsFormProps>();
const address = computed(() => addressReferenceToAddress(props.address));
</script>
<template>
<div>
FORM
</div>
<div>
<address-details-content :address="address"></address-details-content>
</div>
</template>
<style scoped lang="scss">
</style>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -64,5 +64,3 @@ const props = defineProps({
entity: Object,
});
</script>
thirdparty_duplicate: merge: Fussioner find: 'Désigner un tiers doublon'

View File

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

View File

@@ -4,7 +4,7 @@
{% endblock crud_content_header %}
{% block crud_content_view %}
{% block crud_content_view_details %}
<dl class="chill_view_data">
<dt>id</dt>
@@ -20,7 +20,7 @@
{{ 'Cancel'|trans }}
</a>
</li>
{% endblock %}
{% endblock %}
{% block content_view_actions_before %}{% endblock %}
{% block content_form_actions_delete %}
{% if chill_crud_action_exists(crud_name, 'delete') %}
@@ -32,7 +32,7 @@
</li>
{% endif %}
{% endif %}
{% endblock content_form_actions_delete %}
{% endblock content_form_actions_delete %}
{% block content_view_actions_duplicate_link %}
{% if chill_crud_action_exists(crud_name, 'new') %}
{% if is_granted(chill_crud_config('role', crud_name, 'new'), entity) %}
@@ -44,17 +44,6 @@
{% endif %}
{% endif %}
{% endblock content_view_actions_duplicate_link %}
{% block content_view_actions_merge %}
<li>
<a href="{{ chill_path_add_return_path('chill_thirdparty_find_duplicate',
{ 'thirdparty_id': entity.id }) }}"
title="{{ 'Merge'|trans }}"
class="btn btn-misc">
<i class="bi bi-chevron-contract"></i>
{{ 'Merge'|trans }}
</a>
</li>
{% endblock %}
{% block content_view_actions_edit_link %}
{% if chill_crud_action_exists(crud_name, 'edit') %}
{% if is_granted(chill_crud_config('role', crud_name, 'edit'), entity) %}

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

@@ -5,7 +5,7 @@
<strong><i class="fa fa-fw fa-filter"></i>Filtrer la liste</strong>
</button>
</h2>
<div id="filterOrderCollapse" aria-labelledby="filterOrderHeading" data-bs-parent="#filterOrderAccordion">
<div class="accordion-collapse collapse" id="filterOrderCollapse" aria-labelledby="filterOrderHeading" data-bs-parent="#filterOrderAccordion">
{% set btnSubmit = 0 %}
<div class="accordion-body chill_filter_order container-xxl p-5 py-2">
<div class="row my-2">

View File

@@ -280,17 +280,11 @@
</div>
{% endblock %}
{% block pick_linked_entities_widget %}
<input type="hidden" {{ block('widget_attributes') }}
{% if value is not empty %}value="{{ value|escape('html_attr') }}" {% endif %}
data-input-uniqid="{{ form.vars['uniqid'] }}"/>
<div
data-input-uniqid="{{ form.vars['uniqid'] }}"
data-module="pick-linked-entities"
data-pick-entities-type="{{ form.vars['pick-entities-type'] }}"
data-suggested="{{ form.vars['suggested']|json_encode|escape('html_attr') }}"
<input type="hidden" {{ block('widget_attributes') }} {% if value is not empty %}value="{{ value|escape('html_attr') }}" {% endif %} data-input-uniqid="{{ form.vars['uniqid'] }}" />
<div data-input-uniqid="{{ form.vars['uniqid'] }}" data-module="pick-linked-entities" data-pick-entities-type="{{ form.vars['pick-entities-type'] }}"
></div>
{% endblock %}
{% block pick_postal_code_widget %}

View File

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

View File

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

View File

@@ -0,0 +1,88 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Tests\Repository;
use Chill\MainBundle\Entity\AddressReference;
use Chill\MainBundle\Entity\PostalCode;
use Chill\MainBundle\Repository\AddressReferenceRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
/**
* @internal
*
* @coversNothing
*/
class AddressReferenceRepositoryTest extends KernelTestCase
{
private static AddressReferenceRepository $repository;
public static function setUpBeforeClass(): void
{
static::bootKernel();
static::$repository = static::getContainer()->get(AddressReferenceRepository::class);
}
/**
* @dataProvider generateSearchString
*/
public function testFindBySearchString(string $search, int|PostalCode|null $postalCode, string $text, ?array $expected = null): void
{
$actual = static::$repository->findBySearchString($search, $postalCode);
self::assertIsIterable($actual, $text);
if (null !== $expected) {
self::assertEquals($expected, iterator_to_array($actual));
}
}
/**
* @dataProvider generateSearchString
*/
public function testCountBySearchString(string $search, int|PostalCode|null $postalCode, string $text, ?array $expected = null): void
{
$actual = static::$repository->countBySearchString($search, $postalCode);
self::assertIsInt($actual, $text);
}
/**
* @dataProvider generateSearchString
*/
public function testFindAggreggateBySearchString(string $search, int|PostalCode|null $postalCode, string $text, ?array $expected = null): void
{
$actual = static::$repository->findAggregatedBySearchString($search, $postalCode);
self::assertIsIterable($actual, $text);
if (null !== $expected) {
self::assertEquals($expected, iterator_to_array($actual));
}
}
public static function generateSearchString(): iterable
{
self::bootKernel();
$em = static::getContainer()->get(EntityManagerInterface::class);
/** @var AddressReference $ar */
$ar = $em->createQuery('SELECT ar FROM '.AddressReference::class.' ar')
->setMaxResults(1)
->getSingleResult();
yield ['', null, 'search with empty string', []];
yield [' ', null, 'search with spaces only', []];
yield ['rue des moulins', null, 'search contains an empty string'];
yield ['rue des moulins', $ar->getPostcode()->getId(), 'search with postal code as an id'];
yield ['rue des moulins', $ar->getPostcode(), 'search with postal code instance'];
}
}

View File

@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Tests\Repository;
use Chill\MainBundle\Repository\PostalCodeForAddressReferenceRepository;
use Doctrine\DBAL\Connection;
use PHPUnit\Framework\Attributes\DataProvider;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
/**
* @internal
*
* @coversNothing
*/
final class PostalCodeForAddressReferenceRepositoryTest extends KernelTestCase
{
private Connection $connection;
protected function setUp(): void
{
self::bootKernel();
$this->connection = self::getContainer()->get(Connection::class);
}
/**
* @return iterable<string[]>
*/
public static function provideSearches(): iterable
{
yield [''];
yield [' '];
yield ['hugo'];
yield [' hugo'];
yield ['hugo '];
yield ['rue victor hugo'];
yield ['rue victor hugo'];
}
#[DataProvider('provideSearches')]
public function testFindPostalCodeDoesNotErrorAndIsIterable(string $search): void
{
$repository = new PostalCodeForAddressReferenceRepository($this->connection);
$result = $repository->findPostalCode($search);
self::assertIsIterable($result);
// Ensure it can be converted to an array (and iterate without error)
$rows = \is_array($result) ? $result : iterator_to_array($result, false);
self::assertIsArray($rows);
}
}

View File

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

View File

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

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
extra: ""
distribution: cedex
address_picker:
# placeholder
Search for addresses: Chercher des adresses
Create a new address: Créer une nouvelle adresse
Create an address: Créer une adresse
Update address: Modifier l'adresse

View File

@@ -11,7 +11,6 @@ declare(strict_types=1);
namespace Chill\PersonBundle\Controller;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Pagination\PaginatorFactory;
use Chill\MainBundle\Templating\Listing\FilterOrderHelper;
use Chill\MainBundle\Templating\Listing\FilterOrderHelperFactoryInterface;
@@ -131,7 +130,6 @@ final class AccompanyingCourseWorkController extends AbstractController
$this->denyAccessUnlessGranted(AccompanyingPeriodWorkVoter::SEE, $period);
$filter = $this->buildFilterOrder($period);
$currentUser = $this->getUser();
$filterData = [
'types' => $filter->hasEntityChoice('typesFilter') ? $filter->getEntityChoiceData('typesFilter') : [],
@@ -140,10 +138,6 @@ final class AccompanyingCourseWorkController extends AbstractController
'user' => $filter->getUserPickerData('userFilter'),
];
if ($filter->getSingleCheckboxData('currentUserFilter') && $currentUser instanceof User) {
$filterData['currentUser'] = $currentUser;
}
$totalItems = $this->workRepository->countByAccompanyingPeriod($period);
$paginator = $this->paginator->create($totalItems);
@@ -207,8 +201,6 @@ final class AccompanyingCourseWorkController extends AbstractController
->addUserPicker('userFilter', 'accompanying_course_work.user_filter', ['required' => false])
;
$filterBuilder->addSingleCheckbox('currentUserFilter', 'accompanying_course_work.my_actions_filter');
return $filterBuilder->build();
}
}

View File

@@ -18,7 +18,6 @@ use Chill\PersonBundle\Repository\AccompanyingPeriod\AccompanyingPeriodWorkRepos
use Chill\PersonBundle\Service\AccompanyingPeriodWork\AccompanyingPeriodWorkMergeService;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\Session\Session;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Translation\TranslatableMessage;
@@ -33,7 +32,7 @@ class AccompanyingPeriodWorkDuplicateController extends AbstractController
* @ParamConverter("accompanyingPeriodWork", options={"id": "acpw_id"})
*/
#[Route(path: '{_locale}/person/accompanying-period/work/{id}/assign-duplicate', name: 'chill_person_accompanying_period_work_assign_duplicate')]
public function assignDuplicate(AccompanyingPeriodWork $acpw, Request $request): Response
public function assignDuplicate(AccompanyingPeriodWork $acpw, Request $request)
{
$accompanyingPeriod = $acpw->getAccompanyingPeriod();
@@ -80,7 +79,7 @@ class AccompanyingPeriodWorkDuplicateController extends AbstractController
* @ParamConverter("acpw2", options={"id": "acpw2_id"})
*/
#[Route(path: '/{_locale}/person/{acpw1_id}/duplicate/{acpw2_id}/confirm', name: 'chill_person_acpw_duplicate_confirm')]
public function confirmAction(AccompanyingPeriodWork $acpw1, AccompanyingPeriodWork $acpw2, Request $request): Response
public function confirmAction(AccompanyingPeriodWork $acpw1, AccompanyingPeriodWork $acpw2, Request $request)
{
$accompanyingPeriod = $acpw1->getAccompanyingPeriod();
@@ -99,7 +98,6 @@ class AccompanyingPeriodWorkDuplicateController extends AbstractController
$session->getFlashBag()->add('success', new TranslatableMessage('acpw_duplicate.Successfully merged'));
}
return $this->redirectToRoute('chill_person_accompanying_period_work_show', ['id' => $acpw1->getId()]);
}

View File

@@ -12,7 +12,6 @@ declare(strict_types=1);
namespace Chill\PersonBundle\Controller;
use Chill\MainBundle\Routing\ChillUrlGeneratorInterface;
use Chill\PersonBundle\Entity\AccompanyingPeriod\AccompanyingPeriodWorkEvaluation;
use Chill\PersonBundle\Entity\AccompanyingPeriod\AccompanyingPeriodWorkEvaluationDocument;
use Chill\PersonBundle\Security\Authorization\AccompanyingPeriodWorkVoter;
use Chill\PersonBundle\Service\AccompanyingPeriodWorkEvaluationDocument\AccompanyingPeriodWorkEvaluationDocumentDuplicator;
@@ -25,16 +24,15 @@ use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Core\Security;
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
use Symfony\Component\Serializer\SerializerInterface;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\ParamConverter;
readonly class AccompanyingPeriodWorkEvaluationDocumentDuplicateController
class AccompanyingPeriodWorkEvaluationDocumentDuplicateController
{
public function __construct(
private AccompanyingPeriodWorkEvaluationDocumentDuplicator $duplicator,
private Security $security,
private SerializerInterface $serializer,
private EntityManagerInterface $entityManager,
private ChillUrlGeneratorInterface $urlGenerator,
private readonly AccompanyingPeriodWorkEvaluationDocumentDuplicator $duplicator,
private readonly Security $security,
private readonly SerializerInterface $serializer,
private readonly EntityManagerInterface $entityManager,
private readonly ChillUrlGeneratorInterface $urlGenerator,
) {}
#[Route('/api/1.0/person/accompanying-course-work-evaluation-document/{id}/duplicate', methods: ['POST'])]
@@ -58,32 +56,6 @@ readonly class AccompanyingPeriodWorkEvaluationDocumentDuplicateController
);
}
/**
* @ParamConverter("document", options={"id": "document_id"})
* @ParamConverter("evaluation", options={"id": "evaluation_id"})
*/
#[Route('/api/1.0/person/accompanying-course-work-evaluation-document/{document_id}/evaluation/{evaluation_id}/duplicate', methods: ['POST'])]
public function duplicateToEvaluationApi(AccompanyingPeriodWorkEvaluationDocument $document, AccompanyingPeriodWorkEvaluation $evaluation): Response
{
$work = $evaluation->getAccompanyingPeriodWork();
if (!$this->security->isGranted(AccompanyingPeriodWorkVoter::UPDATE, $work)) {
throw new AccessDeniedHttpException('not allowed to edit this accompanying period work');
}
$duplicatedDocument = $this->duplicator->duplicateToEvaluation($document, $evaluation);
$this->entityManager->persist($duplicatedDocument);
$this->entityManager->persist($duplicatedDocument->getStoredObject());
$this->entityManager->persist($evaluation);
$this->entityManager->flush();
return new JsonResponse(
$this->serializer->serialize($duplicatedDocument, 'json', [AbstractNormalizer::GROUPS => ['read']]),
json: true
);
}
#[Route('/{_locale}/person/accompanying-course-work-evaluation-document/{id}/duplicate', name: 'chill_person_accompanying_period_work_evaluation_document_duplicate', methods: ['POST'])]
public function duplicate(AccompanyingPeriodWorkEvaluationDocument $document): Response
{

View File

@@ -1,58 +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\PersonBundle\Controller;
use Chill\PersonBundle\Entity\AccompanyingPeriod\AccompanyingPeriodWorkEvaluation;
use Chill\PersonBundle\Entity\AccompanyingPeriod\AccompanyingPeriodWorkEvaluationDocument;
use Chill\PersonBundle\Security\Authorization\AccompanyingPeriodWorkVoter;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Core\Security;
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
use Symfony\Component\Serializer\SerializerInterface;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\ParamConverter;
readonly class AccompanyingPeriodWorkEvaluationDocumentMoveController
{
public function __construct(
private Security $security,
private SerializerInterface $serializer,
private EntityManagerInterface $entityManager,
) {}
/**
* @ParamConverter("document", options={"id": "document_id"})
* @ParamConverter("evaluation", options={"id": "evaluation_id"})
*/
#[Route('/api/1.0/person/accompanying-course-work-evaluation-document/{document_id}/evaluation/{evaluation_id}/move', methods: ['POST'])]
public function moveToEvaluationApi(AccompanyingPeriodWorkEvaluationDocument $document, AccompanyingPeriodWorkEvaluation $evaluation): Response
{
$work = $evaluation->getAccompanyingPeriodWork();
if (!$this->security->isGranted(AccompanyingPeriodWorkVoter::UPDATE, $work)) {
throw new AccessDeniedHttpException('not allowed to edit this accompanying period work');
}
$document->setAccompanyingPeriodWorkEvaluation($evaluation);
$this->entityManager->persist($document);
$this->entityManager->flush();
return new JsonResponse(
$this->serializer->serialize($document, 'json', [AbstractNormalizer::GROUPS => ['read']]),
json: true
);
}
}

View File

@@ -98,6 +98,16 @@ class AccompanyingPeriodWorkEvaluationDocument implements \Chill\MainBundle\Doct
public function setAccompanyingPeriodWorkEvaluation(?AccompanyingPeriodWorkEvaluation $accompanyingPeriodWorkEvaluation): AccompanyingPeriodWorkEvaluationDocument
{
// if an evaluation is already associated, we cannot change the association (removing the association,
// by setting a null value, is allowed.
if (
$this->accompanyingPeriodWorkEvaluation instanceof AccompanyingPeriodWorkEvaluation
&& $accompanyingPeriodWorkEvaluation instanceof AccompanyingPeriodWorkEvaluation
) {
if ($this->accompanyingPeriodWorkEvaluation !== $accompanyingPeriodWorkEvaluation) {
throw new \RuntimeException('It is not allowed to change the evaluation for a document');
}
}
$this->accompanyingPeriodWorkEvaluation = $accompanyingPeriodWorkEvaluation;
return $this;

View File

@@ -24,7 +24,7 @@ class FindAccompanyingPeriodWorkType extends AbstractType
{
$builder
->add('acpw', PickLinkedAccompanyingPeriodWorkType::class, [
'label' => 'Accompanying period work',
'label' => 'Social action',
'multiple' => false,
'accompanyingPeriod' => $options['accompanyingPeriod'],
])

View File

@@ -16,26 +16,18 @@ use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\Form\FormView;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
class PickLinkedAccompanyingPeriodWorkType extends AbstractType
{
public function __construct(private readonly NormalizerInterface $normalizer) {}
public function buildView(FormView $view, FormInterface $form, array $options)
{
$view->vars['multiple'] = $options['multiple'];
$view->vars['types'] = ['acpw'];
$view->vars['uniqid'] = uniqid('pick_acpw_dyn');
$view->vars['suggested'] = [];
$view->vars['as_id'] = true === $options['as_id'] ? '1' : '0';
$view->vars['submit_on_adding_new_entity'] = false;
$view->vars['pick-entities-type'] = 'acpw';
$view->vars['attr']['data-accompanying-period-id'] = $options['accompanyingPeriod']->getId();
foreach ($options['suggested'] as $suggestion) {
$view->vars['suggested'][] = $this->normalizer->normalize($suggestion, 'json', ['groups' => 'read']);
}
}
public function configureOptions(OptionsResolver $resolver)
@@ -46,7 +38,6 @@ class PickLinkedAccompanyingPeriodWorkType extends AbstractType
->setDefault('multiple', false)
->setAllowedTypes('multiple', ['bool'])
->setDefault('compound', false)
->setDefault('suggested', [])
->setDefault('as_id', false)
->setAllowedTypes('as_id', ['bool'])
->setDefault('submit_on_adding_new_entity', false)

View File

@@ -90,7 +90,7 @@ class AccompanyingPeriodWorkRepository implements ObjectRepository
* * first, opened works
* * then, closed works
*
* @param array{types?: list<SocialAction>, user?: list<User>, currentUser?: User, after?: \DateTimeImmutable|null, before?: \DateTimeImmutable|null} $filters
* @param array{types?: list<SocialAction>, user?: list<User>, after?: \DateTimeImmutable|null, before?: \DateTimeImmutable|null} $filters
*
* @return AccompanyingPeriodWork[]
*/
@@ -101,7 +101,6 @@ class AccompanyingPeriodWorkRepository implements ObjectRepository
$sql = "SELECT {$rsm} FROM chill_person_accompanying_period_work w
LEFT JOIN chill_person_accompanying_period_work_referrer AS rw ON accompanyingperiodwork_id = w.id
AND (rw.enddate IS NULL OR rw.enddate > CURRENT_DATE)
WHERE accompanyingPeriod_id = :periodId";
// implement filters
@@ -120,10 +119,6 @@ class AccompanyingPeriodWorkRepository implements ObjectRepository
.')';
}
if (isset($filters['currentUser'])) {
$sql .= ' AND rw.user_id = :currentUser';
}
$sql .= " AND daterange(:after::date, :before::date) && daterange(w.startDate, w.endDate, '[]')";
// if the start and end date were inversed, we inverse the order to avoid an error
@@ -157,11 +152,6 @@ class AccompanyingPeriodWorkRepository implements ObjectRepository
->setParameter('limit', $limit, Types::INTEGER)
->setParameter('offset', $offset, Types::INTEGER);
if (isset($filters['currentUser'])) {
$nq->setParameter('currentUser', $filters['currentUser']->getId());
}
foreach ($filters['user'] as $key => $user) {
$nq->setParameter('user_'.$key, $user);
}

View File

@@ -41,8 +41,7 @@ document.addEventListener("DOMContentLoaded", () => {
methods: {
pickWork: function (payload: { work: AccompanyingPeriodWork }) {
console.log("payload", payload);
input.value = payload.work.id?.toString() ?? "";
input.value = payload.work.id.toString();
},
},
});

View File

@@ -84,7 +84,7 @@ export interface AccompanyingPeriodWorkEvaluationDocument {
}
export interface AccompanyingPeriodWork {
id?: number;
id: number;
accompanyingPeriod?: AccompanyingPeriod;
accompanyingPeriodWorkEvaluations: AccompanyingPeriodWorkEvaluation[];
createdAt?: string;

View File

@@ -972,7 +972,7 @@ div#workEditor {
font-size: 85%;
}
& > i.fa {
i.fa {
padding: 0.25rem;
color: $white;

View File

@@ -1,31 +0,0 @@
<template>
<div class="row mb-3">
<label class="col-sm-4 col-form-label visually-hidden">{{
trans(EVALUATION_PUBLIC_COMMENT)
}}</label>
<div class="col-sm-12">
<ckeditor
:editor="ClassicEditor"
:config="classicEditorConfig"
:placeholder="trans(EVALUATION_COMMENT_PLACEHOLDER)"
:value="comment"
@input="$emit('update:comment', $event)"
tag-name="textarea"
></ckeditor>
</div>
</div>
</template>
<script setup>
import { Ckeditor } from "@ckeditor/ckeditor5-vue";
import { ClassicEditor } from "ckeditor5";
import classicEditorConfig from "ChillMainAssets/module/ckeditor5/editor_config";
import {
EVALUATION_PUBLIC_COMMENT,
EVALUATION_COMMENT_PLACEHOLDER,
trans,
} from "translator";
defineProps(["comment"]);
defineEmits(["update:comment"]);
</script>

View File

@@ -1,71 +0,0 @@
<template>
<div class="row mb-3">
<label class="col-4 col-sm-2 col-md-4 col-lg-2 col-form-label">
{{ trans(EVALUATION_STARTDATE) }}
</label>
<div class="col-8 col-sm-4 col-md-8 col-lg-4">
<input
class="form-control form-control-sm"
type="date"
:value="startDate"
@input="$emit('update:startDate', $event.target.value)"
/>
</div>
<label class="col-4 col-sm-2 col-md-4 col-lg-2 col-form-label">
{{ trans(EVALUATION_ENDDATE) }}
</label>
<div class="col-8 col-sm-4 col-md-8 col-lg-4">
<input
class="form-control form-control-sm"
type="date"
:value="endDate"
@input="$emit('update:endDate', $event.target.value)"
/>
</div>
</div>
<div class="row mb-3">
<label class="col-4 col-sm-2 col-md-4 col-lg-2 col-form-label">
{{ trans(EVALUATION_MAXDATE) }}
</label>
<div class="col-8 col-sm-4 col-md-8 col-lg-4">
<input
class="form-control form-control-sm"
type="date"
:value="maxDate"
@input="$emit('update:maxDate', $event.target.value)"
/>
</div>
<label class="col-4 col-sm-2 col-md-4 col-lg-2 col-form-label">
{{ trans(EVALUATION_WARNING_INTERVAL) }}
</label>
<div class="col-8 col-sm-4 col-md-8 col-lg-4">
<input
class="form-control form-control-sm"
type="number"
:value="warningInterval"
@input="$emit('update:warningInterval', $event.target.value)"
/>
</div>
</div>
</template>
<script setup>
import {
EVALUATION_STARTDATE,
EVALUATION_ENDDATE,
EVALUATION_MAXDATE,
EVALUATION_WARNING_INTERVAL,
trans,
} from "translator";
defineProps(["startDate", "endDate", "maxDate", "warningInterval"]);
defineEmits([
"update:startDate",
"update:endDate",
"update:maxDate",
"update:warningInterval",
]);
</script>

View File

@@ -1,51 +0,0 @@
<template>
<div class="row mb-3">
<h6>{{ trans(EVALUATION_DOCUMENT_ADD) }} :</h6>
<pick-template
entityClass="Chill\PersonBundle\Entity\AccompanyingPeriod\AccompanyingPeriodWorkEvaluation"
:id="evaluation.id"
:templates="templates"
:preventDefaultMoveToGenerate="true"
@go-to-generate-document="$emit('submitBeforeGenerate', $event)"
>
<template v-slot:title>
<label class="col-form-label">{{
trans(EVALUATION_GENERATE_A_DOCUMENT)
}}</label>
</template>
</pick-template>
<div>
<label class="col-form-label">{{
trans(EVALUATION_DOCUMENT_UPLOAD)
}}</label>
<ul class="record_actions document-upload">
<li>
<drop-file-modal
:allow-remove="false"
@add-document="$emit('addDocument', $event)"
></drop-file-modal>
</li>
</ul>
</div>
</div>
</template>
<script setup>
import PickTemplate from "ChillDocGeneratorAssets/vuejs/_components/PickTemplate.vue";
import DropFileModal from "ChillDocStoreAssets/vuejs/DropFileWidget/DropFileModal.vue";
import {
EVALUATION_DOCUMENT_ADD,
EVALUATION_DOCUMENT_UPLOAD,
EVALUATION_GENERATE_A_DOCUMENT,
trans,
} from "translator";
defineProps(["evaluation", "templates"]);
defineEmits(["addDocument", "submitBeforeGenerate"]);
</script>
<style scoped>
ul.document-upload {
justify-content: flex-start;
}
</style>

View File

@@ -1,345 +0,0 @@
<template>
<div class="row mb-3">
<h5>{{ trans(EVALUATION_DOCUMENTS) }} :</h5>
<div class="flex-table">
<div
class="item-bloc"
v-for="(d, i) in documents"
:key="d.id"
:class="[
parseInt(docAnchorId) === d.id ? 'bg-blink' : 'nothing',
]"
>
<div :id="'document_' + d.id" class="item-row">
<div class="input-group input-group-lg mb-3 row">
<label class="col-sm-3 col-form-label"
>Titre du document:</label
>
<div class="col-sm-9">
<input
class="form-control document-title"
type="text"
:value="d.title"
:id="d.id"
:data-key="i"
@input="$emit('inputDocumentTitle', $event)"
/>
</div>
</div>
</div>
<div class="item-row">
<div class="item-col item-meta">
<p v-if="d.createdBy" class="createdBy">
Créé par {{ d.createdBy.text }}<br />
Le
{{
$d(ISOToDatetime(d.createdAt.datetime), "long")
}}
</p>
</div>
</div>
<div class="item-row">
<div class="item-col">
<ul class="record_actions">
<li
v-if="
d.workflows_availables.length > 0 ||
d.workflows.length > 0
"
>
<list-workflow-modal
:workflows="d.workflows"
:allowCreate="true"
relatedEntityClass="Chill\PersonBundle\Entity\AccompanyingPeriod\AccompanyingPeriodWorkEvaluationDocument"
:relatedEntityId="d.id"
:workflowsAvailables="
d.workflows_availables
"
:preventDefaultMoveToGenerate="true"
:goToGenerateWorkflowPayload="{ doc: d }"
@go-to-generate-workflow="
$emit('goToGenerateWorkflow', $event)
"
></list-workflow-modal>
</li>
<li>
<button
v-if="AmIRefferer"
class="btn btn-notify"
@click="
$emit(
'goToGenerateNotification',
d,
false,
)
"
></button>
<template v-else>
<button
id="btnGroupNotifyButtons"
type="button"
class="btn btn-notify dropdown-toggle"
:title="
trans(EVALUATION_NOTIFICATION_SEND)
"
data-bs-toggle="dropdown"
aria-expanded="false"
>
&nbsp;
</button>
<ul
class="dropdown-menu"
aria-labelledby="btnGroupNotifyButtons"
>
<li>
<a
class="dropdown-item"
@click="
$emit(
'goToGenerateNotification',
d,
true,
)
"
>
{{
trans(
EVALUATION_NOTIFICATION_NOTIFY_REFERRER,
)
}}
</a>
</li>
<li>
<a
class="dropdown-item"
@click="
$emit(
'goToGenerateNotification',
d,
false,
)
"
>
{{
trans(
EVALUATION_NOTIFICATION_NOTIFY_ANY,
)
}}
</a>
</li>
</ul>
</template>
</li>
<li>
<document-action-buttons-group
:stored-object="d.storedObject"
:filename="d.title"
:can-edit="true"
:execute-before-leave="
submitBeforeLeaveToEditor
"
:davLink="
d.storedObject._links?.dav_link.href
"
:davLinkExpiration="
d.storedObject._links?.dav_link
.expiration
"
@on-stored-object-status-change="
$emit('statusDocumentChanged', $event)
"
></document-action-buttons-group>
</li>
<li v-if="Number.isInteger(d.id)">
<div class="duplicate-dropdown">
<button
class="btn btn-edit dropdown-toggle"
type="button"
data-bs-toggle="dropdown"
aria-expanded="false"
>
{{ trans(EVALUATION_DOCUMENT_EDIT) }}
</button>
<ul class="dropdown-menu">
<!--delete-->
<li v-if="d.workflows.length === 0">
<a
class="dropdown-item"
@click="
$emit('removeDocument', d)
"
>
<i class="fa fa-trash-o" aria-hidden="true"></i>
{{
trans(
EVALUATION_DOCUMENT_DELETE,
)
}}
</a>
</li>
<!--replace document-->
<li
v-if="
d.storedObject._permissions
.canEdit
"
>
<drop-file-modal
:existing-doc="d.storedObject"
:allow-remove="false"
@add-document="
(arg) =>
$emit(
'replaceDocument',
d,
arg.stored_object,
arg.stored_object_version,
)
"
></drop-file-modal>
</li>
<!--duplicate document-->
<li>
<a
class="dropdown-item"
@click="
$emit(
'duplicateDocument',
d,
)
"
>
<i class="fa fa-copy" aria-hidden="true"></i>
{{
trans(
EVALUATION_DOCUMENT_DUPLICATE_HERE,
)
}}
</a
>
</li>
<li>
<a
class="dropdown-item"
@click="
prepareDocumentDuplicationToWork(
d,
)
"
>
<i class="fa fa-copy" aria-hidden="true"></i>
{{
trans(
EVALUATION_DOCUMENT_DUPLICATE_TO_OTHER_EVALUATION,
)
}}</a
>
</li>
<!--move document-->
<li
v-if="
d.storedObject._permissions
.canEdit
"
>
<a
class="dropdown-item"
@click="
prepareDocumentMoveToWork(d)
"
>
<i class="fa fa-arrows" aria-hidden="true"></i>
{{
trans(
EVALUATION_DOCUMENT_MOVE,
)
}}</a
>
</li>
</ul>
</div>
</li>
</ul>
</div>
</div>
</div>
</div>
</div>
<AccompanyingPeriodWorkSelectorModal
v-if="showAccompanyingPeriodSelector"
v-model:selectedAcpw="selectedAcpw"
:accompanying-period-id="accompanyingPeriodId"
:is-evaluation-selector="true"
:ignore-accompanying-period-work-ids="[]"
@close-modal="showAccompanyingPeriodSelector = false"
@update:selectedEvaluation="selectedEvaluation = $event"
/>
</template>
<script setup>
import { ISOToDatetime } from "ChillMainAssets/chill/js/date";
import ListWorkflowModal from "ChillMainAssets/vuejs/_components/EntityWorkflow/ListWorkflowModal.vue";
import DocumentActionButtonsGroup from "ChillDocStoreAssets/vuejs/DocumentActionButtonsGroup.vue";
import DropFileModal from "ChillDocStoreAssets/vuejs/DropFileWidget/DropFileModal.vue";
import {
EVALUATION_NOTIFICATION_NOTIFY_REFERRER,
EVALUATION_NOTIFICATION_NOTIFY_ANY,
EVALUATION_NOTIFICATION_SEND,
EVALUATION_DOCUMENTS,
EVALUATION_DOCUMENT_MOVE,
EVALUATION_DOCUMENT_DELETE,
EVALUATION_DOCUMENT_EDIT,
EVALUATION_DOCUMENT_DUPLICATE_HERE,
EVALUATION_DOCUMENT_DUPLICATE_TO_OTHER_EVALUATION,
trans,
} from "translator";
import { ref, watch } from "vue";
import AccompanyingPeriodWorkSelectorModal from "ChillPersonAssets/vuejs/_components/AccompanyingPeriodWorkSelector/AccompanyingPeriodWorkSelectorModal.vue";
defineProps(["documents", "docAnchorId", "accompanyingPeriodId", "accompanyingPeriodWorkId"]);
const emit = defineEmits([
"inputDocumentTitle",
"removeDocument",
"duplicateDocument",
"statusDocumentChanged",
"goToGenerateWorkflow",
"goToGenerateNotification",
"duplicateDocumentToWork",
]);
const showAccompanyingPeriodSelector = ref(false);
const selectedEvaluation = ref(null);
const selectedDocumentToDuplicate = ref(null);
const selectedDocumentToMove = ref(null);
const prepareDocumentDuplicationToWork = (d) => {
selectedDocumentToDuplicate.value = d;
/** ensure selectedDocumentToMove is null */
selectedDocumentToMove.value = null;
showAccompanyingPeriodSelector.value = true;
};
const prepareDocumentMoveToWork = (d) => {
selectedDocumentToMove.value = d;
/** ensure selectedDocumentToDuplicate is null */
selectedDocumentToDuplicate.value = null;
showAccompanyingPeriodSelector.value = true;
};
watch(selectedEvaluation, (val) => {
if (selectedDocumentToDuplicate.value) {
emit("duplicateDocumentToEvaluation", {
evaluation: val,
document: selectedDocumentToDuplicate.value,
});
} else {
emit("moveDocumentToEvaluation", {
evaluationDest: val,
document: selectedDocumentToMove.value,
});
}
});
</script>

View File

@@ -1,32 +0,0 @@
<template>
<div class="row mb-3">
<label class="col-4 col-sm-2 col-md-4 col-lg-2 col-form-label">
{{ trans(EVALUATION_TIME_SPENT) }}
</label>
<div class="col-8 col-sm-4 col-md-8 col-lg-4">
<select
class="form-control form-control-sm"
:value="timeSpent"
@input="$emit('update:timeSpent', $event.target.value)"
>
<option disabled value="">
{{ trans(EVALUATION_TIME_SPENT) }}
</option>
<option
v-for="time in timeSpentChoices"
:value="time.value"
:key="time.value"
>
{{ time.text }}
</option>
</select>
</div>
</div>
</template>
<script setup>
import { EVALUATION_TIME_SPENT, trans } from "translator";
defineProps(["timeSpent", "timeSpentChoices"]);
defineEmits(["update:timeSpent"]);
</script>

View File

@@ -11,13 +11,12 @@ import { findSocialActionsBySocialIssue } from "ChillPersonAssets/vuejs/_api/Soc
import { create } from "ChillPersonAssets/vuejs/_api/AccompanyingCourseWork.js";
import { fetchResults, makeFetch } from "ChillMainAssets/lib/api/apiMethods.ts";
import { fetchTemplates } from "ChillDocGeneratorAssets/api/pickTemplate.js";
import {
duplicate,
duplicateDocumentToEvaluation,
moveDocumentToEvaluation,
} from "../_api/accompanyingCourseWorkEvaluationDocument";
import { duplicate } from "../_api/accompanyingCourseWorkEvaluationDocument";
const debug = process.env.NODE_ENV !== "production";
const evalFQDN = encodeURIComponent(
"Chill\\PersonBundle\\Entity\\AccompanyingPeriod\\AccompanyingPeriodWorkEvaluation",
);
const store = createStore({
strict: debug,
@@ -299,47 +298,15 @@ const store = createStore({
);
},
addDuplicatedDocument(state, { document, evaluation_key }) {
console.log("add duplicated document", document);
console.log("add duplicated dcuemnt - evaluation key", evaluation_key);
let evaluation = state.evaluationsPicked.find(
(e) => e.key === evaluation_key,
);
document.key = evaluation.documents.length + 1;
evaluation.documents.splice(0, 0, document);
},
addDuplicatedDocumentToEvaluation(state, { document, evaluation }) {
let evaluationDest = state.evaluationsPicked.find(
(e) => e.id === evaluation.id,
);
if (evaluationDest) {
console.log("add duplicated document to evaluation", evaluationDest);
document.key = evaluationDest.documents.length + 1;
evaluationDest.documents.splice(0, 0, document);
}
},
moveDocumentToEvaluation(
state,
{ evaluationInitial, evaluationDest, document },
) {
let evaluationA = state.evaluationsPicked.find(
(e) => e.id === evaluationInitial.id,
);
let evaluationB = state.evaluationsPicked.find(
(e) => e.id === evaluationDest.id,
);
if (evaluationB) {
// add document to chosen evaluation if evaluation is part of the same social work
document.key = evaluationB.documents.length + 1;
evaluationB.documents.splice(0, 0, document);
}
// remove document from original evaluation
const indexToRemove = evaluationA.documents.findIndex(
(doc) => doc.id === document.id,
);
if (indexToRemove !== -1) {
evaluationA.documents.splice(indexToRemove, 1);
}
},
/**
* Replaces a document in the state with a new document.
*
@@ -636,44 +603,6 @@ const store = createStore({
const newDoc = await duplicate(document.id);
commit("addDuplicatedDocument", { document: newDoc, evaluation_key });
},
async duplicateDocumentToEvaluation({ commit }, { document, evaluation }) {
try {
const newDoc = await duplicateDocumentToEvaluation(
document.id,
evaluation.id,
);
commit("addDuplicatedDocumentToEvaluation", {
document: newDoc,
evaluation,
});
return newDoc;
} catch (error) {
console.error("Failed to move document:", error);
throw error;
}
},
async moveDocumentToEvaluation(
{ commit },
{ evaluationInitial, evaluationDest, document },
) {
try {
const response = await moveDocumentToEvaluation(
document.id,
evaluationDest.id,
);
commit("moveDocumentToEvaluation", {
evaluationInitial,
evaluationDest,
document,
});
return response;
} catch (error) {
console.error("Failed to move document:", error);
throw error;
}
},
removeDocument({ commit }, payload) {
commit("removeDocument", payload);
},

View File

@@ -9,23 +9,3 @@ export const duplicate = async (
`/api/1.0/person/accompanying-course-work-evaluation-document/${id}/duplicate`,
);
};
export const duplicateDocumentToEvaluation = async (
document_id: number,
evaluation_id: number,
): Promise<AccompanyingPeriodWorkEvaluationDocument> => {
return makeFetch<null, AccompanyingPeriodWorkEvaluationDocument>(
"POST",
`/api/1.0/person/accompanying-course-work-evaluation-document/${document_id}/evaluation/${evaluation_id}/duplicate`,
);
};
export const moveDocumentToEvaluation = async (
document_id: number,
evaluation_id: number,
): Promise<AccompanyingPeriodWorkEvaluationDocument> => {
return makeFetch<null, AccompanyingPeriodWorkEvaluationDocument>(
"POST",
`/api/1.0/person/accompanying-course-work-evaluation-document/${document_id}/evaluation/${evaluation_id}/move`,
);
};

View File

@@ -1,70 +0,0 @@
<template>
<div class="container">
<div class="item-bloc">
<div class="item-row">
<h2 class="badge-title">
<span class="title_label"></span>
<span class="title_action">
<span>
{{ trans(EVALUATION) }}:
<span class="badge bg-light text-dark">
{{ eval?.evaluation?.title.fr }}
</span>
</span>
<ul class="small_in_title columns mt-1">
<li>
<span class="item-key">
{{
trans(
ACCOMPANYING_COURSE_WORK_START_DATE,
)
}}
:
</span>
<b>{{ formatDate(eval.startDate) }}</b>
</li>
<li v-if="eval.endDate">
<span class="item-key">
{{
trans(ACCOMPANYING_COURSE_WORK_END_DATE)
}}
:
</span>
<b>{{ formatDate(eval.endDate) }}</b>
</li>
</ul>
</span>
</h2>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import {
ACCOMPANYING_COURSE_WORK_END_DATE,
ACCOMPANYING_COURSE_WORK_START_DATE,
EVALUATION,
trans,
} from "translator";
import { ISOToDate } from "ChillMainAssets/chill/js/date";
import { DateTime } from "ChillMainAssets/types";
import { AccompanyingPeriodWorkEvaluation } from "../../../types";
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const props = defineProps<{ eval: AccompanyingPeriodWorkEvaluation }>();
const formatDate = (dateObject: DateTime) => {
if (dateObject) {
const parsedDate = ISOToDate(dateObject.datetime);
if (parsedDate) {
return new Intl.DateTimeFormat("default", {
dateStyle: "short",
}).format(parsedDate);
} else {
return "";
}
}
};
</script>

View File

@@ -1,47 +0,0 @@
<template>
<div class="results">
<div
v-for="evaluation in evaluations"
:key="evaluation.id"
class="list-item"
>
<label class="acpw-item">
<div>
<input
type="radio"
:value="evaluation"
v-model="selectedEvaluation"
name="item"
/>
</div>
<accompanying-period-work-evaluation-item :eval="evaluation" />
</label>
</div>
</div>
</template>
<script setup lang="ts">
import { AccompanyingPeriodWorkEvaluation } from "../../../types";
import { defineProps, ref, watch } from "vue";
import AccompanyingPeriodWorkEvaluationItem from "ChillPersonAssets/vuejs/_components/AccompanyingPeriodWorkSelector/AccompanyingPeriodWorkEvaluationItem.vue";
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const props = defineProps<{
evaluations: AccompanyingPeriodWorkEvaluation[];
}>();
const selectedEvaluation = ref<AccompanyingPeriodWorkEvaluation | null>(null);
// eslint-disable-next-line vue/valid-define-emits
const emit = defineEmits();
watch(selectedEvaluation, (newValue) => {
emit("update:selectedEvaluation", newValue);
});
</script>
<style>
.acpw-item {
display: flex;
}
</style>

View File

@@ -26,24 +26,14 @@ import AccompanyingPeriodWorkItem from "./AccompanyingPeriodWorkItem.vue";
import { AccompanyingPeriodWork } from "../../../types";
import { defineProps, ref, watch } from "vue";
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const props = defineProps<{
accompanyingPeriodWorks: AccompanyingPeriodWork[];
selectedAcpw?: AccompanyingPeriodWork | null;
}>();
const selectedAcpw = ref<AccompanyingPeriodWork | null>(
props.selectedAcpw ?? null,
);
const selectedAcpw = ref<AccompanyingPeriodWork | null>(null);
const emit = defineEmits<{
"update:selectedAcpw": [value: AccompanyingPeriodWork | null];
}>();
watch(
() => props.selectedAcpw,
(val) => {
selectedAcpw.value = val ?? null;
},
);
// eslint-disable-next-line vue/valid-define-emits
const emit = defineEmits();
watch(selectedAcpw, (newValue) => {
emit("update:selectedAcpw", newValue);

View File

@@ -1,6 +1,6 @@
<template>
<div>
<div class="row justify-content-end" v-if="!isEvaluationSelector">
<div class="row justify-content-end">
<div class="col-md-6 col-sm-10" v-if="selectedAcpw">
<ul class="list-suggest remove-items">
<li>
@@ -14,7 +14,7 @@
</div>
</div>
<ul v-if="!showModal" class="record_actions">
<ul class="record_actions">
<li>
<a class="btn btn-sm btn-create mt-3" @click="openModal">
{{ trans(ACPW_DUPLICATE_SELECT_ACCOMPANYING_PERIOD_WORK) }}
@@ -40,15 +40,9 @@
<template #body>
<accompanying-period-work-list
v-if="evaluations.length === 0"
:accompanying-period-works="accompanyingPeriodWorks"
v-model:selectedAcpw="selectedAcpw"
/>
<accompanying-period-work-evaluation-list
v-if="evaluations.length > 0"
:evaluations="evaluations"
v-model:selectedEvaluation="selectedEvaluation"
/>
</template>
<template #footer>
@@ -66,107 +60,58 @@
</template>
<script setup lang="ts">
import { ref, watch, onMounted } from "vue";
import { onMounted, ref } from "vue";
import Modal from "ChillMainAssets/vuejs/_components/Modal.vue";
import AccompanyingPeriodWorkList from "./AccompanyingPeriodWorkList.vue";
import { AccompanyingPeriodWork } from "../../../types";
import {
trans,
ACPW_DUPLICATE_SELECT_ACCOMPANYING_PERIOD_WORK,
CONFIRM,
trans,
} from "translator";
import {fetchResults, makeFetch} from "ChillMainAssets/lib/api/apiMethods";
import AccompanyingPeriodWorkEvaluationList from "ChillPersonAssets/vuejs/_components/AccompanyingPeriodWorkSelector/AccompanyingPeriodWorkEvaluationList.vue";
import { AccompanyingPeriodWorkEvaluation } from "../../../types";
import { fetchResults } from "ChillMainAssets/lib/api/apiMethods";
interface AccompanyingPeriodWorkSelectorModalProps {
accompanyingPeriodId: number;
}
const selectedAcpw = ref<AccompanyingPeriodWork | null>(null);
const selectedEvaluation = ref<AccompanyingPeriodWorkEvaluation | null>(null);
const showModal = ref(false);
const accompanyingPeriodWorks = ref<AccompanyingPeriodWork[]>([]);
const evaluations = ref<AccompanyingPeriodWorkEvaluation[]>([]);
const props = defineProps<{
accompanyingPeriodId: string,
isEvaluationSelector: boolean,
ignoreAccompanyingPeriodWorkIds: number[],
}>();
const props = defineProps<AccompanyingPeriodWorkSelectorModalProps>();
const emit = defineEmits<{
pickWork: [payload: { work: AccompanyingPeriodWork | null }];
closeModal: [];
"update:selectedEvaluation": [evaluation: AccompanyingPeriodWorkEvaluation];
}>();
onMounted(() => {
if (props.accompanyingPeriodId) {
getAccompanyingPeriodWorks(parseInt(props.accompanyingPeriodId));
getAccompanyingPeriodWorks(props.accompanyingPeriodId);
} else {
console.error("No accompanyingperiod id was given");
}
showModal.value = true;
});
const getAccompanyingPeriodWorks = async (periodId: number) => {
const url = `/api/1.0/person/accompanying-course/${periodId}/works.json`;
const accompanyingPeriodWorksFetched = await fetchResults<AccompanyingPeriodWork>(url);
if (props.isEvaluationSelector) {
accompanyingPeriodWorks.value = accompanyingPeriodWorksFetched.filter(
(acpw: AccompanyingPeriodWork) => acpw.accompanyingPeriodWorkEvaluations.length > 0
&& typeof acpw.id !== "undefined"
&& !props.ignoreAccompanyingPeriodWorkIds.includes(acpw.id),
);
} else {
accompanyingPeriodWorks.value = accompanyingPeriodWorksFetched
try {
accompanyingPeriodWorks.value = await fetchResults(url);
} catch (error) {
console.log(error);
}
/* makeFetch<number, AccompanyingPeriodWork[]>("GET", url)
.then((response) => {
accompanyingPeriodWorks.value = response;
})
.catch((error) => {
console.log(error);
});*/
};
watch(selectedAcpw, (newValue) => {
const inputField = document.getElementById(
"find_accompanying_period_work_acpw",
) as HTMLInputElement;
if (inputField) {
inputField.value = String(newValue?.id || "");
}
/* if (!props.isEvaluationSelector) {
console.log("Emitting from watch:", { work: newValue });
emit("pickWork", { work: newValue });
}*/
});
const openModal = () => {
showModal.value = true;
};
const closeModal = () => {
showModal.value = false;
selectedEvaluation.value = null;
// selectedAcpw.value = null;
emit("closeModal");
};
const openModal = () => (showModal.value = true);
const closeModal = () => (showModal.value = false);
const confirmSelection = () => {
selectedAcpw.value = selectedAcpw.value;
console.log("selectedAcpw", selectedAcpw.value);
if (!props.isEvaluationSelector) {
if (selectedAcpw.value) {
// only emit if something is actually selected!
emit("pickWork", { work: selectedAcpw.value });
closeModal();
}
// optionally show some error or warning if not selected
return;
}
if (selectedAcpw.value && props.isEvaluationSelector) {
evaluations.value =
selectedAcpw.value.accompanyingPeriodWorkEvaluations;
}
if (selectedEvaluation.value && props.isEvaluationSelector) {
// console.log('evaluation log in modal', selectedEvaluation.value)
emit("update:selectedEvaluation", selectedEvaluation.value);
closeModal();
}
emit("pickWork", { work: selectedAcpw.value });
closeModal();
};
</script>

View File

@@ -1,9 +1,9 @@
{%- macro details(w, accompanyingCourse, options) -%}
{% include '@ChillPerson/AccompanyingCourseWork/_item.html.twig' with {
'displayAction': true,
'displayAction': false,
'displayContent': 'short',
'displayFontSmall': true,
'itemBlocClass': '',
'displayNotification': true
'displayNotification': false
} %}
{% endmacro %}

View File

@@ -2,10 +2,10 @@
{% set activeRouteKey = 'chill_person_accompanying_period_work_assign_duplicate' %}
{% import '@ChillPerson/AccompanyingPeriodWorkDuplicate/_details.html.twig' as details %}
{% block title %}{{ 'Assign an accompanying period work duplicate' }}{% endblock %}
{% import '@ChillPerson/AccompanyingPeriodWorkDuplicate/_details.html.twig' as details %}
{% block content %}
<div class="person-duplicate">
@@ -18,7 +18,7 @@
</div>
</div>
<h1>{{ 'acpw_duplicate.Assign duplicate'|trans }}</h1>
<h3>{{ 'acpw_duplicate.Assign duplicate'|trans }}</h3>
{{ form_start(form) }}
{%- if form.acpw is defined -%}
{{ form_row(form.acpw) }}

View File

@@ -32,16 +32,9 @@
<div class="wl-col list">
<div class="d-flex flex-column justify-content-center">
{% if app != null %}
{% if acp.closingDate != null %}
{{ 'accompanying_period.dates_from_%opening_date%_to_%closing_date%'|trans({
'%opening_date%': acp.openingDate|format_date('long'),
'%closing_date%': acp.closingDate|format_date('long')}
) }}
{% else %}
<div class="date">
{{ 'Since %date%'|trans({'%date%': app.startDate|format_date('medium') }) }}
</div>
{% endif %}
<div class="date">
{{ 'Since %date%'|trans({'%date%': app.startDate|format_date('medium') }) }}
</div>
{% endif %}
{% set notif_counter = chill_count_notifications('Chill\\PersonBundle\\Entity\\AccompanyingPeriod', acp.id) %}
@@ -77,20 +70,6 @@
</div>
</div>
{% if acp.step == 'CLOSED' and acp.closingMotive is not null %}
<div class="wl-row">
<div class="wl-col title">
<h3 class="closingMotive">{{ 'Closing motive'|trans }}</h3>
</div>
<div class="wl-col list">
<div>
{{ acp.closingMotive.name|localize_translatable_string }}
</div>
</div>
</div>
{% endif %}
{% if acp.user is not null %}
<div class="wl-row">
<div class="wl-col title">

View File

@@ -12,7 +12,6 @@ declare(strict_types=1);
namespace Chill\PersonBundle\Service\AccompanyingPeriodWorkEvaluationDocument;
use Chill\DocStoreBundle\Service\StoredObjectDuplicate;
use Chill\PersonBundle\Entity\AccompanyingPeriod\AccompanyingPeriodWorkEvaluation;
use Chill\PersonBundle\Entity\AccompanyingPeriod\AccompanyingPeriodWorkEvaluationDocument;
use Symfony\Component\Clock\ClockInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
@@ -37,17 +36,4 @@ class AccompanyingPeriodWorkEvaluationDocumentDuplicator
return $newDocument;
}
public function duplicateToEvaluation(AccompanyingPeriodWorkEvaluationDocument $document, AccompanyingPeriodWorkEvaluation $evaluation): AccompanyingPeriodWorkEvaluationDocument
{
$newDocument = new AccompanyingPeriodWorkEvaluationDocument();
$newDocument
->setTitle($document->getTitle().' ('.$this->translator->trans('accompanying_course_evaluation_document.duplicated_at', ['at' => $this->clock->now()]).')')
->setStoredObject($this->storedObjectDuplicate->duplicate($document->getStoredObject()))
;
$evaluation->addDocument($newDocument);
return $newDocument;
}
}

View File

@@ -1993,33 +1993,3 @@ paths:
application/json:
schema:
type: object
/1.0/person/accompanying-course-work-evaluation-document/{document_id}/evaluation/{evaluation_id}/duplicate:
post:
tags:
- accompanying-course-work-evaluation-document
summary: Dupliate an an accompanying period work evaluation document to another evaluation
parameters:
- in: path
name: document_id
required: true
description: The document's id
schema:
type: integer
format: integer
minimum: 1
- in: path
name: evaluation_id
required: true
description: The evaluation's id
schema:
type: integer
format: integer
minimum: 1
responses:
200:
description: "OK"
content:
application/json:
schema:
type: object

View File

@@ -750,42 +750,6 @@ evaluation:
delay: Délai
notificationDelay: Délai de notification
url: Lien internet
title: Ecrire une évaluation
status: Statut
choose_a_status: Choisir un statut
startdate: Date d'ouverture
enddate: Date de fin
maxdate: Date d'échéance
warning_interval: Rappel (jours)
public_comment: Note publique
comment_placeholder: Commencez à écrire ...
generate_a_document: Générer un document
choose_a_template: Choisir un modèle
add_a_document: Ajouter un document
add: Ajouter une évaluation
time_spent: Temps de rédaction
select_time_spent: Indiquez le temps de rédaction
Documents: Documents
document_add: Générer ou téléverser un document
document_upload: Téléverser un document
document_title: Titre du document
template_title: Nom du template
browse: Ajouter un document
replace: Remplacer
download: Télécharger le fichier existant
notification_notify_referrer: Notifier le référent
notification_notify_any: Notifier d'autres utilisateurs
notification_send: Envoyer une notification
document:
edit: Modifier
delete: Supprimer
move: Déplacer
duplicate: Dupliquer
duplicate_here: Dupliquer ici
duplicate_to_other_evaluation: Dupliquer vers une autre évaluation
duplicate_success: Le document d'évaluation a été dupliquer
move_success: Le document d'évaluation a été déplacer
goal:
desactivationDate: Date de désactivation
@@ -810,6 +774,7 @@ relation:
reverseTitle: Deuxième membre
days: jours
months: mois
years: années
# specific to closing motive
@@ -961,7 +926,7 @@ accompanying_course_work:
types_filter: Filtrer par type d'action
user_filter: Filtrer par intervenant
On-going works over total: Actions en cours / Actions du parcours
my_actions_filter: Mes actions (où j'interviens)
#
Person addresses: Adresses de résidence
@@ -1557,6 +1522,3 @@ my_parcours_filters:
parcours_intervening: Intervenant
is_open: Parcours ouverts
is_closed: Parcours clôturés
document_duplicate:
to_evaluation_success: "Le document a été dupliquer"

View File

@@ -624,7 +624,8 @@ final class SingleTaskController extends AbstractController
->addCheckbox('status', $statuses, $statuses, $statusTrans);
$states = $this->singleTaskStateRepository->findAllExistingStates();
$checked = array_values(array_filter($states, fn (string $state) => !in_array($state, ['to_validate', 'in_progress', 'closed', 'canceled', 'validated'], true)));
$checked = array_values(array_filter($states, fn (string $state) => !in_array($state, ['in_progress', 'closed', 'canceled', 'validated'], true)));
if ([] !== $states) {
$filterBuilder
->addCheckbox('states', $states, $checked);

View File

@@ -1,125 +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\ThirdPartyBundle\Controller;
use Chill\PersonBundle\Form\PersonConfimDuplicateType;
use Chill\ThirdPartyBundle\Entity\ThirdParty;
use Chill\ThirdPartyBundle\Form\ThirdpartyFindDuplicateType;
use Chill\ThirdPartyBundle\Service\ThirdpartyMergeService;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Session\Session;
use Symfony\Component\Routing\Annotation\Route;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\ParamConverter;
use Symfony\Component\Translation\TranslatableMessage;
use Symfony\Contracts\Translation\TranslatorInterface;
class ThirdpartyDuplicateController extends AbstractController
{
public function __construct(private readonly ThirdpartyMergeService $thirdPartyMergeService, private readonly TranslatorInterface $translator) {}
/**
* @ParamConverter("thirdparty", options={"id": "thirdparty_id"})
*/
#[Route(path: '/{_locale}/3party/{thirdparty_id}/find-manually', name: 'chill_thirdparty_find_duplicate')]
public function findManuallyDuplicateAction(ThirdParty $thirdparty, Request $request)
{
$suggested = [];
if ('child' === $thirdparty->getKind()) {
$suggested = $thirdparty->getParent()->getChildren();
}
$form = $this->createForm(ThirdpartyFindDuplicateType::class, null, ['suggested' => $suggested]);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$thirdparty2 = $form->get('thirdparty')->getData();
$direction = $form->get('direction')->getData();
if ('starting' === $direction) {
$params = [
'thirdparty1_id' => $thirdparty->getId(),
'thirdparty2_id' => $thirdparty2->getId(),
];
} else {
$params = [
'thirdparty1_id' => $thirdparty2->getId(),
'thirdparty2_id' => $thirdparty->getId(),
];
}
return $this->redirectToRoute('chill_thirdparty_duplicate_confirm', $params);
}
return $this->render('@ChillThirdParty/ThirdPartyDuplicate/find_duplicate.html.twig', [
'thirdparty' => $thirdparty,
'form' => $form->createView(),
]);
}
/**
* @ParamConverter("thirdparty1", options={"id": "thirdparty1_id"})
* @ParamConverter("thirdparty2", options={"id": "thirdparty2_id"})
*/
#[Route(path: '/{_locale}/3party/{thirdparty1_id}/duplicate/{thirdparty2_id}/confirm', name: 'chill_thirdparty_duplicate_confirm')]
public function confirmAction(ThirdParty $thirdparty1, ThirdParty $thirdparty2, Request $request)
{
try {
$this->validateThirdpartyMerge($thirdparty1, $thirdparty2);
$form = $this->createForm(PersonConfimDuplicateType::class);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$this->thirdPartyMergeService->merge($thirdparty1, $thirdparty2);
$session = $request->getSession();
if ($session instanceof Session) {
$session->getFlashBag()->add('success', new TranslatableMessage('thirdparty_duplicate.Merge successful'));
}
return $this->redirectToRoute('chill_crud_3party_3party_view', ['id' => $thirdparty1->getId()]);
}
return $this->render('@ChillThirdParty/ThirdPartyDuplicate/confirm.html.twig', [
'thirdparty' => $thirdparty1,
'thirdparty2' => $thirdparty2,
'form' => $form->createView(),
]);
} catch (\InvalidArgumentException $e) {
$this->addFlash('error', $this->translator->trans($e->getMessage()));
return $this->redirectToRoute('chill_thirdparty_find_duplicate', [
'thirdparty_id' => $thirdparty1->getId(),
]);
}
}
private function validateThirdpartyMerge(ThirdParty $thirdparty1, ThirdParty $thirdparty2): void
{
$constraints = [
[$thirdparty1 === $thirdparty2, 'thirdparty_duplicate.You cannot merge a thirdparty with itself. Please choose a different thirdparty'],
[$thirdparty1->getKind() !== $thirdparty2->getKind(), 'thirdparty_duplicate.A thirdparty can only be merged with a thirdparty of the same kind'],
[$thirdparty1->getParent() !== $thirdparty2->getParent(), 'thirdparty_duplicate.Two child thirdparties must have the same parent'],
];
foreach ($constraints as [$condition, $message]) {
if ($condition) {
throw new \InvalidArgumentException($message);
}
}
}
}

View File

@@ -1,41 +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\ThirdPartyBundle\Form;
use Chill\ThirdPartyBundle\Form\Type\PickThirdpartyDynamicType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\HiddenType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class ThirdpartyFindDuplicateType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->add('thirdparty', PickThirdpartyDynamicType::class, [
'label' => 'Find duplicate',
'mapped' => false,
'suggested' => $options['suggested'],
])
->add('direction', HiddenType::class, [
'data' => 'starting',
]);
}
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults([
'suggested' => [],
]);
}
}

View File

@@ -171,13 +171,7 @@
<a class="btn btn-sm btn-show" target="_blank" title="{{ 'Show thirdparty'|trans }}"
href="{{ path('chill_crud_3party_3party_view', { id: thirdparty.isChild ? thirdparty.parent.id : thirdparty.id }) }}"></a>
</li>
{% elseif is_granted('CHILL_3PARTY_3PARTY_UPDATE', thirdparty) %}
<li>
<a href="{{ chill_path_add_return_path('chill_thirdparty_find_duplicate',
{ 'thirdparty_id': thirdparty.id }) }}"
title="{{ 'Merge'|trans }}"
class="btn btn-misc"><i class="bi bi-chevron-contract"></i></a>
</li>
{% else %}
{% endif %}
{% if options['customButtons']['after'] is defined %}

View File

@@ -128,7 +128,7 @@
<div class="flex-table">
{% for tp in thirdParty.activeChildren %}
<div class="item-bloc">
{{ tp|chill_entity_render_box({'render': 'bloc', 'addLink': false, 'isConfidential': tp.contactDataAnonymous ? true : false, 'showFusion': true }) }}
{{ tp|chill_entity_render_box({'render': 'bloc', 'addLink': false, 'isConfidential': tp.contactDataAnonymous ? true : false }) }}
</div>
{% endfor %}
</div>

View File

@@ -1,34 +0,0 @@
{%- macro details(thirdparty, options) -%}
<ul>
<li><b>{{ 'name'|trans }}</b>:
{{ thirdparty.name }}</li>
<li><b>{{ 'First name'|trans }}</b>:
{% if thirdparty.firstname %}{{ thirdparty.firstname }}{% endif %}</li>
<li><b>{{ 'thirdparty.Civility'|trans }}</b>:
{% if thirdparty.getCivility %}{{ thirdparty.getCivility.name|localize_translatable_string }}{% endif %}</li>
<li><b>{{ 'thirdparty.NameCompany'|trans }}</b>:
{% if thirdparty.nameCompany is not empty %}{{ thirdparty.nameCompany }}{% endif %}</li>
<li><b>{{ 'thirdparty.Acronym'|trans }}</b>:
{% if thirdparty.acronym %}{{ thirdparty.acronym }}{% endif %}</li>
<li><b>{{ 'thirdparty.Profession'|trans }}</b>:
{% if thirdparty.profession %}{{ thirdparty.profession }}{% endif %}</li>
<li><b>{{ 'telephone'|trans }}</b>:
{% if thirdparty.telephone %}{{ thirdparty.telephone|chill_format_phonenumber }}{% endif %}</li>
<li><b>{{ 'email'|trans }}</b>:
{% if thirdparty.email is not null %}{{ thirdparty.email }}{% endif %}</li>
<li><b>{{ 'Address'|trans }}</b>:
{%- if thirdparty.getAddress is not empty -%}
{{ thirdparty.getAddress|chill_entity_render_box }}
{% endif %}</li>
<li><b>{{ 'thirdparty.Contact data are confidential'|trans }}</b>:
{{ thirdparty.contactDataAnonymous }}</li>
<li><b>{{ 'Contacts'|trans }}</b>:
<ul>
{% for c in thirdparty.getChildren %}
<li>{{ c.name }} {{ c.firstName }}</li>
{% endfor %}
</ul>
</li>
</ul>
{% endmacro %}

View File

@@ -1,97 +0,0 @@
{% extends "@ChillMain/layout.html.twig" %}
{% import '@ChillThirdParty/ThirdPartyDuplicate/_details.html.twig' as details %}
{% block title %}{{ 'thirdparty_duplicate.Thirdparty duplicate title'|trans ~ ' ' ~ thirdparty.name }}{% endblock %}
{% block content %}
<style>
div.duplicate-content {
margin: 0 2rem;
}
div.col {
padding: 1em;
border: 3px solid #cccccc;
}
div.border {
border: 4px solid #3c9f8d;
}
</style>
<div class="container-fluid content"><div class="duplicate-content">
<h1>{{ 'thirdparty_duplicate.title'|trans }}</h1>
<div class="col-md-11">
<p><b>{{ 'thirdparty_duplicate.Thirdparty to delete'|trans }}</b>:
{{ 'thirdparty_duplicate.Thirdparty to delete explanation'|trans }}
</p>
<div class="col">
<h1><span><a class="btn btn-show" target="_blank" title="{{ 'Open in another window'|trans }}" href="{{ path('chill_crud_3party_3party_view', { id : thirdparty2.id }) }}"></a></span>
{{ thirdparty2 }}
</h1>
<h4>{{ 'Deleted datas'|trans ~ ':' }}</h4>
{{ details.details(thirdparty2) }}
{# <h4>{{ 'Moved links'|trans ~ ':' }}</h4>#}
{# {{ details.links(thirdparty2) }}#}
</div>
</div>
<div class="col-md-11 mt-3">
<p><b>{{ 'thirdparty_duplicate.Thirdparty to keep'|trans }}</b>:
{{ 'thirdparty_duplicate.Thirdparty to keep explanation'|trans }}
</p>
<div class="col border">
<h1><span><a class="btn btn-show" target="_blank" title="{{ 'Open in another window'|trans }}" href="{{ path('chill_crud_3party_3party_view', { id : thirdparty.id }) }}"></a></span>
{{ thirdparty }}
</h1>
<h4>{{ 'thirdparty_duplicate.Data to keep'|trans ~ ':' }}</h4>
{{ details.details(thirdparty) }}
{# <h4>{{ 'thirdparty_duplicate.links to keep'|trans ~ ':' }}</h4>#}
{# {{ sidepane.links(thirdparty) }}#}
</div>
</div>
{{ form_start(form) }}
<div class="col-md-12 centered">
<div class="container-fluid" style="padding-top: 1em;">
<div class="clear" style="padding-top: 10px;">
{{ form_widget(form.confirm) }}
</div>
<div class="col-11">
{{ form_label(form.confirm) }}
</div>
</div>
</div>
<ul class="record_actions sticky-form-buttons">
<li class="cancel">
<a href="{{ path('chill_thirdparty_find_duplicate', {thirdparty_id : thirdparty.id}) }}" class="btn btn-cancel">
{{ 'Cancel'|trans }}
</a>
</li>
<li>
<a href="{{ path('chill_thirdparty_duplicate_confirm', { thirdparty1_id : thirdparty2.id, thirdparty2_id : thirdparty.id }) }}"
class="btn btn-action">
<i class="fa fa-exchange"></i>
{{ 'Invert'|trans }}
</a>
</li>
<li>
<button class="btn btn-submit" type="submit"><i class="fa fa-cog fa-fw"></i>{{ 'Merge'|trans }}</button>
</li>
</ul>
{{ form_end(form) }}
</div></div>
{% endblock %}

View File

@@ -1,38 +0,0 @@
{% extends "@ChillMain/layout.html.twig" %}
{% set activeRouteKey = 'chill_thirdparty_duplicate' %}
{% block title %}{{ 'thirdparty_duplicate.find'|trans ~ ' ' ~ thirdparty.name|capitalize }}{% endblock %}
{% block content %}
<div class="person-duplicate">
<h1>{{ 'thirdparty_duplicate.find'|trans }}</h1>
{{ form_start(form) }}
{{ form_rest(form) }}
<ul class="record_actions sticky-form-buttons">
<li class="cancel">
<a href="{{ path('chill_crud_3party_3party_view', {'id' : thirdparty.id}) }}" class="btn btn-cancel">
{{ 'Return'|trans }}
</a>
</li>
<li>
<button class="btn btn-submit" type="submit">{{ 'Next'|trans }}</button>
</li>
</ul>
{{ form_end(form) }}
</div>
{% endblock %}
{% block js %}
{{ encore_entry_script_tags('mod_pickentity_type') }}
{% endblock %}
{% block css %}
{{ encore_entry_link_tags('mod_pickentity_type') }}
{% endblock %}

View File

@@ -1,115 +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\ThirdPartyBundle\Service;
use Chill\ThirdPartyBundle\Entity\ThirdParty;
use Doctrine\DBAL\Exception;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Mapping\ClassMetadata;
use Doctrine\ORM\Mapping\MappingException;
readonly class ThirdpartyMergeService
{
public function __construct(private EntityManagerInterface $em) {}
/**
* @throws Exception
*/
public function merge(ThirdParty $toKeep, ThirdParty $toDelete): void
{
$conn = $this->em->getConnection();
$conn->beginTransaction();
try {
$queries = [
...$this->updateReferences($toKeep, $toDelete),
...$this->removeThirdparty($toKeep, $toDelete),
];
foreach ($queries as $query) {
$conn->executeStatement($query['sql'], $query['params']);
}
$conn->commit();
} catch (\Exception $e) {
$conn->rollBack();
throw $e;
}
}
/**
* @throws MappingException
*/
private function updateReferences(ThirdParty $toKeep, ThirdParty $toDelete): array
{
$queries = [];
$allMeta = $this->em->getMetadataFactory()->getAllMetadata();
foreach ($allMeta as $meta) {
if ($meta->isMappedSuperclass) {
continue;
}
$tableName = $meta->getTableName();
foreach ($meta->getAssociationMappings() as $assoc) {
if (ThirdParty::class !== $assoc['targetEntity']) {
continue;
}
// phpstan wants boolean for if condition
if (($assoc['type'] & ClassMetadata::TO_ONE) !== 0) {
$joinColumn = $meta->getSingleAssociationJoinColumnName($assoc['fieldName']);
$suffix = (ThirdParty::class === $assoc['sourceEntity']) ? 'chill_3party.' : '';
$queries[] = [
'sql' => "UPDATE {$suffix}{$tableName} SET {$joinColumn} = :toKeep WHERE {$joinColumn} = :toDelete",
'params' => ['toKeep' => $toKeep->getId(), 'toDelete' => $toDelete->getId()],
];
} elseif (ClassMetadata::MANY_TO_MANY === $assoc['type'] && isset($assoc['joinTable'])) {
$joinTable = $assoc['joinTable']['name'];
$prefix = null !== ($assoc['joinTable']['schema'] ?? null) ? $assoc['joinTable']['schema'].'.' : '';
$joinColumn = $assoc['joinTable']['inverseJoinColumns'][0]['name'];
$queries[] = [
'sql' => "UPDATE {$prefix}{$joinTable} SET {$joinColumn} = :toKeep WHERE {$joinColumn} = :toDelete AND NOT EXISTS (SELECT 1 FROM {$prefix}{$joinTable} WHERE {$joinColumn} = :toKeep)",
'params' => ['toDelete' => $toDelete->getId(), 'toKeep' => $toKeep->getId()],
];
$queries[] = [
'sql' => "DELETE FROM {$joinTable} WHERE {$joinColumn} = :toDelete",
'params' => ['toDelete' => $toDelete->getId()],
];
}
}
}
return $queries;
}
public function removeThirdparty(ThirdParty $toKeep, ThirdParty $toDelete): array
{
return [
[
'sql' => 'UPDATE chill_3party.third_party SET parent_id = :toKeep WHERE parent_id = :toDelete',
'params' => ['toKeep' => $toKeep->getId(), 'toDelete' => $toDelete->getId()],
],
[
'sql' => 'UPDATE chill_3party.thirdparty_category SET thirdparty_id = :toKeep WHERE thirdparty_id = :toDelete AND NOT EXISTS (SELECT 1 FROM chill_3party.thirdparty_category WHERE thirdparty_id = :toKeep)',
'params' => ['toKeep' => $toKeep->getId(), 'toDelete' => $toDelete->getId()],
],
[
'sql' => 'DELETE FROM chill_3party.third_party WHERE id = :toDelete',
'params' => ['toDelete' => $toDelete->getId()],
],
];
}
}

View File

@@ -39,7 +39,6 @@ class ThirdPartyRender implements ChillEntityRenderInterface
'showContacts' => $options['showContacts'] ?? false,
'showParent' => $options['showParent'] ?? true,
'isConfidential' => $options['isConfidential'] ?? false,
'showFusion' => $options['showFusion'] ?? false,
];
return

View File

@@ -1,86 +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\ThirdPartyBundle\Tests\Service;
use Chill\ActivityBundle\Entity\Activity;
use Chill\ThirdPartyBundle\Entity\ThirdParty;
use Chill\ThirdPartyBundle\Entity\ThirdPartyCategory;
use Chill\ThirdPartyBundle\Service\ThirdpartyMergeService;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
/**
* @internal
*
* @coversNothing
*/
class ThirdpartyMergeServiceTest extends KernelTestCase
{
private EntityManagerInterface $em;
private ThirdpartyMergeService $service;
protected function setUp(): void
{
self::bootKernel();
$this->em = self::getContainer()->get(EntityManagerInterface::class);
$this->service = new ThirdpartyMergeService($this->em);
}
public function testMergeUpdatesReferencesAndDeletesThirdparty(): void
{
// Create ThirdParty entities
$toKeep = new ThirdParty();
$toKeep->setName('Thirdparty to keep');
$this->em->persist($toKeep);
$toDelete = new ThirdParty();
$toDelete->setName('Thirdparty to delete');
$this->em->persist($toDelete);
// Create a related entity with TO_ONE relation (thirdparty parent)
$relatedToOneEntity = new ThirdParty();
$relatedToOneEntity->setName('RelatedToOne thirdparty');
$relatedToOneEntity->setParent($toDelete);
$this->em->persist($relatedToOneEntity);
// Create a related entity with TO_MANY relation (thirdparty category)
$thirdpartyCategory = new ThirdPartyCategory();
$thirdpartyCategory->setName(['fr' => 'Thirdparty category']);
$this->em->persist($thirdpartyCategory);
$toDelete->addCategory($thirdpartyCategory);
$this->em->persist($toDelete);
$activity = new Activity();
$activity->setDate(new \DateTime());
$activity->addThirdParty($toDelete);
$this->em->persist($activity);
$this->em->flush();
// Run merge
$this->service->merge($toKeep, $toDelete);
$this->em->refresh($toKeep);
$this->em->refresh($relatedToOneEntity);
// Check that references were updated
$this->assertEquals($toKeep->getId(), $relatedToOneEntity->getParent()->getId(), 'The parent thirdparty was succesfully merged');
$updatedRelatedManyEntity = $this->em->find(ThirdPartyCategory::class, $thirdpartyCategory->getId());
$this->assertContains($updatedRelatedManyEntity, $toKeep->getCategories(), 'The thirdparty category was found in the toKeep entity');
// Check that toDelete was removed
$this->em->clear();
$deletedThirdParty = $this->em->find(ThirdParty::class, $toDelete->getId());
$this->assertNull($deletedThirdParty);
}
}

View File

@@ -1,14 +1,14 @@
---
services:
_defaults:
autowire: true
autoconfigure: true
Chill\ThirdPartyBundle\Serializer\Normalizer\:
autowire: true
resource: '../Serializer/Normalizer/'
tags:
- { name: 'serializer.normalizer', priority: 64 }
Chill\ThirdPartyBundle\Export\:
autowire: true
autoconfigure: true
resource: '../Export/'
Chill\ThirdPartyBundle\Validator\:
@@ -16,5 +16,3 @@ services:
autowire: true
resource: '../Validator/'
Chill\ThirdPartyBundle\Service\ThirdpartyMergeService: ~

View File

@@ -135,6 +135,7 @@ is thirdparty: Le demandeur est un tiers
Filter by person's who have a residential address located at a thirdparty of type: Filtrer les usagers qui ont une addresse de résidence chez un tiers
"Filtered by person's who have a residential address located at a thirdparty of type %thirdparty_type% and valid on %date_calc%": "Uniquement les usagers qui ont une addresse de résidence chez un tiers de catégorie %thirdparty_type% et valide sur la date %date_calc%"
# admin
admin:
export_description: Liste des tiers (format CSV)
@@ -154,16 +155,3 @@ Telephone2: Autre téléphone
Contact email: Courrier électronique du contact
Contact address: Adresse du contact
Contact profession: Profession du contact
thirdparty_duplicate:
title: Fusionner les tiers doublons
find: Désigner un tiers doublon
Thirdparty to keep: Tiers à conserver
Thirdparty to delete: Tiers à supprimer
Thirdparty to delete explanation: Ce tiers sera supprimé. Seuls les contacts de ce tiers, énumérés ci-dessous, seront transférés.
Thirdparty to keep explanation: Ce tiers sera conservé
Data to keep: Données conservées
You cannot merge a thirdparty with itself. Please choose a different thirdparty: Vous ne pouvez pas fusionner un tiers avec lui-même. Veuillez choisir un autre tiers.
A thirdparty can only be merged with a thirdparty of the same kind: Un tiers ne peut être fusionné qu'avec un tiers de même type.
Two child thirdparties must have the same parent: Deux tiers de type « contact » doivent avoir le même tiers parent.
Merge successful: La fusion a été effectuée avec succès