mirror of
https://gitlab.com/Chill-Projet/chill-bundles.git
synced 2025-10-03 20:09:42 +00:00
Compare commits
21 Commits
ticket/64-
...
v4.5.0
Author | SHA1 | Date | |
---|---|---|---|
955cb817c4
|
|||
823f9546b9 | |||
be39fa16e7 | |||
c8bb7575e7 | |||
|
80a3734171 | ||
ab98f3a102
|
|||
7516e68d77 | |||
7b60b7a8af | |||
d984dec7db
|
|||
46a4dedab8 | |||
db98519e65 | |||
c39637180a
|
|||
15f9409bc8 | |||
5b90d23367 | |||
c48625d1cd | |||
1195b54a68 | |||
2a280b814f | |||
230c758255 | |||
eafda987ae | |||
7db8a371fc | |||
0d0649dd31 |
@@ -1,6 +0,0 @@
|
||||
kind: Feature
|
||||
body: Add a command to generate a list of permissions
|
||||
time: 2025-09-04T18:10:32.334524026+02:00
|
||||
custom:
|
||||
Issue: ""
|
||||
SchemaChange: No schema change
|
10
.changes/v4.3.0.md
Normal file
10
.changes/v4.3.0.md
Normal file
@@ -0,0 +1,10 @@
|
||||
## v4.3.0 - 2025-09-08
|
||||
### Feature
|
||||
* ([#409](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/409)) Add 45 and 60 min calendar ranges
|
||||
* Add a command to generate a list of permissions
|
||||
* ([#412](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/412)) Add an absence end date
|
||||
|
||||
**Schema Change**: Add columns or tables
|
||||
### Fixed
|
||||
* fix date formatting in calendar range display
|
||||
* Change route URL to avoid clash with person duplicate controller method
|
8
.changes/v4.4.0.md
Normal file
8
.changes/v4.4.0.md
Normal file
@@ -0,0 +1,8 @@
|
||||
## v4.4.0 - 2025-09-11
|
||||
### Feature
|
||||
* ([#359](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/359)) Allow the merge of two accompanying period works
|
||||
* ([#369](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/369)) Duplication of a document to another accompanying period work evaluation
|
||||
* ([#359](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/359)) Fusion of two accompanying period works
|
||||
### Fixed
|
||||
* Fix display of 'duplicate' and 'merge' buttons in CRUD templates
|
||||
* Fix saving notification preferences in user's profile
|
3
.changes/v4.4.1.md
Normal file
3
.changes/v4.4.1.md
Normal file
@@ -0,0 +1,3 @@
|
||||
## v4.4.1 - 2025-09-11
|
||||
### Fixed
|
||||
* fix translations in duplicate evaluation document modal and realign close modal button
|
3
.changes/v4.4.2.md
Normal file
3
.changes/v4.4.2.md
Normal file
@@ -0,0 +1,3 @@
|
||||
## v4.4.2 - 2025-09-12
|
||||
### Fixed
|
||||
* Fix document generation and workflow generation do not work on accompanying period work documents
|
13
.changes/v4.5.0.md
Normal file
13
.changes/v4.5.0.md
Normal file
@@ -0,0 +1,13 @@
|
||||
## v4.5.0 - 2025-10-03
|
||||
### Feature
|
||||
* Only allow delete of attachment on workflows that are not final
|
||||
* Move up signature buttons on index workflow page for easier access
|
||||
* Filter out document from attachment list if it is the same as the workflow document
|
||||
* Block edition on attached document on workflow, if the workflow is finalized or sent external
|
||||
* Convert workflow's attached document to pdf while sending them external
|
||||
* After a signature is canceled or rejected, going to a waiting page until the post-process routines apply a workflow transition
|
||||
### Fixed
|
||||
* ([#426](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/426)) Increased the number of required characters when setting a new password in Chill from 9 to 14 - GDPR compliance
|
||||
* Fix permissions on storedObject which are subject by a workflow
|
||||
### DX
|
||||
* Introduce a WaitingScreen component to display a waiting screen
|
42
CHANGELOG.md
42
CHANGELOG.md
@@ -6,6 +6,48 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html),
|
||||
and is generated by [Changie](https://github.com/miniscruff/changie).
|
||||
|
||||
|
||||
## v4.5.0 - 2025-10-03
|
||||
### Feature
|
||||
* Only allow delete of attachment on workflows that are not final
|
||||
* Move up signature buttons on index workflow page for easier access
|
||||
* Filter out document from attachment list if it is the same as the workflow document
|
||||
* Block edition on attached document on workflow, if the workflow is finalized or sent external
|
||||
* Convert workflow's attached document to pdf while sending them external
|
||||
* After a signature is canceled or rejected, going to a waiting page until the post-process routines apply a workflow transition
|
||||
### Fixed
|
||||
* ([#426](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/426)) Increased the number of required characters when setting a new password in Chill from 9 to 14 - GDPR compliance
|
||||
* Fix permissions on storedObject which are subject by a workflow
|
||||
### DX
|
||||
* Introduce a WaitingScreen component to display a waiting screen
|
||||
|
||||
## v4.4.2 - 2025-09-12
|
||||
### Fixed
|
||||
* Fix document generation and workflow generation do not work on accompanying period work documents
|
||||
|
||||
## v4.4.1 - 2025-09-11
|
||||
### Fixed
|
||||
* fix translations in duplicate evaluation document modal and realign close modal button
|
||||
|
||||
## v4.4.0 - 2025-09-11
|
||||
### Feature
|
||||
* ([#359](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/359)) Allow the merge of two accompanying period works
|
||||
* ([#369](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/369)) Duplication of a document to another accompanying period work evaluation
|
||||
* ([#359](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/359)) Fusion of two accompanying period works
|
||||
### Fixed
|
||||
* Fix display of 'duplicate' and 'merge' buttons in CRUD templates
|
||||
* Fix saving notification preferences in user's profile
|
||||
|
||||
## v4.3.0 - 2025-09-08
|
||||
### Feature
|
||||
* ([#409](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/409)) Add 45 and 60 min calendar ranges
|
||||
* Add a command to generate a list of permissions
|
||||
* ([#412](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/412)) Add an absence end date
|
||||
|
||||
**Schema Change**: Add columns or tables
|
||||
### Fixed
|
||||
* fix date formatting in calendar range display
|
||||
* Change route URL to avoid clash with person duplicate controller method
|
||||
|
||||
## v4.2.1 - 2025-09-03
|
||||
### Fixed
|
||||
* Fix exports to work with DirectExportInterface
|
||||
|
@@ -55,5 +55,6 @@
|
||||
</dl>
|
||||
|
||||
{% endblock %}
|
||||
{% block content_view_actions_duplicate_link %}{% endblock %}
|
||||
{% endembed %}
|
||||
{% endblock %}
|
||||
|
@@ -70,6 +70,8 @@
|
||||
<option value="00:10:00">10 minutes</option>
|
||||
<option value="00:15:00">15 minutes</option>
|
||||
<option value="00:30:00">30 minutes</option>
|
||||
<option value="00:45:00">45 minutes</option>
|
||||
<option value="00:60:00">60 minutes</option>
|
||||
</select>
|
||||
<label class="input-group-text" for="slotMinTime">De</label>
|
||||
<select
|
||||
|
@@ -32,6 +32,8 @@
|
||||
<option value="00:10:00">10 minutes</option>
|
||||
<option value="00:15:00">15 minutes</option>
|
||||
<option value="00:30:00">30 minutes</option>
|
||||
<option value="00:45:00">45 minutes</option>
|
||||
<option value="00:60:00">60 minutes</option>
|
||||
</select>
|
||||
<label class="input-group-text" for="slotMinTime">De</label>
|
||||
<select
|
||||
@@ -102,7 +104,8 @@
|
||||
event.title
|
||||
}}</b>
|
||||
<b v-else-if="event.extendedProps.is === 'range'"
|
||||
>{{ formatDate(event.startStr) }} -
|
||||
>{{ formatDate(event.startStr, "time") }} -
|
||||
{{ formatDate(event.endStr, "time") }}:
|
||||
{{ event.extendedProps.locationName }}</b
|
||||
>
|
||||
<b v-else-if="event.extendedProps.is === 'local'">{{
|
||||
@@ -294,9 +297,26 @@ const nextWeeks = computed((): Weeks[] =>
|
||||
}),
|
||||
);
|
||||
|
||||
const formatDate = (datetime: string) => {
|
||||
console.log(typeof datetime);
|
||||
return ISOToDate(datetime);
|
||||
const formatDate = (datetime: string, format: null | "time" = null) => {
|
||||
const date = ISOToDate(datetime);
|
||||
if (!date) return "";
|
||||
|
||||
if (format === "time") {
|
||||
return date.toLocaleTimeString("fr-FR", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
// French date formatting
|
||||
return date.toLocaleDateString("fr-FR", {
|
||||
weekday: "short",
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
};
|
||||
|
||||
const baseOptions = ref<CalendarOptions>({
|
||||
|
@@ -25,7 +25,7 @@ export interface GenericDoc {
|
||||
type: "doc_store_generic_doc";
|
||||
uniqueKey: string;
|
||||
key: string;
|
||||
identifiers: object;
|
||||
identifiers: { id: number };
|
||||
context: "person" | "accompanying-period";
|
||||
doc_date: DateTime;
|
||||
metadata: GenericDocMetadata;
|
||||
|
@@ -4,6 +4,7 @@ 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_ADD, trans } from "translator";
|
||||
|
||||
interface DropFileConfig {
|
||||
allowRemove: boolean;
|
||||
@@ -75,11 +76,9 @@ function closeModal(): void {
|
||||
@click="openModal"
|
||||
class="btn btn-create"
|
||||
>
|
||||
Ajouter un document
|
||||
</button>
|
||||
<button v-else @click="openModal" class="btn btn-edit">
|
||||
Remplacer le document
|
||||
{{ trans(DOCUMENT_ADD) }}
|
||||
</button>
|
||||
<button v-else @click="openModal" class="btn btn-edit"></button>
|
||||
<modal
|
||||
v-if="state.showModal"
|
||||
:modal-dialog-class="modalClasses"
|
||||
|
@@ -46,6 +46,16 @@ abstract class AbstractStoredObjectVoter implements StoredObjectVoterInterface
|
||||
|
||||
public function voteOnAttribute(StoredObjectRoleEnum $attribute, StoredObject $subject, TokenInterface $token): bool
|
||||
{
|
||||
// we first try to get the permission from the workflow, as attachement (this is the less intensive query)
|
||||
$workflowPermissionAsAttachment = match ($attribute) {
|
||||
StoredObjectRoleEnum::SEE => $this->workflowDocumentService->isAllowedByWorkflowForReadOperation($subject),
|
||||
StoredObjectRoleEnum::EDIT => $this->workflowDocumentService->isAllowedByWorkflowForWriteOperation($subject),
|
||||
};
|
||||
|
||||
if (WorkflowRelatedEntityPermissionHelper::FORCE_DENIED === $workflowPermissionAsAttachment) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Retrieve the related entity
|
||||
$entity = $this->getRepository()->findAssociatedEntityToStoredObject($subject);
|
||||
|
||||
@@ -66,7 +76,7 @@ abstract class AbstractStoredObjectVoter implements StoredObjectVoterInterface
|
||||
return match ($workflowPermission) {
|
||||
WorkflowRelatedEntityPermissionHelper::FORCE_GRANT => true,
|
||||
WorkflowRelatedEntityPermissionHelper::FORCE_DENIED => false,
|
||||
WorkflowRelatedEntityPermissionHelper::ABSTAIN => $regularPermission,
|
||||
WorkflowRelatedEntityPermissionHelper::ABSTAIN => WorkflowRelatedEntityPermissionHelper::FORCE_GRANT === $workflowPermissionAsAttachment || $regularPermission,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@@ -14,6 +14,12 @@ namespace Chill\DocStoreBundle\Security\Authorization;
|
||||
use Chill\DocStoreBundle\Entity\StoredObject;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
||||
|
||||
/**
|
||||
* Interface for voting on stored object permissions.
|
||||
*
|
||||
* Each time a stored object is attached to a document, the voter is responsible for determining
|
||||
* whether the user has the necessary permissions to access or modify the stored object.
|
||||
*/
|
||||
interface StoredObjectVoterInterface
|
||||
{
|
||||
public function supports(StoredObjectRoleEnum $attribute, StoredObject $subject): bool;
|
||||
|
@@ -86,9 +86,165 @@ class AbstractStoredObjectVoterTest extends TestCase
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider dataProviderVoteOnAttribute
|
||||
* @dataProvider dataProviderVoteOnAttributeWithStoredObjectPermission
|
||||
*/
|
||||
public function testVoteOnAttribute(
|
||||
public function testVoteOnAttributeWithStoredObjectPermission(
|
||||
StoredObjectRoleEnum $attribute,
|
||||
bool $expected,
|
||||
bool $isGrantedRegularPermission,
|
||||
string $isGrantedWorkflowPermission,
|
||||
string $isGrantedStoredObjectAttachment,
|
||||
): void {
|
||||
$storedObject = new StoredObject();
|
||||
$repository = new DummyRepository($related = new \stdClass());
|
||||
$token = new UsernamePasswordToken(new User(), 'dummy');
|
||||
|
||||
$security = $this->prophesize(Security::class);
|
||||
$security->isGranted('SOME_ROLE', $related)->willReturn($isGrantedRegularPermission);
|
||||
|
||||
$workflowRelatedEntityPermissionHelper = $this->prophesize(WorkflowRelatedEntityPermissionHelper::class);
|
||||
|
||||
$security = $this->prophesize(Security::class);
|
||||
$security->isGranted('SOME_ROLE', $related)->willReturn($isGrantedRegularPermission);
|
||||
|
||||
if (StoredObjectRoleEnum::SEE === $attribute) {
|
||||
$workflowRelatedEntityPermissionHelper->isAllowedByWorkflowForReadOperation($storedObject)
|
||||
->shouldBeCalled()
|
||||
->willReturn($isGrantedStoredObjectAttachment);
|
||||
$workflowRelatedEntityPermissionHelper->isAllowedByWorkflowForReadOperation($related)
|
||||
->willReturn($isGrantedWorkflowPermission);
|
||||
} elseif (StoredObjectRoleEnum::EDIT === $attribute) {
|
||||
$workflowRelatedEntityPermissionHelper->isAllowedByWorkflowForWriteOperation($storedObject)
|
||||
->shouldBeCalled()
|
||||
->willReturn($isGrantedStoredObjectAttachment);
|
||||
$workflowRelatedEntityPermissionHelper->isAllowedByWorkflowForWriteOperation($related)
|
||||
->willReturn($isGrantedWorkflowPermission);
|
||||
} else {
|
||||
throw new \LogicException('Invalid attribute for StoredObjectVoter');
|
||||
}
|
||||
|
||||
$storedObjectVoter = new class ($repository, $workflowRelatedEntityPermissionHelper->reveal(), $security->reveal()) extends AbstractStoredObjectVoter {
|
||||
public function __construct(private $repository, $helper, $security)
|
||||
{
|
||||
parent::__construct($security, $helper);
|
||||
}
|
||||
|
||||
protected function getRepository(): AssociatedEntityToStoredObjectInterface
|
||||
{
|
||||
return $this->repository;
|
||||
}
|
||||
|
||||
protected function getClass(): string
|
||||
{
|
||||
return \stdClass::class;
|
||||
}
|
||||
|
||||
protected function attributeToRole(StoredObjectRoleEnum $attribute): string
|
||||
{
|
||||
return 'SOME_ROLE';
|
||||
}
|
||||
|
||||
protected function canBeAssociatedWithWorkflow(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
$actual = $storedObjectVoter->voteOnAttribute($attribute, $storedObject, $token);
|
||||
|
||||
self::assertEquals($expected, $actual);
|
||||
}
|
||||
|
||||
public static function dataProviderVoteOnAttributeWithStoredObjectPermission(): iterable
|
||||
{
|
||||
foreach (['read' => StoredObjectRoleEnum::SEE, 'write' => StoredObjectRoleEnum::EDIT] as $action => $attribute) {
|
||||
yield 'Not related to any workflow nor attachment ('.$action.')' => [
|
||||
$attribute,
|
||||
true,
|
||||
true,
|
||||
WorkflowRelatedEntityPermissionHelper::ABSTAIN,
|
||||
WorkflowRelatedEntityPermissionHelper::ABSTAIN,
|
||||
];
|
||||
|
||||
yield 'Not related to any workflow nor attachment (refuse) ('.$action.')' => [
|
||||
$attribute,
|
||||
false,
|
||||
false,
|
||||
WorkflowRelatedEntityPermissionHelper::ABSTAIN,
|
||||
WorkflowRelatedEntityPermissionHelper::ABSTAIN,
|
||||
];
|
||||
|
||||
yield 'Is granted by a workflow takes precedence (workflow) ('.$action.')' => [
|
||||
$attribute,
|
||||
false,
|
||||
true,
|
||||
WorkflowRelatedEntityPermissionHelper::FORCE_DENIED,
|
||||
WorkflowRelatedEntityPermissionHelper::ABSTAIN,
|
||||
];
|
||||
|
||||
yield 'Is granted by a workflow takes precedence (stored object) ('.$action.')' => [
|
||||
$attribute,
|
||||
false,
|
||||
true,
|
||||
WorkflowRelatedEntityPermissionHelper::ABSTAIN,
|
||||
WorkflowRelatedEntityPermissionHelper::FORCE_DENIED,
|
||||
];
|
||||
|
||||
yield 'Is granted by a workflow takes precedence (workflow) although grant ('.$action.')' => [
|
||||
$attribute,
|
||||
false,
|
||||
true,
|
||||
WorkflowRelatedEntityPermissionHelper::FORCE_DENIED,
|
||||
WorkflowRelatedEntityPermissionHelper::FORCE_GRANT,
|
||||
];
|
||||
|
||||
yield 'Is granted by a workflow takes precedence (stored object) although grant ('.$action.')' => [
|
||||
$attribute,
|
||||
false,
|
||||
true,
|
||||
WorkflowRelatedEntityPermissionHelper::FORCE_GRANT,
|
||||
WorkflowRelatedEntityPermissionHelper::FORCE_DENIED,
|
||||
];
|
||||
|
||||
yield 'Is granted by a workflow takes precedence (initially refused) (workflow) although grant ('.$action.')' => [
|
||||
$attribute,
|
||||
false,
|
||||
false,
|
||||
WorkflowRelatedEntityPermissionHelper::FORCE_DENIED,
|
||||
WorkflowRelatedEntityPermissionHelper::FORCE_GRANT,
|
||||
];
|
||||
|
||||
yield 'Is granted by a workflow takes precedence (initially refused) (stored object) although grant ('.$action.')' => [
|
||||
$attribute,
|
||||
false,
|
||||
false,
|
||||
WorkflowRelatedEntityPermissionHelper::FORCE_GRANT,
|
||||
WorkflowRelatedEntityPermissionHelper::FORCE_DENIED,
|
||||
];
|
||||
|
||||
yield 'Force grant inverse the regular permission (workflow) ('.$action.')' => [
|
||||
$attribute,
|
||||
true,
|
||||
false,
|
||||
WorkflowRelatedEntityPermissionHelper::FORCE_GRANT,
|
||||
WorkflowRelatedEntityPermissionHelper::ABSTAIN,
|
||||
];
|
||||
|
||||
yield 'Force grant inverse the regular permission (so) ('.$action.')' => [
|
||||
$attribute,
|
||||
true,
|
||||
false,
|
||||
WorkflowRelatedEntityPermissionHelper::ABSTAIN,
|
||||
WorkflowRelatedEntityPermissionHelper::FORCE_GRANT,
|
||||
];
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider dataProviderVoteOnAttributeWithoutStoredObjectPermission
|
||||
*/
|
||||
public function testVoteOnAttributeWithoutStoredObjectPermission(
|
||||
StoredObjectRoleEnum $attribute,
|
||||
bool $expected,
|
||||
bool $canBeAssociatedWithWorkflow,
|
||||
@@ -105,6 +261,10 @@ class AbstractStoredObjectVoterTest extends TestCase
|
||||
$security->isGranted('SOME_ROLE', $related)->willReturn($isGrantedRegularPermission);
|
||||
|
||||
$workflowRelatedEntityPermissionHelper = $this->prophesize(WorkflowRelatedEntityPermissionHelper::class);
|
||||
|
||||
$workflowRelatedEntityPermissionHelper->isAllowedByWorkflowForReadOperation($storedObject)->willReturn(WorkflowRelatedEntityPermissionHelper::ABSTAIN);
|
||||
$workflowRelatedEntityPermissionHelper->isAllowedByWorkflowForWriteOperation($storedObject)->willReturn(WorkflowRelatedEntityPermissionHelper::ABSTAIN);
|
||||
|
||||
if (null !== $isGrantedWorkflowPermissionRead) {
|
||||
$workflowRelatedEntityPermissionHelper->isAllowedByWorkflowForReadOperation($related)
|
||||
->willReturn($isGrantedWorkflowPermissionRead)->shouldBeCalled();
|
||||
@@ -123,7 +283,7 @@ class AbstractStoredObjectVoterTest extends TestCase
|
||||
self::assertEquals($expected, $voter->voteOnAttribute($attribute, $storedObject, $token), $message);
|
||||
}
|
||||
|
||||
public static function dataProviderVoteOnAttribute(): iterable
|
||||
public static function dataProviderVoteOnAttributeWithoutStoredObjectPermission(): iterable
|
||||
{
|
||||
// not associated on a workflow
|
||||
yield [StoredObjectRoleEnum::SEE, true, false, true, null, null, 'not associated on a workflow, granted by regular access, must not rely on helper'];
|
||||
|
@@ -23,6 +23,8 @@ See the document: Voir le document
|
||||
|
||||
document:
|
||||
Any title: Aucun titre
|
||||
replace: Remplacer
|
||||
Add: Ajouter un document
|
||||
|
||||
generic_doc:
|
||||
filter:
|
||||
|
@@ -118,7 +118,7 @@
|
||||
|
||||
{{ entity.notes|chill_print_or_message("Aucune note", 'blockquote') }}
|
||||
{% endblock crud_content_view_details %}
|
||||
|
||||
{% block content_view_actions_duplicate_link %}{% endblock %}
|
||||
{% block content_view_actions_back %}
|
||||
<li class="cancel">
|
||||
<a class="btn btn-cancel" href="{{ chill_return_path_or('chill_job_report_index', { 'person': entity.person.id }) }}">
|
||||
|
@@ -46,6 +46,7 @@
|
||||
</dd>
|
||||
</dl>
|
||||
{% endblock crud_content_view_details %}
|
||||
{% block content_view_actions_duplicate_link %}{% endblock %}
|
||||
|
||||
{% block content_view_actions_back %}
|
||||
<li class="cancel">
|
||||
|
@@ -206,6 +206,8 @@
|
||||
</a>
|
||||
</li>
|
||||
{% endblock %}
|
||||
{% block content_view_actions_duplicate_link %}{% endblock %}
|
||||
|
||||
{% block content_view_actions_after %}
|
||||
<li>
|
||||
<a class="btn btn-misc" href="{{ chill_return_path_or('chill_crud_immersion_bilan', { 'id': entity.id, 'person_id': entity.person.id }) }}">
|
||||
|
@@ -94,6 +94,7 @@
|
||||
|
||||
|
||||
{% endblock crud_content_view_details %}
|
||||
{% block content_view_actions_duplicate_link %}{% endblock %}
|
||||
|
||||
{% block content_view_actions_back %}
|
||||
<li class="cancel">
|
||||
|
@@ -0,0 +1,64 @@
|
||||
<?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\Action\User\UpdateProfile;
|
||||
|
||||
use Chill\MainBundle\Entity\User;
|
||||
use Chill\MainBundle\Notification\NotificationFlagManager;
|
||||
use Chill\MainBundle\Validation\Constraint\PhonenumberConstraint;
|
||||
use libphonenumber\PhoneNumber;
|
||||
|
||||
final class UpdateProfileCommand
|
||||
{
|
||||
public array $notificationFlags = [];
|
||||
|
||||
public function __construct(
|
||||
#[PhonenumberConstraint]
|
||||
public ?PhoneNumber $phonenumber,
|
||||
) {}
|
||||
|
||||
public static function create(User $user, NotificationFlagManager $flagManager): self
|
||||
{
|
||||
$updateProfileCommand = new self($user->getPhonenumber());
|
||||
|
||||
foreach ($flagManager->getAllNotificationFlagProviders() as $provider) {
|
||||
$updateProfileCommand->setNotificationFlag(
|
||||
$provider->getFlag(),
|
||||
User::NOTIF_FLAG_IMMEDIATE_EMAIL,
|
||||
$user->isNotificationSendImmediately($provider->getFlag())
|
||||
);
|
||||
$updateProfileCommand->setNotificationFlag(
|
||||
$provider->getFlag(),
|
||||
User::NOTIF_FLAG_DAILY_DIGEST,
|
||||
$user->isNotificationDailyDigest($provider->getFlag())
|
||||
);
|
||||
}
|
||||
|
||||
return $updateProfileCommand;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param User::NOTIF_FLAG_IMMEDIATE_EMAIL|User::NOTIF_FLAG_DAILY_DIGEST $kind
|
||||
*/
|
||||
private function setNotificationFlag(string $type, string $kind, bool $value): void
|
||||
{
|
||||
if (!array_key_exists($type, $this->notificationFlags)) {
|
||||
$this->notificationFlags[$type] = ['immediate_email' => true, 'daily_digest' => false];
|
||||
}
|
||||
|
||||
$k = match ($kind) {
|
||||
User::NOTIF_FLAG_IMMEDIATE_EMAIL => 'immediate_email',
|
||||
User::NOTIF_FLAG_DAILY_DIGEST => 'daily_digest',
|
||||
};
|
||||
|
||||
$this->notificationFlags[$type][$k] = $value;
|
||||
}
|
||||
}
|
@@ -0,0 +1,27 @@
|
||||
<?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\Action\User\UpdateProfile;
|
||||
|
||||
use Chill\MainBundle\Entity\User;
|
||||
|
||||
final readonly class UpdateProfileCommandHandler
|
||||
{
|
||||
public function updateProfile(User $user, UpdateProfileCommand $command): void
|
||||
{
|
||||
$user->setPhonenumber($command->phonenumber);
|
||||
|
||||
foreach ($command->notificationFlags as $flag => $values) {
|
||||
$user->setNotificationImmediately($flag, $values['immediate_email']);
|
||||
$user->setNotificationDailyDigest($flag, $values['daily_digest']);
|
||||
}
|
||||
}
|
||||
}
|
@@ -48,6 +48,7 @@ class AbsenceController extends AbstractController
|
||||
$user = $this->security->getUser();
|
||||
|
||||
$user->setAbsenceStart(null);
|
||||
$user->setAbsenceEnd(null);
|
||||
$em = $this->managerRegistry->getManager();
|
||||
$em->flush();
|
||||
|
||||
|
@@ -1,63 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* Chill is a software for social workers
|
||||
*
|
||||
* For the full copyright and license information, please view
|
||||
* the LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Chill\MainBundle\Controller;
|
||||
|
||||
use Chill\MainBundle\Form\UserProfileType;
|
||||
use Chill\MainBundle\Security\ChillSecurity;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use Symfony\Contracts\Translation\TranslatorInterface;
|
||||
use Symfony\Component\Routing\Annotation\Route;
|
||||
|
||||
final class UserProfileController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly TranslatorInterface $translator,
|
||||
private readonly ChillSecurity $security,
|
||||
private readonly \Doctrine\Persistence\ManagerRegistry $managerRegistry,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* User profile that allows editing of phonenumber and visualization of certain data.
|
||||
*/
|
||||
#[Route(path: '/{_locale}/main/user/my-profile', name: 'chill_main_user_profile')]
|
||||
public function __invoke(Request $request)
|
||||
{
|
||||
if (!$this->security->isGranted('ROLE_USER')) {
|
||||
throw new AccessDeniedHttpException();
|
||||
}
|
||||
|
||||
$user = $this->security->getUser();
|
||||
$editForm = $this->createForm(UserProfileType::class, $user);
|
||||
|
||||
$editForm->get('notificationFlags')->setData($user->getNotificationFlags());
|
||||
|
||||
$editForm->handleRequest($request);
|
||||
|
||||
if ($editForm->isSubmitted() && $editForm->isValid()) {
|
||||
$notificationFlagsData = $editForm->get('notificationFlags')->getData();
|
||||
$user->setNotificationFlags($notificationFlagsData);
|
||||
|
||||
$em = $this->managerRegistry->getManager();
|
||||
$em->flush();
|
||||
$this->addFlash('success', $this->translator->trans('user.profile.Profile successfully updated!'));
|
||||
|
||||
return $this->redirectToRoute('chill_main_user_profile');
|
||||
}
|
||||
|
||||
return $this->render('@ChillMain/User/profile.html.twig', [
|
||||
'user' => $user,
|
||||
'form' => $editForm->createView(),
|
||||
]);
|
||||
}
|
||||
}
|
@@ -0,0 +1,75 @@
|
||||
<?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\Action\User\UpdateProfile\UpdateProfileCommand;
|
||||
use Chill\MainBundle\Action\User\UpdateProfile\UpdateProfileCommandHandler;
|
||||
use Chill\MainBundle\Entity\User;
|
||||
use Chill\MainBundle\Form\UpdateProfileType;
|
||||
use Chill\MainBundle\Notification\NotificationFlagManager;
|
||||
use Chill\MainBundle\Security\ChillSecurity;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\Form\FormFactoryInterface;
|
||||
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpFoundation\Session\Session;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||
use Symfony\Contracts\Translation\TranslatorInterface;
|
||||
use Symfony\Component\Routing\Annotation\Route;
|
||||
use Twig\Environment;
|
||||
|
||||
final readonly class UserUpdateProfileController
|
||||
{
|
||||
public function __construct(
|
||||
private TranslatorInterface $translator,
|
||||
private ChillSecurity $security,
|
||||
private EntityManagerInterface $entityManager,
|
||||
private NotificationFlagManager $notificationFlagManager,
|
||||
private FormFactoryInterface $formFactory,
|
||||
private UrlGeneratorInterface $urlGenerator,
|
||||
private Environment $twig,
|
||||
private UpdateProfileCommandHandler $updateProfileCommandHandler,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* User profile that allows editing of phonenumber and visualization of certain data.
|
||||
*/
|
||||
#[Route(path: '/{_locale}/main/user/my-profile', name: 'chill_main_user_profile')]
|
||||
public function __invoke(Request $request, Session $session)
|
||||
{
|
||||
if (!$this->security->isGranted('ROLE_USER')) {
|
||||
throw new AccessDeniedHttpException();
|
||||
}
|
||||
|
||||
$user = $this->security->getUser();
|
||||
|
||||
$command = UpdateProfileCommand::create($user, $this->notificationFlagManager);
|
||||
$editForm = $this->formFactory->create(UpdateProfileType::class, $command);
|
||||
|
||||
$editForm->handleRequest($request);
|
||||
|
||||
if ($editForm->isSubmitted() && $editForm->isValid()) {
|
||||
$this->updateProfileCommandHandler->updateProfile($user, $command);
|
||||
$this->entityManager->flush();
|
||||
$session->getFlashBag()->add('success', $this->translator->trans('user.profile.Profile successfully updated!'));
|
||||
|
||||
return new RedirectResponse($this->urlGenerator->generate('chill_main_user_profile'));
|
||||
}
|
||||
|
||||
return new Response($this->twig->render('@ChillMain/User/profile.html.twig', [
|
||||
'user' => $user,
|
||||
'form' => $editForm->createView(),
|
||||
]));
|
||||
}
|
||||
}
|
@@ -11,6 +11,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace Chill\MainBundle\Controller;
|
||||
|
||||
use Chill\MainBundle\CRUD\Controller\ApiController;
|
||||
use Chill\MainBundle\Entity\User;
|
||||
use Chill\MainBundle\Entity\Workflow\EntityWorkflow;
|
||||
use Chill\MainBundle\Pagination\PaginatorFactory;
|
||||
@@ -27,7 +28,7 @@ use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||
use Symfony\Component\Security\Core\Security;
|
||||
use Symfony\Component\Serializer\SerializerInterface;
|
||||
|
||||
class WorkflowApiController
|
||||
class WorkflowApiController extends ApiController
|
||||
{
|
||||
public function __construct(private readonly EntityManagerInterface $entityManager, private readonly EntityWorkflowRepository $entityWorkflowRepository, private readonly PaginatorFactory $paginatorFactory, private readonly Security $security, private readonly SerializerInterface $serializer) {}
|
||||
|
||||
|
@@ -44,7 +44,7 @@ final readonly class WorkflowSignatureStateChangeController
|
||||
$signature,
|
||||
$request,
|
||||
EntityWorkflowStepSignatureVoter::CANCEL,
|
||||
function (EntityWorkflowStepSignature $signature) {$this->signatureStepStateChanger->markSignatureAsCanceled($signature); },
|
||||
fn (EntityWorkflowStepSignature $signature): string => $this->signatureStepStateChanger->markSignatureAsCanceled($signature),
|
||||
'@ChillMain/WorkflowSignature/cancel.html.twig',
|
||||
);
|
||||
}
|
||||
@@ -56,11 +56,18 @@ final readonly class WorkflowSignatureStateChangeController
|
||||
$signature,
|
||||
$request,
|
||||
EntityWorkflowStepSignatureVoter::REJECT,
|
||||
function (EntityWorkflowStepSignature $signature) {$this->signatureStepStateChanger->markSignatureAsRejected($signature); },
|
||||
fn (EntityWorkflowStepSignature $signature): string => $this->signatureStepStateChanger->markSignatureAsRejected($signature),
|
||||
'@ChillMain/WorkflowSignature/reject.html.twig',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param callable(EntityWorkflowStepSignature): string $markSignature
|
||||
*
|
||||
* @throws \Twig\Error\LoaderError
|
||||
* @throws \Twig\Error\RuntimeError
|
||||
* @throws \Twig\Error\SyntaxError
|
||||
*/
|
||||
private function markSignatureAction(
|
||||
EntityWorkflowStepSignature $signature,
|
||||
Request $request,
|
||||
@@ -79,12 +86,13 @@ final readonly class WorkflowSignatureStateChangeController
|
||||
$form->handleRequest($request);
|
||||
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
$this->entityManager->wrapInTransaction(function () use ($signature, $markSignature) {
|
||||
$markSignature($signature);
|
||||
});
|
||||
$expectedStep = $this->entityManager->wrapInTransaction(fn () => $markSignature($signature));
|
||||
|
||||
return new RedirectResponse(
|
||||
$this->chillUrlGenerator->returnPathOr('chill_main_workflow_show', ['id' => $signature->getStep()->getEntityWorkflow()->getId()])
|
||||
$this->chillUrlGenerator->forwardReturnPath(
|
||||
'chill_main_workflow_wait',
|
||||
['id' => $signature->getStep()->getEntityWorkflow()->getId(), 'expectedStep' => $expectedStep]
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
|
@@ -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\MainBundle\Controller;
|
||||
|
||||
use Chill\MainBundle\Entity\Workflow\EntityWorkflow;
|
||||
use Chill\MainBundle\Routing\ChillUrlGeneratorInterface;
|
||||
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Annotation\Route;
|
||||
use Twig\Environment;
|
||||
|
||||
final readonly class WorkflowWaitStepChangeController
|
||||
{
|
||||
public function __construct(
|
||||
private ChillUrlGeneratorInterface $chillUrlGenerator,
|
||||
private Environment $twig,
|
||||
) {}
|
||||
|
||||
#[Route('/{_locale}/main/workflow/{id}/wait/{expectedStep}', name: 'chill_main_workflow_wait', methods: ['GET'])]
|
||||
public function waitForSignatureChange(EntityWorkflow $entityWorkflow, string $expectedStep): Response
|
||||
{
|
||||
if ($entityWorkflow->getStep() === $expectedStep) {
|
||||
return new RedirectResponse(
|
||||
$this->chillUrlGenerator->returnPathOr('chill_main_workflow_show', ['id' => $entityWorkflow->getId()])
|
||||
);
|
||||
}
|
||||
|
||||
return new Response(
|
||||
$this->twig->render('@ChillMain/Workflow/waiting.html.twig', ['workflow' => $entityWorkflow, 'expectedStep' => $expectedStep])
|
||||
);
|
||||
}
|
||||
}
|
@@ -30,6 +30,7 @@ use Chill\MainBundle\Controller\UserGroupAdminController;
|
||||
use Chill\MainBundle\Controller\UserGroupApiController;
|
||||
use Chill\MainBundle\Controller\UserJobApiController;
|
||||
use Chill\MainBundle\Controller\UserJobController;
|
||||
use Chill\MainBundle\Controller\WorkflowApiController;
|
||||
use Chill\MainBundle\DependencyInjection\Widget\Factory\WidgetFactoryInterface;
|
||||
use Chill\MainBundle\Doctrine\DQL\Age;
|
||||
use Chill\MainBundle\Doctrine\DQL\Extract;
|
||||
@@ -66,6 +67,7 @@ use Chill\MainBundle\Entity\Regroupment;
|
||||
use Chill\MainBundle\Entity\User;
|
||||
use Chill\MainBundle\Entity\UserGroup;
|
||||
use Chill\MainBundle\Entity\UserJob;
|
||||
use Chill\MainBundle\Entity\Workflow\EntityWorkflow;
|
||||
use Chill\MainBundle\Form\CenterType;
|
||||
use Chill\MainBundle\Form\CivilityType;
|
||||
use Chill\MainBundle\Form\CountryType;
|
||||
@@ -79,6 +81,7 @@ use Chill\MainBundle\Form\UserGroupType;
|
||||
use Chill\MainBundle\Form\UserJobType;
|
||||
use Chill\MainBundle\Form\UserType;
|
||||
use Chill\MainBundle\Security\Authorization\ChillExportVoter;
|
||||
use Chill\MainBundle\Security\Authorization\EntityWorkflowVoter;
|
||||
use Misd\PhoneNumberBundle\Doctrine\DBAL\Types\PhoneNumberType;
|
||||
use Ramsey\Uuid\Doctrine\UuidType;
|
||||
use Symfony\Component\Config\FileLocator;
|
||||
@@ -940,6 +943,21 @@ class ChillMainExtension extends Extension implements
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
'class' => EntityWorkflow::class,
|
||||
'name' => 'workflow',
|
||||
'base_path' => '/api/1.0/main/workflow',
|
||||
'base_role' => EntityWorkflowVoter::SEE,
|
||||
'controller' => WorkflowApiController::class,
|
||||
'actions' => [
|
||||
'_entity' => [
|
||||
'methods' => [
|
||||
Request::METHOD_GET => true,
|
||||
Request::METHOD_HEAD => true,
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
@@ -24,6 +24,7 @@ use Symfony\Component\Security\Core\User\UserInterface;
|
||||
use Symfony\Component\Serializer\Annotation as Serializer;
|
||||
use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
||||
use Chill\MainBundle\Validation\Constraint\PhonenumberConstraint;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
/**
|
||||
* User.
|
||||
@@ -45,6 +46,8 @@ class User implements UserInterface, \Stringable, PasswordAuthenticatedUserInter
|
||||
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::DATETIME_IMMUTABLE, nullable: true)]
|
||||
private ?\DateTimeImmutable $absenceStart = null;
|
||||
|
||||
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::DATETIME_IMMUTABLE, nullable: true)]
|
||||
private ?\DateTimeImmutable $absenceEnd = null;
|
||||
/**
|
||||
* Array where SAML attributes's data are stored.
|
||||
*/
|
||||
@@ -157,6 +160,11 @@ class User implements UserInterface, \Stringable, PasswordAuthenticatedUserInter
|
||||
return $this->absenceStart;
|
||||
}
|
||||
|
||||
public function getAbsenceEnd(): ?\DateTimeImmutable
|
||||
{
|
||||
return $this->absenceEnd;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get attributes.
|
||||
*
|
||||
@@ -336,7 +344,13 @@ class User implements UserInterface, \Stringable, PasswordAuthenticatedUserInter
|
||||
|
||||
public function isAbsent(): bool
|
||||
{
|
||||
return null !== $this->getAbsenceStart() && $this->getAbsenceStart() <= new \DateTimeImmutable('now');
|
||||
$now = new \DateTimeImmutable('now');
|
||||
$absenceStart = $this->getAbsenceStart();
|
||||
$absenceEnd = $this->getAbsenceEnd();
|
||||
|
||||
return null !== $absenceStart
|
||||
&& $absenceStart <= $now
|
||||
&& (null === $absenceEnd || $now <= $absenceEnd);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -410,6 +424,11 @@ class User implements UserInterface, \Stringable, PasswordAuthenticatedUserInter
|
||||
$this->absenceStart = $absenceStart;
|
||||
}
|
||||
|
||||
public function setAbsenceEnd(?\DateTimeImmutable $absenceEnd): void
|
||||
{
|
||||
$this->absenceEnd = $absenceEnd;
|
||||
}
|
||||
|
||||
public function setAttributeByDomain(string $domain, string $key, $value): self
|
||||
{
|
||||
$this->attributes[$domain][$key] = $value;
|
||||
@@ -633,46 +652,82 @@ class User implements UserInterface, \Stringable, PasswordAuthenticatedUserInter
|
||||
return true;
|
||||
}
|
||||
|
||||
public function getNotificationFlags(): array
|
||||
private function getNotificationFlagData(string $flag): array
|
||||
{
|
||||
return $this->notificationFlags;
|
||||
}
|
||||
|
||||
public function setNotificationFlags(array $notificationFlags)
|
||||
{
|
||||
$this->notificationFlags = $notificationFlags;
|
||||
}
|
||||
|
||||
public function getNotificationFlagData(string $flag): array
|
||||
{
|
||||
return $this->notificationFlags[$flag] ?? [];
|
||||
}
|
||||
|
||||
public function setNotificationFlagData(string $flag, array $data): void
|
||||
{
|
||||
$this->notificationFlags[$flag] = $data;
|
||||
return $this->notificationFlags[$flag] ?? [self::NOTIF_FLAG_IMMEDIATE_EMAIL];
|
||||
}
|
||||
|
||||
public function isNotificationSendImmediately(string $type): bool
|
||||
{
|
||||
if ([] === $this->getNotificationFlagData($type) || in_array(User::NOTIF_FLAG_IMMEDIATE_EMAIL, $this->getNotificationFlagData($type), true)) {
|
||||
return true;
|
||||
return $this->isNotificationForElement($type, self::NOTIF_FLAG_IMMEDIATE_EMAIL);
|
||||
}
|
||||
|
||||
public function setNotificationImmediately(string $type, bool $active): void
|
||||
{
|
||||
$this->setNotificationFlagElement($type, $active, self::NOTIF_FLAG_IMMEDIATE_EMAIL);
|
||||
}
|
||||
|
||||
public function setNotificationDailyDigest(string $type, bool $active): void
|
||||
{
|
||||
$this->setNotificationFlagElement($type, $active, self::NOTIF_FLAG_DAILY_DIGEST);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param self::NOTIF_FLAG_IMMEDIATE_EMAIL|self::NOTIF_FLAG_DAILY_DIGEST $kind
|
||||
*/
|
||||
private function setNotificationFlagElement(string $type, bool $active, string $kind): void
|
||||
{
|
||||
$notificationFlags = [...$this->notificationFlags];
|
||||
$changed = false;
|
||||
|
||||
if (!isset($notificationFlags[$type])) {
|
||||
$notificationFlags[$type] = [self::NOTIF_FLAG_IMMEDIATE_EMAIL];
|
||||
$changed = true;
|
||||
}
|
||||
|
||||
return false;
|
||||
if ($active) {
|
||||
if (!in_array($kind, $notificationFlags[$type], true)) {
|
||||
$notificationFlags[$type] = [...$notificationFlags[$type], $kind];
|
||||
$changed = true;
|
||||
}
|
||||
} else {
|
||||
if (in_array($kind, $notificationFlags[$type], true)) {
|
||||
$notificationFlags[$type] = array_values(
|
||||
array_filter($notificationFlags[$type], static fn ($k) => $k !== $kind)
|
||||
);
|
||||
$changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if ($changed) {
|
||||
$this->notificationFlags = [...$notificationFlags];
|
||||
}
|
||||
}
|
||||
|
||||
private function isNotificationForElement(string $type, string $kind): bool
|
||||
{
|
||||
return in_array($kind, $this->getNotificationFlagData($type), true);
|
||||
}
|
||||
|
||||
public function isNotificationDailyDigest(string $type): bool
|
||||
{
|
||||
if (in_array(User::NOTIF_FLAG_DAILY_DIGEST, $this->getNotificationFlagData($type), true)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
return $this->isNotificationForElement($type, self::NOTIF_FLAG_DAILY_DIGEST);
|
||||
}
|
||||
|
||||
public function getLocale(): string
|
||||
{
|
||||
return 'fr';
|
||||
}
|
||||
|
||||
#[Assert\Callback]
|
||||
public function validateAbsenceDates(ExecutionContextInterface $context): void
|
||||
{
|
||||
if (null !== $this->getAbsenceEnd() && null === $this->getAbsenceStart()) {
|
||||
$context->buildViolation(
|
||||
'user.absence_end_requires_start'
|
||||
)
|
||||
->atPath('absenceEnd')
|
||||
->addViolation();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -23,9 +23,14 @@ class AbsenceType extends AbstractType
|
||||
{
|
||||
$builder
|
||||
->add('absenceStart', ChillDateType::class, [
|
||||
'required' => true,
|
||||
'required' => false,
|
||||
'input' => 'datetime_immutable',
|
||||
'label' => 'absence.Absence start',
|
||||
])
|
||||
->add('absenceEnd', ChillDateType::class, [
|
||||
'required' => false,
|
||||
'input' => 'datetime_immutable',
|
||||
'label' => 'absence.Absence end',
|
||||
]);
|
||||
}
|
||||
|
||||
|
@@ -1,75 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* Chill is a software for social workers
|
||||
*
|
||||
* For the full copyright and license information, please view
|
||||
* the LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Chill\MainBundle\Form\DataMapper;
|
||||
|
||||
use Chill\MainBundle\Entity\User;
|
||||
use Symfony\Component\Form\DataMapperInterface;
|
||||
|
||||
final readonly class NotificationFlagDataMapper implements DataMapperInterface
|
||||
{
|
||||
public function __construct(private array $notificationFlagProviders) {}
|
||||
|
||||
public function mapDataToForms($viewData, $forms): void
|
||||
{
|
||||
if (null === $viewData) {
|
||||
$viewData = [];
|
||||
}
|
||||
|
||||
$formsArray = iterator_to_array($forms);
|
||||
|
||||
foreach ($this->notificationFlagProviders as $flagProvider) {
|
||||
$flag = $flagProvider->getFlag();
|
||||
|
||||
if (isset($formsArray[$flag])) {
|
||||
$flagForm = $formsArray[$flag];
|
||||
|
||||
$immediateEmailChecked = in_array(User::NOTIF_FLAG_IMMEDIATE_EMAIL, $viewData[$flag] ?? [], true)
|
||||
|| !array_key_exists($flag, $viewData);
|
||||
$dailyEmailChecked = in_array(User::NOTIF_FLAG_DAILY_DIGEST, $viewData[$flag] ?? [], true);
|
||||
|
||||
if ($flagForm->has('immediate_email')) {
|
||||
$flagForm->get('immediate_email')->setData($immediateEmailChecked);
|
||||
}
|
||||
if ($flagForm->has('daily_email')) {
|
||||
$flagForm->get('daily_email')->setData($dailyEmailChecked);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function mapFormsToData($forms, &$viewData): void
|
||||
{
|
||||
$formsArray = iterator_to_array($forms);
|
||||
$viewData = [];
|
||||
|
||||
foreach ($this->notificationFlagProviders as $flagProvider) {
|
||||
$flag = $flagProvider->getFlag();
|
||||
|
||||
if (isset($formsArray[$flag])) {
|
||||
$flagForm = $formsArray[$flag];
|
||||
$viewData[$flag] = [];
|
||||
|
||||
if (true === $flagForm['immediate_email']->getData()) {
|
||||
$viewData[$flag][] = User::NOTIF_FLAG_IMMEDIATE_EMAIL;
|
||||
}
|
||||
|
||||
if (true === $flagForm['daily_email']->getData()) {
|
||||
$viewData[$flag][] = User::NOTIF_FLAG_DAILY_DIGEST;
|
||||
}
|
||||
|
||||
if ([] === $viewData[$flag]) {
|
||||
$viewData[$flag][] = User::NOTIF_FLAG_IMMEDIATE_EMAIL;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@@ -11,11 +11,9 @@ declare(strict_types=1);
|
||||
|
||||
namespace Chill\MainBundle\Form\Type;
|
||||
|
||||
use Chill\MainBundle\Form\DataMapper\NotificationFlagDataMapper;
|
||||
use Chill\MainBundle\Notification\NotificationFlagManager;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\FormType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
@@ -30,27 +28,24 @@ class NotificationFlagsType extends AbstractType
|
||||
|
||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||
{
|
||||
$builder->setDataMapper(new NotificationFlagDataMapper($this->notificationFlagProviders));
|
||||
|
||||
foreach ($this->notificationFlagProviders as $flagProvider) {
|
||||
$flag = $flagProvider->getFlag();
|
||||
$builder->add($flag, FormType::class, [
|
||||
$flagBuilder = $builder->create($flag, options: [
|
||||
'label' => $flagProvider->getLabel(),
|
||||
'required' => false,
|
||||
'compound' => true,
|
||||
]);
|
||||
|
||||
$builder->get($flag)
|
||||
$flagBuilder
|
||||
->add('immediate_email', CheckboxType::class, [
|
||||
'label' => false,
|
||||
'required' => false,
|
||||
'mapped' => false,
|
||||
])
|
||||
->add('daily_email', CheckboxType::class, [
|
||||
->add('daily_digest', CheckboxType::class, [
|
||||
'label' => false,
|
||||
'required' => false,
|
||||
'mapped' => false,
|
||||
])
|
||||
;
|
||||
$builder->add($flagBuilder);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,6 +53,7 @@ class NotificationFlagsType extends AbstractType
|
||||
{
|
||||
$resolver->setDefaults([
|
||||
'data_class' => null,
|
||||
'compound' => true,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
@@ -11,31 +11,29 @@ declare(strict_types=1);
|
||||
|
||||
namespace Chill\MainBundle\Form;
|
||||
|
||||
use Chill\MainBundle\Action\User\UpdateProfile\UpdateProfileCommand;
|
||||
use Chill\MainBundle\Form\Type\ChillPhoneNumberType;
|
||||
use Chill\MainBundle\Form\Type\NotificationFlagsType;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
class UserProfileType extends AbstractType
|
||||
class UpdateProfileType extends AbstractType
|
||||
{
|
||||
public function buildForm(FormBuilderInterface $builder, array $options)
|
||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||
{
|
||||
$builder
|
||||
->add('phonenumber', ChillPhoneNumberType::class, [
|
||||
'required' => false,
|
||||
])
|
||||
->add('notificationFlags', NotificationFlagsType::class, [
|
||||
'label' => false,
|
||||
'mapped' => false,
|
||||
])
|
||||
->add('notificationFlags', NotificationFlagsType::class)
|
||||
;
|
||||
}
|
||||
|
||||
public function configureOptions(OptionsResolver $resolver)
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
{
|
||||
$resolver->setDefaults([
|
||||
'data_class' => \Chill\MainBundle\Entity\User::class,
|
||||
'data_class' => UpdateProfileCommand::class,
|
||||
]);
|
||||
}
|
||||
}
|
@@ -59,7 +59,7 @@ class UserPasswordType extends AbstractType
|
||||
'invalid_message' => 'The password fields must match',
|
||||
'constraints' => [
|
||||
new Length([
|
||||
'min' => 9,
|
||||
'min' => 14,
|
||||
'minMessage' => 'The password must be greater than {{ limit }} characters',
|
||||
]),
|
||||
new NotBlank(),
|
||||
|
@@ -105,6 +105,11 @@ class UserType extends AbstractType
|
||||
'required' => false,
|
||||
'input' => 'datetime_immutable',
|
||||
'label' => 'absence.Absence start',
|
||||
])
|
||||
->add('absenceEnd', ChillDateType::class, [
|
||||
'required' => false,
|
||||
'input' => 'datetime_immutable',
|
||||
'label' => 'absence.Absence end',
|
||||
]);
|
||||
|
||||
// @phpstan-ignore-next-line
|
||||
|
@@ -37,8 +37,13 @@ export const ISOToDate = (str: string | null): Date | null => {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [year, month, day] = str.split("-").map((p) => parseInt(p));
|
||||
// If the string already contains time info, use it directly
|
||||
if (str.includes("T") || str.includes(" ")) {
|
||||
return new Date(str);
|
||||
}
|
||||
|
||||
// Otherwise, parse date only
|
||||
const [year, month, day] = str.split("-").map((p) => parseInt(p));
|
||||
return new Date(year, month - 1, day, 0, 0, 0, 0);
|
||||
};
|
||||
|
||||
@@ -69,20 +74,19 @@ export const ISOToDatetime = (str: string | null): Date | null => {
|
||||
*
|
||||
*/
|
||||
export const datetimeToISO = (date: Date): string => {
|
||||
let cal, time, offset;
|
||||
cal = [
|
||||
const cal = [
|
||||
date.getFullYear(),
|
||||
(date.getMonth() + 1).toString().padStart(2, "0"),
|
||||
date.getDate().toString().padStart(2, "0"),
|
||||
].join("-");
|
||||
|
||||
time = [
|
||||
const time = [
|
||||
date.getHours().toString().padStart(2, "0"),
|
||||
date.getMinutes().toString().padStart(2, "0"),
|
||||
date.getSeconds().toString().padStart(2, "0"),
|
||||
].join(":");
|
||||
|
||||
offset = [
|
||||
const offset = [
|
||||
date.getTimezoneOffset() <= 0 ? "+" : "-",
|
||||
Math.abs(Math.floor(date.getTimezoneOffset() / 60))
|
||||
.toString()
|
||||
|
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Extracts the "returnPath" parameter from the current URL's query string and returns it.
|
||||
* If the parameter is not present, returns the provided fallback path.
|
||||
*
|
||||
* @param {string} fallbackPath - The fallback path to use if "returnPath" is not found in the query string.
|
||||
* @return {string} The "returnPath" from the query string, or the fallback path if "returnPath" is not present.
|
||||
*/
|
||||
export function returnPathOr(fallbackPath: string): string {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const returnPath = urlParams.get("returnPath");
|
||||
|
||||
return returnPath ?? fallbackPath;
|
||||
}
|
@@ -0,0 +1,16 @@
|
||||
import { EntityWorkflow } from "ChillMainAssets/types";
|
||||
import { makeFetch } from "ChillMainAssets/lib/api/apiMethods";
|
||||
|
||||
export const fetchWorkflow = async (
|
||||
workflowId: number,
|
||||
): Promise<EntityWorkflow> => {
|
||||
try {
|
||||
return await makeFetch<null, EntityWorkflow>(
|
||||
"GET",
|
||||
`/api/1.0/main/workflow/${workflowId}.json`,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(`Failed to fetch workflow ${workflowId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
};
|
@@ -1,5 +1,6 @@
|
||||
import { GenericDoc } from "ChillDocStoreAssets/types/generic_doc";
|
||||
import { StoredObject, StoredObjectStatus } from "ChillDocStoreAssets/types";
|
||||
import { Person } from "../../../ChillPersonBundle/Resources/public/types";
|
||||
|
||||
export interface DateTime {
|
||||
datetime: string;
|
||||
@@ -202,6 +203,58 @@ export interface WorkflowAttachment {
|
||||
genericDoc: null | GenericDoc;
|
||||
}
|
||||
|
||||
export interface Workflow {
|
||||
name: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface EntityWorkflowStep {
|
||||
type: "entity_workflow_step";
|
||||
id: number;
|
||||
comment: string;
|
||||
currentStep: StepDefinition;
|
||||
isFinal: boolean;
|
||||
isFreezed: boolean;
|
||||
isFinalized: boolean;
|
||||
transitionPrevious: Transition | null;
|
||||
transitionAfter: Transition | null;
|
||||
previousId: number | null;
|
||||
nextId: number | null;
|
||||
transitionPreviousBy: User | null;
|
||||
transitionPreviousAt: DateTime | null;
|
||||
}
|
||||
|
||||
export interface Transition {
|
||||
name: string;
|
||||
text: string;
|
||||
isForward: boolean;
|
||||
}
|
||||
|
||||
export interface StepDefinition {
|
||||
name: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface EntityWorkflow {
|
||||
type: "entity_workflow";
|
||||
id: number;
|
||||
relatedEntityClass: string;
|
||||
relatedEntityId: number;
|
||||
workflow: Workflow;
|
||||
currentStep: EntityWorkflowStep;
|
||||
steps: EntityWorkflowStep[];
|
||||
datas: WorkflowData;
|
||||
title: string;
|
||||
isOnHoldAtCurrentStep: boolean;
|
||||
_permissions: {
|
||||
CHILL_MAIN_WORKFLOW_ATTACHMENT_EDIT: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface WorkflowData {
|
||||
persons: Person[];
|
||||
}
|
||||
|
||||
export interface ExportGeneration {
|
||||
id: string;
|
||||
type: "export_generation";
|
||||
@@ -215,3 +268,8 @@ export interface ExportGeneration {
|
||||
export interface PrivateCommentEmbeddable {
|
||||
comments: Record<number, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Possible states for the WaitingScreen Component.
|
||||
*/
|
||||
export type WaitingScreenState = "pending" | "failure" | "stopped" | "ready";
|
||||
|
@@ -10,7 +10,8 @@ import { computed, onMounted, ref } from "vue";
|
||||
import { StoredObject, StoredObjectStatus } from "ChillDocStoreAssets/types";
|
||||
import { fetchExportGenerationStatus } from "ChillMainAssets/lib/api/export";
|
||||
import DocumentActionButtonsGroup from "ChillDocStoreAssets/vuejs/DocumentActionButtonsGroup.vue";
|
||||
import { ExportGeneration } from "ChillMainAssets/types";
|
||||
import WaitingScreen from "../_components/WaitingScreen.vue";
|
||||
import { ExportGeneration, WaitingScreenState } from "ChillMainAssets/types";
|
||||
|
||||
interface AppProps {
|
||||
exportGenerationId: string;
|
||||
@@ -34,13 +35,16 @@ const storedObject = computed<null | StoredObject>(() => {
|
||||
});
|
||||
|
||||
const isPending = computed<boolean>(() => status.value === "pending");
|
||||
const isFetching = computed<boolean>(
|
||||
() => tryiesForReady.value < maxTryiesForReady,
|
||||
);
|
||||
const isReady = computed<boolean>(() => status.value === "ready");
|
||||
const isFailure = computed<boolean>(() => status.value === "failure");
|
||||
const filename = computed<string>(() => `${props.title}-${props.createdDate}`);
|
||||
|
||||
const state = computed<WaitingScreenState>((): WaitingScreenState => {
|
||||
if (status.value === "empty") {
|
||||
return "pending";
|
||||
}
|
||||
|
||||
return status.value;
|
||||
});
|
||||
|
||||
/**
|
||||
* counter for the number of times that we check for a new status
|
||||
*/
|
||||
@@ -85,57 +89,36 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div id="waiting-screen">
|
||||
<div
|
||||
v-if="isPending && isFetching"
|
||||
class="alert alert-danger text-center"
|
||||
>
|
||||
<div>
|
||||
<p>
|
||||
{{ trans(EXPORT_GENERATION_EXPORT_GENERATION_IS_PENDING) }}
|
||||
</p>
|
||||
</div>
|
||||
<WaitingScreen :state="state">
|
||||
<template v-slot:pending>
|
||||
<p>
|
||||
{{ trans(EXPORT_GENERATION_EXPORT_GENERATION_IS_PENDING) }}
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<div>
|
||||
<i class="fa fa-cog fa-spin fa-3x fa-fw"></i>
|
||||
<span class="sr-only">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="isPending && !isFetching" class="alert alert-info">
|
||||
<div>
|
||||
<p>
|
||||
{{ trans(EXPORT_GENERATION_TOO_MANY_RETRIES) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="isFailure" class="alert alert-danger text-center">
|
||||
<div>
|
||||
<p>
|
||||
{{ trans(EXPORT_GENERATION_ERROR_WHILE_GENERATING_EXPORT) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="isReady" class="alert alert-success text-center">
|
||||
<div>
|
||||
<p>
|
||||
{{ trans(EXPORT_GENERATION_EXPORT_READY) }}
|
||||
</p>
|
||||
<template v-slot:stopped>
|
||||
<p>
|
||||
{{ trans(EXPORT_GENERATION_TOO_MANY_RETRIES) }}
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<p v-if="storedObject !== null">
|
||||
<document-action-buttons-group
|
||||
:stored-object="storedObject"
|
||||
:filename="filename"
|
||||
></document-action-buttons-group>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<template v-slot:failure>
|
||||
<p>
|
||||
{{ trans(EXPORT_GENERATION_ERROR_WHILE_GENERATING_EXPORT) }}
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<template v-slot:ready>
|
||||
<p>
|
||||
{{ trans(EXPORT_GENERATION_EXPORT_READY) }}
|
||||
</p>
|
||||
|
||||
<p v-if="storedObject !== null">
|
||||
<document-action-buttons-group
|
||||
:stored-object="storedObject"
|
||||
:filename="filename"
|
||||
></document-action-buttons-group>
|
||||
</p>
|
||||
</template>
|
||||
</WaitingScreen>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
#waiting-screen {
|
||||
> .alert {
|
||||
min-height: 350px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
@@ -0,0 +1,75 @@
|
||||
<script setup lang="ts">
|
||||
import { useIntervalFn } from "@vueuse/core";
|
||||
import { fetchWorkflow } from "ChillMainAssets/lib/workflow/api";
|
||||
import { returnPathOr } from "ChillMainAssets/lib/return_path/returnPathHelper";
|
||||
import { ref } from "vue";
|
||||
import WaitingScreen from "ChillMainAssets/vuejs/_components/WaitingScreen.vue";
|
||||
import { WaitingScreenState } from "ChillMainAssets/types";
|
||||
import {
|
||||
trans,
|
||||
WORKFLOW_WAIT_TITLE,
|
||||
WORKFLOW_WAIT_ERROR_WHILE_WAITING,
|
||||
WORKFLOW_WAIT_SUCCESS,
|
||||
} from "translator";
|
||||
|
||||
interface WaitPostProcessWorkflowComponentProps {
|
||||
workflowId: number;
|
||||
expectedStep: string;
|
||||
}
|
||||
|
||||
const props = defineProps<WaitPostProcessWorkflowComponentProps>();
|
||||
const counter = ref<number>(0);
|
||||
const MAX_TRYIES = 50;
|
||||
|
||||
const state = ref<WaitingScreenState>("pending");
|
||||
|
||||
const { pause, resume } = useIntervalFn(
|
||||
async () => {
|
||||
try {
|
||||
const workflow = await fetchWorkflow(props.workflowId);
|
||||
counter.value++;
|
||||
if (workflow.currentStep.currentStep.name === props.expectedStep) {
|
||||
window.location.assign(
|
||||
returnPathOr("/fr/main/workflow" + workflow.id + "/show"),
|
||||
);
|
||||
resume();
|
||||
state.value = "ready";
|
||||
}
|
||||
|
||||
if (counter.value > MAX_TRYIES) {
|
||||
pause();
|
||||
state.value = "failure";
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
pause();
|
||||
}
|
||||
},
|
||||
2000,
|
||||
{ immediate: true },
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="container">
|
||||
<WaitingScreen :state="state">
|
||||
<template v-slot:pending>
|
||||
<p>
|
||||
{{ trans(WORKFLOW_WAIT_TITLE) }}
|
||||
</p>
|
||||
</template>
|
||||
<template v-slot:failure>
|
||||
<p>
|
||||
{{ trans(WORKFLOW_WAIT_ERROR_WHILE_WAITING) }}
|
||||
</p>
|
||||
</template>
|
||||
<template v-slot:ready>
|
||||
<p>
|
||||
{{ trans(WORKFLOW_WAIT_SUCCESS) }}
|
||||
</p>
|
||||
</template>
|
||||
</WaitingScreen>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss"></style>
|
@@ -0,0 +1,51 @@
|
||||
import { createApp } from "vue";
|
||||
import App from "./App.vue";
|
||||
|
||||
function mountApp(): void {
|
||||
const el = document.querySelector<HTMLDivElement>(".screen-wait");
|
||||
if (!el) {
|
||||
console.error(
|
||||
"WaitPostProcessWorkflow: mount element .screen-wait not found",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const workflowIdAttr = el.getAttribute("data-workflow-id");
|
||||
const expectedStep = el.getAttribute("data-expected-step") || "";
|
||||
|
||||
if (!workflowIdAttr) {
|
||||
console.error(
|
||||
"WaitPostProcessWorkflow: data-workflow-id attribute missing on mount element",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!expectedStep) {
|
||||
console.error(
|
||||
"WaitPostProcessWorkflow: data-expected-step attribute missing on mount element",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const workflowId = Number(workflowIdAttr);
|
||||
if (Number.isNaN(workflowId)) {
|
||||
console.error(
|
||||
"WaitPostProcessWorkflow: data-workflow-id is not a valid number:",
|
||||
workflowIdAttr,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const app = createApp(App, {
|
||||
workflowId,
|
||||
expectedStep,
|
||||
});
|
||||
|
||||
app.mount(el);
|
||||
}
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", mountApp);
|
||||
} else {
|
||||
mountApp();
|
||||
}
|
@@ -1,10 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, useTemplateRef } from "vue";
|
||||
import type { WorkflowAttachment } from "ChillMainAssets/types";
|
||||
import { computed, onMounted, ref, useTemplateRef } from "vue";
|
||||
import type { EntityWorkflow, WorkflowAttachment } from "ChillMainAssets/types";
|
||||
import PickGenericDocModal from "ChillMainAssets/vuejs/WorkflowAttachment/Component/PickGenericDocModal.vue";
|
||||
import { GenericDocForAccompanyingPeriod } from "ChillDocStoreAssets/types/generic_doc";
|
||||
import AttachmentList from "ChillMainAssets/vuejs/WorkflowAttachment/Component/AttachmentList.vue";
|
||||
import { GenericDoc } from "ChillDocStoreAssets/types";
|
||||
import { fetchWorkflow } from "ChillMainAssets/lib/workflow/api";
|
||||
|
||||
interface AppConfig {
|
||||
workflowId: number;
|
||||
@@ -34,6 +35,13 @@ const attachedGenericDoc = computed<GenericDocForAccompanyingPeriod[]>(
|
||||
) as GenericDocForAccompanyingPeriod[],
|
||||
);
|
||||
|
||||
const workflow = ref<EntityWorkflow | null>(null);
|
||||
|
||||
onMounted(async () => {
|
||||
workflow.value = await fetchWorkflow(Number(props.workflowId));
|
||||
console.log("workflow", workflow.value);
|
||||
});
|
||||
|
||||
const openModal = function () {
|
||||
pickDocModal.value?.openModal();
|
||||
};
|
||||
@@ -49,20 +57,30 @@ const onPickGenericDoc = ({
|
||||
const onRemoveAttachment = (payload: { attachment: WorkflowAttachment }) => {
|
||||
emit("removeAttachment", payload);
|
||||
};
|
||||
|
||||
const canEditAttachement = computed<boolean>(() => {
|
||||
if (null === workflow.value) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return workflow.value._permissions.CHILL_MAIN_WORKFLOW_ATTACHMENT_EDIT;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<pick-generic-doc-modal
|
||||
:workflow="workflow"
|
||||
:accompanying-period-id="props.accompanyingPeriodId"
|
||||
:to-remove="attachedGenericDoc"
|
||||
ref="pickDocModal"
|
||||
@pickGenericDoc="onPickGenericDoc"
|
||||
></pick-generic-doc-modal>
|
||||
<attachment-list
|
||||
:workflow="workflow"
|
||||
:attachments="props.attachments"
|
||||
@removeAttachment="onRemoveAttachment"
|
||||
></attachment-list>
|
||||
<ul class="record_actions">
|
||||
<ul v-if="canEditAttachement" class="record_actions">
|
||||
<li>
|
||||
<button type="button" class="btn btn-create" @click="openModal">
|
||||
Ajouter une pièce jointe
|
||||
|
@@ -1,10 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { WorkflowAttachment } from "ChillMainAssets/types";
|
||||
import { EntityWorkflow, WorkflowAttachment } from "ChillMainAssets/types";
|
||||
import GenericDocItemBox from "ChillMainAssets/vuejs/WorkflowAttachment/Component/GenericDocItemBox.vue";
|
||||
import DocumentActionButtonsGroup from "ChillDocStoreAssets/vuejs/DocumentActionButtonsGroup.vue";
|
||||
|
||||
interface AttachmentListProps {
|
||||
attachments: WorkflowAttachment[];
|
||||
workflow: EntityWorkflow | null;
|
||||
}
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -36,7 +37,12 @@ const props = defineProps<AttachmentListProps>();
|
||||
:stored-object="a.genericDoc.storedObject"
|
||||
></document-action-buttons-group>
|
||||
</li>
|
||||
<li>
|
||||
<li
|
||||
v-if="
|
||||
!workflow?._permissions
|
||||
.CHILL_MAIN_WORKFLOW_ATTACHMENT_EDIT
|
||||
"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-delete"
|
||||
|
@@ -6,8 +6,10 @@ import {
|
||||
import PickGenericDocItem from "ChillMainAssets/vuejs/WorkflowAttachment/Component/PickGenericDocItem.vue";
|
||||
import { fetch_generic_docs_by_accompanying_period } from "ChillDocStoreAssets/js/generic-doc-api";
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { EntityWorkflow } from "ChillMainAssets/types";
|
||||
|
||||
interface PickGenericDocProps {
|
||||
workflow: EntityWorkflow | null;
|
||||
accompanyingPeriodId: number;
|
||||
pickedList: GenericDocForAccompanyingPeriod[];
|
||||
toRemove: GenericDocForAccompanyingPeriod[];
|
||||
@@ -36,9 +38,21 @@ const isPicked = (genericDoc: GenericDocForAccompanyingPeriod): boolean =>
|
||||
) !== -1;
|
||||
|
||||
onMounted(async () => {
|
||||
genericDocs.value = await fetch_generic_docs_by_accompanying_period(
|
||||
const fetchedGenericDocs = await fetch_generic_docs_by_accompanying_period(
|
||||
props.accompanyingPeriodId,
|
||||
);
|
||||
const documentClasses = [
|
||||
"Chill\\DocStoreBundle\\Entity\\AccompanyingCourseDocument",
|
||||
"Chill\\PersonBundle\\Entity\\AccompanyingPeriod\\AccompanyingPeriodWorkEvaluationDocument",
|
||||
"Chill\\DocStoreBundle\\Entity\\PersonDocument",
|
||||
];
|
||||
|
||||
genericDocs.value = fetchedGenericDocs.filter(
|
||||
(doc) =>
|
||||
!documentClasses.includes(
|
||||
props.workflow?.relatedEntityClass || "",
|
||||
) || props.workflow?.relatedEntityId !== doc.identifiers.id,
|
||||
);
|
||||
loaded.value = true;
|
||||
});
|
||||
|
||||
|
@@ -3,8 +3,10 @@ import Modal from "ChillMainAssets/vuejs/_components/Modal.vue";
|
||||
import { computed, ref, useTemplateRef } from "vue";
|
||||
import PickGenericDoc from "ChillMainAssets/vuejs/WorkflowAttachment/Component/PickGenericDoc.vue";
|
||||
import { GenericDocForAccompanyingPeriod } from "ChillDocStoreAssets/types/generic_doc";
|
||||
import { EntityWorkflow } from "ChillMainAssets/types";
|
||||
|
||||
interface PickGenericDocModalProps {
|
||||
workflow: EntityWorkflow | null;
|
||||
accompanyingPeriodId: number;
|
||||
toRemove: GenericDocForAccompanyingPeriod[];
|
||||
}
|
||||
@@ -80,6 +82,7 @@ defineExpose({ openModal, closeModal });
|
||||
</template>
|
||||
<template v-slot:body>
|
||||
<pick-generic-doc
|
||||
:workflow="props.workflow"
|
||||
:accompanying-period-id="props.accompanyingPeriodId"
|
||||
:to-remove="props.toRemove"
|
||||
:picked-list="pickeds"
|
||||
|
@@ -84,6 +84,8 @@ const emits = defineEmits<{
|
||||
}
|
||||
.modal-header .close {
|
||||
border-top-right-radius: 0.3rem;
|
||||
margin-right: 0;
|
||||
margin-left: auto;
|
||||
}
|
||||
/*
|
||||
* The following styles are auto-applied to elements with
|
||||
|
@@ -0,0 +1,62 @@
|
||||
<script setup lang="ts">
|
||||
import { WaitingScreenState } from "ChillMainAssets/types";
|
||||
|
||||
interface Props {
|
||||
state: WaitingScreenState;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div id="waiting-screen">
|
||||
<div
|
||||
v-if="props.state === 'pending' && !!$slots.pending"
|
||||
class="alert alert-danger text-center"
|
||||
>
|
||||
<div>
|
||||
<slot name="pending"></slot>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<i class="fa fa-cog fa-spin fa-3x fa-fw"></i>
|
||||
<span class="sr-only">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="props.state === 'stopped' && !!$slots.stopped"
|
||||
class="alert alert-info"
|
||||
>
|
||||
<div>
|
||||
<slot name="stopped"></slot>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="props.state === 'failure' && !!$slots.failure"
|
||||
class="alert alert-danger text-center"
|
||||
>
|
||||
<div>
|
||||
<slot name="failure"></slot>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="props.state === 'ready' && !!$slots.ready"
|
||||
class="alert alert-success text-center"
|
||||
>
|
||||
<div>
|
||||
<slot name="ready"></slot>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
#waiting-screen {
|
||||
> .alert {
|
||||
min-height: 350px;
|
||||
}
|
||||
}
|
||||
</style>
|
@@ -44,17 +44,7 @@
|
||||
{% 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_merge %}{% 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) %}
|
||||
|
@@ -280,11 +280,17 @@
|
||||
</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'] }}"
|
||||
></div>
|
||||
|
||||
{% 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') }}"
|
||||
></div>
|
||||
{% endblock %}
|
||||
|
||||
{% block pick_postal_code_widget %}
|
||||
|
@@ -8,36 +8,36 @@
|
||||
|
||||
<div class="col-md-10">
|
||||
<h2>{{ 'absence.My absence'|trans }}</h2>
|
||||
<div>
|
||||
{% if user.absenceStart is not null %}
|
||||
<div class="alert alert-success flash_message">{{ 'absence.You are listed as absent, as of {date, date, short}'|trans({
|
||||
date: user.absenceStart
|
||||
}) }}
|
||||
{% if user.absenceEnd is not null %}
|
||||
{{ 'until %date%'|trans({'%date%': user.absenceEnd|format_date('short') }) }}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="alert alert-warning flash_message">{{ 'absence.No absence listed'|trans }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div>
|
||||
{{ form_start(form) }}
|
||||
{{ form_row(form.absenceStart) }}
|
||||
{{ form_row(form.absenceEnd) }}
|
||||
|
||||
{% if user.absenceStart is not null %}
|
||||
<div>
|
||||
<p>{{ 'absence.You are listed as absent, as of'|trans }} {{ user.absenceStart|format_date('long') }}</p>
|
||||
<ul class="record_actions sticky-form-buttons">
|
||||
<li>
|
||||
<a href="{{ path('chill_main_user_absence_unset') }}"
|
||||
class="btn btn-delete">{{ 'absence.Unset absence'|trans }}</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
{% else %}
|
||||
<div>
|
||||
<p class="chill-no-data-statement">{{ 'absence.No absence listed'|trans }}</p>
|
||||
</div>
|
||||
<div>
|
||||
{{ form_start(form) }}
|
||||
{{ form_row(form.absenceStart) }}
|
||||
|
||||
<ul class="record_actions sticky-form-buttons">
|
||||
<li>
|
||||
<button class="btn btn-save" type="submit">
|
||||
{{ 'Save'|trans }}
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
{{ form_end(form) }}
|
||||
</div>
|
||||
{% endif %}
|
||||
<ul class="record_actions sticky-form-buttons">
|
||||
<li>
|
||||
<a class="btn btn-delete" title="Modifier" href="{{ path('chill_main_user_absence_unset') }}">{{ 'absence.Unset absence'|trans }}</a>
|
||||
</li>
|
||||
<li>
|
||||
<button class="btn btn-save" type="submit">
|
||||
{{ 'Save'|trans }}
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
{{ form_end(form) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
@@ -5,7 +5,7 @@
|
||||
role="button"
|
||||
data-bs-toggle="dropdown"
|
||||
aria-expanded="false">
|
||||
<i class="fa fa-flash"></i>
|
||||
<i class="bi bi-lightning-fill"></i>
|
||||
</a>
|
||||
<div class="dropdown-menu">
|
||||
{% for menu in menus %}
|
||||
|
@@ -64,7 +64,7 @@
|
||||
{{ form_widget(flag.immediate_email, {'label_attr': { 'class': 'checkbox-inline checkbox-switch'}}) }}
|
||||
</td>
|
||||
<td class="text-center">
|
||||
{{ form_widget(flag.daily_email, {'label_attr': { 'class': 'checkbox-inline checkbox-switch'}}) }}
|
||||
{{ form_widget(flag.daily_digest, {'label_attr': { 'class': 'checkbox-inline checkbox-switch'}}) }}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
|
@@ -58,12 +58,14 @@
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
{% if signatures|length > 0 %}
|
||||
<section class="step my-4">{% include '@ChillMain/Workflow/_signature.html.twig' %}</section>
|
||||
{% endif %}
|
||||
|
||||
<section class="step my-4">{% include '@ChillMain/Workflow/_attachment.html.twig' %}</section>
|
||||
|
||||
<section class="step my-4">{% include '@ChillMain/Workflow/_follow.html.twig' %}</section>
|
||||
{% if signatures|length > 0 %}
|
||||
<section class="step my-4">{% include '@ChillMain/Workflow/_signature.html.twig' %}</section>
|
||||
{% elseif entity_workflow.currentStep.sends|length > 0 %}
|
||||
{% if entity_workflow.currentStep.sends|length > 0 %}
|
||||
<section class="step my-4">
|
||||
<h2>{{ 'workflow.external_views.title'|trans({'numberOfSends': entity_workflow.currentStep.sends|length }) }}</h2>
|
||||
{% include '@ChillMain/Workflow/_send_views_list.html.twig' with {'sends': entity_workflow.currentStep.sends} %}
|
||||
|
@@ -0,0 +1,18 @@
|
||||
{% extends '@ChillMain/layout.html.twig' %}
|
||||
|
||||
{% block title %}{{ 'workflow.signature.waiting_for'|trans }}{% endblock %}
|
||||
|
||||
{% block css %}
|
||||
{{ encore_entry_link_tags('page_workflow_waiting_post_process') }}
|
||||
{% endblock %}
|
||||
|
||||
{% block js %}
|
||||
{{ encore_entry_script_tags('page_workflow_waiting_post_process') }}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>{{ block('title') }}</h1>
|
||||
|
||||
<div class="screen-wait" data-workflow-id="{{ workflow.id|e('html_attr') }}" data-expected-step="{{ expectedStep|e('html_attr') }}"></div>
|
||||
|
||||
{% endblock %}
|
@@ -79,7 +79,7 @@
|
||||
<div class="d-flex flex-row mb-5 alert alert-warning" role="alert">
|
||||
<p class="m-2">{{'absence.You are marked as being absent'|trans }}</p>
|
||||
<span class="ms-auto">
|
||||
<a class="btn btn-remove" title="Modifier" href="{{ path('chill_main_user_absence_index') }}">{{ 'absence.Unset absence'|trans }}</a>
|
||||
<a class="btn btn-delete" title="Modifier" href="{{ path('chill_main_user_absence_unset') }}">{{ 'absence.Unset absence'|trans }}</a>
|
||||
</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
@@ -0,0 +1,53 @@
|
||||
<?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\Security\Authorization;
|
||||
|
||||
use Chill\MainBundle\Entity\Workflow\EntityWorkflow;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
||||
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
|
||||
use Symfony\Component\Workflow\Registry;
|
||||
|
||||
final class EntityWorkflowAttachmentVoter extends Voter
|
||||
{
|
||||
public function __construct(
|
||||
private readonly Registry $registry,
|
||||
) {}
|
||||
public const EDIT = 'CHILL_MAIN_WORKFLOW_ATTACHMENT_EDIT';
|
||||
|
||||
protected function supports(string $attribute, $subject): bool
|
||||
{
|
||||
return $subject instanceof EntityWorkflow && self::EDIT === $attribute;
|
||||
}
|
||||
|
||||
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool
|
||||
{
|
||||
if (!$subject instanceof EntityWorkflow) {
|
||||
throw new \UnexpectedValueException('Subject must be an instance of EntityWorkflow');
|
||||
}
|
||||
|
||||
if ($subject->isFinal()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$workflow = $this->registry->get($subject, $subject->getWorkflowName());
|
||||
|
||||
$marking = $workflow->getMarking($subject);
|
||||
foreach ($marking->getPlaces() as $place => $int) {
|
||||
$placeMetadata = $workflow->getMetadataStore()->getPlaceMetadata($place);
|
||||
if ($placeMetadata['isSentExternal'] ?? false) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
@@ -12,18 +12,25 @@ declare(strict_types=1);
|
||||
namespace Chill\MainBundle\Serializer\Normalizer;
|
||||
|
||||
use Chill\MainBundle\Entity\Workflow\EntityWorkflow;
|
||||
use Chill\MainBundle\Security\Authorization\EntityWorkflowAttachmentVoter;
|
||||
use Chill\MainBundle\Workflow\EntityWorkflowManager;
|
||||
use Chill\MainBundle\Workflow\Helper\MetadataExtractor;
|
||||
use Symfony\Component\Security\Core\Security;
|
||||
use Symfony\Component\Serializer\Normalizer\NormalizerAwareInterface;
|
||||
use Symfony\Component\Serializer\Normalizer\NormalizerAwareTrait;
|
||||
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
|
||||
use Symfony\Component\Workflow\Registry;
|
||||
|
||||
class EntityWorkflowNormalizer implements NormalizerInterface, NormalizerAwareInterface
|
||||
final class EntityWorkflowNormalizer implements NormalizerInterface, NormalizerAwareInterface
|
||||
{
|
||||
use NormalizerAwareTrait;
|
||||
|
||||
public function __construct(private readonly EntityWorkflowManager $entityWorkflowManager, private readonly MetadataExtractor $metadataExtractor, private readonly Registry $registry) {}
|
||||
public function __construct(
|
||||
private readonly EntityWorkflowManager $entityWorkflowManager,
|
||||
private readonly MetadataExtractor $metadataExtractor,
|
||||
private readonly Registry $registry,
|
||||
private readonly Security $security,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param EntityWorkflow $object
|
||||
@@ -46,6 +53,9 @@ class EntityWorkflowNormalizer implements NormalizerInterface, NormalizerAwareIn
|
||||
'datas' => $this->normalizer->normalize($handler->getEntityData($object), $format, $context),
|
||||
'title' => $handler->getEntityTitle($object),
|
||||
'isOnHoldAtCurrentStep' => $object->isOnHoldAtCurrentStep(),
|
||||
'_permissions' => [
|
||||
EntityWorkflowAttachmentVoter::EDIT => $this->security->isGranted(EntityWorkflowAttachmentVoter::EDIT, $object),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
|
@@ -39,6 +39,8 @@ class UserNormalizer implements ContextAwareNormalizerInterface, NormalizerAware
|
||||
'label' => '',
|
||||
'email' => '',
|
||||
'isAbsent' => false,
|
||||
'absenceStart' => null,
|
||||
'absenceEnd' => null,
|
||||
];
|
||||
|
||||
public function __construct(private readonly UserRender $userRender, private readonly ClockInterface $clock) {}
|
||||
@@ -77,6 +79,11 @@ class UserNormalizer implements ContextAwareNormalizerInterface, NormalizerAware
|
||||
['docgen:expects' => PhoneNumber::class, 'groups' => 'docgen:read']
|
||||
);
|
||||
|
||||
$absenceDatesContext = array_merge(
|
||||
$context,
|
||||
['docgen:expects' => \DateTimeImmutable::class, 'groups' => 'docgen:read']
|
||||
);
|
||||
|
||||
if (null === $object && 'docgen' === $format) {
|
||||
return [...self::NULL_USER, 'phonenumber' => $this->normalizer->normalize(null, $format, $phonenumberContext), 'civility' => $this->normalizer->normalize(null, $format, $civilityContext), 'user_job' => $this->normalizer->normalize(null, $format, $userJobContext), 'main_center' => $this->normalizer->normalize(null, $format, $centerContext), 'main_scope' => $this->normalizer->normalize(null, $format, $scopeContext), 'current_location' => $this->normalizer->normalize(null, $format, $locationContext), 'main_location' => $this->normalizer->normalize(null, $format, $locationContext)];
|
||||
}
|
||||
@@ -99,6 +106,8 @@ class UserNormalizer implements ContextAwareNormalizerInterface, NormalizerAware
|
||||
'main_center' => $this->normalizer->normalize($object->getMainCenter(), $format, $centerContext),
|
||||
'main_scope' => $this->normalizer->normalize($object->getMainScope($at), $format, $scopeContext),
|
||||
'isAbsent' => $object->isAbsent(),
|
||||
'absenceStart' => $this->normalizer->normalize($object->getAbsenceStart(), $format, $absenceDatesContext),
|
||||
'absenceEnd' => $this->normalizer->normalize($object->getAbsenceEnd(), $format, $absenceDatesContext),
|
||||
];
|
||||
|
||||
if ('docgen' === $format) {
|
||||
|
@@ -0,0 +1,85 @@
|
||||
<?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 Action\User\UpdateProfile;
|
||||
|
||||
use Chill\MainBundle\Action\User\UpdateProfile\UpdateProfileCommand;
|
||||
use Chill\MainBundle\Action\User\UpdateProfile\UpdateProfileCommandHandler;
|
||||
use Chill\MainBundle\Entity\User;
|
||||
use libphonenumber\PhoneNumber;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*
|
||||
* @coversNothing
|
||||
*/
|
||||
final class UpdateProfileCommandHandlerTest extends TestCase
|
||||
{
|
||||
public function testUpdateProfileWithNullPhoneAndFlags(): void
|
||||
{
|
||||
$user = new User();
|
||||
|
||||
// Pre-set some flags to opposite values to check they are updated
|
||||
$flag = 'tickets';
|
||||
$user->setNotificationImmediately($flag, true);
|
||||
$user->setNotificationDailyDigest($flag, true);
|
||||
|
||||
$command = new UpdateProfileCommand(null);
|
||||
$command->notificationFlags = [
|
||||
$flag => [
|
||||
'immediate_email' => false,
|
||||
'daily_digest' => false,
|
||||
],
|
||||
];
|
||||
|
||||
(new UpdateProfileCommandHandler())->updateProfile($user, $command);
|
||||
|
||||
self::assertNull($user->getPhonenumber(), 'Phone should be set to null');
|
||||
self::assertFalse($user->isNotificationSendImmediately($flag));
|
||||
self::assertFalse($user->isNotificationDailyDigest($flag));
|
||||
}
|
||||
|
||||
public function testUpdateProfileWithPhoneAndMultipleFlags(): void
|
||||
{
|
||||
$user = new User();
|
||||
|
||||
$phone = new PhoneNumber();
|
||||
$phone->setCountryCode(33); // France
|
||||
$phone->setNationalNumber(612345678);
|
||||
|
||||
$command = new UpdateProfileCommand($phone);
|
||||
$command->notificationFlags = [
|
||||
'reports' => [
|
||||
'immediate_email' => true,
|
||||
'daily_digest' => false,
|
||||
],
|
||||
'activities' => [
|
||||
'immediate_email' => false,
|
||||
'daily_digest' => true,
|
||||
],
|
||||
];
|
||||
|
||||
(new UpdateProfileCommandHandler())->updateProfile($user, $command);
|
||||
|
||||
// Phone assigned
|
||||
self::assertInstanceOf(PhoneNumber::class, $user->getPhonenumber());
|
||||
self::assertSame(33, $user->getPhonenumber()->getCountryCode());
|
||||
self::assertSame('612345678', (string) $user->getPhonenumber()->getNationalNumber());
|
||||
|
||||
// Flags applied
|
||||
self::assertTrue($user->isNotificationSendImmediately('reports'));
|
||||
self::assertFalse($user->isNotificationDailyDigest('reports'));
|
||||
|
||||
self::assertFalse($user->isNotificationSendImmediately('activities'));
|
||||
self::assertTrue($user->isNotificationDailyDigest('activities'));
|
||||
}
|
||||
}
|
@@ -0,0 +1,103 @@
|
||||
<?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 Action\User\UpdateProfile;
|
||||
|
||||
use Chill\MainBundle\Action\User\UpdateProfile\UpdateProfileCommand;
|
||||
use Chill\MainBundle\Entity\User;
|
||||
use Chill\MainBundle\Notification\FlagProviders\NotificationFlagProviderInterface;
|
||||
use Chill\MainBundle\Notification\NotificationFlagManager;
|
||||
use libphonenumber\PhoneNumber;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\Translation\TranslatableMessage;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*
|
||||
* @coversNothing
|
||||
*/
|
||||
final class UpdateProfileCommandTest extends TestCase
|
||||
{
|
||||
public function testCreateTransfersPhonenumberAndNotificationFlags(): void
|
||||
{
|
||||
$user = new User();
|
||||
|
||||
// set a phone number
|
||||
$phone = new PhoneNumber();
|
||||
$phone->setCountryCode(32); // Belgium
|
||||
$phone->setNationalNumber(471234567);
|
||||
$user->setPhonenumber($phone);
|
||||
|
||||
// configure notification flags on the user via helpers
|
||||
$flagA = 'foo';
|
||||
$flagB = 'bar';
|
||||
|
||||
// For tickets: immediate true, daily false
|
||||
$user->setNotificationImmediately($flagA, true);
|
||||
$user->setNotificationDailyDigest($flagA, false);
|
||||
|
||||
// For reports: immediate false, daily true
|
||||
$user->setNotificationImmediately($flagB, false);
|
||||
$user->setNotificationDailyDigest($flagB, true);
|
||||
|
||||
// a third flag not explicitly set to validate default behavior from User
|
||||
$flagC = 'foobar'; // by default immediate-email is true, daily-digest is false per User::getNotificationFlagData
|
||||
|
||||
$manager = $this->createNotificationFlagManager([$flagA, $flagB, $flagC]);
|
||||
|
||||
$command = UpdateProfileCommand::create($user, $manager);
|
||||
|
||||
// phone number transferred
|
||||
self::assertInstanceOf(PhoneNumber::class, $command->phonenumber);
|
||||
self::assertSame($phone->getCountryCode(), $command->phonenumber->getCountryCode());
|
||||
self::assertSame($phone->getNationalNumber(), $command->phonenumber->getNationalNumber());
|
||||
|
||||
// flags transferred consistently
|
||||
self::assertArrayHasKey($flagA, $command->notificationFlags);
|
||||
self::assertArrayHasKey($flagB, $command->notificationFlags);
|
||||
self::assertArrayHasKey($flagC, $command->notificationFlags);
|
||||
|
||||
self::assertSame([
|
||||
'immediate_email' => true,
|
||||
'daily_digest' => false,
|
||||
], $command->notificationFlags[$flagA]);
|
||||
|
||||
self::assertSame([
|
||||
'immediate_email' => false,
|
||||
'daily_digest' => true,
|
||||
], $command->notificationFlags[$flagB]);
|
||||
|
||||
// default from User::getNotificationFlagData -> immediate true, daily false
|
||||
self::assertSame([
|
||||
'immediate_email' => true,
|
||||
'daily_digest' => false,
|
||||
], $command->notificationFlags[$flagC]);
|
||||
}
|
||||
|
||||
private function createNotificationFlagManager(array $flags): NotificationFlagManager
|
||||
{
|
||||
$providers = array_map(fn (string $flag) => new class ($flag) implements NotificationFlagProviderInterface {
|
||||
public function __construct(private readonly string $flag) {}
|
||||
|
||||
public function getFlag(): string
|
||||
{
|
||||
return $this->flag;
|
||||
}
|
||||
|
||||
public function getLabel(): TranslatableMessage
|
||||
{
|
||||
return new TranslatableMessage($this->flag);
|
||||
}
|
||||
}, $flags);
|
||||
|
||||
return new NotificationFlagManager($providers);
|
||||
}
|
||||
}
|
@@ -45,7 +45,7 @@ final class UserControllerTest extends WebTestCase
|
||||
self::assertResponseIsSuccessful();
|
||||
|
||||
$username = 'Test_user'.uniqid();
|
||||
$password = 'Password1234!';
|
||||
$password = 'Password_1234!';
|
||||
|
||||
// Fill in the form and submit it
|
||||
|
||||
@@ -99,7 +99,7 @@ final class UserControllerTest extends WebTestCase
|
||||
{
|
||||
$client = $this->getClientAuthenticatedAsAdmin();
|
||||
$crawler = $client->request('GET', "/fr/admin/user/{$userId}/edit_password");
|
||||
$newPassword = '1234Password!';
|
||||
$newPassword = '1234_Password!';
|
||||
|
||||
$form = $crawler->selectButton('Changer le mot de passe')->form([
|
||||
'chill_mainbundle_user_password[new_password][first]' => $newPassword,
|
||||
|
@@ -96,11 +96,13 @@ final class NotificationTest extends KernelTestCase
|
||||
$this->assertTrue($user->isNotificationSendImmediately($notification->getType()), 'Should return true when no notification flags are set, by default immediate email');
|
||||
|
||||
// immediate-email preference
|
||||
$user->setNotificationFlags(['test_notification_type' => [User::NOTIF_FLAG_IMMEDIATE_EMAIL, User::NOTIF_FLAG_DAILY_DIGEST]]);
|
||||
$user->setNotificationImmediately('test_notification_type', true);
|
||||
$user->setNotificationDailyDigest('test_notification_type', true);
|
||||
$this->assertTrue($user->isNotificationSendImmediately($notification->getType()), 'Should return true when preferences contain immediate-email');
|
||||
|
||||
// daily-email preference
|
||||
$user->setNotificationFlags(['test_notification_type' => [User::NOTIF_FLAG_DAILY_DIGEST]]);
|
||||
$user->setNotificationDailyDigest('test_notification_type', true);
|
||||
$user->setNotificationImmediately('test_notification_type', false);
|
||||
$this->assertFalse($user->isNotificationSendImmediately($notification->getType()), 'Should return false when preference is daily-email only');
|
||||
$this->assertTrue($user->isNotificationDailyDigest($notification->getType()), 'Should return true when preference is daily-email');
|
||||
|
||||
|
@@ -0,0 +1,82 @@
|
||||
<?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\Entity;
|
||||
|
||||
use Chill\MainBundle\Entity\User;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*
|
||||
* @coversNothing
|
||||
*/
|
||||
class UserNotificationFlagsPersistenceTest extends KernelTestCase
|
||||
{
|
||||
public function testFlushPersistsNotificationFlagsChanges(): void
|
||||
{
|
||||
self::bootKernel();
|
||||
$em = self::getContainer()->get('doctrine')->getManager();
|
||||
|
||||
$user = new User();
|
||||
$user->setUsername('user_'.bin2hex(random_bytes(4)));
|
||||
$user->setLabel('Test User');
|
||||
$user->setPassword('secret');
|
||||
|
||||
// Étape 1: créer et persister l’utilisateur
|
||||
$em->persist($user);
|
||||
$em->flush();
|
||||
$id = $user->getId();
|
||||
self::assertNotNull($id, 'User should have an ID after flush');
|
||||
|
||||
try {
|
||||
// Sanity check: par défaut, pas de daily digest pour "alerts"
|
||||
self::assertFalse($user->isNotificationDailyDigest('alerts'));
|
||||
|
||||
// Étape 2: activer le daily digest -> setNotificationFlagElement réassigne la propriété
|
||||
$user->setNotificationDailyDigest('alerts', true);
|
||||
$em->flush(); // persist le changement
|
||||
$em->clear(); // simule un nouveau cycle de requête
|
||||
|
||||
// Étape 3: recharger depuis la base et vérifier la persistance
|
||||
/** @var User $reloaded */
|
||||
$reloaded = $em->find(User::class, $id);
|
||||
self::assertNotNull($reloaded);
|
||||
self::assertTrue(
|
||||
$reloaded->isNotificationDailyDigest('alerts'),
|
||||
'Daily digest flag should be persisted'
|
||||
);
|
||||
|
||||
// Étape 4: modifier via setNotificationFlagData (remplacement du tableau)
|
||||
// Cette méthode doit réassigner la propriété (copie -> réassignation)
|
||||
$reloaded->setNotificationImmediately('alerts', true);
|
||||
$reloaded->setNotificationDailyDigest('alerts', false);
|
||||
$em->flush();
|
||||
$em->clear();
|
||||
|
||||
/** @var User $reloaded2 */
|
||||
$reloaded2 = $em->find(User::class, $id);
|
||||
self::assertNotNull($reloaded2);
|
||||
|
||||
// Le daily digest n’est plus actif, seul immediate-email est présent
|
||||
self::assertFalse($reloaded2->isNotificationDailyDigest('alerts'));
|
||||
self::assertTrue($reloaded2->isNotificationSendImmediately('alerts'));
|
||||
} finally {
|
||||
// Nettoyage
|
||||
$managed = $em->find(User::class, $id);
|
||||
if (null !== $managed) {
|
||||
$em->remove($managed);
|
||||
$em->flush();
|
||||
}
|
||||
$em->clear();
|
||||
}
|
||||
}
|
||||
}
|
@@ -67,4 +67,54 @@ class UserTest extends TestCase
|
||||
->first()->getEndDate()
|
||||
);
|
||||
}
|
||||
|
||||
public function testIsAbsent()
|
||||
{
|
||||
$user = new User();
|
||||
|
||||
// Absent: today is within absence period
|
||||
$absenceStart = new \DateTimeImmutable('-1 day');
|
||||
$absenceEnd = new \DateTimeImmutable('+1 day');
|
||||
$user->setAbsenceStart($absenceStart);
|
||||
$user->setAbsenceEnd($absenceEnd);
|
||||
self::assertTrue($user->isAbsent(), 'Should be absent when now is between start and end');
|
||||
|
||||
// Absent: end is null
|
||||
$user->setAbsenceStart(new \DateTimeImmutable('-2 days'));
|
||||
$user->setAbsenceEnd(null);
|
||||
self::assertTrue($user->isAbsent(), 'Should be absent when started and no end');
|
||||
|
||||
// Not absent: absenceStart is in the future
|
||||
$user->setAbsenceStart(new \DateTimeImmutable('+2 days'));
|
||||
$user->setAbsenceEnd(null);
|
||||
self::assertFalse($user->isAbsent(), 'Should not be absent if start is in the future');
|
||||
|
||||
// Not absent: absenceEnd is in the past
|
||||
$user->setAbsenceStart(new \DateTimeImmutable('-5 days'));
|
||||
$user->setAbsenceEnd(new \DateTimeImmutable('-1 day'));
|
||||
self::assertFalse($user->isAbsent(), 'Should not be absent if end is in the past');
|
||||
|
||||
// Not absent: both are null
|
||||
$user->setAbsenceStart(null);
|
||||
$user->setAbsenceEnd(null);
|
||||
self::assertFalse($user->isAbsent(), 'Should not be absent if start is null');
|
||||
}
|
||||
|
||||
public function testSetNotification(): void
|
||||
{
|
||||
$user = new User();
|
||||
|
||||
self::assertTrue($user->isNotificationSendImmediately('dummy'));
|
||||
self::assertFalse($user->isNotificationDailyDigest('dummy'));
|
||||
|
||||
$user->setNotificationImmediately('dummy', false);
|
||||
self::assertFalse($user->isNotificationSendImmediately('dummy'));
|
||||
|
||||
$user->setNotificationDailyDigest('dummy', true);
|
||||
self::assertTrue($user->isNotificationDailyDigest('dummy'));
|
||||
|
||||
$user->setNotificationImmediately('dummy', true);
|
||||
self::assertTrue($user->isNotificationSendImmediately('dummy'));
|
||||
self::assertTrue($user->isNotificationDailyDigest('dummy'));
|
||||
}
|
||||
}
|
||||
|
@@ -144,7 +144,7 @@ class NotificationMailerTest extends TestCase
|
||||
$idProperty->setValue($user, 456);
|
||||
|
||||
// Set notification flags for the user
|
||||
$user->setNotificationFlags(['test_notification_type' => [User::NOTIF_FLAG_IMMEDIATE_EMAIL]]);
|
||||
$user->setNotificationImmediately('test_notification_type', true);
|
||||
|
||||
$messageBus = $this->createMock(MessageBusInterface::class);
|
||||
$messageBus->expects($this->once())
|
||||
|
@@ -0,0 +1,173 @@
|
||||
<?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\Security\Authorization;
|
||||
|
||||
use Chill\MainBundle\Entity\User;
|
||||
use Chill\MainBundle\Entity\Workflow\EntityWorkflow;
|
||||
use Chill\MainBundle\Security\Authorization\EntityWorkflowAttachmentVoter;
|
||||
use Chill\MainBundle\Workflow\EntityWorkflowMarkingStore;
|
||||
use Chill\MainBundle\Workflow\WorkflowTransitionContextDTO;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\PhpUnit\ProphecyTrait;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken;
|
||||
use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface;
|
||||
use Symfony\Component\Workflow\DefinitionBuilder;
|
||||
use Symfony\Component\Workflow\Metadata\InMemoryMetadataStore;
|
||||
use Symfony\Component\Workflow\Registry;
|
||||
use Symfony\Component\Workflow\SupportStrategy\WorkflowSupportStrategyInterface;
|
||||
use Symfony\Component\Workflow\Transition;
|
||||
use Symfony\Component\Workflow\Workflow;
|
||||
use Symfony\Component\Workflow\WorkflowInterface;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*
|
||||
* @coversNothing
|
||||
*/
|
||||
class EntityWorkflowAttachmentVoterTest extends TestCase
|
||||
{
|
||||
use ProphecyTrait;
|
||||
|
||||
/**
|
||||
* @dataProvider dataVoteOnAttribute
|
||||
*/
|
||||
public function testVoteOnAttribute(EntityWorkflow $entityWorkflow, int $expected): void
|
||||
{
|
||||
$voter = new EntityWorkflowAttachmentVoter($this->buildRegistry());
|
||||
$actual = $voter->vote(
|
||||
new UsernamePasswordToken(new User(), 'default'),
|
||||
$entityWorkflow,
|
||||
['CHILL_MAIN_WORKFLOW_ATTACHMENT_EDIT'],
|
||||
);
|
||||
$this->assertEquals($expected, $actual);
|
||||
}
|
||||
|
||||
public static function dataVoteOnAttribute(): iterable
|
||||
{
|
||||
$entity = new EntityWorkflow();
|
||||
$entity->setWorkflowName('dummy');
|
||||
|
||||
$workflow = static::buildRegistry()->get($entity, 'dummy');
|
||||
|
||||
$dto = new WorkflowTransitionContextDTO($entity);
|
||||
$dto->futureDestUsers[] = new User();
|
||||
$workflow->apply(
|
||||
$entity,
|
||||
'to_final_positive',
|
||||
['context' => $dto,
|
||||
'byUser' => new User(),
|
||||
'transition' => 'to_final_positive',
|
||||
'transitionAt' => new \DateTimeImmutable()],
|
||||
);
|
||||
// we need to mark manually as final, as the listener is not registered
|
||||
$entity->getCurrentStep()->setIsFinal(true);
|
||||
|
||||
yield 'on final positive' => [
|
||||
$entity,
|
||||
VoterInterface::ACCESS_DENIED,
|
||||
];
|
||||
|
||||
$entity = new EntityWorkflow();
|
||||
$entity->setWorkflowName('dummy');
|
||||
|
||||
$workflow = static::buildRegistry()->get($entity, 'dummy');
|
||||
|
||||
$dto = new WorkflowTransitionContextDTO($entity);
|
||||
$dto->futureDestUsers[] = new User();
|
||||
$workflow->apply(
|
||||
$entity,
|
||||
'to_final_negative',
|
||||
['context' => $dto,
|
||||
'byUser' => new User(),
|
||||
'transition' => 'to_final_negative',
|
||||
'transitionAt' => new \DateTimeImmutable()],
|
||||
);
|
||||
// we need to mark manually as final, as the listener is not registered
|
||||
$entity->getCurrentStep()->setIsFinal(true);
|
||||
|
||||
yield 'on final negative' => [
|
||||
$entity,
|
||||
VoterInterface::ACCESS_DENIED,
|
||||
];
|
||||
|
||||
$entity = new EntityWorkflow();
|
||||
$entity->setWorkflowName('dummy');
|
||||
|
||||
$workflow = static::buildRegistry()->get($entity, 'dummy');
|
||||
|
||||
$dto = new WorkflowTransitionContextDTO($entity);
|
||||
$dto->futureDestUsers[] = new User();
|
||||
$workflow->apply(
|
||||
$entity,
|
||||
'to_sent_external',
|
||||
['context' => $dto,
|
||||
'byUser' => new User(),
|
||||
'transition' => 'to_sent_external',
|
||||
'transitionAt' => new \DateTimeImmutable()],
|
||||
);
|
||||
|
||||
yield 'on sent_external' => [
|
||||
$entity,
|
||||
VoterInterface::ACCESS_DENIED,
|
||||
];
|
||||
|
||||
$entity = new EntityWorkflow();
|
||||
$entity->setWorkflowName('dummy');
|
||||
|
||||
yield 'on initial' => [
|
||||
$entity,
|
||||
VoterInterface::ACCESS_GRANTED,
|
||||
];
|
||||
}
|
||||
|
||||
private static function buildRegistry(): Registry
|
||||
{
|
||||
$builder = new DefinitionBuilder();
|
||||
$builder
|
||||
->setInitialPlaces(['initial'])
|
||||
->addPlaces(['initial', 'sent_external', 'final_positive', 'final_negative'])
|
||||
->addTransitions([
|
||||
new Transition('to_final_positive', ['initial'], 'final_positive'),
|
||||
new Transition('to_sent_external', ['initial'], 'sent_external'),
|
||||
new Transition('to_final_negative', ['initial'], 'final_negative'),
|
||||
|
||||
])
|
||||
->setMetadataStore(
|
||||
new InMemoryMetadataStore(
|
||||
placesMetadata: [
|
||||
'sent_external' => [
|
||||
'isSentExternal' => true,
|
||||
],
|
||||
'final_positive' => [
|
||||
'isFinal' => true,
|
||||
'isFinalPositive' => true,
|
||||
],
|
||||
'final_negative' => [
|
||||
'isFinal' => true,
|
||||
'isFinalPositive' => false,
|
||||
],
|
||||
]
|
||||
)
|
||||
);
|
||||
|
||||
$workflow = new Workflow($builder->build(), new EntityWorkflowMarkingStore(), name: 'dummy');
|
||||
$registry = new Registry();
|
||||
$registry->addWorkflow($workflow, new class () implements WorkflowSupportStrategyInterface {
|
||||
public function supports(WorkflowInterface $workflow, object $subject): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
});
|
||||
|
||||
return $registry;
|
||||
}
|
||||
}
|
@@ -101,6 +101,8 @@ final class UserNormalizerTest extends TestCase
|
||||
'text_without_absent' => 'SomeUser',
|
||||
'isAbsent' => false,
|
||||
'main_center' => ['context' => Center::class],
|
||||
'absenceStart' => ['context' => \DateTimeImmutable::class],
|
||||
'absenceEnd' => ['context' => \DateTimeImmutable::class],
|
||||
]];
|
||||
|
||||
yield [$userNoPhone, 'docgen', ['docgen:expects' => User::class],
|
||||
@@ -120,6 +122,8 @@ final class UserNormalizerTest extends TestCase
|
||||
'text_without_absent' => 'AnotherUser',
|
||||
'isAbsent' => false,
|
||||
'main_center' => ['context' => Center::class],
|
||||
'absenceStart' => ['context' => \DateTimeImmutable::class],
|
||||
'absenceEnd' => ['context' => \DateTimeImmutable::class],
|
||||
]];
|
||||
|
||||
yield [null, 'docgen', ['docgen:expects' => User::class], [
|
||||
@@ -138,6 +142,8 @@ final class UserNormalizerTest extends TestCase
|
||||
'text_without_absent' => '',
|
||||
'isAbsent' => false,
|
||||
'main_center' => ['context' => Center::class],
|
||||
'absenceStart' => null,
|
||||
'absenceEnd' => null,
|
||||
]];
|
||||
}
|
||||
}
|
||||
|
@@ -11,6 +11,9 @@ declare(strict_types=1);
|
||||
|
||||
namespace Chill\MainBundle\Tests\Workflow\Helper;
|
||||
|
||||
use Chill\DocStoreBundle\Entity\StoredObject;
|
||||
use Chill\MainBundle\Entity\Workflow\EntityWorkflowAttachment;
|
||||
use Chill\MainBundle\Repository\Workflow\EntityWorkflowAttachmentRepository;
|
||||
use Chill\MainBundle\Workflow\Helper\WorkflowRelatedEntityPermissionHelper;
|
||||
use Chill\MainBundle\Entity\User;
|
||||
use Chill\MainBundle\Entity\Workflow\EntityWorkflow;
|
||||
@@ -148,8 +151,11 @@ class WorkflowRelatedEntityPermissionHelperTest extends TestCase
|
||||
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
|
||||
$entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User());
|
||||
|
||||
yield [[$entityWorkflow], new User(), WorkflowRelatedEntityPermissionHelper::ABSTAIN, new \DateTimeImmutable(),
|
||||
'abstain because the user is not present as a dest user'];
|
||||
yield [[], new User(), WorkflowRelatedEntityPermissionHelper::ABSTAIN, new \DateTimeImmutable(),
|
||||
'abstain because there is no workflow'];
|
||||
|
||||
yield [[$entityWorkflow], new User(), WorkflowRelatedEntityPermissionHelper::FORCE_DENIED, new \DateTimeImmutable(),
|
||||
'force deny because the user is not present as a dest user'];
|
||||
|
||||
$entityWorkflow = new EntityWorkflow();
|
||||
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
|
||||
@@ -171,6 +177,9 @@ class WorkflowRelatedEntityPermissionHelperTest extends TestCase
|
||||
yield [[$entityWorkflow], $user, WorkflowRelatedEntityPermissionHelper::FORCE_GRANT, new \DateTimeImmutable(),
|
||||
'force grant because the user was a previous user'];
|
||||
|
||||
yield [[$entityWorkflow], new User(), WorkflowRelatedEntityPermissionHelper::FORCE_DENIED, new \DateTimeImmutable(),
|
||||
'force denied because the user was not a previous user'];
|
||||
|
||||
$entityWorkflow = new EntityWorkflow();
|
||||
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
|
||||
$dto->futureDestUsers[] = $user = new User();
|
||||
@@ -232,6 +241,13 @@ class WorkflowRelatedEntityPermissionHelperTest extends TestCase
|
||||
|
||||
yield [[$entityWorkflow], $user, WorkflowRelatedEntityPermissionHelper::ABSTAIN, new \DateTimeImmutable(),
|
||||
'abstain: there is a signature on a canceled workflow'];
|
||||
|
||||
$entityWorkflow = new EntityWorkflow();
|
||||
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
|
||||
$dto->futureDestUsers[] = $user = new User();
|
||||
$entityWorkflow->setStep('sent_external', $dto, 'to_sent_external', new \DateTimeImmutable(), $user);
|
||||
|
||||
yield [[$entityWorkflow], $user, WorkflowRelatedEntityPermissionHelper::FORCE_DENIED, new \DateTimeImmutable(), 'force denied: the workflow is sent to an external user'];
|
||||
}
|
||||
|
||||
public function testNoWorkflow(): void
|
||||
@@ -253,7 +269,217 @@ class WorkflowRelatedEntityPermissionHelperTest extends TestCase
|
||||
$entityWorkflowManager = $this->prophesize(EntityWorkflowManager::class);
|
||||
$entityWorkflowManager->findByRelatedEntity(Argument::type('object'))->willReturn($entityWorkflows);
|
||||
|
||||
return new WorkflowRelatedEntityPermissionHelper($security->reveal(), $entityWorkflowManager->reveal(), $this->buildRegistry(), new MockClock($atDateTime ?? new \DateTimeImmutable()));
|
||||
$repository = $this->prophesize(EntityWorkflowAttachmentRepository::class);
|
||||
$repository->findByStoredObject(Argument::type(StoredObject::class))->willReturn([]);
|
||||
|
||||
return new WorkflowRelatedEntityPermissionHelper($security->reveal(), $entityWorkflowManager->reveal(), $repository->reveal(), $this->buildRegistry(), new MockClock($atDateTime ?? new \DateTimeImmutable()));
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider provideDataAllowedByWorkflowReadOperationByAttachment
|
||||
*
|
||||
* @param list<EntityWorkflow> $entityWorkflows
|
||||
*/
|
||||
public function testAllowedByWorkflowReadByAttachment(
|
||||
array $entityWorkflows,
|
||||
User $user,
|
||||
string $expected,
|
||||
?\DateTimeImmutable $atDate,
|
||||
string $message,
|
||||
): void {
|
||||
// all entities must have this workflow name, so we are ok to set it here
|
||||
foreach ($entityWorkflows as $entityWorkflow) {
|
||||
$entityWorkflow->setWorkflowName('dummy');
|
||||
}
|
||||
$helper = $this->buildHelperForAttachment($entityWorkflows, $user, $atDate);
|
||||
|
||||
self::assertEquals($expected, $helper->isAllowedByWorkflowForReadOperation(new StoredObject()), $message);
|
||||
}
|
||||
|
||||
public static function provideDataAllowedByWorkflowReadOperationByAttachment(): iterable
|
||||
{
|
||||
$entityWorkflow = new EntityWorkflow();
|
||||
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
|
||||
$entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User());
|
||||
|
||||
yield [[$entityWorkflow], new User(), WorkflowRelatedEntityPermissionHelper::ABSTAIN, new \DateTimeImmutable(),
|
||||
'abstain because the user is not present as a dest user'];
|
||||
|
||||
$entityWorkflow = new EntityWorkflow();
|
||||
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
|
||||
$dto->futureDestUsers[] = $user = new User();
|
||||
$entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User());
|
||||
|
||||
yield [[$entityWorkflow], $user, WorkflowRelatedEntityPermissionHelper::FORCE_GRANT, new \DateTimeImmutable(),
|
||||
'force grant because the user is a current user'];
|
||||
|
||||
$entityWorkflow = new EntityWorkflow();
|
||||
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
|
||||
$dto->futureDestUsers[] = $user = new User();
|
||||
$entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User());
|
||||
|
||||
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
|
||||
$dto->futureDestUsers[] = new User();
|
||||
$entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User());
|
||||
|
||||
yield [[$entityWorkflow], $user, WorkflowRelatedEntityPermissionHelper::FORCE_GRANT, new \DateTimeImmutable(),
|
||||
'force grant because the user was a previous user'];
|
||||
|
||||
$entityWorkflow = new EntityWorkflow();
|
||||
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
|
||||
$dto->futurePersonSignatures[] = new Person();
|
||||
$entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User());
|
||||
|
||||
yield [[$entityWorkflow], new User(), WorkflowRelatedEntityPermissionHelper::ABSTAIN, new \DateTimeImmutable(),
|
||||
'Abstain: there is a signature for person, but the attachment is not concerned'];
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider provideDataAllowedByWorkflowWriteOperationByAttachment
|
||||
*
|
||||
* @param list<EntityWorkflow> $entityWorkflows
|
||||
*/
|
||||
public function testAllowedByWorkflowWriteByAttachment(
|
||||
array $entityWorkflows,
|
||||
User $user,
|
||||
string $expected,
|
||||
?\DateTimeImmutable $atDate,
|
||||
string $message,
|
||||
): void {
|
||||
// all entities must have this workflow name, so we are ok to set it here
|
||||
foreach ($entityWorkflows as $entityWorkflow) {
|
||||
$entityWorkflow->setWorkflowName('dummy');
|
||||
}
|
||||
$helper = $this->buildHelperForAttachment($entityWorkflows, $user, $atDate);
|
||||
|
||||
self::assertEquals($expected, $helper->isAllowedByWorkflowForWriteOperation(new StoredObject()), $message);
|
||||
}
|
||||
|
||||
public static function provideDataAllowedByWorkflowWriteOperationByAttachment(): iterable
|
||||
{
|
||||
$entityWorkflow = new EntityWorkflow();
|
||||
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
|
||||
$entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User());
|
||||
|
||||
yield [[], new User(), WorkflowRelatedEntityPermissionHelper::ABSTAIN, new \DateTimeImmutable(),
|
||||
'abstain because there is no workflow'];
|
||||
|
||||
yield [[$entityWorkflow], new User(), WorkflowRelatedEntityPermissionHelper::ABSTAIN, new \DateTimeImmutable(),
|
||||
'abstain because the user is not present as a dest user (and attachment)'];
|
||||
|
||||
$entityWorkflow = new EntityWorkflow();
|
||||
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
|
||||
$dto->futureDestUsers[] = $user = new User();
|
||||
$entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User());
|
||||
|
||||
yield [[$entityWorkflow], $user, WorkflowRelatedEntityPermissionHelper::FORCE_GRANT, new \DateTimeImmutable(),
|
||||
'force grant because the user is a current user'];
|
||||
|
||||
$entityWorkflow = new EntityWorkflow();
|
||||
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
|
||||
$dto->futureDestUsers[] = $user = new User();
|
||||
$entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User());
|
||||
|
||||
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
|
||||
$dto->futureDestUsers[] = new User();
|
||||
$entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User());
|
||||
|
||||
yield [[$entityWorkflow], $user, WorkflowRelatedEntityPermissionHelper::FORCE_GRANT, new \DateTimeImmutable(),
|
||||
'force grant because the user was a previous user'];
|
||||
|
||||
yield [[$entityWorkflow], new User(), WorkflowRelatedEntityPermissionHelper::ABSTAIN, new \DateTimeImmutable(),
|
||||
'abstain because the user was not a previous user'];
|
||||
|
||||
$entityWorkflow = new EntityWorkflow();
|
||||
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
|
||||
$dto->futureDestUsers[] = $user = new User();
|
||||
$entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User());
|
||||
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
|
||||
$entityWorkflow->setStep('final_positive', $dto, 'to_final_positive', new \DateTimeImmutable(), new User());
|
||||
$entityWorkflow->getCurrentStep()->setIsFinal(true);
|
||||
|
||||
yield [[$entityWorkflow], $user, WorkflowRelatedEntityPermissionHelper::FORCE_DENIED, new \DateTimeImmutable(),
|
||||
'force denied: user was a previous user, but it is finalized positive'];
|
||||
|
||||
$entityWorkflow = new EntityWorkflow();
|
||||
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
|
||||
$dto->futureDestUsers[] = $user = new User();
|
||||
$entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable());
|
||||
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
|
||||
$entityWorkflow->setStep('final_negative', $dto, 'to_final_negative', new \DateTimeImmutable());
|
||||
$entityWorkflow->getCurrentStep()->setIsFinal(true);
|
||||
|
||||
yield [[$entityWorkflow], $user, WorkflowRelatedEntityPermissionHelper::ABSTAIN, new \DateTimeImmutable(),
|
||||
'abstain: user was a previous user, it is finalized, but finalized negative'];
|
||||
|
||||
$entityWorkflow = new EntityWorkflow();
|
||||
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
|
||||
$dto->futureDestUsers[] = $user = new User();
|
||||
$entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User());
|
||||
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
|
||||
$dto->futurePersonSignatures[] = new Person();
|
||||
$entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User());
|
||||
$signature = $entityWorkflow->getCurrentStep()->getSignatures()->first();
|
||||
$signature->setState(EntityWorkflowSignatureStateEnum::SIGNED)->setStateDate(new \DateTimeImmutable());
|
||||
|
||||
yield [[$entityWorkflow], new User(), WorkflowRelatedEntityPermissionHelper::ABSTAIN, new \DateTimeImmutable(),
|
||||
'abstain: there is a signature, but not on the attachment'];
|
||||
|
||||
$entityWorkflow = new EntityWorkflow();
|
||||
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
|
||||
$dto->futureDestUsers[] = $user = new User();
|
||||
$entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User());
|
||||
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
|
||||
$dto->futurePersonSignatures[] = new Person();
|
||||
$entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User());
|
||||
|
||||
yield [[$entityWorkflow], new User(), WorkflowRelatedEntityPermissionHelper::ABSTAIN, new \DateTimeImmutable(),
|
||||
'abstain: there is a signature, but the signature is not on the attachment'];
|
||||
|
||||
$entityWorkflow = new EntityWorkflow();
|
||||
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
|
||||
$dto->futureDestUsers[] = $user = new User();
|
||||
$entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User());
|
||||
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
|
||||
$dto->futurePersonSignatures[] = new Person();
|
||||
$entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User());
|
||||
$signature = $entityWorkflow->getCurrentStep()->getSignatures()->first();
|
||||
$signature->setState(EntityWorkflowSignatureStateEnum::SIGNED)->setStateDate(new \DateTimeImmutable());
|
||||
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
|
||||
$entityWorkflow->setStep('final_negative', $dto, 'to_final_negative', new \DateTimeImmutable(), new User());
|
||||
$entityWorkflow->getCurrentStep()->setIsFinal(true);
|
||||
|
||||
yield [[$entityWorkflow], $user, WorkflowRelatedEntityPermissionHelper::ABSTAIN, new \DateTimeImmutable(),
|
||||
'abstain: there is a signature on a canceled workflow'];
|
||||
|
||||
$entityWorkflow = new EntityWorkflow();
|
||||
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
|
||||
$dto->futureDestUsers[] = $user = new User();
|
||||
$entityWorkflow->setStep('sent_external', $dto, 'to_sent_external', new \DateTimeImmutable(), $user);
|
||||
|
||||
yield [[$entityWorkflow], $user, WorkflowRelatedEntityPermissionHelper::FORCE_DENIED, new \DateTimeImmutable(),
|
||||
'force denied: the workflow is sent to an external user'];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<EntityWorkflow> $entityWorkflows
|
||||
*/
|
||||
private function buildHelperForAttachment(array $entityWorkflows, User $user, ?\DateTimeImmutable $atDateTime): WorkflowRelatedEntityPermissionHelper
|
||||
{
|
||||
$security = $this->prophesize(Security::class);
|
||||
$security->getUser()->willReturn($user);
|
||||
|
||||
$entityWorkflowManager = $this->prophesize(EntityWorkflowManager::class);
|
||||
$entityWorkflowManager->findByRelatedEntity(Argument::type('object'))->shouldNotBeCalled();
|
||||
|
||||
$repository = $this->prophesize(EntityWorkflowAttachmentRepository::class);
|
||||
$attachments = [];
|
||||
foreach ($entityWorkflows as $entityWorkflow) {
|
||||
$attachments[] = new EntityWorkflowAttachment('dummy', ['id' => 1], $entityWorkflow, new StoredObject());
|
||||
}
|
||||
$repository->findByStoredObject(Argument::type(StoredObject::class))->willReturn($attachments);
|
||||
|
||||
return new WorkflowRelatedEntityPermissionHelper($security->reveal(), $entityWorkflowManager->reveal(), $repository->reveal(), $this->buildRegistry(), new MockClock($atDateTime ?? new \DateTimeImmutable()));
|
||||
}
|
||||
|
||||
private static function buildRegistry(): Registry
|
||||
@@ -261,10 +487,13 @@ class WorkflowRelatedEntityPermissionHelperTest extends TestCase
|
||||
$builder = new DefinitionBuilder();
|
||||
$builder
|
||||
->setInitialPlaces(['initial'])
|
||||
->addPlaces(['initial', 'test', 'final_positive', 'final_negative'])
|
||||
->addPlaces(['initial', 'test', 'sent_external', 'final_positive', 'final_negative'])
|
||||
->setMetadataStore(
|
||||
new InMemoryMetadataStore(
|
||||
placesMetadata: [
|
||||
'sent_external' => [
|
||||
'isSentExternal' => true,
|
||||
],
|
||||
'final_positive' => [
|
||||
'isFinal' => true,
|
||||
'isFinalPositive' => true,
|
||||
|
@@ -11,8 +11,11 @@ declare(strict_types=1);
|
||||
|
||||
namespace Chill\MainBundle\Tests\Workflow\Messenger;
|
||||
|
||||
use Chill\DocStoreBundle\Entity\StoredObject;
|
||||
use Chill\DocStoreBundle\Service\StoredObjectToPdfConverter;
|
||||
use Chill\MainBundle\Entity\User;
|
||||
use Chill\MainBundle\Entity\Workflow\EntityWorkflow;
|
||||
use Chill\MainBundle\Entity\Workflow\EntityWorkflowAttachment;
|
||||
use Chill\MainBundle\Repository\Workflow\EntityWorkflowRepository;
|
||||
use Chill\MainBundle\Workflow\EntityWorkflowHandlerInterface;
|
||||
use Chill\MainBundle\Workflow\EntityWorkflowManager;
|
||||
@@ -23,6 +26,7 @@ use Chill\ThirdPartyBundle\Entity\ThirdParty;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\Argument;
|
||||
use Prophecy\PhpUnit\ProphecyTrait;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
|
||||
use Symfony\Component\Mailer\MailerInterface;
|
||||
use Symfony\Component\Mime\Address;
|
||||
@@ -39,24 +43,54 @@ class PostSendExternalMessageHandlerTest extends TestCase
|
||||
public function testSendMessageHappyScenario(): void
|
||||
{
|
||||
$entityWorkflow = $this->buildEntityWorkflow();
|
||||
|
||||
// Prepare attachments (2 attachments)
|
||||
$attachmentStoredObject1 = new StoredObject();
|
||||
$attachmentStoredObject2 = new StoredObject();
|
||||
new EntityWorkflowAttachment('generic_doc', ['id' => 1], $entityWorkflow, $attachmentStoredObject1);
|
||||
new EntityWorkflowAttachment('generic_doc', ['id' => 2], $entityWorkflow, $attachmentStoredObject2);
|
||||
|
||||
// Prepare transition DTO and sends
|
||||
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
|
||||
$dto->futureDestineeEmails = ['external@example.com'];
|
||||
$dto->futureDestineeThirdParties = [(new ThirdParty())->setEmail('3party@example.com')];
|
||||
$entityWorkflow->setStep('send_external', $dto, 'to_send_external', new \DateTimeImmutable(), new User());
|
||||
|
||||
// Repository returns our workflow
|
||||
$repository = $this->prophesize(EntityWorkflowRepository::class);
|
||||
$repository->find(1)->willReturn($entityWorkflow);
|
||||
|
||||
// Mailer must send to both recipients
|
||||
$mailer = $this->prophesize(MailerInterface::class);
|
||||
$mailer->send(Argument::that($this->buildCheckAddressCallback('3party@example.com')))->shouldBeCalledOnce();
|
||||
$mailer->send(Argument::that($this->buildCheckAddressCallback('external@example.com')))->shouldBeCalledOnce();
|
||||
|
||||
// Workflow manager and handler
|
||||
$workflowHandler = $this->prophesize(EntityWorkflowHandlerInterface::class);
|
||||
$workflowHandler->getEntityTitle($entityWorkflow, Argument::any())->willReturn('title');
|
||||
$workflowManager = $this->prophesize(EntityWorkflowManager::class);
|
||||
$workflowManager->getHandler($entityWorkflow)->willReturn($workflowHandler->reveal());
|
||||
|
||||
$handler = new PostSendExternalMessageHandler($repository->reveal(), $mailer->reveal(), $workflowManager->reveal());
|
||||
// Associated stored object for the workflow
|
||||
$associatedStoredObject = new StoredObject();
|
||||
$workflowManager->getAssociatedStoredObject($entityWorkflow)->willReturn($associatedStoredObject);
|
||||
|
||||
// Converter should be called for each attachment and the associated stored object
|
||||
$converter = $this->prophesize(StoredObjectToPdfConverter::class);
|
||||
$converter->addConvertedVersion($attachmentStoredObject1, 'fr')->shouldBeCalledOnce();
|
||||
$converter->addConvertedVersion($attachmentStoredObject2, 'fr')->shouldBeCalledOnce();
|
||||
$converter->addConvertedVersion($associatedStoredObject, 'fr')->shouldBeCalledOnce();
|
||||
|
||||
// Logger (not used in happy path, but required by handler)
|
||||
$logger = $this->prophesize(LoggerInterface::class);
|
||||
|
||||
$handler = new PostSendExternalMessageHandler(
|
||||
$repository->reveal(),
|
||||
$mailer->reveal(),
|
||||
$workflowManager->reveal(),
|
||||
$converter->reveal(),
|
||||
$logger->reveal(),
|
||||
);
|
||||
|
||||
$handler(new PostSendExternalMessage(1, 'fr'));
|
||||
|
||||
|
@@ -11,9 +11,12 @@ declare(strict_types=1);
|
||||
|
||||
namespace Chill\MainBundle\Workflow\Helper;
|
||||
|
||||
use Chill\DocStoreBundle\Entity\StoredObject;
|
||||
use Chill\MainBundle\Entity\User;
|
||||
use Chill\MainBundle\Entity\Workflow\EntityWorkflow;
|
||||
use Chill\MainBundle\Entity\Workflow\EntityWorkflowAttachment;
|
||||
use Chill\MainBundle\Entity\Workflow\EntityWorkflowSignatureStateEnum;
|
||||
use Chill\MainBundle\Repository\Workflow\EntityWorkflowAttachmentRepository;
|
||||
use Chill\MainBundle\Workflow\EntityWorkflowManager;
|
||||
use Symfony\Component\Clock\ClockInterface;
|
||||
use Symfony\Component\Security\Core\Security;
|
||||
@@ -58,21 +61,39 @@ class WorkflowRelatedEntityPermissionHelper
|
||||
public function __construct(
|
||||
private readonly Security $security,
|
||||
private readonly EntityWorkflowManager $entityWorkflowManager,
|
||||
private readonly EntityWorkflowAttachmentRepository $entityWorkflowAttachmentRepository,
|
||||
private readonly Registry $registry,
|
||||
private readonly ClockInterface $clock,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param object $entity The entity may be an
|
||||
*
|
||||
* @return 'FORCE_GRANT'|'FORCE_DENIED'|'ABSTAIN'
|
||||
*/
|
||||
public function isAllowedByWorkflowForReadOperation(object $entity): string
|
||||
{
|
||||
$entityWorkflows = $this->entityWorkflowManager->findByRelatedEntity($entity);
|
||||
if ($entity instanceof StoredObject) {
|
||||
$attachments = $this->entityWorkflowAttachmentRepository->findByStoredObject($entity);
|
||||
$entityWorkflows = array_map(static fn (EntityWorkflowAttachment $attachment) => $attachment->getEntityWorkflow(), $attachments);
|
||||
$isAttached = true;
|
||||
} else {
|
||||
$entityWorkflows = $this->entityWorkflowManager->findByRelatedEntity($entity);
|
||||
$isAttached = false;
|
||||
}
|
||||
|
||||
if ([] === $entityWorkflows) {
|
||||
return self::ABSTAIN;
|
||||
}
|
||||
|
||||
if ($this->isUserInvolvedInAWorkflow($entityWorkflows)) {
|
||||
return self::FORCE_GRANT;
|
||||
}
|
||||
|
||||
if ($isAttached) {
|
||||
return self::ABSTAIN;
|
||||
}
|
||||
|
||||
// give a view permission if there is a Person signature pending, or in the 12 hours following
|
||||
// the signature last state
|
||||
foreach ($entityWorkflows as $workflow) {
|
||||
@@ -100,33 +121,51 @@ class WorkflowRelatedEntityPermissionHelper
|
||||
*/
|
||||
public function isAllowedByWorkflowForWriteOperation(object $entity): string
|
||||
{
|
||||
$entityWorkflows = $this->entityWorkflowManager->findByRelatedEntity($entity);
|
||||
$runningWorkflows = [];
|
||||
if ($entity instanceof StoredObject) {
|
||||
$attachments = $this->entityWorkflowAttachmentRepository->findByStoredObject($entity);
|
||||
$entityWorkflows = array_map(static fn (EntityWorkflowAttachment $attachment) => $attachment->getEntityWorkflow(), $attachments);
|
||||
$isAttached = true;
|
||||
} else {
|
||||
$entityWorkflows = $this->entityWorkflowManager->findByRelatedEntity($entity);
|
||||
$isAttached = false;
|
||||
}
|
||||
|
||||
// if a workflow is finalized positive, we are not allowed to edit to document any more
|
||||
if ([] === $entityWorkflows) {
|
||||
return self::ABSTAIN;
|
||||
}
|
||||
|
||||
// if a workflow is finalized positive, anyone is allowed to edit the document anymore
|
||||
foreach ($entityWorkflows as $entityWorkflow) {
|
||||
if ($entityWorkflow->isFinal()) {
|
||||
$workflow = $this->registry->get($entityWorkflow, $entityWorkflow->getWorkflowName());
|
||||
$marking = $workflow->getMarkingStore()->getMarking($entityWorkflow);
|
||||
foreach ($marking->getPlaces() as $place => $int) {
|
||||
$placeMetadata = $workflow->getMetadataStore()->getPlaceMetadata($place);
|
||||
if (true === ($placeMetadata['isFinalPositive'] ?? false)) {
|
||||
// the workflow is final, and final positive, so we stop here.
|
||||
return self::FORCE_DENIED;
|
||||
}
|
||||
$workflow = $this->registry->get($entityWorkflow, $entityWorkflow->getWorkflowName());
|
||||
$marking = $workflow->getMarkingStore()->getMarking($entityWorkflow);
|
||||
foreach ($marking->getPlaces() as $place => $int) {
|
||||
$placeMetadata = $workflow->getMetadataStore()->getPlaceMetadata($place);
|
||||
if (
|
||||
($entityWorkflow->isFinal() && ($placeMetadata['isFinalPositive'] ?? false))
|
||||
|| ($placeMetadata['isSentExternal'] ?? false)
|
||||
) {
|
||||
// the workflow is final, and final positive, or is sentExternal, so we stop here.
|
||||
return self::FORCE_DENIED;
|
||||
}
|
||||
if (
|
||||
// if not finalized positive
|
||||
$entityWorkflow->isFinal() && !($placeMetadata['isFinalPositive'] ?? false)
|
||||
) {
|
||||
return self::ABSTAIN;
|
||||
}
|
||||
} else {
|
||||
$runningWorkflows[] = $entityWorkflow;
|
||||
}
|
||||
}
|
||||
|
||||
// if there is a signature on a **running workflow**, no one can edit the workflow any more
|
||||
foreach ($runningWorkflows as $entityWorkflow) {
|
||||
foreach ($entityWorkflow->getSteps() as $step) {
|
||||
foreach ($step->getSignatures() as $signature) {
|
||||
if (EntityWorkflowSignatureStateEnum::SIGNED === $signature->getState()) {
|
||||
return self::FORCE_DENIED;
|
||||
$runningWorkflows = array_filter($entityWorkflows, fn (EntityWorkflow $ew) => !$ew->isFinal());
|
||||
|
||||
// if there is a signature on a **running workflow**, no one is allowed edit the workflow anymore
|
||||
if (!$isAttached) {
|
||||
foreach ($runningWorkflows as $entityWorkflow) {
|
||||
foreach ($entityWorkflow->getSteps() as $step) {
|
||||
foreach ($step->getSignatures() as $signature) {
|
||||
if (EntityWorkflowSignatureStateEnum::SIGNED === $signature->getState()) {
|
||||
return self::FORCE_DENIED;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -137,7 +176,11 @@ class WorkflowRelatedEntityPermissionHelper
|
||||
return self::FORCE_GRANT;
|
||||
}
|
||||
|
||||
return self::ABSTAIN;
|
||||
if ($isAttached) {
|
||||
return self::ABSTAIN;
|
||||
}
|
||||
|
||||
return self::FORCE_DENIED;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@@ -11,9 +11,13 @@ declare(strict_types=1);
|
||||
|
||||
namespace Chill\MainBundle\Workflow\Messenger;
|
||||
|
||||
use Chill\DocStoreBundle\Exception\StoredObjectManagerException;
|
||||
use Chill\DocStoreBundle\Service\StoredObjectToPdfConverter;
|
||||
use Chill\MainBundle\Entity\Workflow\EntityWorkflow;
|
||||
use Chill\MainBundle\Entity\Workflow\EntityWorkflowSend;
|
||||
use Chill\MainBundle\Repository\Workflow\EntityWorkflowRepository;
|
||||
use Chill\MainBundle\Workflow\EntityWorkflowManager;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
|
||||
use Symfony\Component\Mailer\MailerInterface;
|
||||
use Symfony\Component\Messenger\Exception\UnrecoverableMessageHandlingException;
|
||||
@@ -25,6 +29,8 @@ final readonly class PostSendExternalMessageHandler implements MessageHandlerInt
|
||||
private EntityWorkflowRepository $entityWorkflowRepository,
|
||||
private MailerInterface $mailer,
|
||||
private EntityWorkflowManager $workflowManager,
|
||||
private StoredObjectToPdfConverter $storedObjectToPdfConverter,
|
||||
private LoggerInterface $logger,
|
||||
) {}
|
||||
|
||||
public function __invoke(PostSendExternalMessage $message): void
|
||||
@@ -35,11 +41,34 @@ final readonly class PostSendExternalMessageHandler implements MessageHandlerInt
|
||||
throw new UnrecoverableMessageHandlingException(sprintf('Entity workflow with id %d not found', $message->entityWorkflowId));
|
||||
}
|
||||
|
||||
$this->convertToPdf($entityWorkflow, $message->lang);
|
||||
|
||||
foreach ($entityWorkflow->getCurrentStep()->getSends() as $send) {
|
||||
$this->sendEmailToDestinee($send, $message);
|
||||
}
|
||||
}
|
||||
|
||||
private function convertToPdf(EntityWorkflow $entityWorkflow, string $locale): void
|
||||
{
|
||||
foreach ($entityWorkflow->getAttachments() as $attachment) {
|
||||
try {
|
||||
$this->storedObjectToPdfConverter->addConvertedVersion($attachment->getProxyStoredObject(), $locale);
|
||||
} catch (StoredObjectManagerException $e) {
|
||||
$this->logger->error('Error converting attachment to PDF', ['backtrace' => $e->getTraceAsString(), 'attachment_id' => $attachment->getId()]);
|
||||
}
|
||||
}
|
||||
|
||||
$storedObject = $this->workflowManager->getAssociatedStoredObject($entityWorkflow);
|
||||
|
||||
if (null !== $storedObject) {
|
||||
try {
|
||||
$this->storedObjectToPdfConverter->addConvertedVersion($storedObject, $locale);
|
||||
} catch (StoredObjectManagerException $e) {
|
||||
$this->logger->error('Error converting stored object to PDF', ['backtrace' => $e->getTraceAsString(), 'stored_object_id' => $storedObject->getId(), 'workflow_id' => $entityWorkflow->getId()]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function sendEmailToDestinee(EntityWorkflowSend $send, PostSendExternalMessage $message): void
|
||||
{
|
||||
$entityWorkflow = $send->getEntityWorkflowStep()->getEntityWorkflow();
|
||||
|
@@ -22,6 +22,7 @@ use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\Clock\ClockInterface;
|
||||
use Symfony\Component\Messenger\MessageBusInterface;
|
||||
use Symfony\Component\Workflow\Registry;
|
||||
use Symfony\Component\Workflow\Transition;
|
||||
|
||||
/**
|
||||
* Handles state changes for signature steps within a workflow.
|
||||
@@ -50,8 +51,10 @@ class SignatureStepStateChanger
|
||||
*
|
||||
* @param EntityWorkflowStepSignature $signature the signature entity to be marked as signed
|
||||
* @param int|null $atIndex optional index position for the signature within the zone
|
||||
*
|
||||
* @return string The expected new workflow's step, after transition is applyied
|
||||
*/
|
||||
public function markSignatureAsSigned(EntityWorkflowStepSignature $signature, ?int $atIndex): void
|
||||
public function markSignatureAsSigned(EntityWorkflowStepSignature $signature, ?int $atIndex): string
|
||||
{
|
||||
$this->entityManager->refresh($signature, LockMode::PESSIMISTIC_WRITE);
|
||||
|
||||
@@ -60,7 +63,14 @@ class SignatureStepStateChanger
|
||||
->setZoneSignatureIndex($atIndex)
|
||||
->setStateDate($this->clock->now());
|
||||
$this->logger->info(self::LOG_PREFIX.'Mark signature entity as signed', ['signatureId' => $signature->getId(), 'index' => (string) $atIndex]);
|
||||
['transition' => $transition, 'futureUser' => $futureUser] = $this->decideTransition($signature);
|
||||
|
||||
$this->messageBus->dispatch(new PostSignatureStateChangeMessage((int) $signature->getId()));
|
||||
if (null === $transition) {
|
||||
return $signature->getStep()->getEntityWorkflow()->getStep();
|
||||
}
|
||||
|
||||
return $transition->getTos()[0];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -71,8 +81,10 @@ class SignatureStepStateChanger
|
||||
*
|
||||
* This method updates the signature state to 'canceled' and logs the action.
|
||||
* It also dispatches a message to notify about the state change.
|
||||
*
|
||||
* @return string The expected new workflow's step, after transition is applyied
|
||||
*/
|
||||
public function markSignatureAsCanceled(EntityWorkflowStepSignature $signature): void
|
||||
public function markSignatureAsCanceled(EntityWorkflowStepSignature $signature): string
|
||||
{
|
||||
$this->entityManager->refresh($signature, LockMode::PESSIMISTIC_WRITE);
|
||||
|
||||
@@ -80,7 +92,15 @@ class SignatureStepStateChanger
|
||||
->setState(EntityWorkflowSignatureStateEnum::CANCELED)
|
||||
->setStateDate($this->clock->now());
|
||||
$this->logger->info(self::LOG_PREFIX.'Mark signature entity as canceled', ['signatureId' => $signature->getId()]);
|
||||
|
||||
['transition' => $transition, 'futureUser' => $futureUser] = $this->decideTransition($signature);
|
||||
|
||||
$this->messageBus->dispatch(new PostSignatureStateChangeMessage((int) $signature->getId()));
|
||||
if (null === $transition) {
|
||||
return $signature->getStep()->getEntityWorkflow()->getStep();
|
||||
}
|
||||
|
||||
return $transition->getTos()[0];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -93,8 +113,10 @@ class SignatureStepStateChanger
|
||||
* a state change has occurred.
|
||||
*
|
||||
* @param EntityWorkflowStepSignature $signature the signature entity to be marked as rejected
|
||||
*
|
||||
* @return string The expected new workflow's step, after transition is applyied
|
||||
*/
|
||||
public function markSignatureAsRejected(EntityWorkflowStepSignature $signature): void
|
||||
public function markSignatureAsRejected(EntityWorkflowStepSignature $signature): string
|
||||
{
|
||||
$this->entityManager->refresh($signature, LockMode::PESSIMISTIC_WRITE);
|
||||
|
||||
@@ -102,7 +124,16 @@ class SignatureStepStateChanger
|
||||
->setState(EntityWorkflowSignatureStateEnum::REJECTED)
|
||||
->setStateDate($this->clock->now());
|
||||
$this->logger->info(self::LOG_PREFIX.'Mark signature entity as rejected', ['signatureId' => $signature->getId()]);
|
||||
|
||||
['transition' => $transition, 'futureUser' => $futureUser] = $this->decideTransition($signature);
|
||||
|
||||
$this->messageBus->dispatch(new PostSignatureStateChangeMessage((int) $signature->getId()));
|
||||
|
||||
if (null === $transition) {
|
||||
return $signature->getStep()->getEntityWorkflow()->getStep();
|
||||
}
|
||||
|
||||
return $transition->getTos()[0];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -117,10 +148,35 @@ class SignatureStepStateChanger
|
||||
{
|
||||
$this->entityManager->refresh($signature, LockMode::PESSIMISTIC_READ);
|
||||
|
||||
['transition' => $transition, 'futureUser' => $futureUser] = $this->decideTransition($signature);
|
||||
|
||||
if (null === $transition) {
|
||||
return;
|
||||
}
|
||||
|
||||
$entityWorkflow = $signature->getStep()->getEntityWorkflow();
|
||||
$workflow = $this->registry->get($entityWorkflow, $entityWorkflow->getWorkflowName());
|
||||
$transitionDto = new WorkflowTransitionContextDTO($entityWorkflow);
|
||||
$transitionDto->futureDestUsers[] = $futureUser;
|
||||
|
||||
$workflow->apply($entityWorkflow, $transition->getName(), [
|
||||
'context' => $transitionDto,
|
||||
'transitionAt' => $this->clock->now(),
|
||||
'transition' => $transition->getName(),
|
||||
]);
|
||||
|
||||
$this->logger->info(self::LOG_PREFIX.'Transition automatically applied', ['signatureId' => $signature->getId()]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{transition: Transition|null, futureUser: User|null}
|
||||
*/
|
||||
private function decideTransition(EntityWorkflowStepSignature $signature): array
|
||||
{
|
||||
if (!EntityWorkflowStepSignature::isAllSignatureNotPendingForStep($signature->getStep())) {
|
||||
$this->logger->info(self::LOG_PREFIX.'This is not the last signature, skipping transition to another place', ['signatureId' => $signature->getId()]);
|
||||
|
||||
return;
|
||||
return ['transition' => null, 'futureUser' => null];
|
||||
}
|
||||
|
||||
$this->logger->debug(self::LOG_PREFIX.'Continuing the process to find a transition', ['signatureId' => $signature->getId()]);
|
||||
@@ -144,7 +200,7 @@ class SignatureStepStateChanger
|
||||
if (null === $transition) {
|
||||
$this->logger->info(self::LOG_PREFIX.'The transition is not configured, will not apply a transition', ['signatureId' => $signature->getId()]);
|
||||
|
||||
return;
|
||||
return ['transition' => null, 'futureUser' => null];
|
||||
}
|
||||
|
||||
if ('person' === $signature->getSignerKind()) {
|
||||
@@ -156,19 +212,16 @@ class SignatureStepStateChanger
|
||||
if (null === $futureUser) {
|
||||
$this->logger->info(self::LOG_PREFIX.'No previous user, will not apply a transition', ['signatureId' => $signature->getId()]);
|
||||
|
||||
return;
|
||||
return ['transition' => null, 'futureUser' => null];
|
||||
}
|
||||
|
||||
$transitionDto = new WorkflowTransitionContextDTO($entityWorkflow);
|
||||
$transitionDto->futureDestUsers[] = $futureUser;
|
||||
foreach ($workflow->getDefinition()->getTransitions() as $transitionObj) {
|
||||
if ($transitionObj->getName() === $transition) {
|
||||
return ['transition' => $transitionObj, 'futureUser' => $futureUser];
|
||||
}
|
||||
}
|
||||
|
||||
$workflow->apply($entityWorkflow, $transition, [
|
||||
'context' => $transitionDto,
|
||||
'transitionAt' => $this->clock->now(),
|
||||
'transition' => $transition,
|
||||
]);
|
||||
|
||||
$this->logger->info(self::LOG_PREFIX.'Transition automatically applied', ['signatureId' => $signature->getId()]);
|
||||
throw new \RuntimeException('Transition not found');
|
||||
}
|
||||
|
||||
private function getPreviousSender(EntityWorkflowStep $entityWorkflowStep): ?User
|
||||
|
@@ -965,6 +965,31 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/UserJob"
|
||||
/1.0/main/workflow/{id}.json:
|
||||
get:
|
||||
tags:
|
||||
- workflow
|
||||
summary: Return a workflow
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
description: The workflow id
|
||||
schema:
|
||||
type: integer
|
||||
format: integer
|
||||
minimum: 1
|
||||
responses:
|
||||
200:
|
||||
description: "ok"
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Workflow"
|
||||
404:
|
||||
description: "not found"
|
||||
401:
|
||||
description: "Unauthorized"
|
||||
/1.0/main/workflow/my:
|
||||
get:
|
||||
tags:
|
||||
|
@@ -120,5 +120,8 @@ module.exports = function (encore, entries) {
|
||||
"vue_onthefly",
|
||||
__dirname + "/Resources/public/vuejs/OnTheFly/index.js",
|
||||
);
|
||||
|
||||
encore.addEntry(
|
||||
"page_workflow_waiting_post_process",
|
||||
__dirname + "/Resources/public/vuejs/WaitPostProcessWorkflow/index.ts"
|
||||
);
|
||||
};
|
||||
|
@@ -113,3 +113,5 @@ services:
|
||||
Chill\MainBundle\Service\EntityInfo\ViewEntityInfoManager:
|
||||
arguments:
|
||||
$vienEntityInfoProviders: !tagged_iterator chill_main.entity_info_provider
|
||||
|
||||
Chill\MainBundle\Action\User\UpdateProfile\UpdateProfileCommandHandler: ~
|
||||
|
@@ -0,0 +1,34 @@
|
||||
<?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 Version20250722140048 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Add an absence end date for the user absence';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE users ADD absenceEnd TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL');
|
||||
$this->addSql('COMMENT ON COLUMN users.absenceEnd IS \'(DC2Type:datetime_immutable)\'');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE users DROP absenceEnd');
|
||||
}
|
||||
}
|
@@ -136,3 +136,7 @@ filter_order:
|
||||
Search: Chercher dans la liste
|
||||
By date: Filtrer par date
|
||||
search_box: Filtrer par contenu
|
||||
|
||||
absence:
|
||||
You are listed as absent, as of {date, date, short}: Votre absence est indiquée à partir du {date, date, short}
|
||||
|
||||
|
@@ -666,10 +666,17 @@ workflow:
|
||||
cancel_are_you_sure: Êtes-vous sûr de vouloir annuler la signature de %signer%
|
||||
reject_signature_of: Rejet de la signature de %signer%
|
||||
reject_are_you_sure: Êtes-vous sûr de vouloir rejeter la signature de %signer%
|
||||
waiting_for: En attente de modification de l'état de la signature
|
||||
|
||||
attachments:
|
||||
title: Pièces jointes
|
||||
|
||||
wait:
|
||||
title: En attente de traitement
|
||||
error_while_waiting: Le traitement a échoué
|
||||
success: Traitement terminé. Redirection en cours...
|
||||
|
||||
|
||||
Subscribe final: Recevoir une notification à l'étape finale
|
||||
Subscribe all steps: Recevoir une notification à chaque étape
|
||||
CHILL_MAIN_WORKFLOW_APPLY_ALL_TRANSITION: Appliquer les transitions sur tous les workflows
|
||||
@@ -841,12 +848,12 @@ absence:
|
||||
# single letter for absence
|
||||
A: A
|
||||
My absence: Mon absence
|
||||
Unset absence: Supprimer la date d'absence
|
||||
Unset absence: Supprimer mes dates d'absence
|
||||
Set absence date: Indiquer une date d'absence
|
||||
Absence start: Absent à partir du
|
||||
Absence end: Jusqu'au
|
||||
Absent: Absent
|
||||
You are marked as being absent: Vous êtes indiqué absent.
|
||||
You are listed as absent, as of: Votre absence est indiquée à partir du
|
||||
No absence listed: Aucune absence indiquée.
|
||||
Is absent: Absent?
|
||||
|
||||
|
@@ -40,3 +40,7 @@ workflow:
|
||||
|
||||
rolling_date:
|
||||
When fixed date is selected, you must provide a date: Indiquez la date fixe choisie
|
||||
|
||||
user:
|
||||
absence_end_requires_start: "Vous ne pouvez pas renseigner une date de fin d'absence sans date de début."
|
||||
|
||||
|
@@ -18,6 +18,7 @@ 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;
|
||||
@@ -32,7 +33,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)
|
||||
public function assignDuplicate(AccompanyingPeriodWork $acpw, Request $request): Response
|
||||
{
|
||||
$accompanyingPeriod = $acpw->getAccompanyingPeriod();
|
||||
|
||||
@@ -78,8 +79,8 @@ class AccompanyingPeriodWorkDuplicateController extends AbstractController
|
||||
* @ParamConverter("acpw1", options={"id": "acpw1_id"})
|
||||
* @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)
|
||||
#[Route(path: '/{_locale}/person/{acpw1_id}/acpw-duplicate/{acpw2_id}/confirm', name: 'chill_person_acpw_duplicate_confirm')]
|
||||
public function confirmAction(AccompanyingPeriodWork $acpw1, AccompanyingPeriodWork $acpw2, Request $request): Response
|
||||
{
|
||||
$accompanyingPeriod = $acpw1->getAccompanyingPeriod();
|
||||
|
||||
@@ -98,6 +99,7 @@ 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()]);
|
||||
}
|
||||
|
||||
|
@@ -12,6 +12,7 @@ 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;
|
||||
@@ -24,15 +25,16 @@ 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;
|
||||
|
||||
class AccompanyingPeriodWorkEvaluationDocumentDuplicateController
|
||||
readonly class AccompanyingPeriodWorkEvaluationDocumentDuplicateController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly AccompanyingPeriodWorkEvaluationDocumentDuplicator $duplicator,
|
||||
private readonly Security $security,
|
||||
private readonly SerializerInterface $serializer,
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly ChillUrlGeneratorInterface $urlGenerator,
|
||||
private AccompanyingPeriodWorkEvaluationDocumentDuplicator $duplicator,
|
||||
private Security $security,
|
||||
private SerializerInterface $serializer,
|
||||
private EntityManagerInterface $entityManager,
|
||||
private ChillUrlGeneratorInterface $urlGenerator,
|
||||
) {}
|
||||
|
||||
#[Route('/api/1.0/person/accompanying-course-work-evaluation-document/{id}/duplicate', methods: ['POST'])]
|
||||
@@ -56,6 +58,32 @@ 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
|
||||
{
|
||||
|
@@ -0,0 +1,58 @@
|
||||
<?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
|
||||
);
|
||||
}
|
||||
}
|
@@ -98,16 +98,6 @@ 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;
|
||||
|
@@ -24,7 +24,7 @@ class FindAccompanyingPeriodWorkType extends AbstractType
|
||||
{
|
||||
$builder
|
||||
->add('acpw', PickLinkedAccompanyingPeriodWorkType::class, [
|
||||
'label' => 'Social action',
|
||||
'label' => 'Accompanying period work',
|
||||
'multiple' => false,
|
||||
'accompanyingPeriod' => $options['accompanyingPeriod'],
|
||||
])
|
||||
|
@@ -16,18 +16,26 @@ 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)
|
||||
@@ -38,6 +46,7 @@ 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)
|
||||
|
@@ -41,7 +41,8 @@ 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() ?? "";
|
||||
},
|
||||
},
|
||||
});
|
||||
|
@@ -84,7 +84,7 @@ export interface AccompanyingPeriodWorkEvaluationDocument {
|
||||
}
|
||||
|
||||
export interface AccompanyingPeriodWork {
|
||||
id: number;
|
||||
id?: number;
|
||||
accompanyingPeriod?: AccompanyingPeriod;
|
||||
accompanyingPeriodWorkEvaluations: AccompanyingPeriodWorkEvaluation[];
|
||||
createdAt?: string;
|
||||
|
@@ -972,7 +972,7 @@ div#workEditor {
|
||||
font-size: 85%;
|
||||
}
|
||||
|
||||
i.fa {
|
||||
& > i.fa {
|
||||
padding: 0.25rem;
|
||||
color: $white;
|
||||
|
||||
|
@@ -0,0 +1,31 @@
|
||||
<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>
|
@@ -0,0 +1,71 @@
|
||||
<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>
|
@@ -0,0 +1,76 @@
|
||||
<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="submitBeforeGenerate"
|
||||
>
|
||||
<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";
|
||||
import { buildLink } from "ChillDocGeneratorAssets/lib/document-generator";
|
||||
import { useStore } from "vuex";
|
||||
|
||||
const store = useStore();
|
||||
|
||||
const props = defineProps(["evaluation", "templates"]);
|
||||
const emit = defineEmits(["addDocument"]);
|
||||
|
||||
async function submitBeforeGenerate({ template }) {
|
||||
const callback = (data) => {
|
||||
let evaluationId = data.accompanyingPeriodWorkEvaluations.find(
|
||||
(e) => e.key === props.evaluation.key,
|
||||
).id;
|
||||
|
||||
window.location.assign(
|
||||
buildLink(
|
||||
template,
|
||||
evaluationId,
|
||||
"Chill\\PersonBundle\\Entity\\AccompanyingPeriod\\AccompanyingPeriodWorkEvaluation",
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
return store.dispatch("submit", callback).catch((e) => {
|
||||
console.log(e);
|
||||
throw e;
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
ul.document-upload {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
</style>
|
@@ -0,0 +1,455 @@
|
||||
<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="
|
||||
goToGenerateWorkflowEvaluationDocument
|
||||
"
|
||||
></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"
|
||||
>
|
||||
|
||||
</button>
|
||||
<ul
|
||||
class="dropdown-menu"
|
||||
aria-labelledby="btnGroupNotifyButtons"
|
||||
>
|
||||
<li>
|
||||
<a
|
||||
class="dropdown-item"
|
||||
@click="
|
||||
goToGenerateDocumentNotification(
|
||||
d,
|
||||
false,
|
||||
)
|
||||
"
|
||||
>
|
||||
{{
|
||||
trans(
|
||||
EVALUATION_NOTIFICATION_NOTIFY_REFERRER,
|
||||
)
|
||||
}}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
class="dropdown-item"
|
||||
@click="
|
||||
goToGenerateDocumentNotification(
|
||||
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>
|
||||
<!--replace document-->
|
||||
<li
|
||||
v-if="
|
||||
Number.isInteger(d.id) &&
|
||||
d.storedObject._permissions.canEdit
|
||||
"
|
||||
>
|
||||
<drop-file-modal
|
||||
:existing-doc="d.storedObject"
|
||||
:allow-remove="false"
|
||||
@add-document="
|
||||
(arg) =>
|
||||
replaceDocument(
|
||||
d,
|
||||
arg.stored_object,
|
||||
arg.stored_object_version,
|
||||
)
|
||||
"
|
||||
></drop-file-modal>
|
||||
</li>
|
||||
<li v-if="Number.isInteger(d.id)">
|
||||
<div class="duplicate-dropdown">
|
||||
<button
|
||||
class="btn btn-outline-primary dropdown-toggle"
|
||||
type="button"
|
||||
data-bs-toggle="dropdown"
|
||||
aria-expanded="false"
|
||||
>
|
||||
<i class="bi bi-lightning-fill"></i>
|
||||
</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>
|
||||
<!--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_DUPLICATE_HERE,
|
||||
EVALUATION_DOCUMENT_DUPLICATE_TO_OTHER_EVALUATION,
|
||||
trans,
|
||||
} from "translator";
|
||||
import { computed, ref, watch } from "vue";
|
||||
import AccompanyingPeriodWorkSelectorModal from "ChillPersonAssets/vuejs/_components/AccompanyingPeriodWorkSelector/AccompanyingPeriodWorkSelectorModal.vue";
|
||||
import { buildLinkCreate } from "ChillMainAssets/lib/entity-workflow/api";
|
||||
import { buildLinkCreate as buildLinkCreateNotification } from "ChillMainAssets/lib/entity-notification/api";
|
||||
import { useStore } from "vuex";
|
||||
|
||||
const props = defineProps([
|
||||
"documents",
|
||||
"docAnchorId",
|
||||
"accompanyingPeriodId",
|
||||
"evaluation",
|
||||
]);
|
||||
const emit = defineEmits([
|
||||
"inputDocumentTitle",
|
||||
"removeDocument",
|
||||
"duplicateDocument",
|
||||
"statusDocumentChanged",
|
||||
"duplicateDocumentToWork",
|
||||
]);
|
||||
|
||||
const store = useStore();
|
||||
|
||||
const showAccompanyingPeriodSelector = ref(false);
|
||||
const selectedEvaluation = ref(null);
|
||||
const selectedDocumentToDuplicate = ref(null);
|
||||
const selectedDocumentToMove = ref(null);
|
||||
|
||||
const AmIRefferer = computed(() => {
|
||||
return !(
|
||||
store.state.work.accompanyingPeriod.user &&
|
||||
store.state.me &&
|
||||
store.state.work.accompanyingPeriod.user.id !== store.state.me.id
|
||||
);
|
||||
});
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
async function goToGenerateWorkflowEvaluationDocument({
|
||||
workflowName,
|
||||
payload,
|
||||
}) {
|
||||
const callback = (data) => {
|
||||
let evaluation = data.accompanyingPeriodWorkEvaluations.find(
|
||||
(e) => e.key === props.evaluation.key,
|
||||
);
|
||||
let updatedDocument = evaluation.documents.find(
|
||||
(d) => d.key === payload.doc.key,
|
||||
);
|
||||
window.location.assign(
|
||||
buildLinkCreate(
|
||||
workflowName,
|
||||
"Chill\\PersonBundle\\Entity\\AccompanyingPeriod\\AccompanyingPeriodWorkEvaluationDocument",
|
||||
updatedDocument.id,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
return store.dispatch("submit", callback).catch((e) => {
|
||||
console.log(e);
|
||||
throw e;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces a document in the store with a new document.
|
||||
*
|
||||
* @param {Object} oldDocument - The document to be replaced.
|
||||
* @param {StoredObject} storedObject - The stored object of the new document.
|
||||
* @param {StoredObjectVersion} storedObjectVersion - The new version of the document
|
||||
* @return {void}
|
||||
*/
|
||||
async function replaceDocument(oldDocument, storedObject, storedObjectVersion) {
|
||||
let document = {
|
||||
type: "accompanying_period_work_evaluation_document",
|
||||
storedObject: storedObject,
|
||||
title: oldDocument.title,
|
||||
};
|
||||
|
||||
return store.commit("replaceDocument", {
|
||||
key: props.evaluation.key,
|
||||
document,
|
||||
oldDocument: oldDocument,
|
||||
stored_object_version: storedObjectVersion,
|
||||
});
|
||||
}
|
||||
|
||||
async function goToGenerateDocumentNotification(document, tos) {
|
||||
const callback = (data) => {
|
||||
let evaluation = data.accompanyingPeriodWorkEvaluations.find(
|
||||
(e) => e.key === props.evaluation.key,
|
||||
);
|
||||
let updatedDocument = evaluation.documents.find(
|
||||
(d) => d.key === document.key,
|
||||
);
|
||||
window.location.assign(
|
||||
buildLinkCreateNotification(
|
||||
"Chill\\PersonBundle\\Entity\\AccompanyingPeriod\\AccompanyingPeriodWorkEvaluationDocument",
|
||||
updatedDocument.id,
|
||||
tos === true
|
||||
? store.state.work.accompanyingPeriod.user?.id
|
||||
: null,
|
||||
window.location.pathname +
|
||||
window.location.search +
|
||||
window.location.hash,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
return store.dispatch("submit", callback).catch((e) => {
|
||||
console.log(e);
|
||||
throw e;
|
||||
});
|
||||
}
|
||||
|
||||
async function submitBeforeLeaveToEditor() {
|
||||
console.log("submit beore edit 2");
|
||||
// empty callback
|
||||
const callback = () => null;
|
||||
return store.dispatch("submit", callback).catch((e) => {
|
||||
console.log(e);
|
||||
throw e;
|
||||
});
|
||||
}
|
||||
</script>
|
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,32 @@
|
||||
<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>
|
@@ -11,12 +11,13 @@ 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 } from "../_api/accompanyingCourseWorkEvaluationDocument";
|
||||
import {
|
||||
duplicate,
|
||||
duplicateDocumentToEvaluation,
|
||||
moveDocumentToEvaluation,
|
||||
} from "../_api/accompanyingCourseWorkEvaluationDocument";
|
||||
|
||||
const debug = process.env.NODE_ENV !== "production";
|
||||
const evalFQDN = encodeURIComponent(
|
||||
"Chill\\PersonBundle\\Entity\\AccompanyingPeriod\\AccompanyingPeriodWorkEvaluation",
|
||||
);
|
||||
|
||||
const store = createStore({
|
||||
strict: debug,
|
||||
@@ -298,15 +299,47 @@ 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.
|
||||
*
|
||||
@@ -603,6 +636,44 @@ 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);
|
||||
},
|
||||
|
@@ -9,3 +9,23 @@ 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`,
|
||||
);
|
||||
};
|
||||
|
@@ -0,0 +1,70 @@
|
||||
<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>
|
@@ -0,0 +1,47 @@
|
||||
<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>
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user