Compare commits

..

20 Commits

Author SHA1 Message Date
90d3bf32e6 fix date formatting and parsing logic
- Support time formatting in `formatDate` with `time` option.
- Improve parsing in `ISOToDate` to handle time information or date-only strings.
2025-08-11 15:52:22 +02:00
ebc2921696 Add 45 and 60 minute calendar ranges 2025-08-11 15:23:01 +02:00
aa085a1562 **fix:** add min and step attributes to integer field in DateIntervalType 2025-08-06 17:35:45 +02:00
2754251fdc Merge branch 'master' of https://gitlab.com/Chill-Projet/chill-bundles 2025-08-06 14:20:29 +02:00
2f6cef4238 - **fix:** move closing motive up to be coherent with display elsewhere 2025-08-06 14:20:09 +02:00
2309636eae - **fix:** adjust display logic for accompanying period dates, include closing date if period is closed. 2025-08-06 13:47:29 +02:00
56ec8fb516 Remove 'to_validate' as default for task filter 2025-08-06 09:05:39 +02:00
fe6e6e54c1 Show filters on list pages unfolded by default 2025-07-22 15:50:49 +02:00
2a09594b4a UI improvement: limit display of particapations in event list page 2025-07-22 13:26:44 +02:00
7c798e1f63 Merge branch '387-notification-user-group' into 'master'
Resolve "Notification: envoi à des groupes utilisateurs"

Closes #387

See merge request Chill-Projet/chill-bundles!842
2025-07-20 20:18:49 +00:00
ab8da4ab7a Resolve "Notification: envoi à des groupes utilisateurs" 2025-07-20 20:18:49 +00:00
5bdb2df929 Merge branch 'revert-5f016734' into 'master'
Revert "Merge branch 'ticket/supplementary-comments-on-motive' into 'master'"

See merge request Chill-Projet/chill-bundles!863
2025-07-20 18:51:51 +00:00
e3a6b60fa2 Revert "Merge branch 'ticket/supplementary-comments-on-motive' into 'master'"
This reverts merge request !855
2025-07-20 18:50:33 +00:00
5f01673404 Merge branch 'ticket/supplementary-comments-on-motive' into 'master'
Ajout de commentaires supplémentaires aux motifs

See merge request Chill-Projet/chill-bundles!855
2025-07-11 14:06:40 +00:00
63d0a52ea1 Ajout de commentaires supplémentaires aux motifs 2025-07-11 14:06:40 +00:00
837089ff5d Fix testMerge method in AccompanyingPeriodWorkMergeServiceTest.php 2025-07-10 11:33:23 +02:00
f383fab578 Fix styling 2025-07-09 15:30:39 +02:00
f3cc4a89af Update chill bundles to v4.0.2 2025-07-09 15:23:59 +02:00
703f5dc32d Transfer evaluations (and related documents) during merge 2025-07-09 15:21:42 +02:00
b870e71f77 Add translation for validation message in social action merger 2025-07-09 15:21:24 +02:00
456 changed files with 25771 additions and 35622 deletions

View File

@@ -1,6 +0,0 @@
kind: Feature
body: |
Upgrade import of address list to the last version of compiled addresses of belgian-best-address
time: 2024-05-30T16:00:03.440767606+02:00
custom:
Issue: ""

View File

@@ -1,6 +0,0 @@
kind: Feature
body: |
Upgrade CKEditor and refactor configuration with use of typescript
time: 2024-05-31T19:02:42.776662753+02:00
custom:
Issue: ""

View File

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

View File

@@ -0,0 +1,6 @@
kind: Feature
body: Add 45 and 60 min calendar ranges
time: 2025-08-11T15:21:54.209009751+02:00
custom:
Issue: "409"
SchemaChange: No schema change

View File

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

View File

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

View File

@@ -0,0 +1,6 @@
kind: Fixed
body: fix date formatting in calendar range display
time: 2025-08-11T15:52:12.949078671+02:00
custom:
Issue: ""
SchemaChange: No schema change

View File

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

4
.changes/v4.0.2.md Normal file
View File

@@ -0,0 +1,4 @@
## v4.0.2 - 2025-07-09
### Fixed
* Fix add missing translation
* Fix the transfer of evaluations and documents during of accompanyingperiodwork

View File

@@ -23,7 +23,7 @@ max_line_length = 0
indent_size = 2 indent_size = 2
indent_style = space indent_style = space
[*.rst] [.rst]
indent_size = 3 ident_size = 3
indent_style = space ident_style = space

13
.env
View File

@@ -16,6 +16,9 @@ APP_ENV=prod
APP_SECRET=!ChangeMeInAppEnv! APP_SECRET=!ChangeMeInAppEnv!
###< symfony/framework-bundle ### ###< symfony/framework-bundle ###
## Wopi server for editing documents online
EDITOR_SERVER=http://collabora:9980
# must be manually set in .env.local # must be manually set in .env.local
# ADMIN_PASSWORD= # ADMIN_PASSWORD=
@@ -89,13 +92,3 @@ REDIS_URL=redis://${REDIS_HOST}:${REDIS_PORT}
###> symfony/ovh-cloud-notifier ### ###> symfony/ovh-cloud-notifier ###
# OVHCLOUD_DSN=ovhcloud://APPLICATION_KEY:APPLICATION_SECRET@default?consumer_key=CONSUMER_KEY&service_name=SERVICE_NAME # OVHCLOUD_DSN=ovhcloud://APPLICATION_KEY:APPLICATION_SECRET@default?consumer_key=CONSUMER_KEY&service_name=SERVICE_NAME
###< symfony/ovh-cloud-notifier ### ###< symfony/ovh-cloud-notifier ###
###> symfony/mercure-bundle ###
# See https://symfony.com/doc/current/mercure.html#configuration
# The URL of the Mercure hub, used by the app to publish updates (can be a local URL)
MERCURE_URL=https://example.com/.well-known/mercure
# The public URL of the Mercure hub, used by the browser to connect
MERCURE_PUBLIC_URL=https://example.com/.well-known/mercure
# The secret used to sign the JWTs
MERCURE_JWT_SECRET="!ChangeThisMercureHubJWTSecretKey!"
###< symfony/mercure-bundle ###

File diff suppressed because it is too large Load Diff

View File

@@ -185,14 +185,57 @@ When we need to use a DateTime or DateTimeImmutable that need to express "now",
`Symfony\Component\Clock\ClockInterface`, where possible. This is usually not possible in doctrine entities, `Symfony\Component\Clock\ClockInterface`, where possible. This is usually not possible in doctrine entities,
where injection does not work when restoring an entity from database, but usually possible in services. where injection does not work when restoring an entity from database, but usually possible in services.
In test, we use `\Symfony\Component\Clock\MockClock` which is an implementation of `Symfony\Component\Clock\ClockInterface`
where we have full and easy control of the date.
### Testing Information ### Testing Information
The project uses PHPUnit for testing. Each bundle has its own test suite, and there's also a global test suite at the root level. The project uses PHPUnit for testing. Each bundle has its own test suite, and there's also a global test suite at the root level.
#### Use of mock in tests
##### General mocking
For creating mock, we prefer using prophecy (library phpspec/prophecy). For creating mock, we prefer using prophecy (library phpspec/prophecy).
##### Useful helpers and tips that avoid create a mock
Some notable implementations that are tests helper, and avoid to create a mock:
- `\Psr\Log\NullLogger`, an implementation of `\Psr\Log\LoggerInterface`;
- `\Symfony\Component\Clock\MockClock`, an implementation of `Symfony\Component\Clock\ClockInterface` (already mentioned above);
- `\Symfony\Component\HttpClient\MockHttpClient`, an implementation of `\Symfony\Contracts\HttpClient\HttpClientInterface`;
- When using `\Symfony\Component\Mailer\MailerInterface`, we can create the mock with "InMemoryTransport":
```php
use Symfony\Component\Mailer\Transport\InMemoryTransport;
use \Symfony\Component\Mailer\Mailer;
$transport = new InMemoryTransport();
$mailer = new Mailer($transport);
// After sending:
$messages = $transport->getSent(); // array of SentMessage
```
- When using `\Symfony\Contracts\EventDispatcher\EventDispatcherInterface`, we can use directly an instance of `\Symfony\Component\EventDispatcher\EventDispatcher`;
##### When we prefer not creating a mock
- When we use Doctrine Entities related to the project, we prefer not to use a mock: we instantiate them directly (unless it requires too much code to write);
##### Mocking final and readonly classes
Classes marked as final can't be mocked. To avoid that, either:
- we remove the `final` keyword from the class;
- we extract an interface from the final class.
This must be a decision made by a human, not by an AI. Every AI task must abort with an explicit message in that case.
#### Running Tests #### Running Tests
The tests are run from the project's root (not from the bundle's root).
```bash ```bash
# Run all tests # Run all tests
vendor/bin/phpunit vendor/bin/phpunit

View File

@@ -1,4 +0,0 @@
{
"tabWidth": 2,
"useTabs": false
}

30
.vscode/launch.json vendored
View File

@@ -1,30 +0,0 @@
{
// Use IntelliSense to learn about possible attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "Chill Debug",
"type": "php",
"request": "launch",
"port": 9000,
"pathMappings": {
"/var/www/html": "${workspaceFolder}"
},
"preLaunchTask": "symfony"
},
{
"name": "Yarn Encore Dev (Watch)",
"type": "node-terminal",
"request": "launch",
"command": "yarn encore dev --watch",
"cwd": "${workspaceFolder}"
}
],
"compounds": [
{
"name": "Chill Debug + Yarn Encore Dev (Watch)",
"configurations": ["Chill Debug", "Yarn Encore Dev (Watch)"]
}
]
}

23
.vscode/tasks.json vendored
View File

@@ -1,23 +0,0 @@
{
"tasks": [
{
"type": "shell",
"command": "symfony",
"args": [
"server:start",
"--allow-http",
"--no-tls",
"--port=8000",
"--allow-all-ip",
"-d"
],
"label": "symfony"
},
{
"type": "shell",
"command": "yarn",
"args": ["encore", "dev", "--watch"],
"label": "webpack"
}
]
}

View File

@@ -6,6 +6,11 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html),
and is generated by [Changie](https://github.com/miniscruff/changie). and is generated by [Changie](https://github.com/miniscruff/changie).
## v4.0.2 - 2025-07-09
### Fixed
* Fix add missing translation
* Fix the transfer of evaluations and documents during of accompanyingperiodwork
## v4.0.1 - 2025-07-08 ## v4.0.1 - 2025-07-08
### Fixed ### Fixed
* Fix package.json for compilation * Fix package.json for compilation

View File

@@ -54,7 +54,7 @@ Arborescence:
- person - person
- personvendee - personvendee
- household_edit_metadata - household_edit_metadata
- index.ts - index.js
``` ```
## Organisation des feuilles de styles ## Organisation des feuilles de styles

View File

@@ -32,9 +32,3 @@ services:
hostname: my-rabbit hostname: my-rabbit
volumes: volumes:
- ./docker/rabbitmq/data:/var/lib/rabbitmq - ./docker/rabbitmq/data:/var/lib/rabbitmq
###> symfony/mercure-bundle ###
mercure:
ports:
- "127.0.0.1:8043:443"
###< symfony/mercure-bundle ###

View File

@@ -50,36 +50,7 @@ services:
timeout: 30s timeout: 30s
retries: 3 retries: 3
###> symfony/mercure-bundle ###
mercure:
image: dunglas/mercure
restart: unless-stopped
environment:
# Uncomment the following line to disable HTTPS,
#SERVER_NAME: ':80'
MERCURE_PUBLISHER_JWT_KEY: '!ChangeThisMercureHubJWTSecretKey!'
MERCURE_SUBSCRIBER_JWT_KEY: '!ChangeThisMercureHubJWTSecretKey!'
# Set the URL of your Symfony project (without trailing slash!) as value of the cors_origins directive
MERCURE_EXTRA_DIRECTIVES: |
cors_origins http://chill-bundles.wip https://chill-bundles.wip
# Comment the following line to disable the development mode
command: /usr/bin/caddy run --config /etc/caddy/dev.Caddyfile
healthcheck:
test: [ "CMD", "curl", "-f", "https://localhost/healthz" ]
timeout: 5s
retries: 5
start_period: 60s
volumes:
- mercure_data:/data
- mercure_config:/config
###< symfony/mercure-bundle ###
volumes: volumes:
###> doctrine/doctrine-bundle ### ###> doctrine/doctrine-bundle ###
database_data: database_data:
###< doctrine/doctrine-bundle ### ###< doctrine/doctrine-bundle ###
###> symfony/mercure-bundle ###
mercure_data:
mercure_config:
###< symfony/mercure-bundle ###

View File

@@ -55,7 +55,6 @@
"symfony/http-foundation": "^5.4", "symfony/http-foundation": "^5.4",
"symfony/intl": "^5.4", "symfony/intl": "^5.4",
"symfony/mailer": "^5.4", "symfony/mailer": "^5.4",
"symfony/mercure-bundle": "^0.3.9",
"symfony/messenger": "^5.4", "symfony/messenger": "^5.4",
"symfony/mime": "^5.4", "symfony/mime": "^5.4",
"symfony/monolog-bundle": "^3.5", "symfony/monolog-bundle": "^3.5",
@@ -134,7 +133,6 @@
"Chill\\TaskBundle\\": "src/Bundle/ChillTaskBundle", "Chill\\TaskBundle\\": "src/Bundle/ChillTaskBundle",
"Chill\\ThirdPartyBundle\\": "src/Bundle/ChillThirdPartyBundle", "Chill\\ThirdPartyBundle\\": "src/Bundle/ChillThirdPartyBundle",
"Chill\\WopiBundle\\": "src/Bundle/ChillWopiBundle/src", "Chill\\WopiBundle\\": "src/Bundle/ChillWopiBundle/src",
"Chill\\TicketBundle\\": "src/Bundle/ChillTicketBundle/src",
"Chill\\Utils\\Rector\\": "utils/rector/src" "Chill\\Utils\\Rector\\": "utils/rector/src"
} }
}, },

View File

@@ -35,8 +35,6 @@ return [
Chill\ThirdPartyBundle\ChillThirdPartyBundle::class => ['all' => true], Chill\ThirdPartyBundle\ChillThirdPartyBundle::class => ['all' => true],
Chill\BudgetBundle\ChillBudgetBundle::class => ['all' => true], Chill\BudgetBundle\ChillBudgetBundle::class => ['all' => true],
Chill\WopiBundle\ChillWopiBundle::class => ['all' => true], Chill\WopiBundle\ChillWopiBundle::class => ['all' => true],
Chill\TicketBundle\ChillTicketBundle::class => ['all' => true],
Symfony\Bundle\WebProfilerBundle\WebProfilerBundle::class => ['dev' => true, 'test' => true], Symfony\Bundle\WebProfilerBundle\WebProfilerBundle::class => ['dev' => true, 'test' => true],
Symfony\UX\Translator\UxTranslatorBundle::class => ['all' => true], Symfony\UX\Translator\UxTranslatorBundle::class => ['all' => true],
Symfony\Bundle\MercureBundle\MercureBundle::class => ['all' => true],
]; ];

View File

@@ -1,4 +0,0 @@
chill_ticket:
ticket:
person_per_ticket: one # One of "one"; "many"

View File

@@ -14,7 +14,6 @@ doctrine_migrations:
'Chill\Migrations\Calendar': '@ChillCalendarBundle/migrations' 'Chill\Migrations\Calendar': '@ChillCalendarBundle/migrations'
'Chill\Migrations\Budget': '@ChillBudgetBundle/migrations' 'Chill\Migrations\Budget': '@ChillBudgetBundle/migrations'
'Chill\Migrations\Report': '@ChillReportBundle/migrations' 'Chill\Migrations\Report': '@ChillReportBundle/migrations'
'Chill\Migrations\Ticket': '@ChillTicketBundle/migrations'
all_or_nothing: all_or_nothing:
true true

View File

@@ -1,8 +0,0 @@
mercure:
hubs:
default:
url: '%env(MERCURE_URL)%'
public_url: '%env(MERCURE_PUBLIC_URL)%'
jwt:
secret: '%env(MERCURE_JWT_SECRET)%'
publish: '*'

View File

@@ -62,8 +62,10 @@ framework:
'Chill\MainBundle\Workflow\Messenger\PostSignatureStateChangeMessage': priority 'Chill\MainBundle\Workflow\Messenger\PostSignatureStateChangeMessage': priority
'Chill\MainBundle\Workflow\Messenger\PostPublicViewMessage': async 'Chill\MainBundle\Workflow\Messenger\PostPublicViewMessage': async
'Chill\MainBundle\Service\Workflow\CancelStaleWorkflowMessage': async 'Chill\MainBundle\Service\Workflow\CancelStaleWorkflowMessage': async
'Chill\MainBundle\Notification\Email\NotificationEmailMessages\SendImmediateNotificationEmailMessage': async
'Chill\MainBundle\Export\Messenger\ExportRequestGenerationMessage': priority 'Chill\MainBundle\Export\Messenger\ExportRequestGenerationMessage': priority
'Chill\MainBundle\Export\Messenger\RemoveExportGenerationMessage': async 'Chill\MainBundle\Export\Messenger\RemoveExportGenerationMessage': async
'Chill\MainBundle\Notification\Email\NotificationEmailMessages\ScheduleDailyNotificationDigestMessage': async
# end of routes added by chill-bundles recipes # end of routes added by chill-bundles recipes
# Route your messages to the transports # Route your messages to the transports
# 'App\Message\YourMessage': async # 'App\Message\YourMessage': async

View File

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

View File

@@ -1,2 +0,0 @@
chill_ticket_bundle:
resource: '@ChillTicketBundle/config/routes.yaml'

View File

@@ -11,94 +11,24 @@
Create a new bundle Create a new bundle
******************* *******************
Create your own bundle is not a trivial task.
The easiest way to achieve this is seems to be :
1. Prepare a fresh installation of the chill project, in a new directory
2. Create a new bundle in this project, in the src directory
3. Initialize a git repository **at the root bundle**, and create your initial commit.
4. Register the bundle with composer/packagist. If you do not plan to distribute your bundle with packagist, you may use a custom repository for achieve this [#f1]_
5. Move to a development installation, made as described in the :ref:`installation-for-development` section, and add your new repository to the composer.json file
6. Work as :ref:`usual <editing-code-and-commiting>`
.. warning:: .. warning::
This part of the doc is not yet tested This part of the doc is not yet tested
Create a new directory with Bundle class TODO
----------------------------------------
.. code-block:: bash
mkdir -p src/Bundle/ChillSomeBundle/src/config
mkdir -p src/Bundle/ChillSomeBundle/src/Controller
Add a bundle file
.. code-block:: php
<?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\SomeBundle;
use Symfony\Component\HttpKernel\Bundle\Bundle;
class ChillSomeBundle extends Bundle {}
And a route file:
.. code-block:: yaml
chill_ticket_controller:
resource: '@ChillTicketBundle/Controller/'
type: annotation
Register the new psr-4 namespace
--------------------------------
In composer.json, add the new psr4 namespace
.. code-block:: diff
{
"autoload": {
"psr-4": {
+ "Chill\\SomeBundle\\": "src/Bundle/ChillSomeBundle/src",
}
}
}
Register the bundle .. rubric:: Footnotes
-------------------
Register in the file :code:`config/bundles.php`:
.. code-block:: php
Vendor\Bundle\YourBundle\YourBundle::class => ['all' => true],
And import routes in :code:`config/routes/chill_some_bundle.yaml`:
.. code-block:: yaml
chill_ticket_bundle:
resource: '@ChillSomeBundle/config/routes.yaml'
Add the doctrine_migration namespace
------------------------------------
Add the namespace to :code:`config/packages/doctrine_migrations_chill.yaml`
.. code-block:: diff
doctrine_migrations:
migrations_paths:
+ 'Chill\Some\Ticket': '@ChillSomeBundle/migrations'
Dump autoloading
----------------
.. code-block:: bash
symfony composer dump-autoload
.. [#f1] Be aware that we use the Affero GPL Licence, which ensure that all users must have access to derivative works done with this software.

View File

@@ -11,7 +11,6 @@
"@hotwired/stimulus": "^3.0.0", "@hotwired/stimulus": "^3.0.0",
"@luminateone/eslint-baseline": "^1.0.9", "@luminateone/eslint-baseline": "^1.0.9",
"@symfony/stimulus-bridge": "^3.2.0", "@symfony/stimulus-bridge": "^3.2.0",
"@symfony/ux-translator": "file:vendor/symfony/ux-translator/assets",
"@symfony/webpack-encore": "^4.1.0", "@symfony/webpack-encore": "^4.1.0",
"@tsconfig/node20": "^20.1.4", "@tsconfig/node20": "^20.1.4",
"@types/dompurify": "^3.0.5", "@types/dompurify": "^3.0.5",
@@ -80,12 +79,12 @@
"dev": "encore dev", "dev": "encore dev",
"watch": "encore dev --watch", "watch": "encore dev --watch",
"build": "encore production --progress", "build": "encore production --progress",
"specs-build": "yaml-merge src/Bundle/ChillMainBundle/chill.api.specs.yaml src/Bundle/ChillPersonBundle/chill.api.specs.yaml src/Bundle/ChillCalendarBundle/chill.api.specs.yaml src/Bundle/ChillThirdPartyBundle/chill.api.specs.yaml src/Bundle/ChillDocStoreBundle/chill.api.specs.yaml src/Bundle/ChillTicketBundle/chill.api.specs.yaml> templates/api/specs.yaml", "specs-build": "yaml-merge src/Bundle/ChillMainBundle/chill.api.specs.yaml src/Bundle/ChillPersonBundle/chill.api.specs.yaml src/Bundle/ChillCalendarBundle/chill.api.specs.yaml src/Bundle/ChillThirdPartyBundle/chill.api.specs.yaml src/Bundle/ChillDocStoreBundle/chill.api.specs.yaml> templates/api/specs.yaml",
"specs-validate": "swagger-cli validate templates/api/specs.yaml", "specs-validate": "swagger-cli validate templates/api/specs.yaml",
"specs-create-dir": "mkdir -p templates/api", "specs-create-dir": "mkdir -p templates/api",
"specs": "yarn run specs-create-dir && yarn run specs-build && yarn run specs-validate", "specs": "yarn run specs-create-dir && yarn run specs-build && yarn run specs-validate",
"version": "node --version", "version": "node --version",
"eslint": "eslint-baseline --fix \"src/**/*.{js,ts,vue}\"" "eslint": "npx eslint-baseline --fix \"src/**/*.{js,ts,vue}\""
}, },
"private": true "private": true
} }

View File

@@ -58,10 +58,6 @@
<!-- temporarily removed, the time to find a fix --> <!-- temporarily removed, the time to find a fix -->
<exclude>src/Bundle/ChillPersonBundle/Tests/Controller/PersonDuplicateControllerViewTest.php</exclude> <exclude>src/Bundle/ChillPersonBundle/Tests/Controller/PersonDuplicateControllerViewTest.php</exclude>
</testsuite> </testsuite>
<testsuite name="TicketBundle">
<directory suffix="Test.php">src/Bundle/ChillTicketBundle/tests/</directory>
</testsuite>
<!-- <!--
<testsuite name="ReportBundle"> <testsuite name="ReportBundle">
<directory suffix="Test.php">src/Bundle/ChillReportBundle/Tests/</directory> <directory suffix="Test.php">src/Bundle/ChillReportBundle/Tests/</directory>

View File

@@ -1,20 +0,0 @@
{
# Désactive les redirections automatiques HTTP -> HTTPS
# auto_https off
# Désactive le port 80 par défaut
# default_bind :8080
}
localhost:8043 {
mercure {
# Publisher JWT key
publisher_jwt !ChangeThisMercureHubJWTSecretKey!
# Subscriber JWT key
subscriber_jwt !ChangeThisMercureHubJWTSecretKey!
cors_origins http://chill-bundles.wip https://chill-bundles.wip
ui
demo
}
respond "Not Found" 404
}

View File

@@ -10,7 +10,10 @@
/> />
</div> </div>
<div <div
v-if="getContext === 'accompanyingCourse' && suggestedEntities.length > 0" v-if="
getContext === 'accompanyingCourse' &&
suggestedEntities.length > 0
"
> >
<ul class="list-suggest add-items inline"> <ul class="list-suggest add-items inline">
<li <li

View File

@@ -39,11 +39,17 @@
<option selected disabled value=""> <option selected disabled value="">
{{ trans(ACTIVITY_CHOOSE_LOCATION_TYPE) }} {{ trans(ACTIVITY_CHOOSE_LOCATION_TYPE) }}
</option> </option>
<option v-for="t in locationTypes" :value="t" :key="t.id"> <option
v-for="t in locationTypes"
:value="t"
:key="t.id"
>
{{ localizeString(t.title) }} {{ localizeString(t.title) }}
</option> </option>
</select> </select>
<label>{{ trans(ACTIVITY_LOCATION_FIELDS_TYPE) }}</label> <label>{{
trans(ACTIVITY_LOCATION_FIELDS_TYPE)
}}</label>
</div> </div>
<div class="form-floating mb-3"> <div class="form-floating mb-3">
@@ -102,7 +108,10 @@
</form> </form>
</template> </template>
<template #footer> <template #footer>
<button class="btn btn-save" @click.prevent="saveNewLocation"> <button
class="btn btn-save"
@click.prevent="saveNewLocation"
>
{{ trans(SAVE) }} {{ trans(SAVE) }}
</button> </button>
</template> </template>
@@ -235,7 +244,8 @@ export default {
}, },
hasPhonenumber1() { hasPhonenumber1() {
return ( return (
this.selected.phonenumber1 !== null && this.selected.phonenumber1 !== "" this.selected.phonenumber1 !== null &&
this.selected.phonenumber1 !== ""
); );
}, },
showAddAddress() { showAddAddress() {

View File

@@ -49,7 +49,9 @@
</div> </div>
<div class="col-8"> <div class="col-8">
<div v-if="actionIsLoading === true"> <div v-if="actionIsLoading === true">
<i class="chill-green fa fa-circle-o-notch fa-spin fa-lg"></i> <i
class="chill-green fa fa-circle-o-notch fa-spin fa-lg"
></i>
</div> </div>
<span <span
@@ -62,7 +64,8 @@
<template <template
v-else-if=" v-else-if="
socialActionsList.length > 0 && socialActionsList.length > 0 &&
(socialIssuesSelected.length || socialActionsSelected.length) (socialIssuesSelected.length ||
socialActionsSelected.length)
" "
> >
<div <div
@@ -85,7 +88,9 @@
</template> </template>
<span <span
v-else-if="actionAreLoaded && socialActionsList.length === 0" v-else-if="
actionAreLoaded && socialActionsList.length === 0
"
class="inline-choice chill-no-data-statement mt-3" class="inline-choice chill-no-data-statement mt-3"
> >
{{ trans(ACTIVITY_SOCIAL_ACTION_LIST_EMPTY) }} {{ trans(ACTIVITY_SOCIAL_ACTION_LIST_EMPTY) }}
@@ -164,7 +169,8 @@ export default {
/* Add in list the issues already associated (if not yet listed) */ /* Add in list the issues already associated (if not yet listed) */
this.socialIssuesSelected.forEach((issue) => { this.socialIssuesSelected.forEach((issue) => {
if ( if (
this.socialIssuesList.filter((i) => i.id === issue.id).length !== 1 this.socialIssuesList.filter((i) => i.id === issue.id)
.length !== 1
) { ) {
this.$store.commit("addIssueInList", issue); this.$store.commit("addIssueInList", issue);
} }

View File

@@ -10,7 +10,9 @@
:value="issue" :value="issue"
/> />
<label class="form-check-label" :for="issue.id"> <label class="form-check-label" :for="issue.id">
<span class="badge bg-chill-l-gray text-dark">{{ issue.text }}</span> <span class="badge bg-chill-l-gray text-dark">{{
issue.text
}}</span>
</label> </label>
</div> </div>
</span> </span>

View File

@@ -68,7 +68,9 @@ export type EventInputCalendarRange = EventInput & {
export function isEventInputCalendarRange( export function isEventInputCalendarRange(
toBeDetermined: EventInputCalendarRange | EventInput, toBeDetermined: EventInputCalendarRange | EventInput,
): toBeDetermined is EventInputCalendarRange { ): toBeDetermined is EventInputCalendarRange {
return typeof toBeDetermined.is === "string" && toBeDetermined.is === "range"; return (
typeof toBeDetermined.is === "string" && toBeDetermined.is === "range"
);
} }
export {}; export {};

View File

@@ -61,14 +61,24 @@
<label class="input-group-text" for="slotDuration" <label class="input-group-text" for="slotDuration"
>Durée des créneaux</label >Durée des créneaux</label
> >
<select v-model="slotDuration" id="slotDuration" class="form-select"> <select
v-model="slotDuration"
id="slotDuration"
class="form-select"
>
<option value="00:05:00">5 minutes</option> <option value="00:05:00">5 minutes</option>
<option value="00:10:00">10 minutes</option> <option value="00:10:00">10 minutes</option>
<option value="00:15:00">15 minutes</option> <option value="00:15:00">15 minutes</option>
<option value="00:30:00">30 minutes</option> <option value="00:30:00">30 minutes</option>
<option value="00:45:00">45 minutes</option>
<option value="00:60:00">60 minutes</option>
</select> </select>
<label class="input-group-text" for="slotMinTime">De</label> <label class="input-group-text" for="slotMinTime">De</label>
<select v-model="slotMinTime" id="slotMinTime" class="form-select"> <select
v-model="slotMinTime"
id="slotMinTime"
class="form-select"
>
<option value="00:00:00">0h</option> <option value="00:00:00">0h</option>
<option value="01:00:00">1h</option> <option value="01:00:00">1h</option>
<option value="02:00:00">2h</option> <option value="02:00:00">2h</option>
@@ -84,7 +94,11 @@
<option value="12:00:00">12h</option> <option value="12:00:00">12h</option>
</select> </select>
<label class="input-group-text" for="slotMaxTime">À</label> <label class="input-group-text" for="slotMaxTime">À</label>
<select v-model="slotMaxTime" id="slotMaxTime" class="form-select"> <select
v-model="slotMaxTime"
id="slotMaxTime"
class="form-select"
>
<option value="12:00:00">12h</option> <option value="12:00:00">12h</option>
<option value="13:00:00">13h</option> <option value="13:00:00">13h</option>
<option value="14:00:00">14h</option> <option value="14:00:00">14h</option>
@@ -112,7 +126,9 @@
v-model="hideWeekends" v-model="hideWeekends"
/> />
</span> </span>
<label for="showHideWE" class="form-check-label input-group-text" <label
for="showHideWE"
class="form-check-label input-group-text"
>Week-ends</label >Week-ends</label
> >
</div> </div>
@@ -128,7 +144,9 @@
<b v-else-if="arg.event.extendedProps.is === 'range'" <b v-else-if="arg.event.extendedProps.is === 'range'"
>{{ arg.timeText }} >{{ arg.timeText }}
{{ arg.event.extendedProps.locationName }} {{ arg.event.extendedProps.locationName }}
<small>{{ arg.event.extendedProps.userLabel }}</small></b <small>{{
arg.event.extendedProps.userLabel
}}</small></b
> >
<b v-else-if="arg.event.extendedProps.is === 'current'" <b v-else-if="arg.event.extendedProps.is === 'current'"
>{{ arg.timeText }} {{ $t("current_selected") }} >{{ arg.timeText }} {{ $t("current_selected") }}
@@ -136,7 +154,9 @@
<b v-else-if="arg.event.extendedProps.is === 'local'">{{ <b v-else-if="arg.event.extendedProps.is === 'local'">{{
arg.event.title arg.event.title
}}</b> }}</b>
<b v-else>{{ arg.timeText }} {{ $t("current_selected") }} </b> <b v-else
>{{ arg.timeText }} {{ $t("current_selected") }}
</b>
</span> </span>
</template> </template>
</FullCalendar> </FullCalendar>
@@ -250,7 +270,9 @@ export default {
this.$store.state.activity.endDate !== null) this.$store.state.activity.endDate !== null)
) { ) {
if ( if (
!window.confirm(this.$t("change_main_user_will_reset_event_data")) !window.confirm(
this.$t("change_main_user_will_reset_event_data"),
)
) { ) {
return; return;
} }
@@ -258,9 +280,13 @@ export default {
// add the previous user, if any, in the previous user list (in use for suggestion) // add the previous user, if any, in the previous user list (in use for suggestion)
if (null !== this.$store.getters.getMainUser) { if (null !== this.$store.getters.getMainUser) {
const suggestedUids = new Set(this.$data.previousUser.map((u) => u.id)); const suggestedUids = new Set(
this.$data.previousUser.map((u) => u.id),
);
if (!suggestedUids.has(this.$store.getters.getMainUser.id)) { if (!suggestedUids.has(this.$store.getters.getMainUser.id)) {
this.$data.previousUser.push(this.$store.getters.getMainUser); this.$data.previousUser.push(
this.$store.getters.getMainUser,
);
} }
} }
@@ -290,7 +316,8 @@ export default {
// show an alert if changing mainUser // show an alert if changing mainUser
if ( if (
(this.$store.getters.getMainUser !== null && (this.$store.getters.getMainUser !== null &&
this.$store.state.me.id !== this.$store.getters.getMainUser.id) || this.$store.state.me.id !==
this.$store.getters.getMainUser.id) ||
this.$store.getters.getMainUser === null this.$store.getters.getMainUser === null
) { ) {
if (!window.confirm(this.$t("will_change_main_user_for_me"))) { if (!window.confirm(this.$t("will_change_main_user_for_me"))) {
@@ -334,7 +361,9 @@ export default {
this.$store.getters.getMainUser.id this.$store.getters.getMainUser.id
) { ) {
if ( if (
!window.confirm(this.$t("this_calendar_range_will_change_main_user")) !window.confirm(
this.$t("this_calendar_range_will_change_main_user"),
)
) { ) {
return; return;
} }

View File

@@ -4,9 +4,18 @@
{{ user.text }} {{ user.text }}
<template v-if="invite !== null"> <template v-if="invite !== null">
<i v-if="invite.status === 'accepted'" class="fa fa-check" /> <i v-if="invite.status === 'accepted'" class="fa fa-check" />
<i v-else-if="invite.status === 'declined'" class="fa fa-times" /> <i
<i v-else-if="invite.status === 'pending'" class="fa fa-question-o" /> v-else-if="invite.status === 'declined'"
<i v-else-if="invite.status === 'tentative'" class="fa fa-question" /> class="fa fa-times"
/>
<i
v-else-if="invite.status === 'pending'"
class="fa fa-question-o"
/>
<i
v-else-if="invite.status === 'tentative'"
class="fa fa-question"
/>
<span v-else="">{{ invite.status }}</span> <span v-else="">{{ invite.status }}</span>
</template> </template>
</span> </span>
@@ -60,7 +69,8 @@ export default {
computed: { computed: {
style() { style() {
return { return {
backgroundColor: this.$store.getters.getUserData(this.user).mainColor, backgroundColor: this.$store.getters.getUserData(this.user)
.mainColor,
}; };
}, },
rangeShow: { rangeShow: {
@@ -71,7 +81,9 @@ export default {
}); });
}, },
get() { get() {
return this.$store.getters.isRangeShownOnCalendarForUser(this.user); return this.$store.getters.isRangeShownOnCalendarForUser(
this.user,
);
}, },
}, },
remoteShow: { remoteShow: {
@@ -82,7 +94,9 @@ export default {
}); });
}, },
get() { get() {
return this.$store.getters.isRemoteShownOnCalendarForUser(this.user); return this.$store.getters.isRemoteShownOnCalendarForUser(
this.user,
);
}, },
}, },
}, },

View File

@@ -22,25 +22,33 @@
</button> </button>
<ul class="dropdown-menu" aria-labelledby="btnGroupDrop1"> <ul class="dropdown-menu" aria-labelledby="btnGroupDrop1">
<li v-if="status !== Statuses.ACCEPTED"> <li v-if="status !== Statuses.ACCEPTED">
<a class="dropdown-item" @click="changeStatus(Statuses.ACCEPTED)" <a
><i class="fa fa-check" aria-hidden="true"></i> {{ $t("Accept") }}</a class="dropdown-item"
@click="changeStatus(Statuses.ACCEPTED)"
><i class="fa fa-check" aria-hidden="true"></i>
{{ $t("Accept") }}</a
> >
</li> </li>
<li v-if="status !== Statuses.DECLINED"> <li v-if="status !== Statuses.DECLINED">
<a class="dropdown-item" @click="changeStatus(Statuses.DECLINED)" <a
><i class="fa fa-times" aria-hidden="true"></i> {{ $t("Decline") }}</a class="dropdown-item"
@click="changeStatus(Statuses.DECLINED)"
><i class="fa fa-times" aria-hidden="true"></i>
{{ $t("Decline") }}</a
> >
</li> </li>
<li v-if="status !== Statuses.TENTATIVELY_ACCEPTED"> <li v-if="status !== Statuses.TENTATIVELY_ACCEPTED">
<a <a
class="dropdown-item" class="dropdown-item"
@click="changeStatus(Statuses.TENTATIVELY_ACCEPTED)" @click="changeStatus(Statuses.TENTATIVELY_ACCEPTED)"
><i class="fa fa-question"></i> {{ $t("Tentatively_accept") }}</a ><i class="fa fa-question"></i>
{{ $t("Tentatively_accept") }}</a
> >
</li> </li>
<li v-if="status !== Statuses.PENDING"> <li v-if="status !== Statuses.PENDING">
<a class="dropdown-item" @click="changeStatus(Statuses.PENDING)" <a class="dropdown-item" @click="changeStatus(Statuses.PENDING)"
><i class="fa fa-hourglass-o"></i> {{ $t("Set_pending") }}</a ><i class="fa fa-hourglass-o"></i>
{{ $t("Set_pending") }}</a
> >
</li> </li>
</ul> </ul>
@@ -83,7 +91,9 @@ export default defineComponent({
}, },
}, },
emits: { emits: {
statusChanged(payload: "accepted" | "declined" | "pending" | "tentative") { statusChanged(
payload: "accepted" | "declined" | "pending" | "tentative",
) {
return true; return true;
}, },
}, },

View File

@@ -23,14 +23,24 @@
<label class="input-group-text" for="slotDuration" <label class="input-group-text" for="slotDuration"
>Durée des créneaux</label >Durée des créneaux</label
> >
<select v-model="slotDuration" id="slotDuration" class="form-select"> <select
v-model="slotDuration"
id="slotDuration"
class="form-select"
>
<option value="00:05:00">5 minutes</option> <option value="00:05:00">5 minutes</option>
<option value="00:10:00">10 minutes</option> <option value="00:10:00">10 minutes</option>
<option value="00:15:00">15 minutes</option> <option value="00:15:00">15 minutes</option>
<option value="00:30:00">30 minutes</option> <option value="00:30:00">30 minutes</option>
<option value="00:45:00">45 minutes</option>
<option value="00:60:00">60 minutes</option>
</select> </select>
<label class="input-group-text" for="slotMinTime">De</label> <label class="input-group-text" for="slotMinTime">De</label>
<select v-model="slotMinTime" id="slotMinTime" class="form-select"> <select
v-model="slotMinTime"
id="slotMinTime"
class="form-select"
>
<option value="00:00:00">0h</option> <option value="00:00:00">0h</option>
<option value="01:00:00">1h</option> <option value="01:00:00">1h</option>
<option value="02:00:00">2h</option> <option value="02:00:00">2h</option>
@@ -46,7 +56,11 @@
<option value="12:00:00">12h</option> <option value="12:00:00">12h</option>
</select> </select>
<label class="input-group-text" for="slotMaxTime">À</label> <label class="input-group-text" for="slotMaxTime">À</label>
<select v-model="slotMaxTime" id="slotMaxTime" class="form-select"> <select
v-model="slotMaxTime"
id="slotMaxTime"
class="form-select"
>
<option value="12:00:00">12h</option> <option value="12:00:00">12h</option>
<option value="13:00:00">13h</option> <option value="13:00:00">13h</option>
<option value="14:00:00">14h</option> <option value="14:00:00">14h</option>
@@ -74,7 +88,9 @@
v-model="showWeekends" v-model="showWeekends"
/> />
</span> </span>
<label for="showHideWE" class="form-check-label input-group-text" <label
for="showHideWE"
class="form-check-label input-group-text"
>Week-ends</label >Week-ends</label
> >
</div> </div>
@@ -84,12 +100,16 @@
<FullCalendar :options="calendarOptions" ref="calendarRef"> <FullCalendar :options="calendarOptions" ref="calendarRef">
<template v-slot:eventContent="{ event }: { event: EventApi }"> <template v-slot:eventContent="{ event }: { event: EventApi }">
<span :class="eventClasses"> <span :class="eventClasses">
<b v-if="event.extendedProps.is === 'remote'">{{ event.title }}</b> <b v-if="event.extendedProps.is === 'remote'">{{
event.title
}}</b>
<b v-else-if="event.extendedProps.is === 'range'" <b v-else-if="event.extendedProps.is === 'range'"
>{{ formatDate(event.startStr) }} - >{{ formatDate(event.startStr) }} - {{ formatDate(event.endStr, 'time') }}:
{{ event.extendedProps.locationName }}</b {{ event.extendedProps.locationName }}</b
> >
<b v-else-if="event.extendedProps.is === 'local'">{{ event.title }}</b> <b v-else-if="event.extendedProps.is === 'local'">{{
event.title
}}</b>
<b v-else>no 'is'</b> <b v-else>no 'is'</b>
<a <a
v-if="event.extendedProps.is === 'range'" v-if="event.extendedProps.is === 'range'"
@@ -108,7 +128,11 @@
<h6 class="chill-red">{{ $t("copy_range_from_to") }}</h6> <h6 class="chill-red">{{ $t("copy_range_from_to") }}</h6>
</div> </div>
<div class="col-xs-12 col-sm-9 col-md-2"> <div class="col-xs-12 col-sm-9 col-md-2">
<select v-model="dayOrWeek" id="dayOrWeek" class="form-select"> <select
v-model="dayOrWeek"
id="dayOrWeek"
class="form-select"
>
<option value="day">{{ $t("from_day_to_day") }}</option> <option value="day">{{ $t("from_day_to_day") }}</option>
<option value="week"> <option value="week">
{{ $t("from_week_to_week") }} {{ $t("from_week_to_week") }}
@@ -117,16 +141,27 @@
</div> </div>
<template v-if="dayOrWeek === 'day'"> <template v-if="dayOrWeek === 'day'">
<div class="col-xs-12 col-sm-3 col-md-3"> <div class="col-xs-12 col-sm-3 col-md-3">
<input class="form-control" type="date" v-model="copyFrom" /> <input
class="form-control"
type="date"
v-model="copyFrom"
/>
</div> </div>
<div class="col-xs-12 col-sm-1 col-md-1 copy-chevron"> <div class="col-xs-12 col-sm-1 col-md-1 copy-chevron">
<i class="fa fa-angle-double-right"></i> <i class="fa fa-angle-double-right"></i>
</div> </div>
<div class="col-xs-12 col-sm-3 col-md-3"> <div class="col-xs-12 col-sm-3 col-md-3">
<input class="form-control" type="date" v-model="copyTo" /> <input
class="form-control"
type="date"
v-model="copyTo"
/>
</div> </div>
<div class="col-xs-12 col-sm-5 col-md-1"> <div class="col-xs-12 col-sm-5 col-md-1">
<button class="btn btn-action float-end" @click="copyDay"> <button
class="btn btn-action float-end"
@click="copyDay"
>
{{ $t("copy_range") }} {{ $t("copy_range") }}
</button> </button>
</div> </div>
@@ -138,7 +173,11 @@
id="copyFromWeek" id="copyFromWeek"
class="form-select" class="form-select"
> >
<option v-for="w in lastWeeks" :value="w.value" :key="w.value"> <option
v-for="w in lastWeeks"
:value="w.value"
:key="w.value"
>
{{ w.text }} {{ w.text }}
</option> </option>
</select> </select>
@@ -147,14 +186,25 @@
<i class="fa fa-angle-double-right"></i> <i class="fa fa-angle-double-right"></i>
</div> </div>
<div class="col-xs-12 col-sm-3 col-md-3"> <div class="col-xs-12 col-sm-3 col-md-3">
<select v-model="copyToWeek" id="copyToWeek" class="form-select"> <select
<option v-for="w in nextWeeks" :value="w.value" :key="w.value"> v-model="copyToWeek"
id="copyToWeek"
class="form-select"
>
<option
v-for="w in nextWeeks"
:value="w.value"
:key="w.value"
>
{{ w.text }} {{ w.text }}
</option> </option>
</select> </select>
</div> </div>
<div class="col-xs-12 col-sm-5 col-md-1"> <div class="col-xs-12 col-sm-5 col-md-1">
<button class="btn btn-action float-end" @click="copyWeek"> <button
class="btn btn-action float-end"
@click="copyWeek"
>
{{ $t("copy_range") }} {{ $t("copy_range") }}
</button> </button>
</div> </div>
@@ -246,9 +296,26 @@ const nextWeeks = computed((): Weeks[] =>
}), }),
); );
const formatDate = (datetime: string) => { const formatDate = (datetime: string, format: null | 'time' = null) => {
console.log(typeof datetime); const date = ISOToDate(datetime);
return ISOToDate(datetime); if (!date) return '';
if (format === 'time') {
return date.toLocaleTimeString('fr-FR', {
hour: '2-digit',
minute: '2-digit'
});
}
// French date formatting
return date.toLocaleDateString('fr-FR', {
weekday: 'short',
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
}; };
const baseOptions = ref<CalendarOptions>({ const baseOptions = ref<CalendarOptions>({

View File

@@ -41,7 +41,9 @@ const futureStore = function (): Promise<Store<State>> {
}); });
store.commit("me/setWhoAmi", user, { root: true }); store.commit("me/setWhoAmi", user, { root: true });
store.dispatch("locations/getLocations", null, { root: true }).then((_) => { store
.dispatch("locations/getLocations", null, { root: true })
.then((_) => {
return store.dispatch("locations/getCurrentLocation", null, { return store.dispatch("locations/getCurrentLocation", null, {
root: true, root: true,
}); });

View File

@@ -29,7 +29,10 @@ export default {
(state: CalendarLocalsState) => (state: CalendarLocalsState) =>
({ start, end }: { start: Date; end: Date }): boolean => { ({ start, end }: { start: Date; end: Date }): boolean => {
for (const range of state.localsLoaded) { for (const range of state.localsLoaded) {
if (start.getTime() === range.start && end.getTime() === range.end) { if (
start.getTime() === range.start &&
end.getTime() === range.end
) {
return true; return true;
} }
} }
@@ -51,7 +54,10 @@ export default {
}); });
state.key = state.key + toAdd.length; state.key = state.key + toAdd.length;
}, },
addLoaded(state: CalendarLocalsState, payload: { start: Date; end: Date }) { addLoaded(
state: CalendarLocalsState,
payload: { start: Date; end: Date },
) {
state.localsLoaded.push({ state.localsLoaded.push({
start: payload.start.getTime(), start: payload.start.getTime(),
end: payload.end.getTime(), end: payload.end.getTime(),
@@ -79,7 +85,11 @@ export default {
end: end, end: end,
}); });
return fetchCalendarLocalForUser(ctx.rootGetters["me/getMe"], start, end) return fetchCalendarLocalForUser(
ctx.rootGetters["me/getMe"],
start,
end,
)
.then((remotes: CalendarLight[]) => { .then((remotes: CalendarLight[]) => {
// to be add when reactivity problem will be solve ? // to be add when reactivity problem will be solve ?
//ctx.commit('addRemotes', remotes); //ctx.commit('addRemotes', remotes);

View File

@@ -40,7 +40,10 @@ export default {
(state: CalendarRangesState) => (state: CalendarRangesState) =>
({ start, end }: { start: Date; end: Date }): boolean => { ({ start, end }: { start: Date; end: Date }): boolean => {
for (const range of state.rangesLoaded) { for (const range of state.rangesLoaded) {
if (start.getTime() === range.start && end.getTime() === range.end) { if (
start.getTime() === range.start &&
end.getTime() === range.end
) {
return true; return true;
} }
} }
@@ -107,7 +110,9 @@ export default {
state: CalendarRangesState, state: CalendarRangesState,
externalEvents: (EventInput & { id: string })[], externalEvents: (EventInput & { id: string })[],
) { ) {
const toAdd = externalEvents.filter((r) => !state.rangesIndex.has(r.id)); const toAdd = externalEvents.filter(
(r) => !state.rangesIndex.has(r.id),
);
toAdd.forEach((r) => { toAdd.forEach((r) => {
state.rangesIndex.add(r.id); state.rangesIndex.add(r.id);
@@ -115,7 +120,10 @@ export default {
}); });
state.key = state.key + toAdd.length; state.key = state.key + toAdd.length;
}, },
addLoaded(state: CalendarRangesState, payload: { start: Date; end: Date }) { addLoaded(
state: CalendarRangesState,
payload: { start: Date; end: Date },
) {
state.rangesLoaded.push({ state.rangesLoaded.push({
start: payload.start.getTime(), start: payload.start.getTime(),
end: payload.end.getTime(), end: payload.end.getTime(),
@@ -134,12 +142,17 @@ export default {
}, },
removeRange(state: CalendarRangesState, calendarRangeId: number) { removeRange(state: CalendarRangesState, calendarRangeId: number) {
const found = state.ranges.find( const found = state.ranges.find(
(r) => r.calendarRangeId === calendarRangeId && r.is === "range", (r) =>
r.calendarRangeId === calendarRangeId && r.is === "range",
); );
if (found !== undefined) { if (found !== undefined) {
state.ranges = state.ranges.filter( state.ranges = state.ranges.filter(
(r) => !(r.calendarRangeId === calendarRangeId && r.is === "range"), (r) =>
!(
r.calendarRangeId === calendarRangeId &&
r.is === "range"
),
); );
if (typeof found.id === "string") { if (typeof found.id === "string") {
@@ -198,7 +211,11 @@ export default {
}, },
createRange( createRange(
ctx: Context, ctx: Context,
{ start, end, location }: { start: Date; end: Date; location: Location }, {
start,
end,
location,
}: { start: Date; end: Date; location: Location },
): Promise<null> { ): Promise<null> {
const url = `/api/1.0/calendar/calendar-range.json?`; const url = `/api/1.0/calendar/calendar-range.json?`;
@@ -223,7 +240,11 @@ export default {
}, },
} as CalendarRangeCreate; } as CalendarRangeCreate;
return makeFetch<CalendarRangeCreate, CalendarRange>("POST", url, body) return makeFetch<CalendarRangeCreate, CalendarRange>(
"POST",
url,
body,
)
.then((newRange) => { .then((newRange) => {
ctx.commit("addRange", newRange); ctx.commit("addRange", newRange);
@@ -260,7 +281,11 @@ export default {
}, },
} as CalendarRangeEdit; } as CalendarRangeEdit;
return makeFetch<CalendarRangeEdit, CalendarRange>("PATCH", url, body) return makeFetch<CalendarRangeEdit, CalendarRange>(
"PATCH",
url,
body,
)
.then((range) => { .then((range) => {
ctx.commit("updateRange", range); ctx.commit("updateRange", range);
return Promise.resolve(null); return Promise.resolve(null);
@@ -285,7 +310,11 @@ export default {
}, },
} as CalendarRangeEdit; } as CalendarRangeEdit;
return makeFetch<CalendarRangeEdit, CalendarRange>("PATCH", url, body) return makeFetch<CalendarRangeEdit, CalendarRange>(
"PATCH",
url,
body,
)
.then((range) => { .then((range) => {
ctx.commit("updateRange", range); ctx.commit("updateRange", range);
return Promise.resolve(null); return Promise.resolve(null);
@@ -305,14 +334,20 @@ export default {
for (const r of rangesToCopy) { for (const r of rangesToCopy) {
const start = new Date(ISOToDatetime(r.start) as Date); const start = new Date(ISOToDatetime(r.start) as Date);
start.setFullYear(to.getFullYear(), to.getMonth(), to.getDate()); start.setFullYear(
to.getFullYear(),
to.getMonth(),
to.getDate(),
);
const end = new Date(ISOToDatetime(r.end) as Date); const end = new Date(ISOToDatetime(r.end) as Date);
end.setFullYear(to.getFullYear(), to.getMonth(), to.getDate()); end.setFullYear(to.getFullYear(), to.getMonth(), to.getDate());
const location = ctx.rootGetters["locations/getLocationById"]( const location = ctx.rootGetters["locations/getLocationById"](
r.locationId, r.locationId,
); );
promises.push(ctx.dispatch("createRange", { start, end, location })); promises.push(
ctx.dispatch("createRange", { start, end, location }),
);
} }
return Promise.all(promises).then(() => Promise.resolve(null)); return Promise.all(promises).then(() => Promise.resolve(null));
@@ -334,7 +369,9 @@ export default {
r.locationId, r.locationId,
); );
promises.push(ctx.dispatch("createRange", { start, end, location })); promises.push(
ctx.dispatch("createRange", { start, end, location }),
);
} }
return Promise.all(promises).then(() => Promise.resolve(null)); return Promise.all(promises).then(() => Promise.resolve(null));

View File

@@ -29,7 +29,10 @@ export default {
(state: CalendarRemotesState) => (state: CalendarRemotesState) =>
({ start, end }: { start: Date; end: Date }): boolean => { ({ start, end }: { start: Date; end: Date }): boolean => {
for (const range of state.remotesLoaded) { for (const range of state.remotesLoaded) {
if (start.getTime() === range.start && end.getTime() === range.end) { if (
start.getTime() === range.start &&
end.getTime() === range.end
) {
return true; return true;
} }
} }
@@ -82,7 +85,11 @@ export default {
end: end, end: end,
}); });
return fetchCalendarRemoteForUser(ctx.rootGetters["me/getMe"], start, end) return fetchCalendarRemoteForUser(
ctx.rootGetters["me/getMe"],
start,
end,
)
.then((remotes: CalendarRemote[]) => { .then((remotes: CalendarRemote[]) => {
// to be add when reactivity problem will be solve ? // to be add when reactivity problem will be solve ?
//ctx.commit('addRemotes', remotes); //ctx.commit('addRemotes', remotes);

View File

@@ -112,8 +112,11 @@ export default {
results.forEach((i) => { results.forEach((i) => {
if (!users.some((j) => i.user.id === j.id)) { if (!users.some((j) => i.user.id === j.id)) {
let ratio = Math.floor(users.length / COLORS.length); let ratio = Math.floor(
let colorIndex = users.length - ratio * COLORS.length; users.length / COLORS.length,
);
let colorIndex =
users.length - ratio * COLORS.length;
users.push({ users.push({
id: i.user.id, id: i.user.id,
username: i.user.username, username: i.user.username,
@@ -150,29 +153,45 @@ export default {
(me) => (me) =>
new Promise((resolve, reject) => { new Promise((resolve, reject) => {
this.users.logged = me; this.users.logged = me;
let currentUser = users.find((u) => u.id === me.id); let currentUser = users.find(
(u) => u.id === me.id,
);
this.value = currentUser; this.value = currentUser;
fetchCalendar(currentUser.id).then( fetchCalendar(currentUser.id).then(
(calendar) => (calendar) =>
new Promise((resolve, reject) => { new Promise(
let results = calendar.results; (resolve, reject) => {
let events = results.map((i) => ({ let results =
start: i.startDate.datetime, calendar.results;
end: i.endDate.datetime, let events =
})); results.map(
let calendarEventsCurrentUser = { (i) => ({
start: i
.startDate
.datetime,
end: i
.endDate
.datetime,
}),
);
let calendarEventsCurrentUser =
{
events: events, events: events,
color: "darkblue", color: "darkblue",
id: 1000, id: 1000,
editable: false, editable: false,
}; };
this.calendarEvents.user = calendarEventsCurrentUser; this.calendarEvents.user =
calendarEventsCurrentUser;
this.selectUsers(currentUser); this.selectUsers(
currentUser,
);
resolve(); resolve();
}), },
),
); );
resolve(); resolve();
@@ -190,7 +209,9 @@ export default {
return `${value.username}`; return `${value.username}`;
}, },
coloriseSelectedValues() { coloriseSelectedValues() {
let tags = document.querySelectorAll("div.multiselect__tags-wrap")[0]; let tags = document.querySelectorAll(
"div.multiselect__tags-wrap",
)[0];
if (tags.hasChildNodes()) { if (tags.hasChildNodes()) {
let children = tags.childNodes; let children = tags.childNodes;
@@ -211,8 +232,8 @@ export default {
}, },
selectEvents() { selectEvents() {
let selectedUsersId = this.users.selected.map((a) => a.id); let selectedUsersId = this.users.selected.map((a) => a.id);
this.calendarEvents.selected = this.calendarEvents.loaded.filter((a) => this.calendarEvents.selected = this.calendarEvents.loaded.filter(
selectedUsersId.includes(a.id), (a) => selectedUsersId.includes(a.id),
); );
}, },
selectUsers(value) { selectUsers(value) {
@@ -222,7 +243,9 @@ export default {
this.updateEventsSource(); this.updateEventsSource();
}, },
unSelectUsers(value) { unSelectUsers(value) {
this.users.selected = this.users.selected.filter((a) => a.id != value.id); this.users.selected = this.users.selected.filter(
(a) => a.id != value.id,
);
this.selectEvents(); this.selectEvents();
this.updateEventsSource(); this.updateEventsSource();
}, },

View File

@@ -24,7 +24,11 @@ use Doctrine\ORM\EntityManagerInterface;
class CalendarForShortMessageProvider class CalendarForShortMessageProvider
{ {
public function __construct(private readonly CalendarRepository $calendarRepository, private readonly EntityManagerInterface $em, private readonly RangeGeneratorInterface $rangeGenerator) {} public function __construct(
private readonly CalendarRepository $calendarRepository,
private readonly EntityManagerInterface $em,
private readonly RangeGeneratorInterface $rangeGenerator,
) {}
/** /**
* Generate calendars instance. * Generate calendars instance.

View File

@@ -21,7 +21,6 @@ namespace Chill\CalendarBundle\Tests\Service\ShortMessageNotification;
use Chill\CalendarBundle\Entity\Calendar; use Chill\CalendarBundle\Entity\Calendar;
use Chill\CalendarBundle\Repository\CalendarRepository; use Chill\CalendarBundle\Repository\CalendarRepository;
use Chill\CalendarBundle\Service\ShortMessageNotification\CalendarForShortMessageProvider; use Chill\CalendarBundle\Service\ShortMessageNotification\CalendarForShortMessageProvider;
use Chill\CalendarBundle\Service\ShortMessageNotification\DefaultRangeGenerator;
use Chill\CalendarBundle\Service\ShortMessageNotification\RangeGeneratorInterface; use Chill\CalendarBundle\Service\ShortMessageNotification\RangeGeneratorInterface;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
@@ -82,10 +81,16 @@ final class CalendarForShortMessageProviderTest extends TestCase
$em = $this->prophesize(EntityManagerInterface::class); $em = $this->prophesize(EntityManagerInterface::class);
$em->clear()->shouldBeCalled(); $em->clear()->shouldBeCalled();
$calendarRangeGenerator = $this->prophesize(RangeGeneratorInterface::class);
$calendarRangeGenerator->generateRange(Argument::any())->willReturn([
'startDate' => new \DateTimeImmutable('yesterday'),
'endDate' => new \DateTimeImmutable('now'),
]);
$provider = new CalendarForShortMessageProvider( $provider = new CalendarForShortMessageProvider(
$calendarRepository->reveal(), $calendarRepository->reveal(),
$em->reveal(), $em->reveal(),
new DefaultRangeGenerator() $calendarRangeGenerator->reveal(),
); );
$calendars = iterator_to_array($provider->getCalendars(new \DateTimeImmutable('now'))); $calendars = iterator_to_array($provider->getCalendars(new \DateTimeImmutable('now')));
@@ -103,26 +108,32 @@ final class CalendarForShortMessageProviderTest extends TestCase
Argument::type(\DateTimeImmutable::class), Argument::type(\DateTimeImmutable::class),
Argument::type('int'), Argument::type('int'),
Argument::exact(0) Argument::exact(0)
)->will(static fn ($args) => array_fill(0, 1, new Calendar()))->shouldBeCalledTimes(1); )->will(static fn ($args) => array_fill(0, 10, new Calendar()))->shouldBeCalledTimes(1);
$calendarRepository->findByNotificationAvailable( $calendarRepository->findByNotificationAvailable(
Argument::type(\DateTimeImmutable::class), Argument::type(\DateTimeImmutable::class),
Argument::type(\DateTimeImmutable::class), Argument::type(\DateTimeImmutable::class),
Argument::type('int'), Argument::type('int'),
Argument::not(0) Argument::exact(10)
)->will(static fn ($args) => [])->shouldBeCalledTimes(1); )->will(static fn ($args) => [])->shouldBeCalledTimes(1);
$em = $this->prophesize(EntityManagerInterface::class); $em = $this->prophesize(EntityManagerInterface::class);
$em->clear()->shouldBeCalled(); $em->clear()->shouldBeCalled();
$calendarRangeGenerator = $this->prophesize(RangeGeneratorInterface::class);
$calendarRangeGenerator->generateRange(Argument::any())->willReturn([
'startDate' => new \DateTimeImmutable('yesterday'),
'endDate' => new \DateTimeImmutable('now'),
]);
$provider = new CalendarForShortMessageProvider( $provider = new CalendarForShortMessageProvider(
$calendarRepository->reveal(), $calendarRepository->reveal(),
$em->reveal(), $em->reveal(),
new DefaultRangeGenerator() $calendarRangeGenerator->reveal(),
); );
$calendars = iterator_to_array($provider->getCalendars(new \DateTimeImmutable('now'))); $calendars = iterator_to_array($provider->getCalendars(new \DateTimeImmutable('now')));
$this->assertEquals(1, \count($calendars)); $this->assertEquals(10, \count($calendars));
$this->assertContainsOnly(Calendar::class, $calendars); $this->assertContainsOnly(Calendar::class, $calendars);
} }
} }

View File

@@ -20,7 +20,10 @@
</option> </option>
<template v-for="t in templates" :key="t.id"> <template v-for="t in templates" :key="t.id">
<option :value="t.id"> <option :value="t.id">
{{ localizeString(t.name) || "Aucun nom défini" }} {{
localizeString(t.name) ||
"Aucun nom défini"
}}
</option> </option>
</template> </template>
</select> </select>
@@ -28,7 +31,9 @@
v-if="canGenerate" v-if="canGenerate"
class="btn btn-update btn-sm change-icon" class="btn btn-update btn-sm change-icon"
:href="buildUrlGenerate" :href="buildUrlGenerate"
@click.prevent="clickGenerate($event, buildUrlGenerate)" @click.prevent="
clickGenerate($event, buildUrlGenerate)
"
><i class="fa fa-fw fa-cog" ><i class="fa fa-fw fa-cog"
/></a> /></a>
<a <a

View File

@@ -13,9 +13,8 @@ const startApp = (
const inputTitle = collectionEntry?.querySelector("input[type='text']"); const inputTitle = collectionEntry?.querySelector("input[type='text']");
const input_stored_object: HTMLInputElement | null = divElement.querySelector( const input_stored_object: HTMLInputElement | null =
"input[data-stored-object]", divElement.querySelector("input[data-stored-object]");
);
if (null === input_stored_object) { if (null === input_stored_object) {
throw new Error("input to stored object not found"); throw new Error("input to stored object not found");
} }
@@ -54,7 +53,9 @@ const startApp = (
console.log("version added", stored_object_version); console.log("version added", stored_object_version);
this.$data.existingDoc = stored_object; this.$data.existingDoc = stored_object;
this.$data.existingDoc.currentVersion = stored_object_version; this.$data.existingDoc.currentVersion = stored_object_version;
input_stored_object.value = JSON.stringify(this.$data.existingDoc); input_stored_object.value = JSON.stringify(
this.$data.existingDoc,
);
if (this.$data.inputTitle) { if (this.$data.inputTitle) {
if (!this.$data.inputTitle?.value) { if (!this.$data.inputTitle?.value) {
this.$data.inputTitle.value = file_name; this.$data.inputTitle.value = file_name;

View File

@@ -49,7 +49,9 @@
<li v-if="isHistoryViewable"> <li v-if="isHistoryViewable">
<history-button <history-button
:stored-object="props.storedObject" :stored-object="props.storedObject"
:can-edit="canEdit && props.storedObject._permissions.canEdit" :can-edit="
canEdit && props.storedObject._permissions.canEdit
"
></history-button> ></history-button>
</li> </li>
</ul> </ul>
@@ -127,7 +129,9 @@ const props = withDefaults(defineProps<DocumentActionButtonsGroupConfig>(), {
canDownload: true, canDownload: true,
canConvertPdf: true, canConvertPdf: true,
returnPath: returnPath:
window.location.pathname + window.location.search + window.location.hash, window.location.pathname +
window.location.search +
window.location.hash,
}); });
/** /**

View File

@@ -29,7 +29,9 @@
</modal> </modal>
</teleport> </teleport>
<div class="col-12 m-auto sticky-top"> <div class="col-12 m-auto sticky-top">
<div class="row justify-content-center border-bottom pdf-tools d-md-none"> <div
class="row justify-content-center border-bottom pdf-tools d-md-none"
>
<div class="col-5 text-center turn-page"> <div class="col-5 text-center turn-page">
<select <select
class="form-select form-select-sm" class="form-select form-select-sm"
@@ -90,7 +92,10 @@
v-if="signature.zones.length === 1 && signedState !== 'signed'" v-if="signature.zones.length === 1 && signedState !== 'signed'"
class="col-5 p-0 text-center turnSignature" class="col-5 p-0 text-center turnSignature"
> >
<button class="btn btn-light btn-sm" @click="goToSignatureZoneUnique"> <button
class="btn btn-light btn-sm"
@click="goToSignatureZoneUnique"
>
{{ trans(SIGNATURES_GO_TO_SIGNATURE_UNIQUE) }} {{ trans(SIGNATURES_GO_TO_SIGNATURE_UNIQUE) }}
</button> </button>
</div> </div>
@@ -145,7 +150,10 @@
:title="trans(SIGNATURES_ADD_SIGN_ZONE)" :title="trans(SIGNATURES_ADD_SIGN_ZONE)"
> >
<template v-if="canvasEvent === 'add'"> <template v-if="canvasEvent === 'add'">
<div class="spinner-border spinner-border-sm" role="status"> <div
class="spinner-border spinner-border-sm"
role="status"
>
<span class="visually-hidden">Loading...</span> <span class="visually-hidden">Loading...</span>
</div> </div>
</template> </template>
@@ -199,7 +207,10 @@
v-if="signature.zones.length === 1 && signedState !== 'signed'" v-if="signature.zones.length === 1 && signedState !== 'signed'"
class="col-4 d-xl-none text-center turnSignature p-0" class="col-4 d-xl-none text-center turnSignature p-0"
> >
<button class="btn btn-light btn-sm" @click="goToSignatureZoneUnique"> <button
class="btn btn-light btn-sm"
@click="goToSignatureZoneUnique"
>
{{ trans(SIGNATURES_GO_TO_SIGNATURE_UNIQUE) }} {{ trans(SIGNATURES_GO_TO_SIGNATURE_UNIQUE) }}
</button> </button>
</div> </div>
@@ -227,7 +238,10 @@
v-if="signature.zones.length === 1 && signedState !== 'signed'" v-if="signature.zones.length === 1 && signedState !== 'signed'"
class="col-4 d-none d-xl-flex p-0 text-center turnSignature" class="col-4 d-none d-xl-flex p-0 text-center turnSignature"
> >
<button class="btn btn-light btn-sm" @click="goToSignatureZoneUnique"> <button
class="btn btn-light btn-sm"
@click="goToSignatureZoneUnique"
>
{{ trans(SIGNATURES_GO_TO_SIGNATURE_UNIQUE) }} {{ trans(SIGNATURES_GO_TO_SIGNATURE_UNIQUE) }}
</button> </button>
</div> </div>
@@ -285,7 +299,10 @@
</template> </template>
<template v-else> <template v-else>
{{ trans(SIGNATURES_CLICK_ON_DOCUMENT) }} {{ trans(SIGNATURES_CLICK_ON_DOCUMENT) }}
<div class="spinner-border spinner-border-sm" role="status"> <div
class="spinner-border spinner-border-sm"
role="status"
>
<span class="visually-hidden">Loading...</span> <span class="visually-hidden">Loading...</span>
</div> </div>
</template> </template>
@@ -545,8 +562,14 @@ const addCanvasEvents = () => {
); );
}); });
} else { } else {
const canvas = document.querySelectorAll("canvas")[0] as HTMLCanvasElement; const canvas = document.querySelectorAll(
canvas.addEventListener("pointerup", (e) => canvasClick(e, canvas), false); "canvas",
)[0] as HTMLCanvasElement;
canvas.addEventListener(
"pointerup",
(e) => canvasClick(e, canvas),
false,
);
} }
}; };
@@ -582,7 +605,11 @@ const hitSignature = (
scaleYToCanvas(zone.y, canvas.height, zone.PDFPage.height) < scaleYToCanvas(zone.y, canvas.height, zone.PDFPage.height) <
xy[1] && xy[1] &&
xy[1] < xy[1] <
scaleYToCanvas(zone.height - zone.y, canvas.height, zone.PDFPage.height) + scaleYToCanvas(
zone.height - zone.y,
canvas.height,
zone.PDFPage.height,
) +
zone.PDFPage.height * zoom.value; zone.PDFPage.height * zoom.value;
const selectZone = async (z: SignatureZone, canvas: HTMLCanvasElement) => { const selectZone = async (z: SignatureZone, canvas: HTMLCanvasElement) => {
@@ -598,7 +625,8 @@ const selectZoneEvent = (e: PointerEvent, canvas: HTMLCanvasElement) =>
signature.zones signature.zones
.filter( .filter(
(z) => (z) =>
(z.PDFPage.index + 1 === getCanvasId(canvas) && multiPage.value) || (z.PDFPage.index + 1 === getCanvasId(canvas) &&
multiPage.value) ||
(z.PDFPage.index + 1 === page.value && !multiPage.value), (z.PDFPage.index + 1 === page.value && !multiPage.value),
) )
.map((z) => { .map((z) => {

View File

@@ -153,10 +153,12 @@ const handleFile = async (file: File): Promise<void> => {
</p> </p>
<!-- todo i18n --> <!-- todo i18n -->
<p v-if="has_existing_doc"> <p v-if="has_existing_doc">
Déposez un document ou cliquez ici pour remplacer le document existant Déposez un document ou cliquez ici pour remplacer le document
existant
</p> </p>
<p v-else> <p v-else>
Déposez un document ou cliquez ici pour ouvrir le navigateur de fichier Déposez un document ou cliquez ici pour ouvrir le navigateur de
fichier
</p> </p>
</div> </div>
<div v-else class="waiting"> <div v-else class="waiting">

View File

@@ -35,7 +35,9 @@ async function download_and_open(event: Event): Promise<void> {
if (null === state.content) { if (null === state.content) {
event.preventDefault(); event.preventDefault();
const raw = await download_doc(build_convert_link(props.storedObject.uuid)); const raw = await download_doc(
build_convert_link(props.storedObject.uuid),
);
state.content = window.URL.createObjectURL(raw); state.content = window.URL.createObjectURL(raw);
button.href = window.URL.createObjectURL(raw); button.href = window.URL.createObjectURL(raw);

View File

@@ -42,7 +42,9 @@ const editionUntilFormatted = computed<string>(() => {
<modal v-if="state.modalOpened" @close="state.modalOpened = false"> <modal v-if="state.modalOpened" @close="state.modalOpened = false">
<template v-slot:body> <template v-slot:body>
<div class="desktop-edit"> <div class="desktop-edit">
<p class="center">Veuillez enregistrer vos modifications avant le</p> <p class="center">
Veuillez enregistrer vos modifications avant le
</p>
<p> <p>
<strong>{{ editionUntilFormatted }}</strong> <strong>{{ editionUntilFormatted }}</strong>
</p> </p>
@@ -55,21 +57,23 @@ const editionUntilFormatted = computed<string>(() => {
<p> <p>
<small <small
>Le document peut être édité uniquement en utilisant Libre >Le document peut être édité uniquement en utilisant
Office.</small Libre Office.</small
> >
</p> </p>
<p> <p>
<small <small
>En cas d'échec lors de l'enregistrement, sauver le document sur >En cas d'échec lors de l'enregistrement, sauver le
le poste de travail avant de le déposer à nouveau ici.</small document sur le poste de travail avant de le déposer
à nouveau ici.</small
> >
</p> </p>
<p> <p>
<small <small
>Vous pouvez naviguez sur d'autres pages pendant l'édition.</small >Vous pouvez naviguez sur d'autres pages pendant
l'édition.</small
> >
</p> </p>
</div> </div>

View File

@@ -95,7 +95,10 @@ async function download_and_open(): Promise<void> {
let raw; let raw;
try { try {
raw = await download_and_decrypt_doc(props.storedObject, props.atVersion); raw = await download_and_decrypt_doc(
props.storedObject,
props.atVersion,
);
} catch (e) { } catch (e) {
console.error("error while downloading and decrypting document"); console.error("error while downloading and decrypting document");
console.error(e); console.error(e);

View File

@@ -49,7 +49,8 @@ const isRestored = computed<boolean>(
); );
const isDuplicated = computed<boolean>( const isDuplicated = computed<boolean>(
() => props.version.version === 0 && null !== props.version["from-restored"], () =>
props.version.version === 0 && null !== props.version["from-restored"],
); );
const classes = computed<{ const classes = computed<{
@@ -69,9 +70,16 @@ const classes = computed<{
<div :class="classes"> <div :class="classes">
<div <div
class="col-12 tags" class="col-12 tags"
v-if="isCurrent || isKeptBeforeConversion || isRestored || isDuplicated" v-if="
isCurrent ||
isKeptBeforeConversion ||
isRestored ||
isDuplicated
"
>
<span class="badge bg-success" v-if="isCurrent"
>Version actuelle</span
> >
<span class="badge bg-success" v-if="isCurrent">Version actuelle</span>
<span class="badge bg-info" v-if="isKeptBeforeConversion" <span class="badge bg-info" v-if="isKeptBeforeConversion"
>Conservée avant conversion dans un autre format</span >Conservée avant conversion dans un autre format</span
> >
@@ -88,17 +96,21 @@ const classes = computed<{
<span <span
><strong>&nbsp;#{{ version.version + 1 }}&nbsp;</strong></span ><strong>&nbsp;#{{ version.version + 1 }}&nbsp;</strong></span
> >
<template v-if="version.createdBy !== null && version.createdAt !== null" <template
v-if="version.createdBy !== null && version.createdAt !== null"
><strong v-if="version.version == 0">créé par</strong ><strong v-if="version.version == 0">créé par</strong
><strong v-else>modifié par</strong> ><strong v-else>modifié par</strong>
<span class="badge-user" <span class="badge-user"
><UserRenderBoxBadge :user="version.createdBy"></UserRenderBoxBadge ><UserRenderBoxBadge
:user="version.createdBy"
></UserRenderBoxBadge
></span> ></span>
<strong>à</strong> <strong>à</strong>
{{ {{
$d(ISOToDatetime(version.createdAt.datetime8601), "long") $d(ISOToDatetime(version.createdAt.datetime8601), "long")
}}</template }}</template
><template v-if="version.createdBy === null && version.createdAt !== null" ><template
v-if="version.createdBy === null && version.createdAt !== null"
><strong v-if="version.version == 0">Créé le</strong ><strong v-if="version.version == 0">Créé le</strong
><strong v-else>modifié le</strong> ><strong v-else>modifié le</strong>
{{ {{

View File

@@ -2,7 +2,9 @@
<a <a
:class="Object.assign(props.classes, { btn: true })" :class="Object.assign(props.classes, { btn: true })"
@click="beforeLeave($event)" @click="beforeLeave($event)"
:href="build_wopi_editor_link(props.storedObject.uuid, props.returnPath)" :href="
build_wopi_editor_link(props.storedObject.uuid, props.returnPath)
"
> >
<i class="fa fa-paragraph"></i> <i class="fa fa-paragraph"></i>
Editer en ligne Editer en ligne

View File

@@ -145,7 +145,9 @@ async function download_info_link(
function build_wopi_editor_link(uuid: string, returnPath?: string) { function build_wopi_editor_link(uuid: string, returnPath?: string) {
if (returnPath === undefined) { if (returnPath === undefined) {
returnPath = returnPath =
window.location.pathname + window.location.search + window.location.hash; window.location.pathname +
window.location.search +
window.location.hash;
} }
return ( return (
@@ -184,7 +186,10 @@ async function download_and_decrypt_doc(
) { ) {
downloadInfo = storedObject._links.downloadLink; downloadInfo = storedObject._links.downloadLink;
} else { } else {
downloadInfo = await download_info_link(storedObject, atVersionToDownload); downloadInfo = await download_info_link(
storedObject,
atVersionToDownload,
);
} }
const rawResponse = await window.fetch(downloadInfo.url); const rawResponse = await window.fetch(downloadInfo.url);
@@ -239,7 +244,10 @@ async function download_doc_as_pdf(storedObject: StoredObject): Promise<Blob> {
} }
if (storedObject.currentVersion?.type === "application/pdf") { if (storedObject.currentVersion?.type === "application/pdf") {
return download_and_decrypt_doc(storedObject, storedObject.currentVersion); return download_and_decrypt_doc(
storedObject,
storedObject.currentVersion,
);
} }
const convertLink = build_convert_link(storedObject.uuid); const convertLink = build_convert_link(storedObject.uuid);

View File

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

View File

@@ -20,6 +20,7 @@ use Chill\MainBundle\DependencyInjection\CompilerPass\SearchableServicesCompiler
use Chill\MainBundle\DependencyInjection\CompilerPass\TimelineCompilerClass; use Chill\MainBundle\DependencyInjection\CompilerPass\TimelineCompilerClass;
use Chill\MainBundle\DependencyInjection\CompilerPass\WidgetsCompilerPass; use Chill\MainBundle\DependencyInjection\CompilerPass\WidgetsCompilerPass;
use Chill\MainBundle\DependencyInjection\ConfigConsistencyCompilerPass; use Chill\MainBundle\DependencyInjection\ConfigConsistencyCompilerPass;
use Chill\MainBundle\Notification\FlagProviders\NotificationFlagProviderInterface;
use Chill\MainBundle\Notification\NotificationHandlerInterface; use Chill\MainBundle\Notification\NotificationHandlerInterface;
use Chill\MainBundle\Routing\LocalMenuBuilderInterface; use Chill\MainBundle\Routing\LocalMenuBuilderInterface;
use Chill\MainBundle\Search\SearchApiInterface; use Chill\MainBundle\Search\SearchApiInterface;
@@ -61,6 +62,8 @@ class ChillMainBundle extends Bundle
->addTag('chill_main.entity_info_provider'); ->addTag('chill_main.entity_info_provider');
$container->registerForAutoconfiguration(ProvideRoleInterface::class) $container->registerForAutoconfiguration(ProvideRoleInterface::class)
->addTag('chill_main.provide_role'); ->addTag('chill_main.provide_role');
$container->registerForAutoconfiguration(NotificationFlagProviderInterface::class)
->addTag('chill_main.notification_flag_provider');
$container->addCompilerPass(new SearchableServicesCompilerPass(), \Symfony\Component\DependencyInjection\Compiler\PassConfig::TYPE_BEFORE_OPTIMIZATION, 0); $container->addCompilerPass(new SearchableServicesCompilerPass(), \Symfony\Component\DependencyInjection\Compiler\PassConfig::TYPE_BEFORE_OPTIMIZATION, 0);
$container->addCompilerPass(new ConfigConsistencyCompilerPass(), \Symfony\Component\DependencyInjection\Compiler\PassConfig::TYPE_BEFORE_OPTIMIZATION, 0); $container->addCompilerPass(new ConfigConsistencyCompilerPass(), \Symfony\Component\DependencyInjection\Compiler\PassConfig::TYPE_BEFORE_OPTIMIZATION, 0);

View File

@@ -16,6 +16,7 @@ use Chill\MainBundle\Entity\NotificationComment;
use Chill\MainBundle\Form\NotificationCommentType; use Chill\MainBundle\Form\NotificationCommentType;
use Chill\MainBundle\Form\NotificationType; use Chill\MainBundle\Form\NotificationType;
use Chill\MainBundle\Notification\Exception\NotificationHandlerNotFound; use Chill\MainBundle\Notification\Exception\NotificationHandlerNotFound;
use Chill\MainBundle\Notification\FlagProviders\NotificationByUserFlagProvider;
use Chill\MainBundle\Notification\NotificationHandlerManager; use Chill\MainBundle\Notification\NotificationHandlerManager;
use Chill\MainBundle\Pagination\PaginatorFactory; use Chill\MainBundle\Pagination\PaginatorFactory;
use Chill\MainBundle\Repository\NotificationRepository; use Chill\MainBundle\Repository\NotificationRepository;
@@ -57,7 +58,8 @@ class NotificationController extends AbstractController
$notification $notification
->setRelatedEntityClass($request->query->get('entityClass')) ->setRelatedEntityClass($request->query->get('entityClass'))
->setRelatedEntityId($request->query->getInt('entityId')) ->setRelatedEntityId($request->query->getInt('entityId'))
->setSender($this->security->getUser()); ->setSender($this->security->getUser())
->setType(NotificationByUserFlagProvider::FLAG);
$tos = $request->query->all('tos'); $tos = $request->query->all('tos');

View File

@@ -11,14 +11,11 @@ declare(strict_types=1);
namespace Chill\MainBundle\Controller; namespace Chill\MainBundle\Controller;
use Chill\MainBundle\Form\UserPhonenumberType; use Chill\MainBundle\Form\UserProfileType;
use Chill\MainBundle\Security\ChillSecurity; use Chill\MainBundle\Security\ChillSecurity;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Contracts\Translation\TranslatorInterface; use Symfony\Contracts\Translation\TranslatorInterface;
use Symfony\Component\Routing\Annotation\Route; use Symfony\Component\Routing\Annotation\Route;
@@ -41,16 +38,19 @@ final class UserProfileController extends AbstractController
} }
$user = $this->security->getUser(); $user = $this->security->getUser();
$editForm = $this->createPhonenumberEditForm($user); $editForm = $this->createForm(UserProfileType::class, $user);
$editForm->get('notificationFlags')->setData($user->getNotificationFlags());
$editForm->handleRequest($request); $editForm->handleRequest($request);
if ($editForm->isSubmitted() && $editForm->isValid()) { if ($editForm->isSubmitted() && $editForm->isValid()) {
$phonenumber = $editForm->get('phonenumber')->getData(); $notificationFlagsData = $editForm->get('notificationFlags')->getData();
$user->setNotificationFlags($notificationFlagsData);
$user->setPhonenumber($phonenumber); $em = $this->managerRegistry->getManager();
$em->flush();
$this->managerRegistry->getManager()->flush(); $this->addFlash('success', $this->translator->trans('user.profile.Profile successfully updated!'));
$this->addFlash('success', $this->translator->trans('user.profile.Phonenumber successfully updated!'));
return $this->redirectToRoute('chill_main_user_profile'); return $this->redirectToRoute('chill_main_user_profile');
} }
@@ -60,13 +60,4 @@ final class UserProfileController extends AbstractController
'form' => $editForm->createView(), 'form' => $editForm->createView(),
]); ]);
} }
private function createPhonenumberEditForm(UserInterface $user): FormInterface
{
return $this->createForm(
UserPhonenumberType::class,
$user,
)
->add('submit', SubmitType::class, ['label' => $this->translator->trans('Save')]);
}
} }

View File

@@ -14,6 +14,7 @@ namespace Chill\MainBundle\Entity;
use Chill\MainBundle\Doctrine\Model\TrackUpdateInterface; use Chill\MainBundle\Doctrine\Model\TrackUpdateInterface;
use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection; use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert; use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\Context\ExecutionContextInterface; use Symfony\Component\Validator\Context\ExecutionContextInterface;
@@ -21,10 +22,10 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
#[ORM\Entity] #[ORM\Entity]
#[ORM\HasLifecycleCallbacks] #[ORM\HasLifecycleCallbacks]
#[ORM\Table(name: 'chill_main_notification')] #[ORM\Table(name: 'chill_main_notification')]
#[ORM\Index(name: 'chill_main_notification_related_entity_idx', columns: ['relatedentityclass', 'relatedentityid'])] #[ORM\Index(columns: ['relatedentityclass', 'relatedentityid'], name: 'chill_main_notification_related_entity_idx')]
class Notification implements TrackUpdateInterface class Notification implements TrackUpdateInterface
{ {
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::TEXT, nullable: false)] #[ORM\Column(type: Types::TEXT, nullable: false)]
private string $accessKey; private string $accessKey;
private array $addedAddresses = []; private array $addedAddresses = [];
@@ -36,12 +37,19 @@ class Notification implements TrackUpdateInterface
#[ORM\JoinTable(name: 'chill_main_notification_addresses_user')] #[ORM\JoinTable(name: 'chill_main_notification_addresses_user')]
private Collection $addressees; private Collection $addressees;
/**
* @var Collection<int, UserGroup>
*/
#[ORM\ManyToMany(targetEntity: UserGroup::class)]
#[ORM\JoinTable(name: 'chill_main_notification_addressee_user_group')]
private Collection $addresseeUserGroups;
/** /**
* a list of destinee which will receive notifications. * a list of destinee which will receive notifications.
* *
* @var array|string[] * @var array|string[]
*/ */
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::JSON, options: ['default' => '[]', 'jsonb' => true])] #[ORM\Column(type: Types::JSON, options: ['default' => '[]', 'jsonb' => true])]
private array $addressesEmails = []; private array $addressesEmails = [];
/** /**
@@ -60,21 +68,21 @@ class Notification implements TrackUpdateInterface
#[ORM\OrderBy(['createdAt' => \Doctrine\Common\Collections\Criteria::ASC])] #[ORM\OrderBy(['createdAt' => \Doctrine\Common\Collections\Criteria::ASC])]
private Collection $comments; private Collection $comments;
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::DATETIME_IMMUTABLE)] #[ORM\Column(type: Types::DATETIME_IMMUTABLE)]
private \DateTimeImmutable $date; private \DateTimeImmutable $date;
#[ORM\Id] #[ORM\Id]
#[ORM\GeneratedValue] #[ORM\GeneratedValue]
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::INTEGER)] #[ORM\Column(type: Types::INTEGER)]
private ?int $id = null; private ?int $id = null;
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::TEXT)] #[ORM\Column(type: Types::TEXT)]
private string $message = ''; private string $message = '';
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::STRING, length: 255)] #[ORM\Column(type: Types::STRING, length: 255)]
private string $relatedEntityClass = ''; private string $relatedEntityClass = '';
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::INTEGER)] #[ORM\Column(type: Types::INTEGER)]
private int $relatedEntityId; private int $relatedEntityId;
private array $removedAddresses = []; private array $removedAddresses = [];
@@ -84,7 +92,7 @@ class Notification implements TrackUpdateInterface
private ?User $sender = null; private ?User $sender = null;
#[Assert\NotBlank(message: 'notification.Title must be defined')] #[Assert\NotBlank(message: 'notification.Title must be defined')]
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::TEXT, options: ['default' => ''])] #[ORM\Column(type: Types::TEXT, options: ['default' => ''])]
private string $title = ''; private string $title = '';
/** /**
@@ -94,31 +102,46 @@ class Notification implements TrackUpdateInterface
#[ORM\JoinTable(name: 'chill_main_notification_addresses_unread')] #[ORM\JoinTable(name: 'chill_main_notification_addresses_unread')]
private Collection $unreadBy; private Collection $unreadBy;
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::DATETIME_IMMUTABLE)] #[ORM\Column(type: Types::DATETIME_IMMUTABLE)]
private ?\DateTimeImmutable $updatedAt = null; private ?\DateTimeImmutable $updatedAt = null;
#[ORM\ManyToOne(targetEntity: User::class)] #[ORM\ManyToOne(targetEntity: User::class)]
private ?User $updatedBy = null; private ?User $updatedBy = null;
#[ORM\Column(name: 'type', type: Types::STRING, nullable: true)]
private string $type = '';
public function __construct() public function __construct()
{ {
$this->addressees = new ArrayCollection(); $this->addressees = new ArrayCollection();
$this->addresseeUserGroups = new ArrayCollection();
$this->unreadBy = new ArrayCollection(); $this->unreadBy = new ArrayCollection();
$this->comments = new ArrayCollection(); $this->comments = new ArrayCollection();
$this->setDate(new \DateTimeImmutable()); $this->setDate(new \DateTimeImmutable());
$this->accessKey = bin2hex(openssl_random_pseudo_bytes(24)); $this->accessKey = bin2hex(openssl_random_pseudo_bytes(24));
} }
public function addAddressee(User $addressee): self public function addAddressee(User|UserGroup $addressee): self
{ {
if ($addressee instanceof User) {
if (!$this->addressees->contains($addressee)) { if (!$this->addressees->contains($addressee)) {
$this->addressees[] = $addressee; $this->addressees->add($addressee);
$this->addedAddresses[] = $addressee; $this->addedAddresses[] = $addressee;
} }
return $this; return $this;
} }
if (!$this->addresseeUserGroups->contains($addressee)) {
$this->addresseeUserGroups->add($addressee);
}
return $this;
}
/**
* @deprecated
*/
public function addAddressesEmail(string $email) public function addAddressesEmail(string $email)
{ {
if (!\in_array($email, $this->addressesEmails, true)) { if (!\in_array($email, $this->addressesEmails, true)) {
@@ -152,13 +175,23 @@ class Notification implements TrackUpdateInterface
#[Assert\Callback] #[Assert\Callback]
public function assertCountAddresses(ExecutionContextInterface $context, $payload): void public function assertCountAddresses(ExecutionContextInterface $context, $payload): void
{ {
if (0 === (\count($this->getAddressesEmails()) + \count($this->getAddressees()))) { if (0 === (\count($this->getAddresseeUserGroups()) + \count($this->getAddressees()))) {
$context->buildViolation('notification.At least one addressee') $context->buildViolation('notification.At least one addressee')
->atPath('addressees') ->atPath('addressees')
->addViolation(); ->addViolation();
} }
} }
public function getAddresseeUserGroups(): Collection
{
return $this->addresseeUserGroups;
}
public function setAddresseeUserGroups(Collection $addresseeUserGroups): void
{
$this->addresseeUserGroups = $addresseeUserGroups;
}
public function getAccessKey(): string public function getAccessKey(): string
{ {
return $this->accessKey; return $this->accessKey;
@@ -182,6 +215,23 @@ class Notification implements TrackUpdateInterface
return $this->addressees; return $this->addressees;
} }
public function getAllAddressees(): array
{
$allUsers = [];
foreach ($this->getAddressees() as $user) {
$allUsers[$user->getId()] = $user;
}
foreach ($this->getAddresseeUserGroups() as $userGroup) {
foreach ($userGroup->getUsers() as $user) {
$allUsers[$user->getId()] = $user;
}
}
return array_values($allUsers);
}
/** /**
* @return array|string[] * @return array|string[]
*/ */
@@ -303,11 +353,17 @@ class Notification implements TrackUpdateInterface
$this->addressesOnLoad = null; $this->addressesOnLoad = null;
} }
public function removeAddressee(User $addressee): self public function removeAddressee(User|UserGroup $addressee): self
{ {
if ($this->addressees->removeElement($addressee)) { if ($addressee instanceof User) {
$this->removedAddresses[] = $addressee; if ($this->addressees->contains($addressee)) {
$this->addressees->removeElement($addressee);
return $this;
} }
}
$this->addresseeUserGroups->removeElement($addressee);
return $this; return $this;
} }
@@ -378,7 +434,7 @@ class Notification implements TrackUpdateInterface
public function setUpdatedAt(\DateTimeInterface $datetime): self public function setUpdatedAt(\DateTimeInterface $datetime): self
{ {
$this->updatedAt = $datetime; $this->updatedAt = \DateTimeImmutable::createFromInterface($datetime);
return $this; return $this;
} }
@@ -389,4 +445,16 @@ class Notification implements TrackUpdateInterface
return $this; return $this;
} }
public function setType(string $type): self
{
$this->type = $type;
return $this;
}
public function getType(): string
{
return $this->type;
}
} }

View File

@@ -34,6 +34,9 @@ use Chill\MainBundle\Validation\Constraint\PhonenumberConstraint;
#[ORM\Table(name: 'users')] #[ORM\Table(name: 'users')]
class User implements UserInterface, \Stringable, PasswordAuthenticatedUserInterface class User implements UserInterface, \Stringable, PasswordAuthenticatedUserInterface
{ {
public const NOTIF_FLAG_IMMEDIATE_EMAIL = 'immediate-email';
public const NOTIF_FLAG_DAILY_DIGEST = 'daily-digest';
#[ORM\Id] #[ORM\Id]
#[ORM\Column(name: 'id', type: \Doctrine\DBAL\Types\Types::INTEGER)] #[ORM\Column(name: 'id', type: \Doctrine\DBAL\Types\Types::INTEGER)]
#[ORM\GeneratedValue(strategy: 'AUTO')] #[ORM\GeneratedValue(strategy: 'AUTO')]
@@ -116,6 +119,12 @@ class User implements UserInterface, \Stringable, PasswordAuthenticatedUserInter
#[PhonenumberConstraint] #[PhonenumberConstraint]
private ?PhoneNumber $phonenumber = null; private ?PhoneNumber $phonenumber = null;
/**
* @var array<string, list<string>>
*/
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::JSON, nullable: false, options: ['default' => '[]', 'jsonb' => true])]
private array $notificationFlags = [];
/** /**
* User constructor. * User constructor.
*/ */
@@ -623,4 +632,47 @@ class User implements UserInterface, \Stringable, PasswordAuthenticatedUserInter
{ {
return true; return true;
} }
public function getNotificationFlags(): 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;
}
public function isNotificationSendImmediately(string $type): bool
{
if ([] === $this->getNotificationFlagData($type) || in_array(User::NOTIF_FLAG_IMMEDIATE_EMAIL, $this->getNotificationFlagData($type), true)) {
return true;
}
return false;
}
public function isNotificationDailyDigest(string $type): bool
{
if (in_array(User::NOTIF_FLAG_DAILY_DIGEST, $this->getNotificationFlagData($type), true)) {
return true;
}
return false;
}
public function getLocale(): string
{
return 'fr';
}
} }

View File

@@ -27,8 +27,6 @@ use Symfony\Component\HttpFoundation\Response;
use Symfony\Contracts\Translation\TranslatableInterface; use Symfony\Contracts\Translation\TranslatableInterface;
use Symfony\Contracts\Translation\TranslatorInterface; use Symfony\Contracts\Translation\TranslatorInterface;
// command to get the report with curl : curl --user "center a_social:password" "http://localhost:8000/fr/exports/generate/count_person?export[filters][person_gender_filter][enabled]=&export[filters][person_nationality_filter][enabled]=&export[filters][person_nationality_filter][form][nationalities]=&export[aggregators][person_nationality_aggregator][order]=1&export[aggregators][person_nationality_aggregator][form][group_by_level]=country&export[submit]=&export[_token]=RHpjHl389GrK-bd6iY5NsEqrD5UKOTHH40QKE9J1edU" --globoff
/** /**
* Create a CSV List for the export. * Create a CSV List for the export.
*/ */

View File

@@ -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\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;
}
}
}
}
}

View File

@@ -12,17 +12,12 @@ declare(strict_types=1);
namespace Chill\MainBundle\Form; namespace Chill\MainBundle\Form;
use Chill\MainBundle\Entity\Notification; use Chill\MainBundle\Entity\Notification;
use Chill\MainBundle\Form\Type\ChillCollectionType;
use Chill\MainBundle\Form\Type\ChillTextareaType; use Chill\MainBundle\Form\Type\ChillTextareaType;
use Chill\MainBundle\Form\Type\PickUserDynamicType; use Chill\MainBundle\Form\Type\PickUserGroupOrUserDynamicType;
use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\EmailType;
use Symfony\Component\Form\Extension\Core\Type\TextType; use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver; use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\Email;
use Symfony\Component\Validator\Constraints\NotBlank;
use Symfony\Component\Validator\Constraints\NotNull;
class NotificationType extends AbstractType class NotificationType extends AbstractType
{ {
@@ -33,29 +28,14 @@ class NotificationType extends AbstractType
'label' => 'Title', 'label' => 'Title',
'required' => true, 'required' => true,
]) ])
->add('addressees', PickUserDynamicType::class, [ ->add('addressees', PickUserGroupOrUserDynamicType::class, [
'multiple' => true, 'multiple' => true,
'required' => false, 'label' => 'notification.Pick user or user group',
'empty_data' => '[]',
'required' => true,
]) ])
->add('message', ChillTextareaType::class, [ ->add('message', ChillTextareaType::class, [
'required' => false, 'required' => false,
])
->add('addressesEmails', ChillCollectionType::class, [
'label' => 'notification.dest by email',
'help' => 'notification.dest by email help',
'by_reference' => false,
'allow_add' => true,
'allow_delete' => true,
'entry_type' => EmailType::class,
'button_add_label' => 'notification.Add an email',
'button_remove_label' => 'notification.Remove an email',
'empty_collection_explain' => 'notification.Any email',
'entry_options' => [
'constraints' => [
new NotNull(), new NotBlank(), new Email(),
],
'label' => 'Email',
],
]); ]);
} }

View File

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

View File

@@ -0,0 +1,63 @@
<?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\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;
class NotificationFlagsType extends AbstractType
{
private readonly array $notificationFlagProviders;
public function __construct(NotificationFlagManager $notificationFlagManager)
{
$this->notificationFlagProviders = $notificationFlagManager->getAllNotificationFlagProviders();
}
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, [
'label' => $flagProvider->getLabel(),
'required' => false,
]);
$builder->get($flag)
->add('immediate_email', CheckboxType::class, [
'label' => false,
'required' => false,
'mapped' => false,
])
->add('daily_email', CheckboxType::class, [
'label' => false,
'required' => false,
'mapped' => false,
])
;
}
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => null,
]);
}
}

View File

@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Form;
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
{
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->add('phonenumber', ChillPhoneNumberType::class, [
'required' => false,
])
->add('notificationFlags', NotificationFlagsType::class, [
'label' => false,
'mapped' => false,
])
;
}
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults([
'data_class' => \Chill\MainBundle\Entity\User::class,
]);
}
}

View File

@@ -0,0 +1,102 @@
<?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\Notification\Email;
use Chill\MainBundle\Cron\CronJobInterface;
use Chill\MainBundle\Entity\CronJobExecution;
use Chill\MainBundle\Notification\Email\NotificationEmailMessages\ScheduleDailyNotificationDigestMessage;
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\Exception;
use Psr\Log\LoggerInterface;
use Symfony\Component\Clock\ClockInterface;
use Symfony\Component\Messenger\MessageBusInterface;
readonly class DailyNotificationDigestCronjob implements CronJobInterface
{
public function __construct(
private ClockInterface $clock,
private Connection $connection,
private MessageBusInterface $messageBus,
private LoggerInterface $logger,
) {}
public function canRun(?CronJobExecution $cronJobExecution): bool
{
$now = $this->clock->now();
if (null !== $cronJobExecution && $now->sub(new \DateInterval('PT23H45M')) < $cronJobExecution->getLastStart()) {
return false;
}
// Run between 6 and 9 AM
return in_array((int) $now->format('H'), [6, 7, 8], true);
}
public function getKey(): string
{
return 'daily-notification-digest';
}
/**
* @throws \DateInvalidOperationException
* @throws Exception
*/
public function run(array $lastExecutionData): ?array
{
$now = $this->clock->now();
if (isset($lastExecutionData['last_execution'])) {
$lastExecution = \DateTimeImmutable::createFromFormat(
\DateTimeImmutable::ATOM,
$lastExecutionData['last_execution']
);
} else {
$lastExecution = $now->sub(new \DateInterval('P1D'));
}
// Get distinct users who received notifications since the last execution
$sql = <<<'SQL'
SELECT DISTINCT cmnau.user_id
FROM chill_main_notification cmn
JOIN chill_main_notification_addresses_user cmnau ON cmnau.notification_id = cmn.id
WHERE cmn.date >= :lastExecution AND cmn.date <= :now
SQL;
$sqlStatement = $this->connection->prepare($sql);
$sqlStatement->bindValue('lastExecution', $lastExecution->format(\DateTimeInterface::RFC3339));
$sqlStatement->bindValue('now', $now->format(\DateTimeInterface::RFC3339));
$result = $sqlStatement->executeQuery();
$count = 0;
foreach ($result->fetchAllAssociative() as $row) {
$userId = (int) $row['user_id'];
$message = new ScheduleDailyNotificationDigestMessage(
$userId,
$lastExecution,
$now
);
$this->messageBus->dispatch($message);
++$count;
}
$this->logger->info('[DailyNotificationDigestCronjob] Dispatched daily digest messages', [
'user_count' => $count,
'last_execution' => $lastExecution->format('Y-m-d-H:i:s.u e'),
'current_time' => $now->format('Y-m-d-H:i:s.u e'),
]);
return [
'last_execution' => $now->format('Y-m-d-H:i:s.u e'),
];
}
}

View File

@@ -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\Notification\Email\NotificationEmailHandlers;
use Chill\MainBundle\Notification\Email\NotificationEmailMessages\ScheduleDailyNotificationDigestMessage;
use Chill\MainBundle\Notification\Email\NotificationMailer;
use Chill\MainBundle\Repository\NotificationRepository;
use Chill\MainBundle\Repository\UserRepository;
use Psr\Log\LoggerInterface;
use Symfony\Component\Mailer\Exception\TransportExceptionInterface;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler]
readonly class ScheduleDailyNotificationDigestHandler
{
public function __construct(
private NotificationRepository $notificationRepository,
private UserRepository $userRepository,
private NotificationMailer $notificationMailer,
private LoggerInterface $logger,
) {}
/**
* @throws TransportExceptionInterface
*/
public function __invoke(ScheduleDailyNotificationDigestMessage $message): void
{
$userId = $message->getUserId();
$lastExecutionDate = $message->getLastExecutionDateTime();
$currentDate = $message->getCurrentDateTime();
$user = $this->userRepository->find($userId);
if (null === $user) {
$this->logger->warning('[ScheduleDailyNotificationDigestHandler] User not found', [
'user_id' => $userId,
]);
throw new \InvalidArgumentException(sprintf('User with ID %s not found', $userId));
}
// Get all notifications for this user between last execution and current date
$notifications = $this->notificationRepository->findNotificationsForUserBetweenDates(
$userId,
$lastExecutionDate,
$currentDate
);
// Filter out notifications that should be sent in a daily digest
$dailyNotifications = array_filter($notifications, fn ($notification) => $user->isNotificationDailyDigest($notification->getType()));
if ([] === $dailyNotifications) {
$this->logger->info('[ScheduleDailyNotificationDigestHandler] No daily notifications found for user', [
'user_id' => $userId,
]);
return;
}
$this->notificationMailer->sendDailyDigest($user, $dailyNotifications);
$this->logger->info('[ScheduleDailyNotificationDigestHandler] Sent daily digest', [
'user_id' => $userId,
'notification_count' => count($dailyNotifications),
]);
}
}

View File

@@ -0,0 +1,68 @@
<?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\Notification\Email\NotificationEmailHandlers;
use Chill\MainBundle\Notification\Email\NotificationEmailMessages\SendImmediateNotificationEmailMessage;
use Chill\MainBundle\Notification\Email\NotificationMailer;
use Chill\MainBundle\Repository\NotificationRepository;
use Chill\MainBundle\Repository\UserRepository;
use Psr\Log\LoggerInterface;
use Symfony\Component\Mailer\Exception\TransportExceptionInterface;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler]
readonly class SendImmediateNotificationEmailHandler
{
public function __construct(
private NotificationRepository $notificationRepository,
private UserRepository $userRepository,
private NotificationMailer $notificationMailer,
private LoggerInterface $logger,
) {}
/**
* @throws TransportExceptionInterface
* @throws \Exception
*/
public function __invoke(SendImmediateNotificationEmailMessage $message): void
{
$notification = $this->notificationRepository->find($message->getNotificationId());
$addressee = $this->userRepository->find($message->getAddresseeId());
if (null === $notification) {
$this->logger->error('[SendImmediateNotificationEmailHandler] Notification not found', [
'notification_id' => $message->getNotificationId(),
]);
throw new \InvalidArgumentException(sprintf('Notification with ID %s not found', $message->getNotificationId()));
}
if (null === $addressee) {
$this->logger->error('[SendImmediateNotificationEmailHandler] Addressee not found', [
'addressee_id' => $message->getAddresseeId(),
]);
throw new \InvalidArgumentException(sprintf('User with ID %s not found', $message->getAddresseeId()));
}
try {
$this->notificationMailer->sendEmailToAddressee($notification, $addressee);
} catch (\Exception $e) {
$this->logger->error('[SendImmediateNotificationEmailHandler] Failed to send email', [
'notification_id' => $message->getNotificationId(),
'addressee_id' => $message->getAddresseeId(),
'stacktrace' => $e->getTraceAsString(),
]);
throw $e;
}
}
}

View File

@@ -0,0 +1,36 @@
<?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\Notification\Email\NotificationEmailMessages;
readonly class ScheduleDailyNotificationDigestMessage
{
public function __construct(
private int $userId,
private \DateTimeInterface $lastExecutionDate,
private \DateTimeInterface $currentDate,
) {}
public function getUserId(): int
{
return $this->userId;
}
public function getLastExecutionDateTime(): \DateTimeInterface
{
return $this->lastExecutionDate;
}
public function getCurrentDateTime(): \DateTimeInterface
{
return $this->currentDate;
}
}

View File

@@ -0,0 +1,30 @@
<?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\Notification\Email\NotificationEmailMessages;
readonly class SendImmediateNotificationEmailMessage
{
public function __construct(
private int $notificationId,
private int $addresseeId,
) {}
public function getNotificationId(): int
{
return $this->notificationId;
}
public function getAddresseeId(): int
{
return $this->addresseeId;
}
}

View File

@@ -13,22 +13,32 @@ namespace Chill\MainBundle\Notification\Email;
use Chill\MainBundle\Entity\Notification; use Chill\MainBundle\Entity\Notification;
use Chill\MainBundle\Entity\NotificationComment; use Chill\MainBundle\Entity\NotificationComment;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Notification\Email\NotificationEmailMessages\SendImmediateNotificationEmailMessage;
use Doctrine\ORM\Event\PostPersistEventArgs; use Doctrine\ORM\Event\PostPersistEventArgs;
use Doctrine\ORM\Event\PostUpdateEventArgs;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
use Symfony\Bridge\Twig\Mime\TemplatedEmail; use Symfony\Bridge\Twig\Mime\TemplatedEmail;
use Symfony\Component\Mailer\Exception\TransportExceptionInterface; use Symfony\Component\Mailer\Exception\TransportExceptionInterface;
use Symfony\Component\Mailer\MailerInterface; use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Mime\Email; use Symfony\Component\Mime\Email;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Contracts\Translation\TranslatorInterface; use Symfony\Contracts\Translation\TranslatorInterface;
class NotificationMailer readonly class NotificationMailer
{ {
public function __construct(private readonly MailerInterface $mailer, private readonly LoggerInterface $logger, private readonly TranslatorInterface $translator) {} public function __construct(
private MailerInterface $mailer,
private LoggerInterface $logger,
private MessageBusInterface $messageBus,
private TranslatorInterface $translator,
) {}
public function postPersistComment(NotificationComment $comment, PostPersistEventArgs $eventArgs): void public function postPersistComment(NotificationComment $comment, PostPersistEventArgs $eventArgs): void
{ {
$dests = [$comment->getNotification()->getSender(), ...$comment->getNotification()->getAddressees()->toArray()]; $dests = [
$comment->getNotification()->getSender(),
...$comment->getNotification()->getAddressees()->toArray(),
];
$uniqueDests = []; $uniqueDests = [];
foreach ($dests as $dest) { foreach ($dests as $dest) {
@@ -69,26 +79,67 @@ class NotificationMailer
*/ */
public function postPersistNotification(Notification $notification, PostPersistEventArgs $eventArgs): void public function postPersistNotification(Notification $notification, PostPersistEventArgs $eventArgs): void
{ {
$this->sendNotificationEmailsToAddresses($notification); $this->sendNotificationEmailsToAddressees($notification);
$this->sendNotificationEmailsToAddressesEmails($notification); $this->sendNotificationEmailsToAddressesEmails($notification);
} }
public function postUpdateNotification(Notification $notification, PostUpdateEventArgs $eventArgs): void private function sendNotificationEmailsToAddressees(Notification $notification): void
{ {
$this->sendNotificationEmailsToAddressesEmails($notification); if ('' === $notification->getType()) {
$this->logger->warning('[NotificationMailer] Notification has no type, skipping email processing', [
'notification_id' => $notification->getId(),
]);
return;
} }
private function sendNotificationEmailsToAddresses(Notification $notification): void foreach ($notification->getAllAddressees() as $addressee) {
{
foreach ($notification->getAddressees() as $addressee) {
if (null === $addressee->getEmail()) { if (null === $addressee->getEmail()) {
continue; continue;
} }
$this->processNotificationForAddressee($notification, $addressee);
}
}
private function processNotificationForAddressee(Notification $notification, User $addressee): void
{
$notificationType = $notification->getType();
if ($addressee->isNotificationSendImmediately($notificationType)) {
$this->scheduleImmediateEmail($notification, $addressee);
}
}
private function scheduleImmediateEmail(Notification $notification, User $addressee): void
{
$message = new SendImmediateNotificationEmailMessage(
$notification->getId(),
$addressee->getId()
);
$this->messageBus->dispatch($message);
$this->logger->info('[NotificationMailer] Scheduled immediate email', [
'notification_id' => $notification->getId(),
'addressee_email' => $addressee->getEmail(),
]);
}
/**
* This method sends the email but is now called by the immediate notification email message handler.
*
* @throws TransportExceptionInterface
*/
public function sendEmailToAddressee(Notification $notification, User $addressee): void
{
if (null === $addressee->getEmail()) {
return;
}
if ($notification->isSystem()) { if ($notification->isSystem()) {
$email = new Email(); $email = new Email();
$email $email->text($notification->getMessage());
->text($notification->getMessage());
} else { } else {
$email = new TemplatedEmail(); $email = new TemplatedEmail();
$email $email
@@ -105,19 +156,70 @@ class NotificationMailer
try { try {
$this->mailer->send($email); $this->mailer->send($email);
$this->logger->info('[NotificationMailer] Email sent successfully', [
'notification_id' => $notification->getId(),
'addressee_email' => $addressee->getEmail(),
]);
} catch (TransportExceptionInterface $e) { } catch (TransportExceptionInterface $e) {
$this->logger->warning('[NotificationMailer] could not send an email notification', [ $this->logger->warning('[NotificationMailer] Could not send an email notification', [
'to' => $addressee->getEmail(), 'to' => $addressee->getEmail(),
'notification_id' => $notification->getId(),
'error_message' => $e->getMessage(), 'error_message' => $e->getMessage(),
'error_trace' => $e->getTraceAsString(), 'error_trace' => $e->getTraceAsString(),
]); ]);
throw $e;
} }
} }
/**
* Send daily digest email with multiple notifications to a user.
*
* @throws TransportExceptionInterface
*/
public function sendDailyDigest(User $user, array $notifications): void
{
if (null === $user->getEmail() || [] === $notifications) {
return;
}
$email = new TemplatedEmail();
$email
->htmlTemplate('@ChillMain/Notification/email_daily_digest.fr.md.twig')
->context([
'user' => $user,
'notifications' => $notifications,
'notification_count' => count($notifications),
])
->subject($this->translator->trans('notification.Daily Notification Digest'))
->to($user->getEmail());
try {
$this->mailer->send($email);
$this->logger->info('[NotificationMailer] Daily digest email sent successfully', [
'user_email' => $user->getEmail(),
'notification_count' => count($notifications),
]);
} catch (TransportExceptionInterface $e) {
$this->logger->warning('[NotificationMailer] Could not send daily digest email', [
'to' => $user->getEmail(),
'notification_count' => count($notifications),
'error_message' => $e->getMessage(),
'error_trace' => $e->getTraceAsString(),
]);
throw $e;
}
} }
private function sendNotificationEmailsToAddressesEmails(Notification $notification): void private function sendNotificationEmailsToAddressesEmails(Notification $notification): void
{ {
foreach ($notification->getAddressesEmailsAdded() as $emailAddress) { foreach ($notification->getAddresseeUserGroups() as $userGroup) {
if (!$userGroup->hasEmail()) {
continue;
}
$emailAddress = $userGroup->getEmail();
$email = new TemplatedEmail(); $email = new TemplatedEmail();
$email $email
->textTemplate('@ChillMain/Notification/email_non_system_notification_content_to_email.fr.md.twig') ->textTemplate('@ChillMain/Notification/email_non_system_notification_content_to_email.fr.md.twig')

View File

@@ -0,0 +1,30 @@
<?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\Notification\FlagProviders;
use Symfony\Component\Translation\TranslatableMessage;
use Symfony\Contracts\Translation\TranslatableInterface;
class NotificationByUserFlagProvider implements NotificationFlagProviderInterface
{
public const FLAG = 'notif-by-user';
public function getFlag(): string
{
return self::FLAG;
}
public function getLabel(): TranslatableInterface
{
return new TranslatableMessage('notification.flags.by-user');
}
}

View File

@@ -0,0 +1,21 @@
<?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\Notification\FlagProviders;
use Symfony\Contracts\Translation\TranslatableInterface;
interface NotificationFlagProviderInterface
{
public function getFlag(): string;
public function getLabel(): TranslatableInterface;
}

View File

@@ -0,0 +1,30 @@
<?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\Notification\FlagProviders;
use Symfony\Component\Translation\TranslatableMessage;
use Symfony\Contracts\Translation\TranslatableInterface;
class WorkflowTransitionNotificationFlagProvider implements NotificationFlagProviderInterface
{
public const FLAG = 'workflow-trans-notif';
public function getFlag(): string
{
return self::FLAG;
}
public function getLabel(): TranslatableInterface
{
return new TranslatableMessage('notification.flags.workflow-trans');
}
}

View File

@@ -0,0 +1,33 @@
<?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\Notification;
use Chill\MainBundle\Notification\FlagProviders\NotificationFlagProviderInterface;
final readonly class NotificationFlagManager
{
/**
* @var array<NotificationFlagProviderInterface>
*/
private array $notificationFlagProviders;
public function __construct(
iterable $notificationFlagProviders,
) {
$this->notificationFlagProviders = iterator_to_array($notificationFlagProviders);
}
public function getAllNotificationFlagProviders(): array
{
return $this->notificationFlagProviders;
}
}

View File

@@ -76,24 +76,6 @@ final class PhonenumberHelper implements PhoneNumberHelperInterface
->formatOutOfCountryCallingNumber($phoneNumber, $this->config['default_carrier_code']); ->formatOutOfCountryCallingNumber($phoneNumber, $this->config['default_carrier_code']);
} }
/**
* @throws NumberParseException
*/
public function parse(string $phoneNumber): PhoneNumber
{
$sanitizedPhoneNumber = $phoneNumber;
if (str_starts_with($sanitizedPhoneNumber, '00')) {
$sanitizedPhoneNumber = '+'.substr($sanitizedPhoneNumber, 2, null);
}
if (!str_starts_with($sanitizedPhoneNumber, '+') && !str_starts_with($sanitizedPhoneNumber, '0')) {
$sanitizedPhoneNumber = '+'.$sanitizedPhoneNumber;
}
return $this->phoneNumberUtil->parse($sanitizedPhoneNumber, $this->config['default_carrier_code']);
}
/** /**
* Get type (mobile, landline, ...) for phone number. * Get type (mobile, landline, ...) for phone number.
*/ */

View File

@@ -290,12 +290,19 @@ final class NotificationRepository implements ObjectRepository
return $qb; return $qb;
} }
private function queryByAddressee(User $addressee, bool $countQuery = false): QueryBuilder private function queryByAddressee(User $addressee): QueryBuilder
{ {
$qb = $this->repository->createQueryBuilder('n'); $qb = $this->repository->createQueryBuilder('n');
$qb $qb
->where($qb->expr()->isMemberOf(':addressee', 'n.addressees')) ->leftJoin('n.addresseeUserGroups', 'aug')
->leftJoin('aug.users', 'ugu')
->where(
$qb->expr()->orX(
$qb->expr()->isMemberOf(':addressee', 'n.addressees'),
$qb->expr()->eq('ugu.id', ':addressee')
)
)
->setParameter('addressee', $addressee); ->setParameter('addressee', $addressee);
return $qb; return $qb;
@@ -393,4 +400,30 @@ final class NotificationRepository implements ObjectRepository
return $nq->getResult(); return $nq->getResult();
} }
/**
* Find all notifications for a user that were created between two dates.
*
* @return array|Notification[]
*/
public function findNotificationsForUserBetweenDates(int $userId, \DateTimeInterface $startDate, \DateTimeInterface $endDate): array
{
$rsm = new Query\ResultSetMappingBuilder($this->em);
$rsm->addRootEntityFromClassMetadata(Notification::class, 'cmn');
$sql = 'SELECT '.$rsm->generateSelectClause(['cmn' => 'cmn']).' '.
'FROM chill_main_notification cmn '.
'JOIN chill_main_notification_addresses_user cmnau ON cmnau.notification_id = cmn.id '.
'WHERE cmnau.user_id = :userId '.
'AND cmn.date >= :startDate '.
'AND cmn.date <= :endDate '.
'ORDER BY cmn.date DESC';
$nq = $this->em->createNativeQuery($sql, $rsm)
->setParameter('userId', $userId)
->setParameter('startDate', $startDate, Types::DATETIME_MUTABLE)
->setParameter('endDate', $endDate, Types::DATETIME_MUTABLE);
return $nq->getResult();
}
} }

View File

@@ -37,8 +37,13 @@ export const ISOToDate = (str: string | null): Date | null => {
return null; return null;
} }
const [year, month, day] = str.split("-").map((p) => parseInt(p)); // If the string already contains time info, use it directly
if (str.includes('T') || str.includes(' ')) {
return new Date(str);
}
// Otherwise, parse date only
const [year, month, day] = str.split("-").map((p) => parseInt(p));
return new Date(year, month - 1, day, 0, 0, 0, 0); return new Date(year, month - 1, day, 0, 0, 0, 0);
}; };
@@ -56,7 +61,9 @@ export const ISOToDatetime = (str: string | null): Date | null => {
[time, timezone] = times.split(times.charAt(8)), [time, timezone] = times.split(times.charAt(8)),
[hours, minutes, seconds] = time.split(":").map((s) => parseInt(s)); [hours, minutes, seconds] = time.split(":").map((s) => parseInt(s));
if ("0000" === timezone) { if ("0000" === timezone) {
return new Date(Date.UTC(year, month - 1, date, hours, minutes, seconds)); return new Date(
Date.UTC(year, month - 1, date, hours, minutes, seconds),
);
} }
return new Date(year, month - 1, date, hours, minutes, seconds); return new Date(year, month - 1, date, hours, minutes, seconds);
@@ -152,7 +159,9 @@ export const intervalISOToDays = (str: string | null): number | null => {
vstring = ""; vstring = "";
break; break;
default: default:
throw Error("this character should not appears: " + str.charAt(i)); throw Error(
"this character should not appears: " + str.charAt(i),
);
} }
} }

View File

@@ -126,8 +126,7 @@ function loadDynamicPicker(element) {
-1 === -1 ===
this.suggested.findIndex( this.suggested.findIndex(
(e) => e.type === entity.type && e.id === entity.id, (e) => e.type === entity.type && e.id === entity.id,
) && )
"me" !== entity
) { ) {
this.suggested.push(entity); this.suggested.push(entity);
} }

View File

@@ -11,11 +11,6 @@ export interface Civility {
// TODO // TODO
} }
export interface Household {
type: "household";
id: number;
}
export interface Job { export interface Job {
id: number; id: number;
type: "user_job"; type: "user_job";
@@ -220,61 +215,3 @@ export interface ExportGeneration {
export interface PrivateCommentEmbeddable { export interface PrivateCommentEmbeddable {
comments: Record<number, string>; comments: Record<number, string>;
} }
// API Exception types
export interface TransportExceptionInterface {
name: string;
}
export interface ValidationExceptionInterface
extends TransportExceptionInterface {
name: "ValidationException";
error: object;
violations: string[];
titles: string[];
propertyPaths: string[];
}
export interface AccessExceptionInterface extends TransportExceptionInterface {
name: "AccessException";
violations: string[];
}
export interface NotFoundExceptionInterface
extends TransportExceptionInterface {
name: "NotFoundException";
}
export interface ServerExceptionInterface extends TransportExceptionInterface {
name: "ServerException";
message: string;
code: number;
body: string;
}
export interface ConflictHttpExceptionInterface
extends TransportExceptionInterface {
name: "ConflictHttpException";
violations: string[];
}
export type ApiException =
| ValidationExceptionInterface
| AccessExceptionInterface
| NotFoundExceptionInterface
| ServerExceptionInterface
| ConflictHttpExceptionInterface;
export interface Modal {
showModal: boolean;
modalDialogClass: string;
}
export interface Selected {
result: UserGroupOrUser;
}
export interface addNewEntities {
selected: Selected[];
modal: Modal;
}

View File

@@ -144,7 +144,10 @@
</template> </template>
<template #action> <template #action>
<li v-if="!this.context.edit && this.useDatePane"> <li v-if="!this.context.edit && this.useDatePane">
<button class="btn btn-update change-icon" @click="closeEditPane"> <button
class="btn btn-update change-icon"
@click="closeEditPane"
>
{{ $t("nav.next") }} {{ $t("nav.next") }}
<i class="fa fa-fw fa-arrow-right" /> <i class="fa fa-fw fa-arrow-right" />
</button> </button>
@@ -367,7 +370,8 @@ export default {
getTextTitle() { getTextTitle() {
if ( if (
typeof this.options.title !== "undefined" && typeof this.options.title !== "undefined" &&
(this.options.title.edit !== null || this.options.title.create !== null) (this.options.title.edit !== null ||
this.options.title.create !== null)
) { ) {
return this.context.edit return this.context.edit
? this.options.title.edit ? this.options.title.edit
@@ -485,7 +489,10 @@ export default {
if (!this.context.edit) { if (!this.context.edit) {
this.context.edit = true; this.context.edit = true;
this.context.addressId = params.addressId; this.context.addressId = params.addressId;
console.log("context is now edit, with address", params.addressId); console.log(
"context is now edit, with address",
params.addressId,
);
} }
} }
}, },
@@ -612,7 +619,9 @@ export default {
? this.entity.address.confidential ? this.entity.address.confidential
: false; : false;
this.entity.selected.isNoAddress = this.entity.selected.isNoAddress =
this.context.edit && this.entity.address.text === "" ? true : false; this.context.edit && this.entity.address.text === ""
? true
: false;
this.entity.selected.country = this.context.edit this.entity.selected.country = this.context.edit
? this.entity.address.country ? this.entity.address.country
@@ -707,7 +716,8 @@ export default {
// add the address reference, if any // add the address reference, if any
if (this.entity.selected.address.addressReference !== undefined) { if (this.entity.selected.address.addressReference !== undefined) {
newAddress = Object.assign(newAddress, { newAddress = Object.assign(newAddress, {
addressReference: this.entity.selected.address.addressReference, addressReference:
this.entity.selected.address.addressReference,
}); });
} else { } else {
newAddress = Object.assign(newAddress, { newAddress = Object.assign(newAddress, {
@@ -727,7 +737,10 @@ export default {
}); });
} }
if (this.validTo && null !== this.entity.selected.valid.to) { if (this.validTo && null !== this.entity.selected.valid.to) {
console.log("add validTo in fetch body", this.entity.selected.valid.to); console.log(
"add validTo in fetch body",
this.entity.selected.valid.to,
);
newAddress = Object.assign(newAddress, { newAddress = Object.assign(newAddress, {
validTo: { validTo: {
datetime: `${this.entity.selected.valid.to.toISOString().split("T")[0]}T00:00:00+0100`, datetime: `${this.entity.selected.valid.to.toISOString().split("T")[0]}T00:00:00+0100`,
@@ -739,7 +752,10 @@ export default {
newPostcode = Object.assign(newPostcode, { newPostcode = Object.assign(newPostcode, {
country: { id: this.entity.selected.country.id }, country: { id: this.entity.selected.country.id },
}); //TODO why not assign postcodeBody here = Object.assign(postcodeBody, {'origin': 3}); ? }); //TODO why not assign postcodeBody here = Object.assign(postcodeBody, {'origin': 3}); ?
console.log("writeNew postcode is true! newPostcode: ", newPostcode); console.log(
"writeNew postcode is true! newPostcode: ",
newPostcode,
);
newAddress = Object.assign(newAddress, { newAddress = Object.assign(newAddress, {
newPostcode: newPostcode, newPostcode: newPostcode,
}); });

View File

@@ -72,7 +72,10 @@ export default {
this.entity.addressMap.zoom, this.entity.addressMap.zoom,
); );
} else { } else {
this.map.setView(lonLatForLeaflet(this.addressPoint.coordinates), 15); this.map.setView(
lonLatForLeaflet(this.addressPoint.coordinates),
15,
);
} }
this.map.scrollWheelZoom.disable(); this.map.scrollWheelZoom.disable();
@@ -102,7 +105,9 @@ export default {
}, },
update() { update() {
if (this.marker && this.entity.addressMap.center) { if (this.marker && this.entity.addressMap.center) {
this.marker.setLatLng(lonLatForLeaflet(this.entity.addressMap.center)); this.marker.setLatLng(
lonLatForLeaflet(this.entity.addressMap.center),
);
this.map.panTo(lonLatForLeaflet(this.entity.addressMap.center)); this.map.panTo(lonLatForLeaflet(this.entity.addressMap.center));
} }
}, },

View File

@@ -76,7 +76,9 @@ export default {
props: ["entity", "context", "updateMapCenter", "flag", "checkErrors"], props: ["entity", "context", "updateMapCenter", "flag", "checkErrors"],
data() { data() {
return { return {
value: this.context.edit ? this.entity.address.addressReference : null, value: this.context.edit
? this.entity.address.addressReference
: null,
isLoading: false, isLoading: false,
}; };
}, },
@@ -149,7 +151,8 @@ export default {
.then( .then(
(addresses) => (addresses) =>
new Promise((resolve, reject) => { new Promise((resolve, reject) => {
this.entity.loaded.addresses = addresses.results; this.entity.loaded.addresses =
addresses.results;
this.isLoading = false; this.isLoading = false;
resolve(); resolve();
}), }),
@@ -166,7 +169,8 @@ export default {
.then( .then(
(addresses) => (addresses) =>
new Promise((resolve, reject) => { new Promise((resolve, reject) => {
this.entity.loaded.addresses = addresses.results; this.entity.loaded.addresses =
addresses.results;
this.isLoading = false; this.isLoading = false;
resolve(); resolve();
}), }),

View File

@@ -136,7 +136,9 @@ export default {
}, },
methods: { methods: {
transName(value) { transName(value) {
return value.code && value.name ? `${value.name} (${value.code})` : ""; return value.code && value.name
? `${value.name} (${value.code})`
: "";
}, },
selectCity(value) { selectCity(value) {
console.log(value); console.log(value);
@@ -144,7 +146,8 @@ export default {
this.entity.selected.postcode.name = value.name; this.entity.selected.postcode.name = value.name;
this.entity.selected.postcode.code = value.code; this.entity.selected.postcode.code = value.code;
if (value.center) { if (value.center) {
this.entity.selected.postcode.coordinates = value.center.coordinates; this.entity.selected.postcode.coordinates =
value.center.coordinates;
} }
this.entity.selected.writeNew.postcode = false; this.entity.selected.writeNew.postcode = false;
this.$emit("getReferenceAddresses", value); this.$emit("getReferenceAddresses", value);
@@ -165,7 +168,8 @@ export default {
.then( .then(
(cities) => (cities) =>
new Promise((resolve, reject) => { new Promise((resolve, reject) => {
this.entity.loaded.cities = cities.results.filter( this.entity.loaded.cities =
cities.results.filter(
(c) => c.origin !== 3, (c) => c.origin !== 3,
); // filter out user-defined cities ); // filter out user-defined cities
this.isLoading = false; this.isLoading = false;
@@ -184,7 +188,8 @@ export default {
.then( .then(
(cities) => (cities) =>
new Promise((resolve, reject) => { new Promise((resolve, reject) => {
this.entity.loaded.cities = cities.results.filter( this.entity.loaded.cities =
cities.results.filter(
(c) => c.origin !== 3, (c) => c.origin !== 3,
); // filter out user-defined cities ); // filter out user-defined cities
this.isLoading = false; this.isLoading = false;

View File

@@ -42,8 +42,12 @@ export default {
sortedCountries() { sortedCountries() {
const countries = this.entity.loaded.countries; const countries = this.entity.loaded.countries;
let sortedCountries = []; let sortedCountries = [];
sortedCountries.push(...countries.filter((c) => c.countryCode === "FR")); sortedCountries.push(
sortedCountries.push(...countries.filter((c) => c.countryCode === "BE")); ...countries.filter((c) => c.countryCode === "FR"),
);
sortedCountries.push(
...countries.filter((c) => c.countryCode === "BE"),
);
sortedCountries.push( sortedCountries.push(
...countries ...countries
.filter((c) => c.countryCode !== "FR") .filter((c) => c.countryCode !== "FR")

View File

@@ -1,6 +1,9 @@
<template> <template>
<div v-if="insideModal === false" class="loading"> <div v-if="insideModal === false" class="loading">
<i v-if="flag.loading" class="fa fa-circle-o-notch fa-spin fa-2x fa-fw" /> <i
v-if="flag.loading"
class="fa fa-circle-o-notch fa-spin fa-2x fa-fw"
/>
<span class="sr-only">{{ $t("loading") }}</span> <span class="sr-only">{{ $t("loading") }}</span>
</div> </div>
@@ -139,7 +142,8 @@ export default {
address["street"] = this.entity.selected.address.street address["street"] = this.entity.selected.address.street
? this.entity.selected.address.street ? this.entity.selected.address.street
: null; : null;
address["streetNumber"] = this.entity.selected.address.streetNumber address["streetNumber"] = this.entity.selected.address
.streetNumber
? this.entity.selected.address.streetNumber ? this.entity.selected.address.streetNumber
: null; : null;
address["floor"] = this.entity.selected.address.floor address["floor"] = this.entity.selected.address.floor
@@ -154,10 +158,12 @@ export default {
address["flat"] = this.entity.selected.address.flat address["flat"] = this.entity.selected.address.flat
? this.entity.selected.address.flat ? this.entity.selected.address.flat
: null; : null;
address["buildingName"] = this.entity.selected.address.buildingName address["buildingName"] = this.entity.selected.address
.buildingName
? this.entity.selected.address.buildingName ? this.entity.selected.address.buildingName
: null; : null;
address["distribution"] = this.entity.selected.address.distribution address["distribution"] = this.entity.selected.address
.distribution
? this.entity.selected.address.distribution ? this.entity.selected.address.distribution
: null; : null;
address["extra"] = this.entity.selected.address.extra address["extra"] = this.entity.selected.address.extra

View File

@@ -2,7 +2,10 @@
<div class="address-form"> <div class="address-form">
<!-- Not display in modal --> <!-- Not display in modal -->
<div v-if="insideModal === false" class="loading"> <div v-if="insideModal === false" class="loading">
<i v-if="flag.loading" class="fa fa-circle-o-notch fa-spin fa-2x fa-fw" /> <i
v-if="flag.loading"
class="fa fa-circle-o-notch fa-spin fa-2x fa-fw"
/>
<span class="sr-only">Loading...</span> <span class="sr-only">Loading...</span>
</div> </div>

View File

@@ -1,7 +1,10 @@
<template> <template>
<div v-if="!onlyButton" class="mt-4 flex-grow-1"> <div v-if="!onlyButton" class="mt-4 flex-grow-1">
<div class="loading"> <div class="loading">
<i v-if="flag.loading" class="fa fa-circle-o-notch fa-spin fa-2x fa-fw" /> <i
v-if="flag.loading"
class="fa fa-circle-o-notch fa-spin fa-2x fa-fw"
/>
<span class="sr-only">{{ $t("loading") }}</span> <span class="sr-only">{{ $t("loading") }}</span>
</div> </div>
@@ -42,7 +45,9 @@
name="button" name="button"
:title="$t(getTextButton)" :title="$t(getTextButton)"
> >
<span v-if="displayTextButton">{{ $t(getTextButton) }}</span> <span v-if="displayTextButton">{{
$t(getTextButton)
}}</span>
</button> </button>
</template> </template>
</action-buttons> </action-buttons>
@@ -55,7 +60,9 @@
:use-date-pane="useDatePane" :use-date-pane="useDatePane"
/> />
<div v-if="this.context.target.name === 'household' || this.context.edit"> <div
v-if="this.context.target.name === 'household' || this.context.edit"
>
<action-buttons :options="this.options" :defaultz="this.defaultz"> <action-buttons :options="this.options" :defaultz="this.defaultz">
<template #action> <template #action>
<button <button
@@ -66,7 +73,9 @@
name="button" name="button"
:title="$t(getTextButton)" :title="$t(getTextButton)"
> >
<span v-if="displayTextButton">{{ $t(getTextButton) }}</span> <span v-if="displayTextButton">{{
$t(getTextButton)
}}</span>
</button> </button>
</template> </template>
</action-buttons> </action-buttons>
@@ -88,7 +97,9 @@
name="button" name="button"
:title="$t(getTextButton)" :title="$t(getTextButton)"
> >
<span v-if="displayTextButton">{{ $t(getTextButton) }}</span> <span v-if="displayTextButton">{{
$t(getTextButton)
}}</span>
</button> </button>
</template> </template>
</action-buttons> </action-buttons>
@@ -154,7 +165,9 @@ export default {
: this.defaultz.button.text.create; : this.defaultz.button.text.create;
}, },
getSuccessText() { getSuccessText() {
return this.context.edit ? "address_edit_success" : "address_new_success"; return this.context.edit
? "address_edit_success"
: "address_new_success";
}, },
onlyButton() { onlyButton() {
return typeof this.options.onlyButton !== "undefined" return typeof this.options.onlyButton !== "undefined"

View File

@@ -1,6 +1,9 @@
<template> <template>
<div v-if="insideModal === false" class="loading"> <div v-if="insideModal === false" class="loading">
<i v-if="flag.loading" class="fa fa-circle-o-notch fa-spin fa-2x fa-fw" /> <i
v-if="flag.loading"
class="fa fa-circle-o-notch fa-spin fa-2x fa-fw"
/>
<span class="sr-only">{{ $t("loading") }}</span> <span class="sr-only">{{ $t("loading") }}</span>
</div> </div>

View File

@@ -86,7 +86,10 @@ onMounted(() => {
<template> <template>
<div id="waiting-screen"> <div id="waiting-screen">
<div v-if="isPending && isFetching" class="alert alert-danger text-center"> <div
v-if="isPending && isFetching"
class="alert alert-danger text-center"
>
<div> <div>
<p> <p>
{{ trans(EXPORT_GENERATION_EXPORT_GENERATION_IS_PENDING) }} {{ trans(EXPORT_GENERATION_EXPORT_GENERATION_IS_PENDING) }}

View File

@@ -67,7 +67,9 @@
@click="selectTab('MyWorkflows')" @click="selectTab('MyWorkflows')"
> >
{{ $t("my_workflows.tab") }} {{ $t("my_workflows.tab") }}
<tab-counter :count="state.workflows.count + state.workflowsCc.count" /> <tab-counter
:count="state.workflows.count + state.workflowsCc.count"
/>
</a> </a>
</li> </li>
<li class="nav-item loading ms-auto py-2" v-if="loading"> <li class="nav-item loading ms-auto py-2" v-if="loading">

View File

@@ -25,9 +25,11 @@
</template> </template>
<template #body> <template #body>
<p class="news-date"> <p class="news-date">
<time class="createdBy" datetime="{{item.startDate.datetime}}">{{ <time
$d(newsItemStartDate(), "text") class="createdBy"
}}</time> datetime="{{item.startDate.datetime}}"
>{{ $d(newsItemStartDate(), "text") }}</time
>
</p> </p>
<div v-html="convertMarkdownToHtml(item.content)"></div> <div v-html="convertMarkdownToHtml(item.content)"></div>
</template> </template>
@@ -91,7 +93,10 @@ const truncateContent = (content: string): string => {
// Truncate if amount of lines are too many // Truncate if amount of lines are too many
if (lines.length > props.maxLines && content.length < props.maxLength) { if (lines.length > props.maxLines && content.length < props.maxLength) {
const truncatedContent = lines.slice(0, props.maxLines).join("\n").trim(); const truncatedContent = lines
.slice(0, props.maxLines)
.join("\n")
.trim();
return truncatedContent + "..."; return truncatedContent + "...";
} }
@@ -120,7 +125,8 @@ const truncateContent = (content: string): string => {
if (linkStartIndex !== -1) { if (linkStartIndex !== -1) {
const linkEndIndex = content.indexOf(")", linkStartIndex); const linkEndIndex = content.indexOf(")", linkStartIndex);
const url = content.slice(linkStartIndex + 1, linkEndIndex); const url = content.slice(linkStartIndex + 1, linkEndIndex);
truncatedContent = truncatedContent.slice(0, linkStartIndex) + `(${url})`; truncatedContent =
truncatedContent.slice(0, linkStartIndex) + `(${url})`;
} }
truncatedContent += "..."; truncatedContent += "...";

View File

@@ -20,7 +20,10 @@
<th scope="col" /> <th scope="col" />
</template> </template>
<template #tbody> <template #tbody>
<tr v-for="(c, i) in accompanyingCourses.results" :key="`course-${i}`"> <tr
v-for="(c, i) in accompanyingCourses.results"
:key="`course-${i}`"
>
<td>{{ $d(c.openingDate.datetime, "short") }}</td> <td>{{ $d(c.openingDate.datetime, "short") }}</td>
<td> <td>
<span <span
@@ -34,7 +37,11 @@
</span> </span>
</td> </td>
<td> <td>
<span v-for="p in c.participations" class="me-1" :key="p.person.id"> <span
v-for="p in c.participations"
class="me-1"
:key="p.person.id"
>
<on-the-fly <on-the-fly
:type="p.person.type" :type="p.person.type"
:id="p.person.id" :id="p.person.id"
@@ -45,12 +52,16 @@
</span> </span>
</td> </td>
<td> <td>
<span v-if="c.emergency" class="badge rounded-pill bg-danger me-1">{{ <span
$t("emergency") v-if="c.emergency"
}}</span> class="badge rounded-pill bg-danger me-1"
<span v-if="c.confidential" class="badge rounded-pill bg-danger">{{ >{{ $t("emergency") }}</span
$t("confidential") >
}}</span> <span
v-if="c.confidential"
class="badge rounded-pill bg-danger"
>{{ $t("confidential") }}</span
>
</td> </td>
<td> <td>
<a class="btn btn-sm btn-show" :href="getUrl(c)"> <a class="btn btn-sm btn-show" :href="getUrl(c)">

View File

@@ -27,7 +27,9 @@
:plural="counter.accompanyingCourses" :plural="counter.accompanyingCourses"
> >
<template #n> <template #n>
<span>{{ counter.accompanyingCourses }}</span> <span>{{
counter.accompanyingCourses
}}</span>
</template> </template>
</i18n-t> </i18n-t>
</li> </li>

Some files were not shown because too many files have changed in this diff Show More