Merge branch 'homepage/rewrite'

This commit is contained in:
Julien Fastré 2022-01-31 15:40:40 +01:00
commit 72815e4b70
39 changed files with 1665 additions and 102 deletions

View File

@ -11,6 +11,8 @@ and this project adheres to
## Unreleased
<!-- write down unreleased development here -->
[fast_actions] improve fast-actions buttons override mechanism, fix https://gitlab.com/champs-libres/departement-de-la-vendee/accent-suivi-developpement/-/issues/413
[homepage widget] add vue homepage_widget with asynchone loading, give a global view resume of the user concerned actions, notifications, etc.
* [person] accompanying course: optimisation: do not fetch some resources for the banner (https://gitlab.com/champs-libres/departement-de-la-vendee/chill/-/issues/409)
* [person] accompanying course: close modal when edit participation (https://gitlab.com/champs-libres/departement-de-la-vendee/chill/-/issues/420)
* [person] accompanying course: treat validation error when editing on-the-fly entities (https://gitlab.com/champs-libres/departement-de-la-vendee/chill/-/issues/420)

View File

@ -80,11 +80,6 @@ parameters:
count: 1
path: src/Bundle/ChillPersonBundle/Form/ChoiceLoader/PersonChoiceLoader.php
-
message: "#^Foreach overwrites \\$action with its value variable\\.$#"
count: 1
path: src/Bundle/ChillPersonBundle/Repository/AccompanyingPeriod/AccompanyingPeriodWorkRepository.php
-
message: "#^Foreach overwrites \\$action with its value variable\\.$#"
count: 1

View File

@ -30,36 +30,6 @@ parameters:
count: 2
path: src/Bundle/ChillPersonBundle/Household/MembersEditorFactory.php
-
message: "#^Parameter \\$action of method Chill\\\\PersonBundle\\\\Repository\\\\AccompanyingPeriod\\\\AccompanyingPeriodWorkRepository\\:\\:buildQueryBySocialActionWithDescendants\\(\\) has invalid type Chill\\\\PersonBundle\\\\Repository\\\\AccompanyingPeriod\\\\SocialAction\\.$#"
count: 1
path: src/Bundle/ChillPersonBundle/Repository/AccompanyingPeriod/AccompanyingPeriodWorkRepository.php
-
message: "#^Parameter \\$action of method Chill\\\\PersonBundle\\\\Repository\\\\AccompanyingPeriod\\\\AccompanyingPeriodWorkRepository\\:\\:countBySocialActionWithDescendants\\(\\) has invalid type Chill\\\\PersonBundle\\\\Repository\\\\AccompanyingPeriod\\\\SocialAction\\.$#"
count: 1
path: src/Bundle/ChillPersonBundle/Repository/AccompanyingPeriod/AccompanyingPeriodWorkRepository.php
-
message: "#^Undefined variable\\: \\$action$#"
count: 1
path: src/Bundle/ChillPersonBundle/Repository/AccompanyingPeriod/AccompanyingPeriodWorkRepository.php
-
message: "#^Undefined variable\\: \\$limit$#"
count: 1
path: src/Bundle/ChillPersonBundle/Repository/AccompanyingPeriod/AccompanyingPeriodWorkRepository.php
-
message: "#^Undefined variable\\: \\$offset$#"
count: 1
path: src/Bundle/ChillPersonBundle/Repository/AccompanyingPeriod/AccompanyingPeriodWorkRepository.php
-
message: "#^Undefined variable\\: \\$orderBy$#"
count: 1
path: src/Bundle/ChillPersonBundle/Repository/AccompanyingPeriod/AccompanyingPeriodWorkRepository.php
-
message: "#^Variable variables are not allowed\\.$#"
count: 4

View File

@ -400,11 +400,6 @@ parameters:
count: 1
path: src/Bundle/ChillPersonBundle/Form/Type/PersonPhoneType.php
-
message: "#^Method Chill\\\\PersonBundle\\\\Repository\\\\AccompanyingPeriod\\\\AccompanyingPeriodWorkRepository\\:\\:buildQueryBySocialActionWithDescendants\\(\\) has invalid return type Chill\\\\PersonBundle\\\\Repository\\\\AccompanyingPeriod\\\\QueryBuilder\\.$#"
count: 1
path: src/Bundle/ChillPersonBundle/Repository/AccompanyingPeriod/AccompanyingPeriodWorkRepository.php
-
message: "#^Construct empty\\(\\) is not allowed\\. Use more strict comparison\\.$#"
count: 3

View File

@ -34,6 +34,8 @@ p.date-label {
font-size: 18pt;
}
div.dashboard,
h4.badge-title,
h3.badge-title,
h2.badge-title {
ul.list-content {
font-size: 70%;

View File

@ -13,13 +13,19 @@ namespace Chill\MainBundle\Controller;
use Chill\MainBundle\Entity\Notification;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Pagination\PaginatorFactory;
use Chill\MainBundle\Repository\NotificationRepository;
use Chill\MainBundle\Security\Authorization\NotificationVoter;
use Chill\MainBundle\Serializer\Model\Collection;
use Chill\MainBundle\Serializer\Model\Counter;
use Doctrine\ORM\EntityManagerInterface;
use RuntimeException;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use Symfony\Component\Security\Core\Security;
use Symfony\Component\Serializer\SerializerInterface;
use UnexpectedValueException;
/**
@ -29,12 +35,26 @@ class NotificationApiController
{
private EntityManagerInterface $entityManager;
private NotificationRepository $notificationRepository;
private PaginatorFactory $paginatorFactory;
private Security $security;
public function __construct(EntityManagerInterface $entityManager, Security $security)
{
private SerializerInterface $serializer;
public function __construct(
EntityManagerInterface $entityManager,
NotificationRepository $notificationRepository,
PaginatorFactory $paginatorFactory,
Security $security,
SerializerInterface $serializer
) {
$this->entityManager = $entityManager;
$this->notificationRepository = $notificationRepository;
$this->paginatorFactory = $paginatorFactory;
$this->security = $security;
$this->serializer = $serializer;
}
/**
@ -53,6 +73,37 @@ class NotificationApiController
return $this->markAs('unread', $notification);
}
/**
* @Route("/my/unread")
*/
public function myUnreadNotifications(Request $request): JsonResponse
{
$total = $this->notificationRepository->countUnreadByUser($this->security->getUser());
if ($request->query->getBoolean('countOnly')) {
return new JsonResponse(
$this->serializer->serialize(new Counter($total), 'json', ['groups' => ['read']]),
JsonResponse::HTTP_OK,
[],
true
);
}
$paginator = $this->paginatorFactory->create($total);
$notifications = $this->notificationRepository->findUnreadByUser(
$this->security->getUser(),
$paginator->getItemsPerPage(),
$paginator->getCurrentPageFirstItemNumber()
);
$collection = new Collection($notifications, $paginator);
return new JsonResponse(
$this->serializer->serialize($collection, 'json', ['groups' => ['read']]),
JsonResponse::HTTP_OK,
[],
true
);
}
private function markAs(string $target, Notification $notification): JsonResponse
{
if (!$this->security->isGranted(NotificationVoter::NOTIFICATION_TOGGLE_READ_STATUS, $notification)) {

View File

@ -193,6 +193,29 @@ final class NotificationRepository implements ObjectRepository
return $this->repository->findOneBy($criteria, $orderBy);
}
/**
* @return array|Notification[]
*/
public function findUnreadByUser(User $user, int $limit = 20, int $offset = 0): array
{
$rsm = new Query\ResultSetMappingBuilder($this->em);
$rsm->addRootEntityFromClassMetadata(Notification::class, 'cmn');
$sql = 'SELECT ' . $rsm->generateSelectClause(['cmn' => 'cmn']) . ' ' .
'FROM chill_main_notification cmn ' .
'WHERE ' .
'EXISTS (select 1 FROM chill_main_notification_addresses_unread cmnau WHERE cmnau.user_id = :userId and cmnau.notification_id = cmn.id) ' .
'ORDER BY cmn.date DESC ' .
'LIMIT :limit OFFSET :offset';
$nq = $this->em->createNativeQuery($sql, $rsm)
->setParameter('userId', $user->getId())
->setParameter('limit', $limit)
->setParameter('offset', $offset);
return $nq->getResult();
}
public function getClassName()
{
return Notification::class;

View File

@ -136,3 +136,18 @@ $chill-theme-buttons: (
.btn-sm, .btn-group-sm > .btn {
min-width: 36px;
}
// Homepage special fast action buttons
div.sticky-buttons {
position: fixed;
bottom: 3em;
right: 2em;
.btn-circle {
width: 50px; height: 50px;
border-radius: 50%;
text-align: center;
padding: 0.45rem 0.7rem;
display: block;
margin-bottom: 0.5rem;
}
}

View File

@ -0,0 +1,16 @@
import { createApp } from 'vue';
import { _createI18n } from 'ChillMainAssets/vuejs/_js/i18n';
import { appMessages } from 'ChillMainAssets/vuejs/HomepageWidget/js/i18n';
import { store } from 'ChillMainAssets/vuejs/HomepageWidget/js/store';
import App from 'ChillMainAssets/vuejs/HomepageWidget/App';
const i18n = _createI18n(appMessages);
const app = createApp({
template: `<app></app>`,
})
.use(store)
.use(i18n)
.component('app', App)
.mount('#homepage_widget')
;

View File

@ -0,0 +1,140 @@
<template>
<h2>{{ $t('main_title') }}</h2>
<ul class="nav nav-tabs">
<li class="nav-item">
<a class="nav-link"
:class="{'active': activeTab === 'MyCustoms'}"
@click="selectTab('MyCustoms')">
<i class="fa fa-dashboard"></i>
</a>
</li>
<li class="nav-item">
<a class="nav-link"
:class="{'active': activeTab === 'MyNotifications'}"
@click="selectTab('MyNotifications')">
{{ $t('my_notifications.tab') }}
<tab-counter :count="state.notifications.count"></tab-counter>
</a>
</li>
<li class="nav-item">
<a class="nav-link"
:class="{'active': activeTab === 'MyAccompanyingCourses'}"
@click="selectTab('MyAccompanyingCourses')">
{{ $t('my_accompanying_courses.tab') }}
<tab-counter :count="state.accompanyingCourses.count"></tab-counter>
</a>
</li>
<li class="nav-item">
<a class="nav-link"
:class="{'active': activeTab === 'MyWorks'}"
@click="selectTab('MyWorks')">
{{ $t('my_works.tab') }}
<tab-counter :count="state.works.count"></tab-counter>
</a>
</li>
<li class="nav-item">
<a class="nav-link"
:class="{'active': activeTab === 'MyEvaluations'}"
@click="selectTab('MyEvaluations')">
{{ $t('my_evaluations.tab') }}
<tab-counter :count="state.evaluations.count"></tab-counter>
</a>
</li>
<li class="nav-item">
<a class="nav-link"
:class="{'active': activeTab === 'MyTasks'}"
@click="selectTab('MyTasks')">
{{ $t('my_tasks.tab') }}
<tab-counter :count="state.tasks.warning.count"></tab-counter>
<tab-counter :count="state.tasks.alert.count"></tab-counter>
</a>
</li>
<li class="nav-item loading ms-auto py-2" v-if="loading">
<i class="fa fa-circle-o-notch fa-spin fa-lg text-chill-gray" :title="$t('loading')"></i>
</li>
</ul>
<div class="my-4">
<my-customs
v-if="activeTab === 'MyCustoms'">
</my-customs>
<my-works
v-else-if="activeTab === 'MyWorks'">
</my-works>
<my-evaluations
v-else-if="activeTab === 'MyEvaluations'">
</my-evaluations>
<my-tasks
v-else-if="activeTab === 'MyTasks'">
</my-tasks>
<my-accompanying-courses
v-else-if="activeTab === 'MyAccompanyingCourses'">
</my-accompanying-courses>
<my-notifications
v-else-if="activeTab === 'MyNotifications'">
</my-notifications>
</div>
</template>
<script>
import MyCustoms from './MyCustoms';
import MyWorks from './MyWorks';
import MyEvaluations from './MyEvaluations';
import MyTasks from './MyTasks';
import MyAccompanyingCourses from './MyAccompanyingCourses';
import MyNotifications from './MyNotifications';
import TabCounter from './TabCounter';
import { mapState } from "vuex";
export default {
name: "App",
components: {
MyCustoms,
MyWorks,
MyEvaluations,
MyTasks,
MyAccompanyingCourses,
MyNotifications,
TabCounter,
},
data() {
return {
activeTab: 'MyCustoms'
}
},
computed: {
...mapState([
'loading',
]),
// just to see all in devtool :
...mapState({
state: (state) => state,
}),
},
methods: {
selectTab(tab) {
this.$store.dispatch('getByTab', { tab: tab });
this.activeTab = tab;
}
},
mounted() {
for (const m of [
'MyNotifications',
'MyAccompanyingCourses',
'MyWorks',
'MyEvaluations',
'MyTasks',
]) {
this.$store.dispatch('getByTab', { tab: m, param: "countOnly=1" });
}
}
}
</script>
<style scoped>
a.nav-link {
cursor: pointer;
}
</style>

View File

@ -0,0 +1,60 @@
<template>
<div class="alert alert-light">{{ $t('my_accompanying_courses.description') }}</div>
<span v-if="noResults" class="chill-no-data-statement">{{ $t('no_data') }}</span>
<tab-table v-else>
<template v-slot:thead>
<th scope="col">id</th>
<th scope="col">Ouvert le</th>
<th scope="col">Usagers concernés</th>
<th scope="col"></th>
</template>
<template v-slot:tbody>
<tr v-for="(c, i) in accompanyingCourses.results" :key="`course-${i}`">
<td>{{ c.id}}</td>
<td>{{ $d(c.openingDate.datetime, 'long') }}</td>
<td>{{ c.participations.length }}</td>
<td>
<a class="btn btn-sm btn-show" :href="getUrl(c)">
{{ $t('show_entity', { entity: $t('the_course') }) }}
</a>
</td>
</tr>
</template>
</tab-table>
</template>
<script>
import { mapState, mapGetters } from "vuex";
import TabTable from "./TabTable";
export default {
name: "MyAccompanyingCourses",
components: {
TabTable
},
computed: {
...mapState([
'accompanyingCourses',
]),
...mapGetters([
'isAccompanyingCoursesLoaded',
]),
noResults() {
if (!this.isAccompanyingCoursesLoaded) {
return false;
} else {
return this.accompanyingCourses.count === 0;
}
},
},
methods: {
getUrl(c) {
return `/fr/parcours/${c.id}`
}
}
}
</script>
<style scoped>
</style>

View File

@ -0,0 +1,77 @@
<template>
<span v-if="noResults" class="chill-no-data-statement">{{ $t('no_dashboard') }}</span>
<div v-else id="dashboards" class="row g-3" data-masonry='{"percentPosition": true }'>
<div class="mbloc col col-sm-6 col-lg-4">
<div class="custom1">
<ul class="list-unstyled">
<li v-if="counter.notifications > 0">
<b>{{ counter.notifications }}</b> {{ $t('counter.unread_notifications') }}
</li>
<li v-if="counter.accompanyingCourses > 0">
<b>{{ counter.accompanyingCourses }}</b> {{ $t('counter.assignated_courses') }}
</li>
<li v-if="counter.works > 0">
<b>{{ counter.works }}</b> {{ $t('counter.assignated_actions') }}
</li>
<li v-if="counter.evaluations > 0">
<b>{{ counter.evaluations }}</b> {{ $t('counter.assignated_evaluations') }}
</li>
<li v-if="counter.tasksAlert > 0">
<b>{{ counter.tasksAlert }}</b> {{ $t('counter.alert_tasks') }}
</li>
<li v-if="counter.tasksWarning > 0">
<b>{{ counter.tasksWarning }}</b> {{ $t('counter.warning_tasks') }}
</li>
</ul>
</div>
</div>
<!--
<div class="mbloc col col-sm-6 col-lg-4">
<div class="custom2">
Mon dashboard personnalisé
</div>
</div>
<div class="mbloc col col-sm-6 col-lg-4">
<div class="custom3">
Mon dashboard personnalisé
</div>
</div>
<div class="mbloc col col-sm-6 col-lg-4">
<div class="custom4">
Mon dashboard personnalisé
</div>
</div>
-->
</div>
</template>
<script>
import { mapGetters } from "vuex";
import Masonry from 'masonry-layout/masonry';
export default {
name: "MyCustoms",
computed: {
...mapGetters(['counter']),
noResults() {
return false
},
},
mounted() {
const elem = document.querySelector('#dashboards');
const masonry = new Masonry(elem, {});
}
}
</script>
<style scoped>
div.custom4,
div.custom3,
div.custom2 {
font-style: italic;
color: var(--bs-chill-gray);
}
</style>

View File

@ -0,0 +1,57 @@
<template>
<div class="alert alert-light">{{ $t('my_evaluations.description') }}</div>
<span v-if="noResults" class="chill-no-data-statement">{{ $t('no_data') }}</span>
<tab-table v-else>
<template v-slot:thead>
<th scope="col">id</th>
<th scope="col"></th>
</template>
<template v-slot:tbody>
<tr v-for="(e, i) in evaluations.results" :key="`evaluation-${i}`">
<td>{{ e.id}}</td>
<td>
<a class="btn btn-sm btn-show" :href="getUrl(e)">
{{ $t('show_entity', { entity: $t('the_evaluation') }) }}
</a>
</td>
</tr>
</template>
</tab-table>
</template>
<script>
import { mapState, mapGetters } from "vuex";
import TabTable from "./TabTable";
export default {
name: "MyEvaluations",
components: {
TabTable
},
computed: {
...mapState([
'evaluations',
]),
...mapGetters([
'isEvaluationsLoaded',
]),
noResults() {
if (!this.isEvaluationsLoaded) {
return false;
} else {
return this.evaluations.count === 0;
}
}
},
methods: {
getUrl(e) {
let anchor = '#evaluations';
return `/fr/person/accompanying-period/work/${e.id}/edit${anchor}`
}
},
}
</script>
<style scoped>
</style>

View File

@ -0,0 +1,96 @@
<template>
<div class="alert alert-light">{{ $t('my_notifications.description') }}</div>
<span v-if="noResults" class="chill-no-data-statement">{{ $t('no_data') }}</span>
<tab-table v-else>
<template v-slot:thead>
<th scope="col">{{ $t('Date') }}</th>
<th scope="col">{{ $t('Subject') }}</th>
<th scope="col">{{ $t('From') }}</th>
<th scope="col"></th>
</template>
<template v-slot:tbody>
<tr v-for="(n, i) in notifications.results" :key="`notify-${i}`">
<td>{{ $d(n.date.datetime, 'long') }}</td>
<td>
<span class="unread">
<i class="fa fa-envelope-o"></i>
<a :href="getNotificationUrl(n)">{{ n.title }}</a>
</span>
</td>
<td>{{ n.sender.text }}</td>
<td>
<a class="btn btn-sm btn-show"
:href="getEntityUrl(n)">
{{ $t('show_entity', { entity: getEntityName(n) }) }}
</a>
</td>
</tr>
</template>
</tab-table>
</template>
<script>
import { mapState, mapGetters } from "vuex";
import TabTable from "./TabTable";
import { appMessages } from 'ChillMainAssets/vuejs/HomepageWidget/js/i18n';
export default {
name: "MyNotifications",
components: {
TabTable
},
computed: {
...mapState([
'notifications',
]),
...mapGetters([
'isNotificationsLoaded',
]),
noResults() {
if (!this.isNotificationsLoaded) {
return false;
} else {
return this.notifications.count === 0;
}
}
},
methods: {
getNotificationUrl(n) {
return `/fr/notification/${n.id}/show`
},
getEntityName(n) {
switch (n.relatedEntityClass) {
case 'Chill\\ActivityBundle\\Entity\\Activity':
return appMessages.fr.the_activity;
case 'Chill\\PersonBundle\\Entity\\AccompanyingPeriod':
return appMessages.fr.the_course;
default:
throw 'notification type unknown';
}
},
getEntityUrl(n) {
switch (n.relatedEntityClass) {
case 'Chill\\ActivityBundle\\Entity\\Activity':
return `/fr/activity/${n.relatedEntityId}/show`
case 'Chill\\PersonBundle\\Entity\\AccompanyingPeriod':
return `/fr/parcours/${n.relatedEntityId}`
default:
throw 'notification type unknown';
}
}
}
}
</script>
<style lang="scss" scoped>
span.unread {
font-weight: bold;
i {
margin-right: 0.5em;
}
a {
text-decoration: unset;
}
}
</style>

View File

@ -0,0 +1,85 @@
<template>
<div class="alert alert-light">{{ $t('my_tasks.description_alert') }}</div>
<span v-if="noResultsWarning" class="chill-no-data-statement">{{ $t('no_data') }}</span>
<tab-table v-else>
<template v-slot:thead>
<th scope="col">id</th>
<th scope="col"></th>
</template>
<template v-slot:tbody>
<tr v-for="(t, i) in tasks.warning" :key="`task-warning-${i}`">
<td>{{ t.id}}</td>
<td>
<a class="btn btn-sm btn-show" :href="getUrl(t)">
{{ $t('show_entity', { entity: $t('the_task') }) }}
</a>
</td>
</tr>
</template>
</tab-table>
<div class="alert alert-light">{{ $t('my_tasks.description_warning') }}</div>
<span v-if="noResultsAlert" class="chill-no-data-statement">{{ $t('no_data') }}</span>
<tab-table v-else>
<template v-slot:thead>
<th scope="col">id</th>
<th scope="col"></th>
</template>
<template v-slot:tbody>
<tr v-for="(t, i) in tasks.alert" :key="`task-alert-${i}`">
<td>{{ t.id}}</td>
<td>
<a class="btn btn-sm btn-show" :href="getUrl(t)">
{{ $t('show_entity', { entity: $t('the_task') }) }}
</a>
</td>
</tr>
</template>
</tab-table>
</template>
<script>
import { mapState, mapGetters } from "vuex";
import TabTable from "./TabTable";
export default {
name: "MyTasks",
components: {
TabTable
},
computed: {
...mapState([
'tasks',
]),
...mapGetters([
'isTasksWarningLoaded',
'isTasksAlertLoaded',
]),
noResultsAlert() {
if (!this.isTasksAlertLoaded) {
return false;
} else {
return this.tasks.alert.count === 0;
}
},
noResultsWarning() {
if (!this.isTasksWarningLoaded) {
return false;
} else {
return this.tasks.warning.count === 0;
}
}
},
methods: {
getUrl(t) {
return `/fr/task/single-task/${t.id}/show`
}
},
}
</script>
<style scoped>
</style>

View File

@ -0,0 +1,79 @@
<template>
<div class="accompanying_course_work">
<div class="alert alert-light">{{ $t('my_works.description') }}</div>
<span v-if="noResults" class="chill-no-data-statement">{{ $t('no_data') }}</span>
<tab-table v-else>
<template v-slot:thead>
<th scope="col">{{ $t('StartDate') }}</th>
<th scope="col">{{ $t('SocialAction') }}</th>
<th scope="col"></th>
</template>
<template v-slot:tbody>
<tr v-for="(w, i) in works.results" :key="`works-${i}`">
<td>{{ $d(w.startDate.datetime, 'short') }}</td>
<td>
<h4 class="badge-title">
<span class="title_label"></span>
<span class="title_action">
{{ w.socialAction.text }}
</span>
</h4>
</td>
<td>
<div class="btn-group" role="group" aria-label="Actions">
<a class="btn btn-sm btn-update" :href="getUrl(w)">
{{ $t('show_entity', { entity: $t('the_action') }) }}
</a>
<a class="btn btn-sm btn-show" :href="getUrl(w.accompanyingPeriod)">
{{ $t('show_entity', { entity: $t('the_course') }) }}
</a>
</div>
</td>
</tr>
</template>
</tab-table>
</div>
</template>
<script>
import { mapState, mapGetters } from "vuex";
import TabTable from "./TabTable";
export default {
name: "MyWorks",
components: {
TabTable
},
computed: {
...mapState([
'works',
]),
...mapGetters([
'isWorksLoaded',
]),
noResults() {
if (!this.isWorksLoaded) {
return false;
} else {
return this.works.count === 0;
}
}
},
methods: {
getUrl(e) {
switch (e.type) {
case 'accompanying_period_work':
return `/fr/person/accompanying-period/work/${e.id}/edit`
case 'accompanying_period':
return `/fr/parcours/${e.id}`
default:
throw 'entity type unknown';
}
}
},
}
</script>
<style scoped>
</style>

View File

@ -0,0 +1,18 @@
<template>
<span v-if="isCounterAvailable"
class="badge rounded-pill bg-danger counter">
{{ count }}
</span>
</template>
<script>
export default {
name: "TabCounter",
props: ['count'],
computed: {
isCounterAvailable() {
return (typeof this.count !== 'undefined' && this.count > 0 )
}
}
}
</script>

View File

@ -0,0 +1,21 @@
<template>
<table class="table table-striped table-hover">
<thead>
<tr>
<slot name="thead"></slot>
</tr>
</thead>
<tbody>
<slot name="tbody"></slot>
</tbody>
</table>
</template>
<script>
export default {
name: "TabTable",
props: []
}
</script>
<style scoped></style>

View File

@ -0,0 +1,54 @@
const appMessages = {
fr: {
main_title: "Vue d'ensemble",
my_works: {
tab: "Mes actions",
description: "Liste des actions d'accompagnement dont je suis référent et qui arrivent à échéance.",
},
my_evaluations: {
tab: "Mes évaluations",
description: "Liste des évaluations dont je suis référent et qui arrivent à échéance.",
},
my_tasks: {
tab: "Mes tâches",
description_alert: "Liste des tâches auxquelles je suis assigné et dont la date de rappel est dépassée.",
description_warning: "Liste des tâches auxquelles je suis assigné et dont la date d'échéance est dépassée.",
},
my_accompanying_courses: {
tab: "Mes parcours",
description: "Liste des parcours d'accompagnement que l'on vient de m'attribuer.",
},
my_notifications: {
tab: "Mes notifications",
description: "Liste des notifications reçues et non lues.",
},
Date: "Date",
From: "De",
Subject: "Objet",
Entity: "Associé à",
show_entity: "Voir {entity}",
the_activity: "l'échange",
the_course: "le parcours",
the_action: "l'action",
the_evaluation: "l'évaluation",
the_task: "la tâche",
StartDate: "Date d'ouverture",
SocialAction: "Action d'accompagnement",
no_data: "Aucun résultats",
no_dashboard: "Pas de tableaux de bord",
counter: {
unread_notifications: "notifications non lues",
assignated_courses: "parcours récents assignés",
assignated_actions: "actions assignées",
assignated_evaluations: "évaluations assignées",
alert_tasks: "tâches en rappel",
warning_tasks: "tâches à échéances",
}
}
};
Object.assign(appMessages.fr);
export {
appMessages
};

View File

@ -0,0 +1,200 @@
import 'es6-promise/auto';
import { createStore } from 'vuex';
import { makeFetch } from "ChillMainAssets/lib/api/apiMethods";
import MyCustoms from "../MyCustoms";
import MyWorks from "../MyWorks";
import MyEvaluations from "../MyEvaluations";
import MyTasks from "../MyTasks";
import MyAccompanyingCourses from "../MyAccompanyingCourses";
import MyNotifications from "../MyNotifications";
const debug = process.env.NODE_ENV !== 'production';
const isEmpty = (obj) => {
return obj
&& Object.keys(obj).length <= 1
&& Object.getPrototypeOf(obj) === Object.prototype;
};
const store = createStore({
strict: debug,
state: {
works: {},
evaluations: {},
tasks: {
warning: {},
alert: {}
},
accompanyingCourses: {},
notifications: {},
errorMsg: [],
loading: false
},
getters: {
isWorksLoaded(state) {
return !isEmpty(state.works);
},
isEvaluationsLoaded(state) {
return !isEmpty(state.evaluations);
},
isTasksWarningLoaded(state) {
return !isEmpty(state.tasks.warning);
},
isTasksAlertLoaded(state) {
return !isEmpty(state.tasks.alert);
},
isAccompanyingCoursesLoaded(state) {
return !isEmpty(state.accompanyingCourses);
},
isNotificationsLoaded(state) {
return !isEmpty(state.notifications);
},
counter(state) {
return {
works: state.works.count,
evaluations: state.evaluations.count,
tasksWarning: state.tasks.warning.count,
tasksAlert: state.tasks.alert.count,
accompanyingCourses: state.accompanyingCourses.count,
notifications: state.notifications.count,
}
}
},
mutations: {
addWorks(state, works) {
console.log('addWorks', works);
state.works = works;
},
addEvaluations(state, evaluations) {
console.log('addEvaluations', evaluations);
state.evaluations = evaluations;
},
addTasksWarning(state, tasks) {
console.log('addTasksWarning', tasks);
state.tasks.warning = tasks;
},
addTasksAlert(state, tasks) {
console.log('addTasksAlert', tasks);
state.tasks.alert = tasks;
},
addCourses(state, courses) {
console.log('addCourses', courses);
state.accompanyingCourses = courses;
},
addNotifications(state, notifications) {
console.log('addNotifications', notifications);
state.notifications = notifications;
},
setLoading(state, bool) {
state.loading = bool;
},
catchError(state, error) {
state.errorMsg.push(error);
}
},
actions: {
getByTab({ commit, getters }, { tab, param }) {
switch (tab) {
case 'MyCustoms':
break;
case 'MyWorks':
if (!getters.isWorksLoaded) {
commit('setLoading', true);
const url = `/api/1.0/person/accompanying-period/work/my-near-end${'?'+ param}`;
makeFetch('GET', url)
.then((response) => {
commit('addWorks', response);
commit('setLoading', false);
})
.catch((error) => {
commit('catchError', error);
throw error;
})
;
}
break;
case 'MyEvaluations':
if (!getters.isEvaluationsLoaded) {
commit('setLoading', true);
const url = `/api/1.0/person/accompanying-period/work/evaluation/my-near-end${'?'+ param}`;
makeFetch('GET', url)
.then((response) => {
commit('addEvaluations', response);
commit('setLoading', false);
})
.catch((error) => {
commit('catchError', error);
throw error;
})
;
}
break;
case 'MyTasks':
if (!(getters.isTasksWarningLoaded && getters.isTasksAlertLoaded)) {
commit('setLoading', true);
const
urlWarning = `/api/1.0/task/single-task/list/my?f[q]=&f[checkboxes][status][]=warning&f[checkboxes][states][]=new&f[checkboxes][states][]=in_progress${'&'+ param}`,
urlAlert = `/api/1.0/task/single-task/list/my?f[q]=&f[checkboxes][status][]=alert&f[checkboxes][states][]=new&f[checkboxes][states][]=in_progress${'&'+ param}`
;
makeFetch('GET', urlWarning)
.then((response) => {
commit('addTasksWarning', response);
commit('setLoading', false);
})
.catch((error) => {
commit('catchError', error);
throw error;
})
;
makeFetch('GET', urlAlert)
.then((response) => {
commit('addTasksAlert', response);
commit('setLoading', false);
})
.catch((error) => {
commit('catchError', error);
throw error;
})
;
}
break;
case 'MyAccompanyingCourses':
if (!getters.isAccompanyingCoursesLoaded) {
commit('setLoading', true);
const url = `/api/1.0/person/accompanying-course/list/by-recent-attributions${'?'+ param}`;
makeFetch('GET', url)
.then((response) => {
commit('addCourses', response);
commit('setLoading', false);
})
.catch((error) => {
commit('catchError', error);
throw error;
})
;
}
break;
case 'MyNotifications':
if (!getters.isNotificationsLoaded) {
commit('setLoading', true);
const url = `/api/1.0/main/notification/my/unread${'?'+ param}`;
makeFetch('GET', url)
.then((response) => {
commit('addNotifications', response);
commit('setLoading', false);
})
.catch((error) => {
commit('catchError', error);
throw error;
})
;
}
break;
default:
throw 'tab '+ tab;
}
}
},
});
export { store };

View File

@ -0,0 +1,3 @@
<div class="sticky-buttons">
{# Override this file to add fast actions buttons #}
</div>

View File

@ -0,0 +1,15 @@
<div class="col-10 mt-5">
{# vue component #}
<div id="homepage_widget"></div>
{% include '@ChillMain/Homepage/fast_actions.html.twig' %}
</div>
{% block css %}
{{ encore_entry_link_tags('page_homepage_widget') }}
{% endblock %}
{% block js %}
{{ encore_entry_script_tags('page_homepage_widget') }}
{% endblock %}

View File

@ -60,26 +60,26 @@
{% endif %}
{% block content %}
<div class="col-8 main_search">
<h2>{{ 'Search'|trans }}</h2>
<form action="{{ path('chill_main_search') }}" method="get">
<input class="form-control form-control-lg" name="q" type="search" placeholder="{{ 'Search persons, ...'|trans }}" />
<center>
<button type="submit" class="btn btn-lg btn-warning mt-3">
<i class="fa fa-fw fa-search"></i> {{ 'Search'|trans }}
</button>
<a class="btn btn-lg btn-misc mt-3" href="{{ path('chill_main_advanced_search_list') }}">
<i class="fa fa-fw fa-search"></i> {{ 'Advanced search'|trans }}
</a>
</center>
</form>
</div>
<div class="col-8">
<a href="{{ path('chill_crud_aside_activity_new', {'type' : 7, 'duration' : '600', 'note' : 'Pas des remarques' }) }}"><div class="bloc btn btn-success btn-md btn-block">Appel téléphonique</div></a>
</div>
{{ chill_widget('homepage', {} ) }}
<div class="col-8 main_search">
<h2>{{ 'Search'|trans }}</h2>
<form action="{{ path('chill_main_search') }}" method="get">
<input class="form-control form-control-lg" name="q" type="search" placeholder="{{ 'Search persons, ...'|trans }}" />
<center>
<button type="submit" class="btn btn-lg btn-warning mt-3">
<i class="fa fa-fw fa-search"></i> {{ 'Search'|trans }}
</button>
<a class="btn btn-lg btn-misc mt-3" href="{{ path('chill_main_advanced_search_list') }}">
<i class="fa fa-fw fa-search"></i> {{ 'Advanced search'|trans }}
</a>
</center>
</form>
</div>
{# DISABLED {{ chill_widget('homepage', {} ) }} #}
{% include '@ChillMain/Homepage/index.html.twig' %}
{% endblock %}
</div>

View File

@ -0,0 +1,41 @@
<?php
/**
* 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.
*/
declare(strict_types=1);
namespace Chill\MainBundle\Serializer\Model;
use JsonSerializable;
class Counter implements JsonSerializable
{
private int $counter;
public function __construct(?int $counter)
{
$this->counter = $counter;
}
public function getCounter(): ?int
{
return $this->counter;
}
public function jsonSerialize()
{
return ['count' => $this->counter];
}
public function setCounter(?int $counter): Counter
{
$this->counter = $counter;
return $this;
}
}

View File

@ -0,0 +1,71 @@
<?php
/**
* 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.
*/
declare(strict_types=1);
namespace Chill\MainBundle\Serializer\Normalizer;
use ArrayObject;
use Chill\MainBundle\Entity\Notification;
use Chill\MainBundle\Notification\NotificationHandlerManager;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Security\Core\Security;
use Symfony\Component\Serializer\Normalizer\NormalizerAwareInterface;
use Symfony\Component\Serializer\Normalizer\NormalizerAwareTrait;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
class NotificationNormalizer implements NormalizerAwareInterface, NormalizerInterface
{
use NormalizerAwareTrait;
private EntityManagerInterface $entityManager;
private NotificationHandlerManager $notificationHandlerManager;
private Security $security;
public function __construct(NotificationHandlerManager $notificationHandlerManager, EntityManagerInterface $entityManager, Security $security)
{
$this->notificationHandlerManager = $notificationHandlerManager;
$this->entityManager = $entityManager;
$this->security = $security;
}
/**
* @param Notification $object
*
* @return array|ArrayObject|bool|float|int|string|void|null
*/
public function normalize($object, ?string $format = null, array $context = [])
{
dump($object);
$entity = $this->entityManager
->getRepository($object->getRelatedEntityClass())
->find($object->getRelatedEntityId());
return [
'type' => 'notification',
'id' => $object->getId(),
'addressees' => $this->normalizer->normalize($object->getAddressees(), $format, $context),
'date' => $this->normalizer->normalize($object->getDate(), $format, $context),
'isRead' => $object->isReadBy($this->security->getUser()),
'message' => $object->getMessage(),
'relatedEntityClass' => $object->getRelatedEntityClass(),
'relatedEntityId' => $object->getRelatedEntityId(),
'sender' => $this->normalizer->normalize($object->getSender(), $format, $context),
'title' => $object->getTitle(),
'entity' => null !== $entity ? $this->normalizer->normalize($entity, $format, $context) : null,
];
}
public function supportsNormalization($data, ?string $format = null)
{
return $data instanceof Notification && 'json' === $format;
}
}

View File

@ -53,6 +53,7 @@ module.exports = function(encore, entries)
encore.addEntry('page_login', __dirname + '/Resources/public/page/login/index.js');
encore.addEntry('page_location', __dirname + '/Resources/public/page/location/index.js');
encore.addEntry('page_workflow_show', __dirname + '/Resources/public/page/workflow-show/index.js');
encore.addEntry('page_homepage_widget', __dirname + '/Resources/public/page/homepage_widget/index.js');
buildCKEditor(encore);

View File

@ -13,7 +13,9 @@ namespace Chill\PersonBundle\Controller;
use Chill\MainBundle\CRUD\Controller\ApiController;
use Chill\MainBundle\Entity\Scope;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Serializer\Model\Collection;
use Chill\MainBundle\Serializer\Model\Counter;
use Chill\PersonBundle\AccompanyingPeriod\Suggestion\ReferralsSuggestionInterface;
use Chill\PersonBundle\Entity\AccompanyingPeriod;
use Chill\PersonBundle\Entity\AccompanyingPeriod\AccompanyingPeriodWork;
@ -23,8 +25,11 @@ use Chill\PersonBundle\Entity\Person;
use Chill\PersonBundle\Entity\SocialWork\SocialIssue;
use Chill\PersonBundle\Privacy\AccompanyingPeriodPrivacyEvent;
use Chill\PersonBundle\Repository\AccompanyingPeriodACLAwareRepository;
use Chill\PersonBundle\Repository\AccompanyingPeriodRepository;
use Chill\PersonBundle\Security\Authorization\AccompanyingPeriodVoter;
use Chill\ThirdPartyBundle\Entity\ThirdParty;
use DateInterval;
use DateTimeImmutable;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\ParamConverter;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\HttpFoundation\Exception\BadRequestException;
@ -32,13 +37,14 @@ use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use Symfony\Component\Serializer\Exception\RuntimeException;
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
use Symfony\Component\Validator\ConstraintViolationList;
use Symfony\Component\Validator\ConstraintViolationListInterface;
use Symfony\Component\Validator\Validator\ValidatorInterface;
use Symfony\Component\Workflow\Registry;
use function array_values;
use function count;
@ -46,6 +52,8 @@ final class AccompanyingCourseApiController extends ApiController
{
private AccompanyingPeriodACLAwareRepository $accompanyingPeriodACLAwareRepository;
private AccompanyingPeriodRepository $accompanyingPeriodRepository;
private EventDispatcherInterface $eventDispatcher;
private ReferralsSuggestionInterface $referralAvailable;
@ -55,17 +63,19 @@ final class AccompanyingCourseApiController extends ApiController
private ValidatorInterface $validator;
public function __construct(
EventDispatcherInterface $eventDispatcher,
ValidatorInterface $validator,
Registry $registry,
AccompanyingPeriodRepository $accompanyingPeriodRepository,
AccompanyingPeriodACLAwareRepository $accompanyingPeriodACLAwareRepository,
ReferralsSuggestionInterface $referralAvailable
EventDispatcherInterface $eventDispatcher,
ReferralsSuggestionInterface $referralAvailable,
Registry $registry,
ValidatorInterface $validator
) {
$this->eventDispatcher = $eventDispatcher;
$this->validator = $validator;
$this->registry = $registry;
$this->accompanyingPeriodRepository = $accompanyingPeriodRepository;
$this->accompanyingPeriodACLAwareRepository = $accompanyingPeriodACLAwareRepository;
$this->eventDispatcher = $eventDispatcher;
$this->referralAvailable = $referralAvailable;
$this->registry = $registry;
$this->validator = $validator;
}
public function commentApi($id, Request $request, string $_format): Response
@ -99,6 +109,57 @@ final class AccompanyingCourseApiController extends ApiController
]);
}
/**
* @Route("/api/1.0/person/accompanying-course/list/by-recent-attributions")
*/
public function findMyRecentCourseAttribution(Request $request): JsonResponse
{
$this->denyAccessUnlessGranted('ROLE_USER');
$user = $this->getUser();
if (!$user instanceof User) {
throw new AccessDeniedException();
}
$since = (new DateTimeImmutable('now'))->sub(new DateInterval('P15D'));
$total = $this->accompanyingPeriodRepository->countByRecentUserHistory($user, $since);
if ($request->query->getBoolean('countOnly', false)) {
return new JsonResponse(
$this->getSerializer()->serialize(new Counter($total), 'json'),
JsonResponse::HTTP_OK,
[],
true
);
}
$paginator = $this->getPaginatorFactory()->create($total);
if (0 === $total) {
return new JsonResponse(
$this->getSerializer()->serialize(new Collection([], $paginator), 'json'),
JsonResponse::HTTP_OK,
[],
true
);
}
$courses = $this->accompanyingPeriodRepository->findByRecentUserHistory(
$user,
$since,
$paginator->getItemsPerPage(),
$paginator->getCurrentPageFirstItemNumber()
);
return new JsonResponse(
$this->getSerializer()->serialize(new Collection($courses, $paginator), 'json', ['groups' => ['read']]),
JsonResponse::HTTP_OK,
[],
true
);
}
/**
* @ParamConverter("person", options={"id": "person_id"})
*/

View File

@ -12,10 +12,54 @@ declare(strict_types=1);
namespace Chill\PersonBundle\Controller;
use Chill\MainBundle\CRUD\Controller\ApiController;
use Chill\MainBundle\Serializer\Model\Collection;
use Chill\MainBundle\Serializer\Model\Counter;
use Chill\PersonBundle\Repository\AccompanyingPeriod\AccompanyingPeriodWorkRepository;
use DateInterval;
use DateTimeImmutable;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Annotation\Route;
class AccompanyingCourseWorkApiController extends ApiController
{
private AccompanyingPeriodWorkRepository $accompanyingPeriodWorkRepository;
public function __construct(AccompanyingPeriodWorkRepository $accompanyingPeriodWorkRepository)
{
$this->accompanyingPeriodWorkRepository = $accompanyingPeriodWorkRepository;
}
/**
* @Route("/api/1.0/person/accompanying-period/work/my-near-end")
*/
public function myWorksNearEndDate(Request $request): JsonResponse
{
$since = (new DateTimeImmutable('now'))
->sub(new DateInterval('P' . $request->query->getInt('since', 15) . 'D'));
$until = (new DateTimeImmutable('now'))
->add(new DateInterval('P' . $request->query->getInt('since', 15) . 'D'));
$total = $this->accompanyingPeriodWorkRepository
->countNearEndDateByUser($this->getUser(), $since, $until);
if ($request->query->getBoolean('countOnly', false)) {
return $this->json(
new Counter($total),
JsonResponse::HTTP_OK,
[],
['groups' => ['read']]
);
}
$paginator = $this->getPaginatorFactory()->create($total);
$works = $this->accompanyingPeriodWorkRepository
->findNearEndDateByUser($this->getUser(), $since, $until, $paginator->getItemsPerPage(), $paginator->getCurrentPageFirstItemNumber());
$collection = new Collection($works, $paginator);
return $this->json($collection, 200, [], ['groups' => ['read']]);
}
protected function getContextForSerialization(string $action, Request $request, string $_format, $entity): array
{
switch ($action) {

View File

@ -15,11 +15,15 @@ use Chill\DocGeneratorBundle\Entity\DocGeneratorTemplate;
use Chill\DocGeneratorBundle\Repository\DocGeneratorTemplateRepository;
use Chill\MainBundle\Pagination\PaginatorFactory;
use Chill\MainBundle\Serializer\Model\Collection;
use Chill\MainBundle\Serializer\Model\Counter;
use Chill\PersonBundle\Entity\AccompanyingPeriod\AccompanyingPeriodWorkEvaluation;
use Chill\PersonBundle\Entity\SocialWork\Evaluation;
use Chill\PersonBundle\Repository\AccompanyingPeriod\AccompanyingPeriodWorkEvaluationRepository;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Core\Security;
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
use Symfony\Component\Serializer\SerializerInterface;
@ -28,20 +32,28 @@ use function in_array;
class AccompanyingPeriodWorkEvaluationApiController
{
private AccompanyingPeriodWorkEvaluationRepository $accompanyingPeriodWorkEvaluationRepository;
private DocGeneratorTemplateRepository $docGeneratorTemplateRepository;
private PaginatorFactory $paginatorFactory;
private Security $security;
private SerializerInterface $serializer;
public function __construct(
AccompanyingPeriodWorkEvaluationRepository $accompanyingPeriodWorkEvaluationRepository,
DocGeneratorTemplateRepository $docGeneratorTemplateRepository,
SerializerInterface $serializer,
PaginatorFactory $paginatorFactory
PaginatorFactory $paginatorFactory,
Security $security
) {
$this->accompanyingPeriodWorkEvaluationRepository = $accompanyingPeriodWorkEvaluationRepository;
$this->docGeneratorTemplateRepository = $docGeneratorTemplateRepository;
$this->serializer = $serializer;
$this->paginatorFactory = $paginatorFactory;
$this->security = $security;
}
/**
@ -76,4 +88,39 @@ class AccompanyingPeriodWorkEvaluationApiController
]
), JsonResponse::HTTP_OK, [], true);
}
/**
* @Route("/api/1.0/person/accompanying-period/work/evaluation/my-near-end")
*/
public function myWorksNearEndDate(Request $request): JsonResponse
{
$total = $this->accompanyingPeriodWorkEvaluationRepository
->countNearMaxDateByUser($this->security->getUser());
if ($request->query->getBoolean('countOnly', false)) {
return new JsonResponse(
$this->serializer->serialize(new Counter($total), 'json', ['groups' => 'read']),
JsonResponse::HTTP_OK,
[],
true
);
}
$paginator = $this->paginatorFactory->create($total);
$works = $this->accompanyingPeriodWorkEvaluationRepository
->findNearMaxDateByUser(
$this->security->getUser(),
$paginator->getItemsPerPage(),
$paginator->getCurrentPageFirstItemNumber()
);
$collection = new Collection($works, $paginator);
return new JsonResponse(
$this->serializer->serialize($collection, 'json', ['groups' => 'read']),
JsonResponse::HTTP_OK,
[],
true
);
}
}

View File

@ -26,6 +26,7 @@ use Chill\PersonBundle\Entity\AccompanyingPeriod\ClosingMotive;
use Chill\PersonBundle\Entity\AccompanyingPeriod\Comment;
use Chill\PersonBundle\Entity\AccompanyingPeriod\Origin;
use Chill\PersonBundle\Entity\AccompanyingPeriod\Resource;
use Chill\PersonBundle\Entity\AccompanyingPeriod\UserHistory;
use Chill\PersonBundle\Entity\SocialWork\SocialIssue;
use Chill\PersonBundle\Validator\Constraints\AccompanyingPeriod\AccompanyingPeriodValidity;
use Chill\PersonBundle\Validator\Constraints\AccompanyingPeriod\ParticipationOverlap;
@ -338,6 +339,14 @@ class AccompanyingPeriod implements
*/
private ?User $user = null;
/**
* @ORM\OneToMany(targetEntity=UserHistory::class, mappedBy="accompanyingPeriod", orphanRemoval=true,
* cascade={"persist", "remove"})
*
* @var Collection|UserHistory[]
*/
private Collection $userHistories;
/**
* Temporary field, which is filled when the user is changed.
*
@ -370,6 +379,7 @@ class AccompanyingPeriod implements
$this->comments = new ArrayCollection();
$this->works = new ArrayCollection();
$this->resources = new ArrayCollection();
$this->userHistories = new ArrayCollection();
}
/**
@ -1214,10 +1224,20 @@ class AccompanyingPeriod implements
return $this;
}
public function setUser(User $user): self
public function setUser(?User $user): self
{
if ($this->user !== $user) {
$this->userPrevious = $this->user;
foreach ($this->userHistories as $history) {
if (null === $history->getEndDate()) {
$history->setEndDate(new DateTimeImmutable('now'));
}
}
if (null !== $user) {
$this->userHistories->add(new UserHistory($this, $user));
}
}
$this->user = $user;

View File

@ -0,0 +1,96 @@
<?php
/**
* 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.
*/
declare(strict_types=1);
namespace Chill\PersonBundle\Entity\AccompanyingPeriod;
use Chill\MainBundle\Doctrine\Model\TrackCreationInterface;
use Chill\MainBundle\Doctrine\Model\TrackCreationTrait;
use Chill\MainBundle\Entity\User;
use Chill\PersonBundle\Entity\AccompanyingPeriod;
use DateTimeImmutable;
use Doctrine\ORM\Mapping as ORM;
/**
* @ORM\Entity
* @ORM\Table("chill_person_accompanying_period_user_history")
*/
class UserHistory implements TrackCreationInterface
{
use TrackCreationTrait;
/**
* @ORM\ManyToOne(targetEntity=AccompanyingPeriod::class, inversedBy="userHistories")
* @ORM\JoinColumn(nullable=false)
*/
private ?AccompanyingPeriod $accompanyingPeriod;
/**
* @ORM\Column(type="datetime_immutable", nullable=true, options={"default": null})
*/
private ?DateTimeImmutable $endDate = null;
/**
* @ORM\Id
* @ORM\Column(name="id", type="integer")
* @ORM\GeneratedValue(strategy="AUTO")
*/
private ?int $id = null;
/**
* @ORM\Column(type="datetime_immutable", nullable=false)
*/
private DateTimeImmutable $startDate;
/**
* @ORM\ManyToOne(targetEntity=User::class)
* @ORM\JoinColumn(nullable=false)
*/
private User $user;
public function __construct(AccompanyingPeriod $accompanyingPeriod, User $user, ?DateTimeImmutable $startDate = null)
{
$this->startDate = $startDate ?? new DateTimeImmutable('now');
$this->accompanyingPeriod = $accompanyingPeriod;
$this->user = $user;
}
public function getAccompanyingPeriod(): AccompanyingPeriod
{
return $this->accompanyingPeriod;
}
public function getEndDate(): ?DateTimeImmutable
{
return $this->endDate;
}
public function getId(): ?int
{
return $this->id;
}
public function getStartDate(): DateTimeImmutable
{
return $this->startDate;
}
public function getUser(): User
{
return $this->user;
}
public function setEndDate(?DateTimeImmutable $endDate): UserHistory
{
$this->endDate = $endDate;
return $this;
}
}

View File

@ -11,9 +11,12 @@ declare(strict_types=1);
namespace Chill\PersonBundle\Repository\AccompanyingPeriod;
use Chill\MainBundle\Entity\User;
use Chill\PersonBundle\Entity\AccompanyingPeriod\AccompanyingPeriodWorkEvaluation;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\QueryBuilder;
use Doctrine\Persistence\ObjectRepository;
class AccompanyingPeriodWorkEvaluationRepository implements ObjectRepository
@ -25,6 +28,12 @@ class AccompanyingPeriodWorkEvaluationRepository implements ObjectRepository
$this->repository = $entityManager->getRepository(AccompanyingPeriodWorkEvaluation::class);
}
public function countNearMaxDateByUser(User $user): int
{
return $this->buildQueryNearMaxDateByUser($user)
->select('count(e)')->getQuery()->getSingleScalarResult();
}
public function find($id): ?AccompanyingPeriodWorkEvaluation
{
return $this->repository->find($id);
@ -39,8 +48,8 @@ class AccompanyingPeriodWorkEvaluationRepository implements ObjectRepository
}
/**
* @param null|mixed $limit
* @param null|mixed $offset
* @param int $limit
* @param int $offset
*
* @return array|AccompanyingPeriodWorkEvaluation[]
*/
@ -49,13 +58,45 @@ class AccompanyingPeriodWorkEvaluationRepository implements ObjectRepository
return $this->repository->findBy($criteria, $orderBy, $limit, $offset);
}
public function findOneBy(array $criteria): ?AccompanyingPeriodWorkEvaluation
public function findNearMaxDateByUser(User $user, int $limit = 20, int $offset = 0): array
{
return $this->findOneBy($criteria);
return $this->buildQueryNearMaxDateByUser($user)
->select('e')
->setFirstResult($offset)
->setMaxResults($limit)
->getQuery()
->getResult();
}
public function getClassName()
public function findOneBy(array $criteria): ?AccompanyingPeriodWorkEvaluation
{
return $this->repository->findOneBy($criteria);
}
public function getClassName(): string
{
return AccompanyingPeriodWorkEvaluation::class;
}
private function buildQueryNearMaxDateByUser(User $user): QueryBuilder
{
$qb = $this->repository->createQueryBuilder('e');
$qb
->join('e.accompanyingPeriodWork', 'work')
->join('work.accompanyingPeriod', 'period')
->where(
$qb->expr()->andX(
$qb->expr()->eq('period.user', ':user'),
$qb->expr()->isNull('e.endDate'),
$qb->expr()->gte(':now', $qb->expr()->diff('e.maxDate', 'e.warningInterval'))
)
)
->setParameters([
'user' => $user,
'now' => new DateTimeImmutable('now'),
]);
return $qb;
}
}

View File

@ -11,10 +11,14 @@ declare(strict_types=1);
namespace Chill\PersonBundle\Repository\AccompanyingPeriod;
use Chill\MainBundle\Entity\User;
use Chill\PersonBundle\Entity\AccompanyingPeriod;
use Chill\PersonBundle\Entity\AccompanyingPeriod\AccompanyingPeriodWork;
use Chill\PersonBundle\Entity\SocialWork\SocialAction;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\QueryBuilder;
use Doctrine\Persistence\ObjectRepository;
final class AccompanyingPeriodWorkRepository implements ObjectRepository
@ -41,6 +45,12 @@ final class AccompanyingPeriodWorkRepository implements ObjectRepository
->getSingleScalarResult();
}
public function countNearEndDateByUser(User $user, DateTimeImmutable $since, DateTimeImmutable $until): int
{
return $this->buildQueryNearEndDateByUser($user, $since, $until)
->select('count(w)')->getQuery()->getSingleScalarResult();
}
public function find($id): ?AccompanyingPeriodWork
{
return $this->repository->find($id);
@ -68,6 +78,16 @@ final class AccompanyingPeriodWorkRepository implements ObjectRepository
return $this->repository->findByAccompanyingPeriod($period, $orderBy, $limit, $offset);
}
public function findNearEndDateByUser(User $user, DateTimeImmutable $since, DateTimeImmutable $until, int $limit = 20, int $offset = 0): array
{
return $this->buildQueryNearEndDateByUser($user, $since, $until)
->select('w')
->setFirstResult($offset)
->setMaxResults($limit)
->getQuery()
->getResult();
}
public function findOneBy(array $criteria): ?AccompanyingPeriodWork
{
return $this->repository->findOneBy($criteria);
@ -78,22 +98,6 @@ final class AccompanyingPeriodWorkRepository implements ObjectRepository
return AccompanyingPeriodWork::class;
}
public function toDelete()
{
$qb = $this->buildQueryBySocialActionWithDescendants($action);
$qb->select('g');
foreach ($orderBy as $sort => $order) {
$qb->addOrderBy('g.' . $sort, $order);
}
return $qb
->setMaxResults($limit)
->setFirstResult($offset)
->getQuery()
->getResult();
}
private function buildQueryBySocialActionWithDescendants(SocialAction $action): QueryBuilder
{
$actions = $action->getDescendantsWithThis();
@ -103,12 +107,34 @@ final class AccompanyingPeriodWorkRepository implements ObjectRepository
$orx = $qb->expr()->orX();
$i = 0;
foreach ($actions as $action) {
foreach ($actions as $a) {
$orx->add(":action_{$i} MEMBER OF g.socialActions");
$qb->setParameter("action_{$i}", $action);
$qb->setParameter("action_{$i}", $a);
}
$qb->where($orx);
return $qb;
}
private function buildQueryNearEndDateByUser(User $user, DateTimeImmutable $since, DateTimeImmutable $until): QueryBuilder
{
$qb = $this->repository->createQueryBuilder('w');
$qb
->join('w.accompanyingPeriod', 'period')
->where(
$qb->expr()->andX(
$qb->expr()->eq('period.user', ':user'),
$qb->expr()->gte('w.endDate', ':since'),
$qb->expr()->lte('w.startDate', ':until')
)
)
->setParameters([
'user' => $user,
'since' => $since,
'until' => $until,
]);
return $qb;
}
}

View File

@ -11,7 +11,9 @@ declare(strict_types=1);
namespace Chill\PersonBundle\Repository;
use Chill\MainBundle\Entity\User;
use Chill\PersonBundle\Entity\AccompanyingPeriod;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\QueryBuilder;
@ -26,6 +28,13 @@ final class AccompanyingPeriodRepository implements ObjectRepository
$this->repository = $entityManager->getRepository(AccompanyingPeriod::class);
}
public function countByRecentUserHistory(User $user, DateTimeImmutable $since): int
{
$qb = $this->buildQueryByRecentUserHistory($user, $since);
return $qb->select('count(a)')->getQuery()->getSingleScalarResult();
}
public function countBy(array $criteria): int
{
return $this->repository->count($criteria);
@ -54,6 +63,21 @@ final class AccompanyingPeriodRepository implements ObjectRepository
return $this->repository->findBy($criteria, $orderBy, $limit, $offset);
}
/**
* @return array|AccompanyingPeriod[]
*/
public function findByRecentUserHistory(User $user, DateTimeImmutable $since, ?int $limit = 20, ?int $offset = 0): array
{
$qb = $this->buildQueryByRecentUserHistory($user, $since);
return $qb->select('a')
->distinct(true)
->getQuery()
->setMaxResults($limit)
->setFirstResult($offset)
->getResult();
}
public function findOneBy(array $criteria): ?AccompanyingPeriod
{
return $this->findOneBy($criteria);
@ -63,4 +87,19 @@ final class AccompanyingPeriodRepository implements ObjectRepository
{
return AccompanyingPeriod::class;
}
private function buildQueryByRecentUserHistory(User $user, DateTimeImmutable $since): QueryBuilder
{
$qb = $this->repository->createQueryBuilder('a');
$qb
->join('a.userHistories', 'userHistory')
->where($qb->expr()->eq('a.user', ':user'))
->andWhere($qb->expr()->gte('userHistory.startDate', ':since'))
->andWhere($qb->expr()->isNull('userHistory.endDate'))
->setParameter('user', $user)
->setParameter('since', $since);
return $qb;
}
}

View File

@ -80,6 +80,8 @@ div.dashboard {
}
}
div.dashboard,
h4.badge-title,
h3.badge-title,
h2.badge-title {
display: flex;
flex-direction: row;
@ -128,6 +130,8 @@ ul.columns { // XS:1 SM:2 MD:1 LG:2 XL:2 XXL:2
/// dashboard_like_badge in AccompanyingCourse Work list Page
div[class*='accompanying_course_work'] {
div.dashboard,
h4.badge-title,
h3.badge-title,
h2.badge-title {
span.title_label {
// Calculate same color then border:groove
@ -143,6 +147,8 @@ div[class*='accompanying_course_work'] {
/// dashboard_like_badge in Activities on resume page
div[class*='activity-'] {
div.dashboard,
h4.badge-title,
h3.badge-title,
h2.badge-title {
span.title_label {
// Calculate same color then border:groove

View File

@ -1,5 +1,6 @@
<template>
<div>
<a id="evaluations"></a>
<div class="item-title" :title="evaluation.id || 'no id yet'">
<span>{{ evaluation.evaluation.title.fr }}</span>
</div>

View File

@ -0,0 +1,51 @@
<?php
/**
* 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.
*/
declare(strict_types=1);
namespace Chill\Migrations\Person;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20220128133039 extends AbstractMigration
{
public function down(Schema $schema): void
{
$this->addSql('DROP SEQUENCE chill_person_accompanying_period_user_history_id_seq CASCADE');
$this->addSql('DROP TABLE chill_person_accompanying_period_user_history');
}
public function getDescription(): string
{
return 'Add table for tracking user history on accompanying period';
}
public function up(Schema $schema): void
{
$this->addSql('CREATE SEQUENCE chill_person_accompanying_period_user_history_id_seq INCREMENT BY 1 MINVALUE 1 START 1');
$this->addSql('CREATE TABLE chill_person_accompanying_period_user_history (id INT NOT NULL, user_id INT, endDate TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, startDate TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, accompanyingPeriod_id INT, PRIMARY KEY(id))');
$this->addSql('CREATE INDEX IDX_6C258C49D7FA8EF0 ON chill_person_accompanying_period_user_history (accompanyingPeriod_id)');
$this->addSql('CREATE INDEX IDX_6C258C49A76ED395 ON chill_person_accompanying_period_user_history (user_id)');
$this->addSql('COMMENT ON COLUMN chill_person_accompanying_period_user_history.endDate IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('COMMENT ON COLUMN chill_person_accompanying_period_user_history.startDate IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('ALTER TABLE chill_person_accompanying_period_user_history ADD CONSTRAINT FK_6C258C49D7FA8EF0 FOREIGN KEY (accompanyingPeriod_id) REFERENCES chill_person_accompanying_period (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('ALTER TABLE chill_person_accompanying_period_user_history ADD CONSTRAINT FK_6C258C49A76ED395 FOREIGN KEY (user_id) REFERENCES users (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('ALTER TABLE chill_person_accompanying_period_user_history ADD CHECK (startdate <= enddate)');
$this->addSql('INSERT INTO chill_person_accompanying_period_user_history (id, user_id, accompanyingperiod_id, startDate, endDate) ' .
'SELECT nextval(\'chill_person_accompanying_period_user_history_id_seq\'), user_id, id, openingDate, null FROM chill_person_accompanying_period WHERE user_id IS NOT NULL');
$this->addSql('ALTER TABLE chill_person_accompanying_period_user_history ADD createdAt TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL;');
$this->addSql('ALTER TABLE chill_person_accompanying_period_user_history ADD createdBy_id INT DEFAULT NULL;');
$this->addSql('ALTER TABLE chill_person_accompanying_period_user_history ALTER user_id SET NOT NULL;');
$this->addSql('ALTER TABLE chill_person_accompanying_period_user_history ALTER accompanyingperiod_id SET NOT NULL;');
$this->addSql('COMMENT ON COLUMN chill_person_accompanying_period_user_history.createdAt IS \'(DC2Type:datetime_immutable)\';');
$this->addSql('ALTER TABLE chill_person_accompanying_period_user_history ADD CONSTRAINT FK_6C258C493174800F FOREIGN KEY (createdBy_id) REFERENCES users (id) NOT DEFERRABLE INITIALLY IMMEDIATE;');
$this->addSql('CREATE INDEX IDX_6C258C493174800F ON chill_person_accompanying_period_user_history (createdBy_id);');
}
}

View File

@ -13,6 +13,8 @@ namespace Chill\TaskBundle\Controller;
use Chill\MainBundle\Pagination\PaginatorFactory;
use Chill\MainBundle\Security\Resolver\CenterResolverDispatcherInterface;
use Chill\MainBundle\Serializer\Model\Collection;
use Chill\MainBundle\Serializer\Model\Counter;
use Chill\MainBundle\Templating\Listing\FilterOrderHelper;
use Chill\MainBundle\Templating\Listing\FilterOrderHelperFactoryInterface;
use Chill\MainBundle\Timeline\TimelineBuilder;
@ -31,6 +33,8 @@ use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\FormFactoryInterface;
use Symfony\Component\HttpFoundation\Exception\BadRequestException;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
@ -431,10 +435,15 @@ final class SingleTaskController extends AbstractController
* @return Response
* @Route(
* "/{_locale}/task/single-task/list/my",
* name="chill_task_singletask_my_tasks"
* name="chill_task_singletask_my_tasks",
* defaults={"_format": "html"}
* )
* @Route(
* "/api/1.0/task/single-task/list/my",
* defaults={"_format": "json"}
* )
*/
public function myTasksAction()
public function myTasksAction(string $_format, Request $request)
{
$this->denyAccessUnlessGranted('ROLE_USER');
@ -447,6 +456,13 @@ final class SingleTaskController extends AbstractController
$filterOrder->getQueryString(),
$flags
);
if ('json' === $_format && $request->query->getBoolean('countOnly')) {
return $this->json(
new Counter($nb),
);
}
$paginator = $this->paginatorFactory->create($nb);
$tasks = $this->singleTaskAclAwareRepository->findByCurrentUsersTasks(
$filterOrder->getQueryString(),
@ -459,11 +475,27 @@ final class SingleTaskController extends AbstractController
]
);
return $this->render('@ChillTask/SingleTask/List/index_my_tasks.html.twig', [
'tasks' => $tasks,
'paginator' => $paginator,
'filter_order' => $filterOrder,
]);
switch ($_format) {
case 'html':
return $this->render('@ChillTask/SingleTask/List/index_my_tasks.html.twig', [
'tasks' => $tasks,
'paginator' => $paginator,
'filter_order' => $filterOrder,
]);
case 'json':
$collection = new Collection($tasks, $paginator);
return $this->json(
$collection,
JsonResponse::HTTP_OK,
[],
['groups' => ['read']]
);
default:
throw new BadRequestException("format not supported: {$_format}");
}
}
/**

View File

@ -18,6 +18,7 @@ use Chill\MainBundle\Entity\User;
use Chill\PersonBundle\Entity\AccompanyingPeriod;
use Chill\PersonBundle\Entity\Person;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Annotation as Serializer;
use Symfony\Component\Validator\Constraints as Assert;
use function array_fill_keys;
@ -27,6 +28,9 @@ use function array_keys;
* AbstractTask.
*
* @ORM\MappedSuperclass
* @Serializer\DiscriminatorMap(typeProperty="type", mapping={
* "single_task": SingleTask::class
* })
*/
abstract class AbstractTask implements HasCenterInterface, HasScopeInterface
{
@ -35,6 +39,7 @@ abstract class AbstractTask implements HasCenterInterface, HasScopeInterface
* @ORM\ManyToOne(
* targetEntity="\Chill\MainBundle\Entity\User"
* )
* @Serializer\Groups({"read"})
*/
private $assignee;
@ -49,12 +54,14 @@ abstract class AbstractTask implements HasCenterInterface, HasScopeInterface
/**
* @var bool
* @ORM\Column(name="closed", type="boolean", options={ "default": false })
* @Serializer\Groups({"read"})
*/
private $closed = false;
/**
* @var AccompanyingPeriod
* @ORM\ManyToOne(targetEntity="\Chill\PersonBundle\Entity\AccompanyingPeriod")
* @Serializer\Groups({"read"})
*/
private $course;
@ -62,6 +69,7 @@ abstract class AbstractTask implements HasCenterInterface, HasScopeInterface
* @var json
*
* @ORM\Column(name="current_states", type="json")
* @Serializer\Groups({"read"})
*/
private $currentStates = [];
@ -69,6 +77,7 @@ abstract class AbstractTask implements HasCenterInterface, HasScopeInterface
* @var string
*
* @ORM\Column(name="description", type="text")
* @Serializer\Groups({"read"})
*/
private $description = '';
@ -77,6 +86,7 @@ abstract class AbstractTask implements HasCenterInterface, HasScopeInterface
* @ORM\ManyToOne(
* targetEntity="\Chill\PersonBundle\Entity\Person"
* )
* @Serializer\Groups({"read"})
*/
private $person;
@ -85,6 +95,7 @@ abstract class AbstractTask implements HasCenterInterface, HasScopeInterface
*
* @ORM\Column(name="title", type="text")
* @Assert\NotBlank
* @Serializer\Groups({"read"})
*/
private $title = '';
@ -92,6 +103,7 @@ abstract class AbstractTask implements HasCenterInterface, HasScopeInterface
* @var string
*
* @ORM\Column(name="type", type="string", length=255)
* @Serializer\Groups({"read"})
*/
private $type;