Merge branch 'user_absences' into 'master'

Feature: allow users to say they are out of office

See merge request Chill-Projet/chill-bundles!476
This commit is contained in:
Julien Fastré 2023-03-01 15:40:22 +00:00
commit 21a16dcbe2
22 changed files with 307 additions and 40 deletions

View File

@ -340,11 +340,6 @@ parameters:
count: 1
path: src/Bundle/ChillPersonBundle/Form/Type/PersonPhoneType.php
-
message: "#^Construct empty\\(\\) is not allowed\\. Use more strict comparison\\.$#"
count: 3
path: src/Bundle/ChillPersonBundle/Search/PersonSearch.php
-
message: "#^Method Chill\\\\PersonBundle\\\\Search\\\\PersonSearch\\:\\:renderResult\\(\\) should return string but return statement is missing\\.$#"
count: 1

View File

@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Controller;
use Chill\MainBundle\Form\AbsenceType;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Annotation\Route;
class AbsenceController extends AbstractController
{
/**
* @Route(
* "/{_locale}/absence",
* name="chill_main_user_absence_index",
* methods={"GET", "POST"}
* )
*/
public function setAbsence(Request $request)
{
$user = $this->getUser();
$form = $this->createForm(AbsenceType::class, $user);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$em = $this->getDoctrine()->getManager();
$em->flush();
return $this->redirect($this->generateUrl('chill_main_user_absence_index'));
}
return $this->render('@ChillMain/Menu/absence.html.twig', [
'user' => $user,
'form' => $form->createView(),
]);
}
/**
* @Route(
* "/{_locale}/absence/unset",
* name="chill_main_user_absence_unset",
* methods={"GET", "POST"}
* )
*/
public function unsetAbsence(Request $request)
{
$user = $this->getUser();
$user->setAbsenceStart(null);
$em = $this->getDoctrine()->getManager();
$em->flush();
return $this->redirect($this->generateUrl('chill_main_user_absence_index'));
}
}

View File

@ -11,14 +11,15 @@ declare(strict_types=1);
namespace Chill\MainBundle\Entity;
use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use RuntimeException;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Serializer\Annotation as Serializer;
use Symfony\Component\Validator\Context\ExecutionContextInterface;
use Symfony\Component\Validator\Context\ExecutionContextInterface;
use function in_array;
/**
@ -40,6 +41,11 @@ class User implements UserInterface
*/
protected ?int $id = null;
/**
* @ORM\Column(type="datetime_immutable", nullable=true)
*/
private ?DateTimeImmutable $absenceStart = null;
/**
* Array where SAML attributes's data are stored.
*
@ -173,6 +179,11 @@ class User implements UserInterface
{
}
public function getAbsenceStart(): ?DateTimeImmutable
{
return $this->absenceStart;
}
/**
* Get attributes.
*
@ -291,6 +302,11 @@ class User implements UserInterface
return $this->usernameCanonical;
}
public function isAbsent(): bool
{
return null !== $this->getAbsenceStart() && $this->getAbsenceStart() <= new DateTimeImmutable('now');
}
/**
* @return bool
*/
@ -355,6 +371,11 @@ class User implements UserInterface
}
}
public function setAbsenceStart(?DateTimeImmutable $absenceStart): void
{
$this->absenceStart = $absenceStart;
}
public function setAttributeByDomain(string $domain, string $key, $value): self
{
$this->attributes[$domain][$key] = $value;

View File

@ -0,0 +1,38 @@
<?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\Entity\User;
use Chill\MainBundle\Form\Type\ChillDateType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class AbsenceType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->add('absenceStart', ChillDateType::class, [
'required' => true,
'input' => 'datetime_immutable',
'label' => 'absence.Absence start',
]);
}
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults([
'data_class' => User::class,
]);
}
}

View File

@ -15,6 +15,7 @@ use Chill\MainBundle\Entity\Center;
use Chill\MainBundle\Entity\Location;
use Chill\MainBundle\Entity\Scope;
use Chill\MainBundle\Entity\UserJob;
use Chill\MainBundle\Form\Type\ChillDateType;
use Chill\MainBundle\Form\Type\PickCivilityType;
use Chill\MainBundle\Templating\TranslatableStringHelper;
use Doctrine\ORM\EntityRepository;
@ -110,6 +111,11 @@ class UserType extends AbstractType
return $qb;
},
])
->add('absenceStart', ChillDateType::class, [
'required' => false,
'input' => 'datetime_immutable',
'label' => 'absence.Absence start',
]);
// @phpstan-ignore-next-line

View File

@ -35,6 +35,7 @@ export interface User {
id: number;
username: string;
text: string;
text_without_absence: string;
email: string;
user_job: Job;
label: string;

View File

@ -1,8 +1,7 @@
<template>
<span class="chill-entity entity-user">
{{ user.label }}
<span class="user-job" v-if="user.user_job !== null">({{ user.user_job.label.fr }})</span>
<span class="main-scope" v-if="user.main_scope !== null">({{ user.main_scope.name.fr }})</span>
<span class="user-job" v-if="user.user_job !== null">({{ user.user_job.label.fr }})</span> <span class="main-scope" v-if="user.main_scope !== null">({{ user.main_scope.name.fr }})</span> <span v-if="user.isAbsent" class="badge bg-danger rounded-pill" :title="Absent">A</span>
</span>
</template>

View File

@ -6,4 +6,7 @@
{%- if opts['main_scope'] and user.mainScope is not null %}
<span class="main-scope">({{ user.mainScope.name|localize_translatable_string }})</span>
{%- endif -%}
{%- if opts['absence'] and user.isAbsent %}
<span class="badge bg-danger rounded-pill" title="{{ 'absence.Absent'|trans|escape('html_attr') }}">{{ 'absence.A'|trans }}</span>
{%- endif -%}
</span>

View File

@ -1,15 +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 %}
{% endblock %}

View File

@ -0,0 +1,43 @@
{% extends '@ChillMain/Admin/layout.html.twig' %}
{% block title %}
{{ 'absence.My absence'|trans }}
{% endblock title %}
{% block content %}
<div class="col-md-10">
<h2>{{ 'absence.My absence'|trans }}</h2>
{% if user.absenceStart is not null %}
<div>
<p>{{ 'absence.You are listed as absent, as of'|trans }} {{ user.absenceStart|format_date('long') }}</p>
<ul class="record_actions sticky-form-buttons">
<li>
<a href="{{ path('chill_main_user_absence_unset') }}"
class="btn btn-delete">{{ 'absence.Unset absence'|trans }}</a>
</li>
</ul>
</div>
{% else %}
<div>
<p class="chill-no-data-statement">{{ 'absence.No absence listed'|trans }}</p>
</div>
<div>
{{ form_start(form) }}
{{ form_row(form.absenceStart) }}
<ul class="record_actions sticky-form-buttons">
<li>
<button class="btn btn-save" type="submit">
{{ 'Save'|trans }}
</button>
</li>
</ul>
{{ form_end(form) }}
</div>
{% endif %}
</div>
{% endblock %}

View File

@ -2,20 +2,21 @@
{% block admin_content %}
{% embed '@ChillMain/CRUD/_index.html.twig' %}
{% block index_header %}
<h1>{{"Users"|trans}}</h1>
{% endblock %}
{% block filter_order %}{{ filter_order|chill_render_filter_order_helper }}{% endblock %}
{% block table_entities_thead_tr %}
<th>{{ 'Active'|trans }}</th>
<th>{{ 'absence.Is absent'|trans }}</th>
<th>{{ 'Username'|trans }}</th>
<th>{{ 'Datas'|trans }}</th>
<th>{{ 'Actions'|trans }}</th>
{% endblock %}
{% block table_entities_tbody %}
{% for entity in entities %}
<tr>
@ -26,6 +27,13 @@
<i class="fa fa-square-o"></i>
{% endif %}
</td>
<td>
{% if entity.isAbsent %}
<i class="fa fa-check-square-o"></i>
{% else %}
<i class="fa fa-square-o"></i>
{% endif %}
</td>
<td>
{#
{% if entity.civility is not null %}
@ -64,13 +72,13 @@
<li>
<a class="btn btn-edit" title="{{ 'Edit'|trans }}" href="{{ path('chill_crud_admin_user_edit', { 'id': entity.id }) }}"></a>
</li>
{% if allow_change_password is same as(true) %}
<li>
<a class="btn btn-chill-red" href="{{ path('admin_user_edit_password', { 'id' : entity.id }) }}" title="{{ 'Edit password'|trans|e('html_attr') }}"><i class="fa fa-ellipsis-h"></i></a>
</li>
{% endif %}
{% if is_granted('ROLE_ALLOWED_TO_SWITCH') %}
<li>
<a class="btn btn-chill-blue" href="{{ path('chill_main_homepage', {'_switch_user': entity.username }) }}" title="{{ "Impersonate"|trans|e('html_attr') }}"><i class="fa fa-user-secret"></i></a>
@ -81,9 +89,9 @@
</tr>
{% endfor %}
{% endblock %}
{% block pagination %}{{ chill_pagination(paginator) }}{% endblock %}
{% block list_actions %}
<ul class="record_actions sticky-form-buttons">
<li class='cancel'>
@ -94,6 +102,6 @@
</li>
</ul>
{% endblock list_actions %}
{% endembed %}
{% endblock %}

View File

@ -69,18 +69,26 @@
{% block content %}
<div class="col-8 main_search">
{% if app.user.isAbsent %}
<div class="d-flex flex-row mb-5 alert alert-warning" role="alert">
<p class="m-2">{{'absence.You are marked as being absent'|trans }}</p>
<span class="ms-auto">
<a class="btn btn-remove" title="Modifier" href="{{ path('chill_main_user_absence_index') }}">{{ 'absence.Unset absence'|trans }}</a>
</span>
</div>
{% endif %}
<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>
<div class="text-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>
</div>
</form>
</div>

View File

@ -78,6 +78,15 @@ class UserMenuBuilder implements LocalMenuBuilderInterface
$nbNotifications = $this->notificationByUserCounter->countUnreadByUser($user);
//TODO add an icon? How exactly? For example a clock icon...
$menu
->addChild($this->translator->trans('absence.Set absence date'), [
'route' => 'chill_main_user_absence_index',
])
->setExtras([
'order' => -8888888,
]);
$menu
->addChild(
$this->translator->trans('notification.My notifications with counter', ['nb' => $nbNotifications]),

View File

@ -31,6 +31,7 @@ class UserNormalizer implements ContextAwareNormalizerInterface, NormalizerAware
'id' => '',
'username' => '',
'text' => '',
'text_without_absent' => '',
'label' => '',
'email' => '',
];
@ -82,11 +83,13 @@ class UserNormalizer implements ContextAwareNormalizerInterface, NormalizerAware
'id' => $object->getId(),
'username' => $object->getUsername(),
'text' => $this->userRender->renderString($object, []),
'text_without_absent' => $this->userRender->renderString($object, ['absence' => false]),
'label' => $object->getLabel(),
'email' => (string) $object->getEmail(),
'user_job' => $this->normalizer->normalize($object->getUserJob(), $format, $userJobContext),
'main_center' => $this->normalizer->normalize($object->getMainCenter(), $format, $centerContext),
'main_scope' => $this->normalizer->normalize($object->getMainScope(), $format, $scopeContext),
'isAbsent' => $object->isAbsent(),
];
if ('docgen' === $format) {

View File

@ -13,8 +13,10 @@ namespace Chill\MainBundle\Templating\Entity;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Templating\TranslatableStringHelper;
use Symfony\Component\Templating\EngineInterface;
use DateTimeImmutable;
use Symfony\Component\Templating\EngineInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
use function array_merge;
class UserRender implements ChillEntityRenderInterface
@ -22,16 +24,20 @@ class UserRender implements ChillEntityRenderInterface
public const DEFAULT_OPTIONS = [
'main_scope' => true,
'user_job' => true,
'absence' => true,
];
private EngineInterface $engine;
private TranslatableStringHelper $translatableStringHelper;
public function __construct(TranslatableStringHelper $translatableStringHelper, EngineInterface $engine)
private TranslatorInterface $translator;
public function __construct(TranslatableStringHelper $translatableStringHelper, EngineInterface $engine, TranslatorInterface $translator)
{
$this->translatableStringHelper = $translatableStringHelper;
$this->engine = $engine;
$this->translator = $translator;
}
public function renderBox($entity, array $options): string
@ -63,6 +69,10 @@ class UserRender implements ChillEntityRenderInterface
->localize($entity->getMainScope()->getName()) . ')';
}
if ($entity->isAbsent() && $opts['absence']) {
$str .= ' (' . $this->translator->trans('absence.Absent') . ')';
}
return $str;
}

View File

@ -138,9 +138,8 @@ services:
autowire: true
autoconfigure: true
Chill\MainBundle\Form\RegroupmentType:
autowire: true
autoconfigure: true
Chill\MainBundle\Form\AbsenceType: ~
Chill\MainBundle\Form\RegroupmentType: ~
Chill\MainBundle\Form\DataTransformer\IdToLocationDataTransformer: ~
Chill\MainBundle\Form\DataTransformer\IdToUserDataTransformer: ~

View File

@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\Migrations\Main;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20230111160610 extends AbstractMigration
{
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE users DROP absenceStart');
}
public function getDescription(): string
{
return 'Add absence property to user';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE users ADD absenceStart TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL');
$this->addSql('COMMENT ON COLUMN users.absenceStart IS \'(DC2Type:datetime_immutable)\'');
}
}

View File

@ -584,3 +584,17 @@ saved_export:
Export is deleted: L'export est supprimé
Saved export is saved!: L'export est enregistré
Created on %date%: Créé le %date%
absence:
# single letter for absence
A: A
My absence: Mon absence
Unset absence: Supprimer la date d'absence
Set absence date: Indiquer une date d'absence
Absence start: Absent à partir du
Absent: Absent
You are marked as being absent: Vous êtes indiqué absent.
You are listed as absent, as of: Votre absence est indiquée à partir du
No absence listed: Aucune absence indiquée.
Is absent: Absent?

View File

@ -22,7 +22,12 @@
<i>{{ $t('course.open_at') }}{{ $d(accompanyingCourse.openingDate.datetime, 'text') }}</i>
</span>
<span v-if="accompanyingCourse.user" class="d-md-block ms-3 ms-md-0">
<span class="item-key">{{ $t('course.referrer') }}:</span> <b>{{ accompanyingCourse.user.text }}</b>
<span class="item-key">{{ $t('course.referrer') }}:</span>&nbsp;
<b>{{ accompanyingCourse.user.text }}</b>
<template v-if="accompanyingCourse.user.isAbsent">
&nbsp;
<span class="badge bg-danger rounded-pill" title="Absent">A</span>
</template>
</span>
</span>
</span>
@ -59,13 +64,15 @@
import ToggleFlags from './Banner/ToggleFlags';
import SocialIssue from './Banner/SocialIssue.vue';
import PersonsAssociated from './Banner/PersonsAssociated.vue';
import UserRenderBoxBadge from 'ChillMainAssets/vuejs/_components/Entity/UserRenderBoxBadge.vue';
export default {
name: 'Banner',
components: {
ToggleFlags,
SocialIssue,
PersonsAssociated
PersonsAssociated,
UserRenderBoxBadge,
},
computed: {
accompanyingCourse() {

View File

@ -29,7 +29,8 @@ const appMessages = {
emergency: "urgent",
confidential: "confidentiel",
regular: "régulier",
occasional: "ponctuel"
occasional: "ponctuel",
absent: "Absent",
},
origin: {
title: "Origine de la demande",

View File

@ -1,9 +1,7 @@
<template>
<div class="container usercontainer">
<div class="user-identification">
<span class="name">
{{ item.result.text }}
</span>
<user-render-box-badge :user="item.result"></user-render-box-badge>
</div>
</div>
<div class="right_actions">
@ -16,10 +14,12 @@
<script>
import BadgeEntity from 'ChillMainAssets/vuejs/_components/BadgeEntity.vue';
import UserRenderBoxBadge from 'ChillMainAssets/vuejs/_components/Entity/UserRenderBoxBadge.vue';
export default {
name: 'SuggestionUser',
components: {
UserRenderBoxBadge,
BadgeEntity
},
props: ['item'],

View File

@ -115,23 +115,23 @@ class PersonSearch extends AbstractSearch implements HasAdvancedSearchFormInterf
{
$string = '@person ';
$string .= empty($data['_default']) ? '' : $data['_default'] . ' ';
$string .= $data['_default'] ? '' : $data['_default'] . ' ';
foreach (['firstname', 'lastname', 'gender', 'city'] as $key) {
$string .= empty($data[$key]) ? '' : $key . ':' .
$string .= $data[$key] ? '' : $key . ':' .
// add quote if contains spaces
(strpos($data[$key], ' ') !== false ? '"' . $data[$key] . '"' : $data[$key])
. ' ';
}
foreach (['birthdate', 'birthdate-before', 'birthdate-after'] as $key) {
$string .= empty($data[$key]) ?
$string .= $data[$key] ?
''
:
$key . ':' . $data[$key]->format('Y-m-d') . ' ';
}
$string .= empty($data['phonenumber']) ? '' : 'phonenumber:' . $data['phonenumber']->getNationalNumber();
$string .= $data['phonenumber'] ? '' : 'phonenumber:' . $data['phonenumber']->getNationalNumber();
return $string;
}
@ -162,13 +162,13 @@ class PersonSearch extends AbstractSearch implements HasAdvancedSearchFormInterf
$phonenumber = new PhoneNumber();
$phonenumber->setNationalNumber($terms['phonenumber']);
} catch (Exception $ex) {
throw new ParsingException("The date for {$key} is "
throw new ParsingException('The date for phonenumber is '
. 'not parsable', 0, $ex);
}
$data['phonenumber'] = $phonenumber ?? null;
}
$data['phonenumber'] = $phonenumber ?? null;
return $data;
}