Compare commits

..

8 Commits

Author SHA1 Message Date
4d4367712e Pipeline fixes 2025-08-25 19:49:11 +02:00
3654005a74 Add missing parameter in creation of new AssignTaskEvent 2025-08-25 19:45:55 +02:00
9df423a201 Add TaskAssignEventSubscriber unit tests
- Covered `getSubscribedEvents` method behavior
- Added tests for `onTaskAssigned` with changed and unchanged assignees
- Validated notification properties in appropriate scenarios
2025-08-25 19:38:42 +02:00
6d9834de71 Improve assignee handling and task assignment logic
- Added `hasAssigneeChanged` method for detecting assignee change
- Updated `onTaskAssigned` logic to trigger only on assignee change
2025-08-25 19:38:15 +02:00
bc5f8d56fe Trans task assignment notification email
- Updated `task_assignment_notification_content.txt.twig` with dynamic translations and URLs
2025-08-25 15:35:00 +02:00
5daa83c93e Add task assignment notification system
- Introduced `AssignTaskEvent`, `TaskAssignEventSubscriber`, and `AssignTaskNotificationFlagProvider` for task assignment notifications
- WIP : Added notification templates for task assignment title and content
- Updated `SingleTaskController` to dispatch `AssignTaskEvent`
- Adjusted translations and service configurations accordingly
2025-08-14 14:54:18 +02:00
88dd7116f3 Refactor TaskNotificationHandler logic 2025-08-12 10:25:47 +02:00
ff25ab2f91 feat: add task notification handler
- WIP Implement `TaskNotificationHandler` for generating task-related notifications
- Created `showInNotification.html.twig` for rendering task notification view
- Added logic to fetch and display task data in notifications
2025-08-08 15:53:34 +02:00
64 changed files with 1185 additions and 2025 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

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

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

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

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

@@ -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,361 +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,109 +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 } from "ChillMainAssets/lib/api/apiMethods";
import AccompanyingPeriodWorkEvaluationList from "ChillPersonAssets/vuejs/_components/AccompanyingPeriodWorkSelector/AccompanyingPeriodWorkEvaluationList.vue";
import { AccompanyingPeriodWorkEvaluation } from "../../../types";
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

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

@@ -13,7 +13,6 @@ namespace Chill\TaskBundle\Controller;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Pagination\PaginatorFactory;
use Chill\MainBundle\Security\Resolver\CenterResolverDispatcherInterface;
use Chill\MainBundle\Serializer\Model\Collection;
use Chill\MainBundle\Serializer\Model\Counter;
use Chill\MainBundle\Templating\Listing\FilterOrderHelper;
@@ -23,6 +22,7 @@ use Chill\PersonBundle\Entity\AccompanyingPeriod;
use Chill\PersonBundle\Entity\Person;
use Chill\PersonBundle\Privacy\PrivacyEvent;
use Chill\TaskBundle\Entity\SingleTask;
use Chill\TaskBundle\Event\AssignTaskEvent;
use Chill\TaskBundle\Event\TaskEvent;
use Chill\TaskBundle\Event\UI\UIEvent;
use Chill\TaskBundle\Form\SingleTaskType;
@@ -48,7 +48,6 @@ use Symfony\Contracts\Translation\TranslatorInterface;
final class SingleTaskController extends AbstractController
{
public function __construct(
private readonly CenterResolverDispatcherInterface $centerResolverDispatcher,
private readonly PaginatorFactory $paginatorFactory,
private readonly SingleTaskAclAwareRepositoryInterface $singleTaskAclAwareRepository,
private readonly TranslatorInterface $translator,
@@ -169,6 +168,9 @@ final class SingleTaskController extends AbstractController
->setForm($this->setCreateForm($task, TaskVoter::UPDATE));
$this->eventDispatcher->dispatch($event, UIEvent::EDIT_FORM);
// To keep track of specific assignee change
$initialAssignee = $task->getAssignee();
$form = $event->getForm();
$form->handleRequest($request);
@@ -178,6 +180,13 @@ final class SingleTaskController extends AbstractController
$em = $this->managerRegistry->getManager();
$em->persist($task);
if (null !== $task->getAssignee()) {
$this->eventDispatcher->dispatch(
new AssignTaskEvent($task, $initialAssignee),
AssignTaskEvent::PERSIST
);
}
$em->flush();
$this->addFlash('success', $this->translator
@@ -525,6 +534,13 @@ final class SingleTaskController extends AbstractController
$this->eventDispatcher->dispatch(new TaskEvent($task), TaskEvent::PERSIST);
if (null !== $task->getAssignee()) {
$this->eventDispatcher->dispatch(
new AssignTaskEvent($task, null),
AssignTaskEvent::PERSIST
);
}
$em->flush();
$this->addFlash('success', $this->translator->trans('The task is created'));

View File

@@ -42,6 +42,7 @@ class ChillTaskExtension extends Extension implements PrependExtensionInterface
$loader->load('services/timeline.yaml');
$loader->load('services/fixtures.yaml');
$loader->load('services/form.yaml');
$loader->load('services/notification.yaml');
}
public function prepend(ContainerBuilder $container)

View File

@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\TaskBundle\Event;
use Chill\MainBundle\Entity\User;
use Chill\TaskBundle\Entity\SingleTask;
use Symfony\Contracts\EventDispatcher\Event;
class AssignTaskEvent extends Event
{
final public const PERSIST = 'chill_task.assign_task';
public function __construct(
private readonly SingleTask $task,
private readonly ?User $initialAssignee,
) {}
public function getTask(): SingleTask
{
return $this->task;
}
public function getInitialAssignee(): ?User
{
return $this->initialAssignee;
}
public function hasAssigneeChanged(): bool
{
return $this->initialAssignee !== $this->task->getAssignee();
}
}

View File

@@ -0,0 +1,66 @@
<?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\TaskBundle\Event;
use Chill\MainBundle\Entity\Notification;
use Chill\TaskBundle\Entity\SingleTask;
use Chill\TaskBundle\Notification\AssignTaskNotificationFlagProvider;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
readonly class TaskAssignEventSubscriber implements EventSubscriberInterface
{
public function __construct(
private EntityManagerInterface $entityManager,
private \Twig\Environment $engine,
) {}
public static function getSubscribedEvents(): array
{
return [
AssignTaskEvent::PERSIST => ['onTaskAssigned', 0],
];
}
/**
* Send a notification when a user is assigned to a task.
* Only triggers when the assignee actually changes.
*/
public function onTaskAssigned(AssignTaskEvent $event): void
{
if (!$event->hasAssigneeChanged()) {
return;
}
$task = $event->getTask();
$assignedUser = $task->getAssignee();
$title = $task->getTitle();
$context = [
'task' => $task,
'assignedUser' => $assignedUser,
'title' => $title,
];
$notification = new Notification();
$notification
->setRelatedEntityId($task->getId())
->setRelatedEntityClass(SingleTask::class)
->setTitle($this->engine->render('@ChillTask/Notification/task_assignment_notification_title.txt.twig', $context))
->setMessage($this->engine->render('@ChillTask/Notification/task_assignment_notification_content.txt.twig', $context))
->addAddressee($assignedUser)
->setType(AssignTaskNotificationFlagProvider::FLAG);
$this->entityManager->persist($notification);
}
}

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\TaskBundle\Notification;
use Chill\MainBundle\Notification\FlagProviders\NotificationFlagProviderInterface;
use Symfony\Component\Translation\TranslatableMessage;
use Symfony\Contracts\Translation\TranslatableInterface;
class AssignTaskNotificationFlagProvider implements NotificationFlagProviderInterface
{
public const FLAG = 'task-assign-notif';
public function getFlag(): string
{
return self::FLAG;
}
public function getLabel(): TranslatableInterface
{
return new TranslatableMessage('notification.flags.task_assign');
}
}

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\TaskBundle\Notification;
use Chill\MainBundle\Entity\Notification;
use Chill\MainBundle\Notification\NotificationHandlerInterface;
use Chill\TaskBundle\Entity\SingleTask;
use Chill\TaskBundle\Repository\SingleTaskRepository;
use Symfony\Component\Translation\TranslatableMessage;
use Symfony\Contracts\Translation\TranslatableInterface;
final readonly class TaskNotificationHandler implements NotificationHandlerInterface
{
public function __construct(private SingleTaskRepository $taskRepository) {}
public function getTemplate(Notification $notification, array $options = []): string
{
return '@ChillTask/SingleTask/showInNotification.html.twig';
}
public function getTemplateData(Notification $notification, array $options = []): array
{
return [
'notification' => $notification,
'task' => $this->taskRepository->find($notification->getRelatedEntityId()),
];
}
public function supports(Notification $notification, array $options = []): bool
{
return SingleTask::class === $notification->getRelatedEntityClass();
}
public function getTitle(Notification $notification, array $options = []): TranslatableInterface
{
if (null === $task = $this->getRelatedEntity($notification)) {
return new TranslatableMessage('task.deleted');
}
return new TranslatableMessage('notification.task.title %title%', ['title' => $task->getTitle()]);
}
public function getAssociatedPersons(Notification $notification, array $options = []): array
{
if (null === $task = $this->getRelatedEntity($notification)) {
return [];
}
if (null !== $task->getCourse()) {
return $task->getCourse()->getParticipations()->getValues();
}
return [$task->getPerson()];
}
public function getRelatedEntity(Notification $notification): ?object
{
return $this->taskRepository->find($notification->getRelatedEntityId());
}
}

View File

@@ -0,0 +1,15 @@
{{ assignedUser.label }},
{{ 'notification.email.task_assigned'|trans({}, null, assignedUser.getLocale) }}
{{ 'notification.email.title_label'|trans({}, null, assignedUser.getLocale) }} "{{ task.title }}".
{% if task.endDate %}
{{ 'notification.email.deadline'|trans({'%date%': task.endDate|format_date('long')}, null, assignedUser.getLocale) }}
{% endif %}
{{ 'notification.email.view_task'|trans({}, null, assignedUser.getLocale) }}
{{ absolute_url(path('chill_task_single_task_show', {'id': task.id, '_locale': assignedUser.getLocale})) }}
{{ 'notification.email.regards'|trans({}, null, assignedUser.getLocale) }},

View File

@@ -0,0 +1,3 @@
{{ 'notification.email.title'|trans({}, null, assignedUser.getLocale) }}

View File

@@ -18,14 +18,14 @@
<div>
{% if task.person is not null %}
<span class="chill-task-list__row__person">
{% include '@ChillMain/OnTheFly/_insert_vue_onthefly.html.twig' with {
targetEntity: { name: 'person', id: task.person.id },
action: 'show',
displayBadge: true,
buttonText: task.person|chill_entity_render_string,
isDead: task.person.deathdate is not null
} %}
</span>
{% include '@ChillMain/OnTheFly/_insert_vue_onthefly.html.twig' with {
targetEntity: { name: 'person', id: task.person.id },
action: 'show',
displayBadge: true,
buttonText: task.person|chill_entity_render_string,
isDead: task.person.deathdate is not null
} %}
</span>
{% elseif task.course is not null %}
<div style="margin-bottom: 1rem;">
{% for part in task.course.currentParticipations %}

View File

@@ -110,4 +110,5 @@
</li>
{% endif %}
</ul></div>
</ul>
</div>

View File

@@ -0,0 +1,14 @@
{% macro recordAction(task) %}
<li>
<a href="{{ path('chill_person_accompanying_course_index', { 'task_id': task }) }}"
class="btn btn-show" title="{{ 'See task'|trans }}"></a>
</li>
{% endmacro %}
{% if task is not null %}
{# <div>Todo : display task? </div>#}
{% else %}
<div class="alert alert-warning border-warning border-1">
{{ 'You are getting a notification for a task which does not exist any more'|trans }}
</div>
{% endif %}

View File

@@ -0,0 +1,131 @@
<?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\TaskBundle\Tests\EventSubscriber;
use Chill\MainBundle\Entity\Notification;
use Chill\MainBundle\Entity\User;
use Chill\TaskBundle\Entity\SingleTask;
use Chill\TaskBundle\Event\AssignTaskEvent;
use Chill\TaskBundle\Event\TaskAssignEventSubscriber;
use Chill\TaskBundle\Notification\AssignTaskNotificationFlagProvider;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\TestCase;
use Twig\Environment;
/**
* @internal
*
* @coversNothing
*/
class TaskAssignEventSubscriberTest extends TestCase
{
private EntityManagerInterface $entityManager;
private Environment $twig;
private TaskAssignEventSubscriber $subscriber;
protected function setUp(): void
{
$this->entityManager = $this->createMock(EntityManagerInterface::class);
$this->twig = $this->createMock(Environment::class);
$this->subscriber = new TaskAssignEventSubscriber($this->entityManager, $this->twig);
}
public function testGetSubscribedEvents(): void
{
$events = TaskAssignEventSubscriber::getSubscribedEvents();
$this->assertArrayHasKey(AssignTaskEvent::PERSIST, $events);
$this->assertEquals(['onTaskAssigned', 0], $events[AssignTaskEvent::PERSIST]);
}
public function testOnTaskAssignedCreatesNotificationWhenAssigneeChanges(): void
{
// Arrange
$task = $this->createMock(SingleTask::class);
$assignee = $this->createMock(User::class);
$event = $this->createMock(AssignTaskEvent::class);
$task->method('getId')->willReturn(123);
$task->method('getTitle')->willReturn('Test Task');
$task->method('getAssignee')->willReturn($assignee);
$event->method('hasAssigneeChanged')->willReturn(true);
$event->method('getTask')->willReturn($task);
$this->twig->expects($this->exactly(2))
->method('render')
->with(
$this->logicalOr(
'@ChillTask/Notification/task_assignment_notification_title.txt.twig',
'@ChillTask/Notification/task_assignment_notification_content.txt.twig'
),
$this->isType('array')
)
->willReturnOnConsecutiveCalls('Notification Title', 'Notification Content');
$this->entityManager->expects($this->once())
->method('persist')
->with($this->isInstanceOf(Notification::class));
// Act
$this->subscriber->onTaskAssigned($event);
}
public function testOnTaskAssignedDoesNothingWhenAssigneeDoesNotChange(): void
{
// Arrange
$event = $this->createMock(AssignTaskEvent::class);
$event->method('hasAssigneeChanged')->willReturn(false);
$this->twig->expects($this->never())->method('render');
$this->entityManager->expects($this->never())->method('persist');
// Act
$this->subscriber->onTaskAssigned($event);
}
public function testNotificationHasCorrectProperties(): void
{
// Arrange
$task = $this->createMock(SingleTask::class);
$assignee = $this->createMock(User::class);
$event = $this->createMock(AssignTaskEvent::class);
$task->method('getId')->willReturn(456);
$task->method('getTitle')->willReturn('Important Task');
$task->method('getAssignee')->willReturn($assignee);
$event->method('hasAssigneeChanged')->willReturn(true);
$event->method('getTask')->willReturn($task);
$this->twig->method('render')->willReturn('Test Content');
// Capture the persisted notification
$persistedNotification = null;
$this->entityManager->expects($this->once())
->method('persist')
->willReturnCallback(function ($notification) use (&$persistedNotification) {
$persistedNotification = $notification;
});
// Act
$this->subscriber->onTaskAssigned($event);
// Assert
$this->assertInstanceOf(Notification::class, $persistedNotification);
$this->assertEquals(456, $persistedNotification->getRelatedEntityId());
$this->assertEquals(SingleTask::class, $persistedNotification->getRelatedEntityClass());
$this->assertEquals(AssignTaskNotificationFlagProvider::FLAG, $persistedNotification->getType());
$this->assertEquals('Test Content', $persistedNotification->getTitle());
$this->assertEquals('Test Content', $persistedNotification->getMessage());
}
}

View File

@@ -1,7 +1,13 @@
services:
_defaults:
autowire: true
autoconfigure: true
Chill\TaskBundle\Event\Lifecycle\TaskLifecycleEvent:
arguments:
$em: '@Doctrine\ORM\EntityManagerInterface'
$tokenStorage: '@Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface'
tags:
- { name: kernel.event_subscriber }
Chill\TaskBundle\Event\TaskAssignEventSubscriber: ~

View File

@@ -0,0 +1,7 @@
services:
_defaults:
autowire: true
autoconfigure: true
Chill\TaskBundle\Notification\TaskNotificationHandler: ~
Chill\TaskBundle\Notification\AssignTaskNotificationFlagProvider: ~

View File

@@ -116,3 +116,16 @@ CHILL_TASK_TASK_UPDATE: Modifier une tâche
CHILL_TASK_TASK_CREATE_FOR_COURSE: Créer une tâche pour un parcours
CHILL_TASK_TASK_CREATE_FOR_PERSON: Créer une tâche pour un usager
notification:
task:
title %title%: "Tâche: title"
flags:
task_assign: Lorsqu'un autre utilisateur m'assigne à une tâche.
email:
title: "Une tâche demande votre attention"
task_assigned: "Une tâche vous a été assignée."
title_label: "Titre de la tâche:"
deadline: "Vous êtes invités à accomplir cette tâche avant le %date%"
view_task: "Vous pouvez visualiser la tâche sur cette page:"
regards: "Cordialement"

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