Merge remote-tracking branch 'origin/ticket-app-master' into migrate_to_sf72

# Conflicts:
#	.gitlab-ci.yml
#	composer.json
#	config/services.yaml
#	phpunit.xml.dist
#	src/Bundle/ChillAsideActivityBundle/src/Entity/AsideActivity.php
#	src/Bundle/ChillCalendarBundle/Entity/CancelReason.php
#	src/Bundle/ChillCalendarBundle/Messenger/Handler/CalendarRemoveHandler.php
#	src/Bundle/ChillCalendarBundle/RemoteCalendar/DependencyInjection/RemoteCalendarCompilerPass.php
#	src/Bundle/ChillDocGeneratorBundle/Service/Messenger/OnGenerationFails.php
#	src/Bundle/ChillJobBundle/src/Entity/Immersion.php
#	src/Bundle/ChillMainBundle/CRUD/Controller/CRUDController.php
#	src/Bundle/ChillMainBundle/DataFixtures/ORM/LoadLocationType.php
#	src/Bundle/ChillMainBundle/Entity/Location.php
#	src/Bundle/ChillMainBundle/Routing/MenuComposer.php
#	src/Bundle/ChillMainBundle/Routing/MenuTwig.php
#	src/Bundle/ChillMainBundle/Security/PasswordRecover/RecoverPasswordHelper.php
#	src/Bundle/ChillMainBundle/Serializer/Normalizer/DateNormalizer.php
#	src/Bundle/ChillMainBundle/Tests/Form/Type/ScopePickerTypeTest.php
#	src/Bundle/ChillMainBundle/Tests/Services/MenuComposerTest.php
#	src/Bundle/ChillPersonBundle/Controller/PersonController.php
#	src/Bundle/ChillPersonBundle/Entity/Person.php
#	src/Bundle/ChillPersonBundle/Form/DataMapper/PersonAltNameDataMapper.php
#	src/Bundle/ChillPersonBundle/Repository/PersonACLAwareRepository.php
#	src/Bundle/ChillPersonBundle/Serializer/Normalizer/PersonJsonNormalizer.php
#	src/Bundle/ChillPersonBundle/Tests/Serializer/Normalizer/PersonJsonNormalizerTest.php
#	src/Bundle/ChillTaskBundle/Form/SingleTaskType.php
This commit is contained in:
2025-12-22 16:36:57 +01:00
888 changed files with 64991 additions and 32076 deletions

View File

@@ -382,6 +382,7 @@ final class ActivityController extends AbstractController
$entity = new Activity();
$entity->setUser($this->security->getUser());
$entity->addUser($this->security->getUser());
if ($person instanceof Person) {
$entity->setPerson($person);

View File

@@ -27,7 +27,8 @@ class ByActivityNumberAggregator implements AggregatorInterface
public function alterQuery(QueryBuilder $qb, $data, \Chill\MainBundle\Export\ExportGenerationContext $exportGenerationContext): void
{
$qb
->addSelect('(SELECT COUNT(activity.id) FROM '.Activity::class.' activity WHERE activity.accompanyingPeriod = acp) AS activity_by_number_aggregator')
// Use a distinct alias inside the subquery to avoid colliding with the root alias "activity"
->addSelect('(SELECT COUNT(agg_activity.id) FROM '.Activity::class.' agg_activity WHERE agg_activity.accompanyingPeriod = acp) AS activity_by_number_aggregator')
->addGroupBy('activity_by_number_aggregator');
}
@@ -65,7 +66,7 @@ class ByActivityNumberAggregator implements AggregatorInterface
{
return static function ($value) {
if ('_header' === $value) {
return '';
return 'Count activities linked to an accompanying period';
}
if (null === $value) {

View File

@@ -66,6 +66,9 @@ class ListActivityHelper
->leftJoin('activity.location', 'location')
->addSelect('location.name AS locationName')
->addSelect('activity.sentReceived')
->addSelect('activity.comment.comment AS commentText')
->addSelect('activity.comment.date AS commentDate')
->addSelect('JSON_BUILD_OBJECT(\'uid\', activity.comment.userId, \'d\', activity.comment.date) AS commentUser')
->addSelect('JSON_BUILD_OBJECT(\'uid\', IDENTITY(activity.createdBy), \'d\', activity.createdAt) AS createdBy')
->addSelect('activity.createdAt')
->addSelect('JSON_BUILD_OBJECT(\'uid\', IDENTITY(activity.updatedBy), \'d\', activity.updatedAt) AS updatedBy')
@@ -87,6 +90,8 @@ class ListActivityHelper
'createdAt', 'updatedAt' => $this->dateTimeHelper->getLabel($key),
'createdBy', 'updatedBy' => $this->userHelper->getLabel($key, $values, $key),
'date' => $this->dateTimeHelper->getLabel(self::MSG_KEY.$key),
'commentDate' => $this->dateTimeHelper->getLabel(self::MSG_KEY.'comment_date'),
'commentUser' => $this->userHelper->getLabel($key, $values, self::MSG_KEY.'comment_user'),
'attendeeName' => function ($value) {
if ('_header' === $value) {
return 'Attendee';
@@ -176,6 +181,9 @@ class ListActivityHelper
'usersNames',
'thirdPartiesIds',
'thirdPartiesNames',
'commentText',
'commentDate',
'commentUser',
'createdBy',
'createdAt',
'updatedBy',

View File

@@ -90,7 +90,9 @@ class ActivityReasonFilter implements ExportElementValidatedInterface, FilterInt
public function getFormDefaultData(): array
{
return [];
return [
'reasons' => [],
];
}
public function describeAction($data, ExportGenerationContext $context): string|\Symfony\Contracts\Translation\TranslatableInterface|array

View File

@@ -42,6 +42,8 @@ final readonly class PersonHavingActivityBetweenDateFilter implements ExportElem
public function alterQuery(QueryBuilder $qb, $data, ExportGenerationContext $exportGenerationContext): void
{
error_log('alterQuery called with data: '.json_encode(array_keys($data)));
// create a subquery for activity
$sqb = $qb->getEntityManager()->createQueryBuilder();
$sqb->select('1')
@@ -59,7 +61,6 @@ final readonly class PersonHavingActivityBetweenDateFilter implements ExportElem
if (\in_array('activity', $qb->getAllAliases(), true)) {
$sqb->andWhere('activity_person_having_activity.id = activity.id');
}
if (isset($data['reasons']) && [] !== $data['reasons']) {
// add clause activity reason
$sqb->join('activity_person_having_activity.reasons', 'reasons_person_having_activity');
@@ -124,12 +125,38 @@ final readonly class PersonHavingActivityBetweenDateFilter implements ExportElem
public function normalizeFormData(array $formData): array
{
return ['date_from_rolling' => $formData['date_from_rolling']->normalize(), 'date_to_rolling' => $formData['date_to_rolling']->normalize()];
$normalized = [
'date_from_rolling' => $formData['date_from_rolling']->normalize(),
'date_to_rolling' => $formData['date_to_rolling']->normalize(),
'reasons' => [],
];
if (isset($formData['reasons']) && [] !== $formData['reasons']) {
$normalized['reasons'] = array_map(
fn (ActivityReason $reason) => $reason->getId(),
$formData['reasons']
);
}
return $normalized;
}
public function denormalizeFormData(array $formData, int $fromVersion): array
{
return ['date_from_rolling' => RollingDate::fromNormalized($formData['date_from_rolling']), 'date_to_rolling' => RollingDate::fromNormalized($formData['date_to_rolling'])];
$denormalized = [
'date_from_rolling' => RollingDate::fromNormalized($formData['date_from_rolling']),
'date_to_rolling' => RollingDate::fromNormalized($formData['date_to_rolling']),
'reasons' => [],
];
if (isset($formData['reasons']) && [] !== $formData['reasons']) {
$denormalized['reasons'] = array_map(
fn ($id) => $this->activityReasonRepository->find($id),
$formData['reasons']
);
}
return $denormalized;
}
public function getFormDefaultData(): array
@@ -143,10 +170,12 @@ final readonly class PersonHavingActivityBetweenDateFilter implements ExportElem
public function describeAction($data, ExportGenerationContext $context): array
{
$reasons = $data['reasons'] ?? [];
return [
[] === $data['reasons'] ?
'export.filter.person_between_dates.describe_action_with_no_subject'
: 'export.filter.person_between_dates.describe_action_with_subject',
[] === $reasons ?
'export.filter.activity.describe_action_with_no_subject'
: 'export.filter.activity.describe_action_with_subject',
[
'date_from' => $this->rollingDateConverter->convert($data['date_from_rolling']),
'date_to' => $this->rollingDateConverter->convert($data['date_to_rolling']),
@@ -154,7 +183,7 @@ final readonly class PersonHavingActivityBetweenDateFilter implements ExportElem
', ',
array_map(
fn (ActivityReason $r): string => '"'.$this->translatableStringHelper->localize($r->getName()).'"',
$data['reasons']
$reasons
)
),
],
@@ -168,6 +197,7 @@ final readonly class PersonHavingActivityBetweenDateFilter implements ExportElem
public function validateForm($data, ExecutionContextInterface $context): void
{
error_log('validateForm called with data: '.json_encode(array_keys($data)));
if ($this->rollingDateConverter->convert($data['date_from_rolling'])
>= $this->rollingDateConverter->convert($data['date_to_rolling'])) {
$context->buildViolation('export.filter.activity.person_between_dates.date mismatch')

View File

@@ -88,8 +88,8 @@ class ActivityType extends AbstractType
if (null !== $options['data']->getPerson()) {
$builder->add('scope', ScopePickerType::class, [
'center' => $options['center'],
'role' => ActivityVoter::CREATE === (string) $options['role'] ? ActivityVoter::CREATE_PERSON : (string) $options['role'],
'center' => $options['center'],
'required' => true,
]);
}

View File

@@ -1,7 +1,7 @@
<template>
<concerned-groups v-if="hasPerson" />
<social-issues-acc v-if="hasSocialIssues" />
<location v-if="hasLocation" />
<concerned-groups v-if="hasPerson" />
<social-issues-acc v-if="hasSocialIssues" />
<location v-if="hasLocation" />
</template>
<script>
@@ -10,12 +10,12 @@ import SocialIssuesAcc from "./components/SocialIssuesAcc.vue";
import Location from "./components/Location.vue";
export default {
name: "App",
props: ["hasSocialIssues", "hasLocation", "hasPerson"],
components: {
ConcernedGroups,
SocialIssuesAcc,
Location,
},
name: "App",
props: ["hasSocialIssues", "hasLocation", "hasPerson"],
components: {
ConcernedGroups,
SocialIssuesAcc,
Location,
},
};
</script>

View File

@@ -1,46 +1,43 @@
<template>
<teleport to="#add-persons" v-if="isComponentVisible">
<div class="flex-bloc concerned-groups" :class="getContext">
<persons-bloc
v-for="bloc in contextPersonsBlocs"
:key="bloc.key"
:bloc="bloc"
:bloc-width="getBlocWidth"
:set-persons-in-bloc="setPersonsInBloc"
/>
</div>
<div
v-if="
getContext === 'accompanyingCourse' &&
suggestedEntities.length > 0
"
<teleport to="#add-persons" v-if="isComponentVisible">
<div class="flex-bloc concerned-groups" :class="getContext">
<persons-bloc
v-for="bloc in contextPersonsBlocs"
:key="bloc.key"
:bloc="bloc"
:bloc-width="getBlocWidth"
:set-persons-in-bloc="setPersonsInBloc"
/>
</div>
<div
v-if="getContext === 'accompanyingCourse' && suggestedEntities.length > 0"
>
<ul class="list-suggest add-items inline">
<li
v-for="(p, i) in suggestedEntities"
@click="addSuggestedEntity(p)"
:key="`suggestedEntities-${i}`"
>
<ul class="list-suggest add-items inline">
<li
v-for="(p, i) in suggestedEntities"
@click="addSuggestedEntity(p)"
:key="`suggestedEntities-${i}`"
>
<person-text v-if="p.type === 'person'" :person="p" />
<span v-else>{{ p.text }}</span>
</li>
</ul>
</div>
<person-text v-if="p.type === 'person'" :person="p" />
<span v-else>{{ p.text }}</span>
</li>
</ul>
</div>
<ul class="record_actions">
<li class="add-persons">
<add-persons
:buttonTitle="trans(ACTIVITY_ADD_PERSONS)"
:modalTitle="trans(ACTIVITY_ADD_PERSONS)"
v-bind:key="addPersons.key"
v-bind:options="addPersonsOptions"
@addNewPersons="addNewPersons"
ref="addPersons"
>
</add-persons>
</li>
</ul>
</teleport>
<ul class="record_actions">
<li class="add-persons">
<add-persons
:buttonTitle="trans(ACTIVITY_ADD_PERSONS)"
:modalTitle="trans(ACTIVITY_ADD_PERSONS)"
v-bind:key="addPersons.key"
v-bind:options="addPersonsOptions"
@addNewPersons="addNewPersons"
ref="addPersons"
>
</add-persons>
</li>
</ul>
</teleport>
</template>
<script>
@@ -49,208 +46,208 @@ import AddPersons from "ChillPersonAssets/vuejs/_components/AddPersons.vue";
import PersonsBloc from "./ConcernedGroups/PersonsBloc.vue";
import PersonText from "ChillPersonAssets/vuejs/_components/Entity/PersonText.vue";
import {
ACTIVITY_BLOC_PERSONS,
ACTIVITY_BLOC_PERSONS_ASSOCIATED,
ACTIVITY_BLOC_THIRDPARTY,
ACTIVITY_BLOC_USERS,
ACTIVITY_ADD_PERSONS,
trans,
ACTIVITY_BLOC_PERSONS,
ACTIVITY_BLOC_PERSONS_ASSOCIATED,
ACTIVITY_BLOC_THIRDPARTY,
ACTIVITY_BLOC_USERS,
ACTIVITY_ADD_PERSONS,
trans,
} from "translator";
export default {
name: "ConcernedGroups",
components: {
AddPersons,
PersonsBloc,
PersonText,
name: "ConcernedGroups",
components: {
AddPersons,
PersonsBloc,
PersonText,
},
setup() {
return {
trans,
ACTIVITY_ADD_PERSONS,
};
},
data() {
return {
personsBlocs: [
{
key: "persons",
title: trans(ACTIVITY_BLOC_PERSONS),
persons: [],
included: false,
},
{
key: "personsAssociated",
title: trans(ACTIVITY_BLOC_PERSONS_ASSOCIATED),
persons: [],
included: window.activity
? window.activity.activityType.personsVisible !== 0
: true,
},
{
key: "personsNotAssociated",
title: "activity.bloc_persons_not_associated",
persons: [],
included: window.activity
? window.activity.activityType.personsVisible !== 0
: true,
},
{
key: "thirdparty",
title: trans(ACTIVITY_BLOC_THIRDPARTY),
persons: [],
included: window.activity
? window.activity.activityType.thirdPartiesVisible !== 0
: true,
},
{
key: "users",
title: trans(ACTIVITY_BLOC_USERS),
persons: [],
included: window.activity
? window.activity.activityType.usersVisible !== 0
: true,
},
],
addPersons: {
key: "activity",
},
};
},
computed: {
isComponentVisible() {
return window.activity
? window.activity.activityType.personsVisible !== 0 ||
window.activity.activityType.thirdPartiesVisible !== 0 ||
window.activity.activityType.usersVisible !== 0
: true;
},
setup() {
return {
trans,
ACTIVITY_ADD_PERSONS,
};
...mapState({
persons: (state) => state.activity.persons,
thirdParties: (state) => state.activity.thirdParties,
users: (state) => state.activity.users,
accompanyingCourse: (state) => state.activity.accompanyingPeriod,
}),
...mapGetters(["suggestedEntities"]),
getContext() {
return this.accompanyingCourse ? "accompanyingCourse" : "person";
},
data() {
return {
personsBlocs: [
{
key: "persons",
title: trans(ACTIVITY_BLOC_PERSONS),
persons: [],
included: false,
},
{
key: "personsAssociated",
title: trans(ACTIVITY_BLOC_PERSONS_ASSOCIATED),
persons: [],
included: window.activity
? window.activity.activityType.personsVisible !== 0
: true,
},
{
key: "personsNotAssociated",
title: "activity.bloc_persons_not_associated",
persons: [],
included: window.activity
? window.activity.activityType.personsVisible !== 0
: true,
},
{
key: "thirdparty",
title: trans(ACTIVITY_BLOC_THIRDPARTY),
persons: [],
included: window.activity
? window.activity.activityType.thirdPartiesVisible !== 0
: true,
},
{
key: "users",
title: trans(ACTIVITY_BLOC_USERS),
persons: [],
included: window.activity
? window.activity.activityType.usersVisible !== 0
: true,
},
],
addPersons: {
key: "activity",
},
};
contextPersonsBlocs() {
return this.personsBlocs.filter((bloc) => bloc.included !== false);
},
computed: {
isComponentVisible() {
return window.activity
? window.activity.activityType.personsVisible !== 0 ||
window.activity.activityType.thirdPartiesVisible !== 0 ||
window.activity.activityType.usersVisible !== 0
: true;
},
...mapState({
persons: (state) => state.activity.persons,
thirdParties: (state) => state.activity.thirdParties,
users: (state) => state.activity.users,
accompanyingCourse: (state) => state.activity.accompanyingPeriod,
}),
...mapGetters(["suggestedEntities"]),
getContext() {
return this.accompanyingCourse ? "accompanyingCourse" : "person";
},
contextPersonsBlocs() {
return this.personsBlocs.filter((bloc) => bloc.included !== false);
},
addPersonsOptions() {
let optionsType = [];
if (window.activity) {
if (window.activity.activityType.personsVisible !== 0) {
optionsType.push("person");
}
if (window.activity.activityType.thirdPartiesVisible !== 0) {
optionsType.push("thirdparty");
}
if (window.activity.activityType.usersVisible !== 0) {
optionsType.push("user");
}
} else {
optionsType = ["person", "thirdparty", "user"];
}
return {
type: optionsType,
priority: null,
uniq: false,
button: {
size: "btn-sm",
},
};
},
getBlocWidth() {
return Math.round(100 / this.contextPersonsBlocs.length) + "%";
addPersonsOptions() {
let optionsType = [];
if (window.activity) {
if (window.activity.activityType.personsVisible !== 0) {
optionsType.push("person");
}
if (window.activity.activityType.thirdPartiesVisible !== 0) {
optionsType.push("thirdparty");
}
if (window.activity.activityType.usersVisible !== 0) {
optionsType.push("user");
}
} else {
optionsType = ["person", "thirdparty", "user"];
}
return {
type: optionsType,
priority: null,
uniq: false,
button: {
size: "btn-sm",
},
};
},
mounted() {
this.setPersonsInBloc();
getBlocWidth() {
return Math.round(100 / this.contextPersonsBlocs.length) + "%";
},
methods: {
setPersonsInBloc() {
let groups;
if (this.accompanyingCourse) {
groups = this.splitPersonsInGroups();
}
this.personsBlocs.forEach((bloc) => {
if (this.accompanyingCourse) {
switch (bloc.key) {
case "personsAssociated":
bloc.persons = groups.personsAssociated;
bloc.included = true;
break;
case "personsNotAssociated":
bloc.persons = groups.personsNotAssociated;
bloc.included = true;
break;
}
} else {
switch (bloc.key) {
case "persons":
bloc.persons = this.persons;
bloc.included = true;
break;
}
}
switch (bloc.key) {
case "thirdparty":
bloc.persons = this.thirdParties;
break;
case "users":
bloc.persons = this.users;
break;
}
}, groups);
},
splitPersonsInGroups() {
let personsAssociated = [];
let personsNotAssociated = this.persons;
let participations = this.getCourseParticipations();
this.persons.forEach((person) => {
participations.forEach((participation) => {
if (person.id === participation.id) {
//console.log(person.id);
personsAssociated.push(person);
personsNotAssociated = personsNotAssociated.filter(
(p) => p !== person,
);
}
});
});
return {
personsAssociated: personsAssociated,
personsNotAssociated: personsNotAssociated,
};
},
getCourseParticipations() {
let participations = [];
this.accompanyingCourse.participations.forEach((participation) => {
if (!participation.endDate) {
participations.push(participation.person);
}
});
return participations;
},
addNewPersons({ selected, modal }) {
console.log("@@@ CLICK button addNewPersons", selected);
selected.forEach((item) => {
this.$store.dispatch("addPersonsInvolved", item);
}, this);
this.$refs.addPersons.resetSearch(); // to cast child method
modal.showModal = false;
this.setPersonsInBloc();
},
addSuggestedEntity(person) {
this.$store.dispatch("addPersonsInvolved", {
result: person,
type: "person",
});
this.setPersonsInBloc();
},
},
mounted() {
this.setPersonsInBloc();
},
methods: {
setPersonsInBloc() {
let groups;
if (this.accompanyingCourse) {
groups = this.splitPersonsInGroups();
}
this.personsBlocs.forEach((bloc) => {
if (this.accompanyingCourse) {
switch (bloc.key) {
case "personsAssociated":
bloc.persons = groups.personsAssociated;
bloc.included = true;
break;
case "personsNotAssociated":
bloc.persons = groups.personsNotAssociated;
bloc.included = true;
break;
}
} else {
switch (bloc.key) {
case "persons":
bloc.persons = this.persons;
bloc.included = true;
break;
}
}
switch (bloc.key) {
case "thirdparty":
bloc.persons = this.thirdParties;
break;
case "users":
bloc.persons = this.users;
break;
}
}, groups);
},
splitPersonsInGroups() {
let personsAssociated = [];
let personsNotAssociated = this.persons;
let participations = this.getCourseParticipations();
this.persons.forEach((person) => {
participations.forEach((participation) => {
if (person.id === participation.id) {
//console.log(person.id);
personsAssociated.push(person);
personsNotAssociated = personsNotAssociated.filter(
(p) => p !== person,
);
}
});
});
return {
personsAssociated: personsAssociated,
personsNotAssociated: personsNotAssociated,
};
},
getCourseParticipations() {
let participations = [];
this.accompanyingCourse.participations.forEach((participation) => {
if (!participation.endDate) {
participations.push(participation.person);
}
});
return participations;
},
addNewPersons({ selected, modal }) {
console.log("@@@ CLICK button addNewPersons", selected);
selected.forEach((item) => {
this.$store.dispatch("addPersonsInvolved", item);
}, this);
this.$refs.addPersons.resetSearch(); // to cast child method
modal.showModal = false;
this.setPersonsInBloc();
},
addSuggestedEntity(person) {
this.$store.dispatch("addPersonsInvolved", {
result: person,
type: "person",
});
this.setPersonsInBloc();
},
},
};
</script>

View File

@@ -1,29 +1,29 @@
<template>
<li>
<span :title="person.text" @click.prevent="$emit('remove', person)">
<span class="chill_denomination">
<person-text :person="person" :is-cut="true" />
</span>
</span>
</li>
<li>
<span :title="person.text" @click.prevent="$emit('remove', person)">
<span class="chill_denomination">
<person-text :person="person" :is-cut="true" />
</span>
</span>
</li>
</template>
<script>
import PersonText from "ChillPersonAssets/vuejs/_components/Entity/PersonText.vue";
export default {
name: "PersonBadge",
props: ["person"],
components: {
PersonText,
},
// computed: {
// textCutted() {
// let more = (this.person.text.length > 15) ?'…' : '';
// return this.person.text.slice(0,15) + more;
// }
// },
emits: ["remove"],
name: "PersonBadge",
props: ["person"],
components: {
PersonText,
},
// computed: {
// textCutted() {
// let more = (this.person.text.length > 15) ?'…' : '';
// return this.person.text.slice(0,15) + more;
// }
// },
emits: ["remove"],
};
</script>

View File

@@ -1,38 +1,38 @@
<template>
<div class="item-bloc" :style="{ 'flex-basis': blocWidth }">
<div class="item-row">
<div class="item-col">
<h4>{{ $t(bloc.title) }}</h4>
</div>
<div class="item-col">
<ul class="list-suggest remove-items">
<person-badge
v-for="person in bloc.persons"
:key="person.id"
:person="person"
@remove="removePerson"
/>
</ul>
</div>
</div>
<div class="item-bloc" :style="{ 'flex-basis': blocWidth }">
<div class="item-row">
<div class="item-col">
<h4>{{ $t(bloc.title) }}</h4>
</div>
<div class="item-col">
<ul class="list-suggest remove-items">
<person-badge
v-for="person in bloc.persons"
:key="person.id"
:person="person"
@remove="removePerson"
/>
</ul>
</div>
</div>
</div>
</template>
<script>
import PersonBadge from "./PersonBadge.vue";
export default {
name: "PersonsBloc",
components: {
PersonBadge,
},
props: ["bloc", "setPersonsInBloc", "blocWidth"],
methods: {
removePerson(item) {
console.log("@@ CLICK remove person: item", item);
this.$store.dispatch("removePersonInvolved", item);
this.setPersonsInBloc();
},
name: "PersonsBloc",
components: {
PersonBadge,
},
props: ["bloc", "setPersonsInBloc", "blocWidth"],
methods: {
removePerson(item) {
console.log("@@ CLICK remove person: item", item);
this.$store.dispatch("removePersonInvolved", item);
this.setPersonsInBloc();
},
},
};
</script>

View File

@@ -1,32 +1,32 @@
<template>
<teleport to="#location">
<div class="mb-3 row">
<label :class="locationClassList">
{{ trans(ACTIVITY_LOCATION) }}
</label>
<div class="col-sm-8">
<VueMultiselect
name="selectLocation"
id="selectLocation"
label="name"
track-by="id"
open-direction="top"
:multiple="false"
:searchable="true"
:placeholder="trans(ACTIVITY_CHOOSE_LOCATION)"
:custom-label="customLabel"
:select-label="trans(MULTISELECT_SELECT_LABEL)"
:deselect-label="trans(MULTISELECT_DESELECT_LABEL)"
:selected-label="trans(MULTISELECT_SELECTED_LABEL)"
:options="availableLocations"
group-values="locations"
group-label="locationGroup"
v-model="location"
/>
<new-location v-bind:available-locations="availableLocations" />
</div>
</div>
</teleport>
<teleport to="#location">
<div class="mb-3 row">
<label :class="locationClassList">
{{ trans(ACTIVITY_LOCATION) }}
</label>
<div class="col-sm-8">
<VueMultiselect
name="selectLocation"
id="selectLocation"
label="name"
track-by="id"
open-direction="top"
:multiple="false"
:searchable="true"
:placeholder="trans(ACTIVITY_CHOOSE_LOCATION)"
:custom-label="customLabel"
:select-label="trans(MULTISELECT_SELECT_LABEL)"
:deselect-label="trans(MULTISELECT_DESELECT_LABEL)"
:selected-label="trans(MULTISELECT_SELECTED_LABEL)"
:options="availableLocations"
group-values="locations"
group-label="locationGroup"
v-model="location"
/>
<new-location v-bind:available-locations="availableLocations" />
</div>
</div>
</teleport>
</template>
<script>
@@ -35,60 +35,60 @@ import VueMultiselect from "vue-multiselect";
import NewLocation from "./Location/NewLocation.vue";
import { localizeString } from "ChillMainAssets/lib/localizationHelper/localizationHelper";
import {
trans,
ACTIVITY_LOCATION,
ACTIVITY_CHOOSE_LOCATION,
MULTISELECT_SELECT_LABEL,
MULTISELECT_DESELECT_LABEL,
MULTISELECT_SELECTED_LABEL,
trans,
ACTIVITY_LOCATION,
ACTIVITY_CHOOSE_LOCATION,
MULTISELECT_SELECT_LABEL,
MULTISELECT_DESELECT_LABEL,
MULTISELECT_SELECTED_LABEL,
} from "translator";
export default {
name: "Location",
components: {
NewLocation,
VueMultiselect,
name: "Location",
components: {
NewLocation,
VueMultiselect,
},
setup() {
return {
trans,
ACTIVITY_LOCATION,
ACTIVITY_CHOOSE_LOCATION,
MULTISELECT_SELECT_LABEL,
MULTISELECT_DESELECT_LABEL,
MULTISELECT_SELECTED_LABEL,
};
},
data() {
return {
locationClassList: `col-form-label col-sm-4 ${document.querySelector("input#chill_activitybundle_activity_location").getAttribute("required") ? "required" : ""}`,
};
},
computed: {
...mapState(["activity", "availableLocations"]),
...mapGetters(["suggestedEntities"]),
location: {
get() {
return this.activity.location;
},
set(value) {
this.$store.dispatch("updateLocation", value);
},
},
setup() {
return {
trans,
ACTIVITY_LOCATION,
ACTIVITY_CHOOSE_LOCATION,
MULTISELECT_SELECT_LABEL,
MULTISELECT_DESELECT_LABEL,
MULTISELECT_SELECTED_LABEL,
};
},
methods: {
labelAccompanyingCourseLocation(value) {
return `${value.address.text} (${localizeString(value.locationType.title)})`;
},
data() {
return {
locationClassList: `col-form-label col-sm-4 ${document.querySelector("input#chill_activitybundle_activity_location").getAttribute("required") ? "required" : ""}`,
};
},
computed: {
...mapState(["activity", "availableLocations"]),
...mapGetters(["suggestedEntities"]),
location: {
get() {
return this.activity.location;
},
set(value) {
this.$store.dispatch("updateLocation", value);
},
},
},
methods: {
labelAccompanyingCourseLocation(value) {
return `${value.address.text} (${localizeString(value.locationType.title)})`;
},
customLabel(value) {
return value.locationType
? value.name
? value.name === "__AccompanyingCourseLocation__"
? this.labelAccompanyingCourseLocation(value)
: `${value.name} (${localizeString(value.locationType.title)})`
: localizeString(value.locationType.title)
: "";
},
customLabel(value) {
return value.locationType
? value.name
? value.name === "__AccompanyingCourseLocation__"
? this.labelAccompanyingCourseLocation(value)
: `${value.name} (${localizeString(value.locationType.title)})`
: localizeString(value.locationType.title)
: "";
},
},
};
</script>

View File

@@ -136,6 +136,8 @@ import {
ACTIVITY_LOCATION_FIELDS_TYPE,
ACTIVITY_CHOOSE_LOCATION_TYPE,
ACTIVITY_CREATE_NEW_LOCATION,
ACTIVITY_EDIT_ADDRESS,
ACTIVITY_CREATE_ADDRESS,
trans,
} from "translator";
@@ -156,6 +158,8 @@ export default {
ACTIVITY_LOCATION_FIELDS_TYPE,
ACTIVITY_CHOOSE_LOCATION_TYPE,
ACTIVITY_CREATE_NEW_LOCATION,
ACTIVITY_EDIT_ADDRESS,
ACTIVITY_CREATE_ADDRESS,
};
},
props: ["availableLocations"],
@@ -179,14 +183,14 @@ export default {
options: {
button: {
text: {
create: "activity.create_address",
edit: "activity.edit_address",
create: ACTIVITY_CREATE_ADDRESS,
edit: ACTIVITY_EDIT_ADDRESS,
},
size: "btn-sm",
},
title: {
create: "activity.create_address",
edit: "activity.edit_address",
create: ACTIVITY_CREATE_ADDRESS,
edit: ACTIVITY_EDIT_ADDRESS,
},
},
context: {

View File

@@ -1,103 +1,98 @@
<template>
<teleport to="#social-issues-acc">
<div class="mb-3 row">
<div class="col-4">
<label :class="socialIssuesClassList">{{
trans(ACTIVITY_SOCIAL_ISSUES)
}}</label>
</div>
<div class="col-8">
<check-social-issue
v-for="issue in socialIssuesList"
:key="issue.id"
:issue="issue"
:selection="socialIssuesSelected"
@updateSelected="updateIssuesSelected"
>
</check-social-issue>
<teleport to="#social-issues-acc">
<div class="mb-3 row">
<div class="col-4">
<label :class="socialIssuesClassList">{{
trans(ACTIVITY_SOCIAL_ISSUES)
}}</label>
</div>
<div class="col-8">
<check-social-issue
v-for="issue in socialIssuesList"
:key="issue.id"
:issue="issue"
:selection="socialIssuesSelected"
@updateSelected="updateIssuesSelected"
>
</check-social-issue>
<div class="my-3">
<VueMultiselect
name="otherIssues"
label="text"
track-by="id"
open-direction="bottom"
:close-on-select="true"
:preserve-search="false"
:reset-after="true"
:hide-selected="true"
:taggable="false"
:multiple="false"
:searchable="true"
:allow-empty="true"
:show-labels="false"
:loading="issueIsLoading"
:placeholder="trans(ACTIVITY_CHOOSE_OTHER_SOCIAL_ISSUE)"
:options="socialIssuesOther"
@select="addIssueInList"
>
</VueMultiselect>
</div>
</div>
<div class="my-3">
<VueMultiselect
name="otherIssues"
label="text"
track-by="id"
open-direction="bottom"
:close-on-select="true"
:preserve-search="false"
:reset-after="true"
:hide-selected="true"
:taggable="false"
:multiple="false"
:searchable="true"
:allow-empty="true"
:show-labels="false"
:loading="issueIsLoading"
:placeholder="trans(ACTIVITY_CHOOSE_OTHER_SOCIAL_ISSUE)"
:options="socialIssuesOther"
@select="addIssueInList"
>
</VueMultiselect>
</div>
</div>
</div>
<div class="mb-3 row">
<div class="col-4">
<label :class="socialActionsClassList">{{
trans(ACTIVITY_SOCIAL_ACTIONS)
}}</label>
</div>
<div class="col-8">
<div v-if="actionIsLoading === true">
<i class="chill-green fa fa-circle-o-notch fa-spin fa-lg"></i>
</div>
<div class="mb-3 row">
<div class="col-4">
<label :class="socialActionsClassList">{{
trans(ACTIVITY_SOCIAL_ACTIONS)
}}</label>
</div>
<div class="col-8">
<div v-if="actionIsLoading === true">
<i
class="chill-green fa fa-circle-o-notch fa-spin fa-lg"
></i>
</div>
<span
v-else-if="socialIssuesSelected.length === 0"
class="inline-choice chill-no-data-statement mt-3"
>
{{ trans(ACTIVITY_SELECT_FIRST_A_SOCIAL_ISSUE) }}
</span>
<span
v-else-if="socialIssuesSelected.length === 0"
class="inline-choice chill-no-data-statement mt-3"
>
{{ trans(ACTIVITY_SELECT_FIRST_A_SOCIAL_ISSUE) }}
</span>
<template
v-else-if="
socialActionsList.length > 0 &&
(socialIssuesSelected.length || socialActionsSelected.length)
"
>
<div
id="actionsList"
v-for="group in socialActionsList"
:key="group.issue"
>
<span class="badge bg-chill-l-gray text-dark">{{
group.issue
}}</span>
<check-social-action
v-for="action in group.actions"
:key="action.id"
:action="action"
:selection="socialActionsSelected"
@updateSelected="updateActionsSelected"
>
</check-social-action>
</div>
</template>
<template
v-else-if="
socialActionsList.length > 0 &&
(socialIssuesSelected.length ||
socialActionsSelected.length)
"
>
<div
id="actionsList"
v-for="group in socialActionsList"
:key="group.issue"
>
<span class="badge bg-chill-l-gray text-dark">{{
group.issue
}}</span>
<check-social-action
v-for="action in group.actions"
:key="action.id"
:action="action"
:selection="socialActionsSelected"
@updateSelected="updateActionsSelected"
>
</check-social-action>
</div>
</template>
<span
v-else-if="
actionAreLoaded && socialActionsList.length === 0
"
class="inline-choice chill-no-data-statement mt-3"
>
{{ trans(ACTIVITY_SOCIAL_ACTION_LIST_EMPTY) }}
</span>
</div>
</div>
</teleport>
<span
v-else-if="actionAreLoaded && socialActionsList.length === 0"
class="inline-choice chill-no-data-statement mt-3"
>
{{ trans(ACTIVITY_SOCIAL_ACTION_LIST_EMPTY) }}
</span>
</div>
</div>
</teleport>
</template>
<script>
@@ -106,154 +101,174 @@ import CheckSocialIssue from "./SocialIssuesAcc/CheckSocialIssue.vue";
import CheckSocialAction from "./SocialIssuesAcc/CheckSocialAction.vue";
import { getSocialIssues, getSocialActionByIssue } from "../api.js";
import {
ACTIVITY_SOCIAL_ACTION_LIST_EMPTY,
ACTIVITY_SELECT_FIRST_A_SOCIAL_ISSUE,
ACTIVITY_SOCIAL_ACTIONS,
ACTIVITY_SOCIAL_ISSUES,
ACTIVITY_CHOOSE_OTHER_SOCIAL_ISSUE,
trans,
ACTIVITY_SOCIAL_ACTION_LIST_EMPTY,
ACTIVITY_SELECT_FIRST_A_SOCIAL_ISSUE,
ACTIVITY_SOCIAL_ACTIONS,
ACTIVITY_SOCIAL_ISSUES,
ACTIVITY_CHOOSE_OTHER_SOCIAL_ISSUE,
trans,
} from "translator";
export default {
name: "SocialIssuesAcc",
components: {
CheckSocialIssue,
CheckSocialAction,
VueMultiselect,
name: "SocialIssuesAcc",
components: {
CheckSocialIssue,
CheckSocialAction,
VueMultiselect,
},
setup() {
return {
trans,
ACTIVITY_SOCIAL_ACTION_LIST_EMPTY,
ACTIVITY_SELECT_FIRST_A_SOCIAL_ISSUE,
ACTIVITY_SOCIAL_ACTIONS,
ACTIVITY_SOCIAL_ISSUES,
ACTIVITY_CHOOSE_OTHER_SOCIAL_ISSUE,
};
},
data() {
return {
issueIsLoading: false,
actionIsLoading: false,
actionAreLoaded: false,
socialIssuesClassList: {
"col-form-label": true,
required: false,
},
socialActionsClassList: {
"col-form-label": true,
required: false,
},
};
},
computed: {
socialIssuesList() {
return this.$store.state.activity.accompanyingPeriod.socialIssues;
},
setup() {
return {
trans,
ACTIVITY_SOCIAL_ACTION_LIST_EMPTY,
ACTIVITY_SELECT_FIRST_A_SOCIAL_ISSUE,
ACTIVITY_SOCIAL_ACTIONS,
ACTIVITY_SOCIAL_ISSUES,
ACTIVITY_CHOOSE_OTHER_SOCIAL_ISSUE,
};
socialIssuesSelected() {
return this.$store.state.activity.socialIssues;
},
data() {
return {
issueIsLoading: false,
actionIsLoading: false,
actionAreLoaded: false,
socialIssuesClassList: `col-form-label ${document.querySelector("input#chill_activitybundle_activity_socialIssues").getAttribute("required") ? "required" : ""}`,
socialActionsClassList: `col-form-label ${document.querySelector("input#chill_activitybundle_activity_socialActions").getAttribute("required") ? "required" : ""}`,
};
socialIssuesOther() {
return this.$store.state.socialIssuesOther;
},
computed: {
socialIssuesList() {
return this.$store.state.activity.accompanyingPeriod.socialIssues;
},
socialIssuesSelected() {
return this.$store.state.activity.socialIssues;
},
socialIssuesOther() {
return this.$store.state.socialIssuesOther;
},
socialActionsList() {
return this.$store.getters.socialActionsListSorted;
},
socialActionsSelected() {
return this.$store.state.activity.socialActions;
},
socialActionsList() {
return this.$store.getters.socialActionsListSorted;
},
mounted() {
/* Load other issues in multiselect */
this.issueIsLoading = true;
this.actionAreLoaded = false;
getSocialIssues().then((response) => {
/* Add issues to the store */
this.$store.commit("updateIssuesOther", response);
/* Add in list the issues already associated (if not yet listed) */
this.socialIssuesSelected.forEach((issue) => {
if (
this.socialIssuesList.filter((i) => i.id === issue.id)
.length !== 1
) {
this.$store.commit("addIssueInList", issue);
}
});
/* Remove from multiselect the issues that are not yet in the checkbox list */
this.socialIssuesList.forEach((issue) => {
this.$store.commit("removeIssueInOther", issue);
});
/* Filter issues */
this.$store.commit("filterList", "issues");
/* Add in list the actions already associated (if not yet listed) */
this.socialActionsSelected.forEach((action) => {
this.$store.commit("addActionInList", action);
});
/* Filter actions */
this.$store.commit("filterList", "actions");
this.issueIsLoading = false;
this.actionAreLoaded = true;
this.updateActionsList();
});
socialActionsSelected() {
return this.$store.state.activity.socialActions;
},
methods: {
/* When choosing an issue in multiselect, add it in checkboxes (as selected),
},
mounted() {
/* Load classNames after element is present */
const socialActionsEl = document.querySelector(
"input#chill_activitybundle_activity_socialActions",
);
if (socialActionsEl && socialActionsEl.hasAttribute("required")) {
this.socialActionsClassList.required = true;
}
const socialIssuesEl = document.querySelector(
"input#chill_activitybundle_activity_socialIssues",
);
if (socialIssuesEl && socialIssuesEl.hasAttribute("required")) {
this.socialIssuesClassList.required = true;
}
/* Load other issues in multiselect */
this.issueIsLoading = true;
this.actionAreLoaded = false;
getSocialIssues().then((response) => {
/* Add issues to the store */
this.$store.commit("updateIssuesOther", response);
/* Add in list the issues already associated (if not yet listed) */
this.socialIssuesSelected.forEach((issue) => {
if (
this.socialIssuesList.filter((i) => i.id === issue.id).length !== 1
) {
this.$store.commit("addIssueInList", issue);
}
});
/* Remove from multiselect the issues that are not yet in the checkbox list */
this.socialIssuesList.forEach((issue) => {
this.$store.commit("removeIssueInOther", issue);
});
/* Filter issues */
this.$store.commit("filterList", "issues");
/* Add in list the actions already associated (if not yet listed) */
this.socialActionsSelected.forEach((action) => {
this.$store.commit("addActionInList", action);
});
/* Filter actions */
this.$store.commit("filterList", "actions");
this.issueIsLoading = false;
this.actionAreLoaded = true;
this.updateActionsList();
});
},
methods: {
/* When choosing an issue in multiselect, add it in checkboxes (as selected),
remove it from multiselect, and add socialActions concerned
*/
addIssueInList(value) {
//console.log('addIssueInList', value);
this.$store.commit("addIssueInList", value);
this.$store.commit("removeIssueInOther", value);
this.$store.dispatch("addIssueSelected", value);
this.updateActionsList();
},
/* Update value for selected issues checkboxes
*/
updateIssuesSelected(issues) {
//console.log('updateIssuesSelected', issues);
this.$store.dispatch("updateIssuesSelected", issues);
this.updateActionsList();
},
/* Update value for selected actions checkboxes
*/
updateActionsSelected(actions) {
//console.log('updateActionsSelected', actions);
this.$store.dispatch("updateActionsSelected", actions);
},
/* Add socialActions concerned: after reset, loop on each issue selected
addIssueInList(value) {
//console.log('addIssueInList', value);
this.$store.commit("addIssueInList", value);
this.$store.commit("removeIssueInOther", value);
this.$store.dispatch("addIssueSelected", value);
this.updateActionsList();
},
/* Update value for selected issues checkboxes
*/
updateIssuesSelected(issues) {
//console.log('updateIssuesSelected', issues);
this.$store.dispatch("updateIssuesSelected", issues);
this.updateActionsList();
},
/* Update value for selected actions checkboxes
*/
updateActionsSelected(actions) {
//console.log('updateActionsSelected', actions);
this.$store.dispatch("updateActionsSelected", actions);
},
/* Add socialActions concerned: after reset, loop on each issue selected
to get social actions concerned
*/
updateActionsList() {
this.resetActionsList();
this.socialIssuesSelected.forEach((item) => {
this.actionIsLoading = true;
getSocialActionByIssue(item.id).then(
(actions) =>
new Promise((resolve) => {
actions.results.forEach((action) => {
this.$store.commit("addActionInList", action);
}, this);
updateActionsList() {
this.resetActionsList();
this.socialIssuesSelected.forEach((item) => {
this.actionIsLoading = true;
getSocialActionByIssue(item.id).then(
(actions) =>
new Promise((resolve) => {
actions.results.forEach((action) => {
this.$store.commit("addActionInList", action);
}, this);
this.$store.commit("filterList", "actions");
this.$store.commit("filterList", "actions");
this.actionIsLoading = false;
this.actionAreLoaded = true;
resolve();
}),
);
}, this);
},
/* Reset socialActions List: flush list and restore selected actions
*/
resetActionsList() {
this.$store.commit("resetActionsList");
this.actionAreLoaded = false;
this.socialActionsSelected.forEach((item) => {
this.$store.commit("addActionInList", item);
}, this);
},
this.actionIsLoading = false;
this.actionAreLoaded = true;
resolve();
}),
);
}, this);
},
/* Reset socialActions List: flush list and restore selected actions
*/
resetActionsList() {
this.$store.commit("resetActionsList");
this.actionAreLoaded = false;
this.socialActionsSelected.forEach((item) => {
this.$store.commit("addActionInList", item);
}, this);
},
},
};
</script>
@@ -263,18 +278,18 @@ export default {
@import "ChillMainAssets/chill/scss/chill_variables";
span.multiselect__single {
display: none !important;
display: none !important;
}
#actionsList {
border-radius: 0.5rem;
padding: 1rem;
margin: 0.5rem;
background-color: whitesmoke;
border-radius: 0.5rem;
padding: 1rem;
margin: 0.5rem;
background-color: whitesmoke;
}
span.badge {
margin-bottom: 0.5rem;
@include badge_social($social-issue-color);
margin-bottom: 0.5rem;
@include badge_social($social-issue-color);
}
</style>

View File

@@ -1,38 +1,38 @@
<template>
<span class="inline-choice">
<div class="form-check">
<input
class="form-check-input"
type="checkbox"
v-model="selected"
name="action"
:id="action.id"
:value="action"
/>
<label class="form-check-label" :for="action.id">
<span class="badge bg-light text-dark" :title="action.text">{{
action.text
}}</span>
</label>
</div>
</span>
<span class="inline-choice">
<div class="form-check">
<input
class="form-check-input"
type="checkbox"
v-model="selected"
name="action"
:id="action.id"
:value="action"
/>
<label class="form-check-label" :for="action.id">
<span class="badge bg-light text-dark" :title="action.text">{{
action.text
}}</span>
</label>
</div>
</span>
</template>
<script>
export default {
name: "CheckSocialAction",
props: ["action", "selection"],
emits: ["updateSelected"],
computed: {
selected: {
set(value) {
this.$emit("updateSelected", value);
},
get() {
return this.selection;
},
},
name: "CheckSocialAction",
props: ["action", "selection"],
emits: ["updateSelected"],
computed: {
selected: {
set(value) {
this.$emit("updateSelected", value);
},
get() {
return this.selection;
},
},
},
};
</script>
@@ -41,13 +41,24 @@ export default {
@import "ChillPersonAssets/chill/scss/mixins";
@import "ChillMainAssets/chill/scss/chill_variables";
span.badge {
@include badge_social($social-action-color);
font-size: 95%;
margin-bottom: 5px;
margin-right: 1em;
max-width: 100%; /* Adjust as needed */
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@include badge_social($social-action-color);
font-size: 95%;
white-space: normal;
word-wrap: break-word;
word-break: break-word;
display: inline-block;
max-width: 100%;
margin-bottom: 5px;
margin-right: 1em;
text-align: left;
line-height: 1.2em;
&::before {
position: absolute;
left: 11px;
top: 0;
margin: 0 0.3em 0 -0.75em;
}
position: relative;
padding-left: 1.5em;
}
</style>

View File

@@ -1,38 +1,36 @@
<template>
<span class="inline-choice">
<div class="form-check">
<input
class="form-check-input"
type="checkbox"
v-model="selected"
name="issue"
:id="issue.id"
:value="issue"
/>
<label class="form-check-label" :for="issue.id">
<span class="badge bg-chill-l-gray text-dark">{{
issue.text
}}</span>
</label>
</div>
</span>
<span class="inline-choice">
<div class="form-check">
<input
class="form-check-input"
type="checkbox"
v-model="selected"
name="issue"
:id="issue.id"
:value="issue"
/>
<label class="form-check-label" :for="issue.id">
<span class="badge bg-chill-l-gray text-dark">{{ issue.text }}</span>
</label>
</div>
</span>
</template>
<script>
export default {
name: "CheckSocialIssue",
props: ["issue", "selection"],
emits: ["updateSelected"],
computed: {
selected: {
set(value) {
this.$emit("updateSelected", value);
},
get() {
return this.selection;
},
},
name: "CheckSocialIssue",
props: ["issue", "selection"],
emits: ["updateSelected"],
computed: {
selected: {
set(value) {
this.$emit("updateSelected", value);
},
get() {
return this.selection;
},
},
},
};
</script>
@@ -41,9 +39,24 @@ export default {
@import "ChillPersonAssets/chill/scss/mixins";
@import "ChillMainAssets/chill/scss/chill_variables";
span.badge {
@include badge_social($social-issue-color);
font-size: 95%;
margin-bottom: 5px;
margin-right: 1em;
@include badge_social($social-issue-color);
font-size: 95%;
white-space: normal;
word-wrap: break-word;
word-break: break-word;
display: inline-block;
max-width: 100%;
margin-bottom: 5px;
margin-right: 1em;
text-align: left;
&::before {
position: absolute;
left: 11px;
top: 0;
margin: 0 0.3em 0 -0.75em;
}
position: relative;
padding-left: 1.5em;
}
</style>

View File

@@ -103,7 +103,7 @@ const store = createStore({
}
// console.log("suggested users", suggestedUsers);
return suggestedUsers;
return suggestedUsers.filter((u) => u.enabled === true);
},
suggestedResources(state) {
// const resources = state.activity.accompanyingPeriod.resources;

View File

@@ -136,7 +136,6 @@
<div class="wl-col list">
{{ activity.comment|chill_entity_render_box({
'disable_markdown': false,
'limit_lines': 3,
'metadata': false,
}) }}
</div>

View File

@@ -0,0 +1,50 @@
<?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\Activity;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Migration fixing the automatic association of users to activities (exchanges).
*
* Originally, the user who created an exchange was not automatically associated
* to it (the "TMS" column), which led to incomplete data and biased statistics.
*
* This migration:
* - retroactively associates the creator of each exchange to the corresponding
* activity;
* - flags these backfilled associations with a temporary column so it is clear
* they were added by this data correction and can be safely cleaned up later.
*/
final class Version20251118124241 extends AbstractMigration
{
public function getDescription(): string
{
return 'Insert the creator of activity into the activity_user table';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE activity_user ADD COLUMN by_migration BOOL DEFAULT FALSE');
$this->addSql("COMMENT ON COLUMN activity_user.by_migration IS 'For backup purpose - can be safely deleted after a while. See migration \\Chill\\Migrations\\Activity\\Version20251118124241'");
$this->addSql('INSERT INTO activity_user (activity_id, user_id, by_migration)
SELECT id, user_id, true FROM activity
ON CONFLICT DO NOTHING');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE activity_user DROP COLUMN by_migration');
}
}

View File

@@ -0,0 +1,18 @@
export:
filter:
activity:
course_having_activity_between_date:
Only course having an activity between from and to: Alleen trajecten met een activiteit tussen {from, date, short} en {to, date, short}
acp_by_activity_type:
'acp_containing_at_least_one_activitytypes': >-
Gefilterde trajecten: alleen die welke ten minste één activiteit bevatten van een van de volgende types: {activitytypes}
{has_date_after, select, 1 {, na {date_after, date}} other {}}
{has_date_before, select, 1 {, voor {date_before, date}} other {}}
describe_action_with_no_subject: >-
Gefilterd op persoon die een activiteit had tussen {date_from, date} en {date_to, date}
describe_action_with_subject: >-
Gefilterd op persoon die een activiteit had tussen {date_from, date} en {date_to, date}, en een van deze gekozen onderwerpen: {reasons}
activity:
title: Activiteit van {date, date, long} - {type}

View File

@@ -10,7 +10,7 @@ Attendee: Présence de l'usager
attendee: présence de l'usager
list_reasons: liste des sujets
user_username: nom de l'utilisateur
circle_name: nom du cercle
circle_name: nom du service
Remark: Commentaire
No comments: Aucun commentaire
Add a new activity: Ajouter une nouvel échange
@@ -20,7 +20,7 @@ not present: absent
Delete: Supprimer
Update: Mettre à jour
Update activity: Modifier l'échange
Scope: Cercle
Scope: Service
Activity data: Données de l'échange
Activity location: Localisation de l'échange
No reason associated: Aucun sujet
@@ -398,13 +398,15 @@ export:
sent received: Envoyé ou reçu
emergency: Urgence
accompanying course id: Identifiant du parcours
course circles: Cercles du parcours
course circles: Services du parcours
travelTime: Durée de déplacement
durationTime: Durée
id: Identifiant
List activities linked to an accompanying course: Liste les échanges liés à un parcours en fonction de différents filtres.
List activity linked to a course: Liste des échanges liés à un parcours
commentText: Commentaire
comment_date: Date de la dernière édition du commentaire
comment_user: Dernière édition par
filter:
activity:

View File

@@ -1,234 +1,500 @@
#general
Show the activity: Toon activiteit
Edit the activity: Wijzig activiteit
Activity: Activiteit
Show the activity: Uitwisseling bekijken
Edit the activity: Uitwisseling bewerken
Activity: Uitwisseling
Duration time: Duur
Duration Time: Duur
durationTime: duur
Travel time: Duur van verplaatsing
Attendee: Aanwezigheden
attendee: aanwezigheden
list_reasons: Onderwerpen
user_username: gebruikersnaam
circle_name: naam kring
durationTime: duur
Travel time: Reisduur
Attendee: Aanwezigheid van de gebruiker
attendee: aanwezigheid van de gebruiker
list_reasons: lijst van onderwerpen
user_username: naam van de gebruiker
circle_name: naam van de dienst
Remark: Opmerking
No comments: Geen opmerkingen
Add a new activity: Voeg een nieuwe activiteit toe
Activity list: Lijst van activiteiten
No comments: Geen opmerking
Add a new activity: Nieuwe uitwisseling toevoegen
Activity list: Lijst van uitwisselingen
present: aanwezig
not present: afwezig
Delete: Verwijderen
Update: Bijwerken
Update activity: Activieit bijwerken
Scope: Werkingsgebied
Activity data: Gegevens activiteit
Activity location: Locatie activiteit
Update activity: Uitwisseling bewerken
Scope: Dienst
Activity data: Gegevens van de uitwisseling
Activity location: Locatie van de uitwisseling
No reason associated: Geen onderwerp
No social issues associated: Geen sociaal vraagstuk
No social actions associated: Geen maatschappelijke actie
There isn't any activities.: Er zijn geen activiteiten
type_name: Soort activiteit
No social issues associated: Geen sociale problematiek
No social actions associated: Geen begeleidingsactie
There isn't any activities.: Geen uitwisseling geregistreerd.
type_name: type van de uitwisseling
person_firstname: voornaam
person_lastname: familienaam
person_id: Identificatienummer persoon
Type: Soort
person_lastname: achternaam
person_id: identificatie van de gebruiker
Type: Type
Invisible: Onzichtbaar
Optional: Optioneel
Required: Verplicht
Persons: Personen
Persons: Gebruikers
Users: Gebruikers
Emergency: Dringend
Emergency: Urgent
Sent received: Inkomend / Uitgaand
Sent: Verzenden
Received: Ontvangen
by: 'Door '
location: Plaats
Reasons: Onderwerpen
Private comment: Privé opmerking
sent: Verzonden
received: Ontvangen
#forms
Activity creation: Nouvel échange
Create: Créer
Back to the list: Retour à la liste
Save activity: Sauver l'échange
Reset form: Remise à zéro du formulaire
Choose the duration: Choisir la durée
Choose a type: Choisir un type
5 minutes: 5 minutes
10 minutes: 10 minutes
15 minutes: 15 minutes
20 minutes: 20 minutes
25 minutes: 25 minutes
30 minutes: 30 minutes
45 minutes: 45 minutes
1 hour: 1 heure
1 hour 15: 1 heure 15
1 hour 30: 1 heure 30
1 hour 45: 1 heure 45
2 hours: 2 heures
Concerned groups: Parties concernées
Persons in accompanying course: Usagers du parcours
Third persons: Tiers non-pro.
Others persons: Usagers
Third parties: Tiers professionnels
Activity creation: Nieuwe uitwisseling
Create: Aanmaken
Back to the list: Terug naar de lijst
Save activity: Uitwisseling opslaan
Reset form: Formulier resetten
Choose the duration: Duur kiezen
Choose a type: Type kiezen
5 minutes: 5 minuten
10 minutes: 10 minuten
15 minutes: 15 minuten
20 minutes: 20 minuten
25 minutes: 25 minuten
30 minutes: 30 minuten
45 minutes: 45 minuten
1 hour: 1 uur
1 hour 15: 1 uur 15
1 hour 30: 1 uur 30
1 hour 45: 1 uur 45
2 hours: 2 uur
2 hours 15: 2 uur 15
2 hours 30: 2 uur 30
2 hours 45: 2 uur 45
3 hours: 3 uur
3 hours 30: 3 uur 30
4 hours: 4 uur
4 hours 30: 4 uur 30
5 hours: 5 uur
5 hours 30: 5 uur 30
6 hours: 6 uur
6 hours 30: 6 uur 30
7 hours: 7 uur
7 hours 30: 7 uur 30
8 hours: 8 uur
8 hours 30: 8 uur 30
9 hours: 9 uur
9 hours 30: 9 uur 30
10 hours: 10 uur
11 hours: 11 uur
12 hours: 12 uur
Concerned groups: Betrokken partijen bij de uitwisseling
Persons in accompanying course: Gebruikers van het traject
Third persons: Niet-prof. derden
Others persons: Gebruikers
Third parties: Professionele derden
Users concerned: T(M)S
activity:
Insert a document: Insérer un document
Remove a document: Supprimer le document
comment: Commentaire
No documents: Aucun document
date: Datum van de uitwisseling
Insert a document: Document invoegen
Remove a document: Document verwijderen
comment: Opmerking
deleted: Uitwisseling verwijderd
errors: Het formulier bevat fouten
social_issues: Sociale problematieken
choose_other_social_issue: Andere sociale problematiek toevoegen...
social_actions: Begeleidingsacties
select_first_a_social_issue: Selecteer eerst een sociale problematiek
social_action_list_empty: Geen sociale actie beschikbaar
add_persons: Betrokken personen toevoegen
bloc_persons: Gebruikers
bloc_persons_associated: Gebruikers van het traject
bloc_persons_not_associated: Niet-prof. derden
bloc_thirdparty: Professionele derden
bloc_users: T(M)S
location: Locatie
choose_location: Kies een locatie
choose_location_type: Kies een type locatie
create_new_location: Nieuwe locatie aanmaken
location_fields:
name: Naam
type: Type
phonenumber1: Telefoon
phonenumber2: Andere telefoon
email: E-mailadres
create_address: Adres aanmaken
edit_address: Adres bewerken
No documents: Geen document
# activity filter in list page
activity_filter:
My activities: Mijn uitwisselingen (waar ik aan deelneem)
Types: Op type uitwisseling
Jobs: Op betrokken beroep
#timeline
'%user% has done an %activity_type%': '%user% a effectué un échange de type "%activity_type%"'
'%user% has done an %activity_type%': '%user% heeft een uitwisseling van type "%activity_type%" uitgevoerd'
#controller
'Success : activity created!': L'échange a été créé.
'The form is not valid. The activity has not been created !': Le formulaire est invalide. L'échange n'a pas été créé.
'Success : activity updated!': L'échange a été mis à jour.
'The form is not valid. The activity has not been updated !': Le formulaire est invalide. L'échange n'a pas été mis à jour.
'Success : activity created!': De uitwisseling is aangemaakt.
'The form is not valid. The activity has not been created !': Het formulier is ongeldig. De uitwisseling is niet aangemaakt.
'Success : activity updated!': De uitwisseling is bijgewerkt.
'The form is not valid. The activity has not been updated !': Het formulier is ongeldig. De uitwisseling is niet bijgewerkt.
# ROLES
CHILL_ACTIVITY_CREATE: Créer un échange
CHILL_ACTIVITY_UPDATE: Modifier un échange
CHILL_ACTIVITY_SEE: Voir un échange
CHILL_ACTIVITY_SEE_DETAILS: Voir le détail des échanges
CHILL_ACTIVITY_DELETE: Supprimer un échange
CHILL_ACTIVITY_STATS: Statistique des échanges
CHILL_ACTIVITY_LIST: Liste des échanges
CHILL_ACTIVITY_CREATE: Uitwisseling aanmaken
CHILL_ACTIVITY_UPDATE: Uitwisseling bewerken
CHILL_ACTIVITY_SEE: Uitwisseling bekijken
CHILL_ACTIVITY_SEE_DETAILS: Detail van uitwisselingen bekijken
CHILL_ACTIVITY_DELETE: Uitwisseling verwijderen
CHILL_ACTIVITY_STATS: Statistieken van uitwisselingen
CHILL_ACTIVITY_LIST: Lijst van uitwisselingen
CHILL_ACTIVITY_CREATE_PERSON: Uitwisseling aanmaken gekoppeld aan een gebruiker
CHILL_ACTIVITY_CREATE_ACCOMPANYING_COURSE: Uitwisseling aanmaken gekoppeld aan een traject
CHILL_ACTIVITY_FULL: Details bekijken, aanmaken, verwijderen en bijwerken van een uitwisseling
# admin
Activities: Échanges
Activity configuration: Configuration des échanges
Activity configuration menu: Configuration des échanges
Activity types: Types d'échange
Activity type configuration: Configuration des categories d'échanges
Activity Reasons: Sujets d'un échange
Activity Reasons Category: Catégories de sujet d'échanges
Activity Types Categories: Catégories des types d'échanges
Activity Presences: Presences des échanges
Activities: Uitwisselingen
Activity configuration: Configuratie van uitwisselingen
Activity configuration menu: Configuratie van uitwisselingen
Activity types: Types uitwisseling
Activity type configuration: Configuratie van categorieën van uitwisselingen
Activity Reasons: Onderwerpen van een uitwisseling
Activity Reasons Category: Categorieën van onderwerpen van uitwisselingen
Activity Types Categories: Categorieën van types uitwisseling
Activity Presences: Aanwezigheden bij uitwisselingen
Associated activity reason category is inactive: De gekoppelde onderwerpscategorie is inactief
# Crud
crud:
activity_type:
title_new: Nouveau type d'échange
title_edit: Edition d'un type d'échange
activity_type_category:
title_new: Nouvelle catégorie de type d'échange
title_edit: Edition d'une catégorie de type d'échange
activity_type:
title_new: Nieuw type uitwisseling
title_edit: Type uitwisseling bewerken
activity_type_category:
title_new: Nieuwe categorie van type uitwisseling
title_edit: Categorie van type uitwisseling bewerken
activity_presence:
title_new: Nieuwe aanwezigheid bij uitwisselingen
title_edit: Aanwezigheid bij uitwisselingen bewerken
# activity reason admin
ActivityReason list: Liste des sujets
Create a new activity reason: Créer un nouveau sujet
Active: Actif
Category: Catégorie
ActivityReason creation: Nouveau sujet
ActivityReason edit: Modification d'un sujet
ActivityReason: Sujet d'échange
The entity is inactive and won't be proposed: Le sujet est inactif et ne sera pas proposé
The entity is active and will be proposed: Le sujet est actif et sera proposé
ActivityReason list: Lijst van onderwerpen
Create a new activity reason: Nieuw onderwerp aanmaken
Active: Actief
Category: Categorie
ActivityReason creation: Nieuw onderwerp
ActivityReason edit: Onderwerp bewerken
ActivityReason: Onderwerp van uitwisseling
The entity is inactive and won't be proposed: Het onderwerp is inactief en zal niet worden voorgesteld
The entity is active and will be proposed: Het onderwerp is actief en zal worden voorgesteld
#activity reason category admin
ActivityReasonCategory list: Catégories de sujets
Create a new activity category reason: Créer une nouvelle catégorie
ActivityReasonCategory creation: Nouvelle catégorie de sujet
ActivityReasonCategory edit: Modification d'une catégorie de sujet
ActivityReasonCategory: Catégorie de sujet d'échange
ActivityReasonCategory is active and will be proposed: La catégorie est active et sera proposée
ActivityReasonCategory is inactive and won't be proposed: La catégorie est inactive et ne sera pas proposée
ActivityReasonCategory list: Categorieën van onderwerpen
Create a new activity category reason: Nieuwe categorie aanmaken
ActivityReasonCategory creation: Nieuwe categorie van onderwerp
ActivityReasonCategory edit: Categorie van onderwerp bewerken
ActivityReasonCategory: Categorie van onderwerp van uitwisseling
ActivityReasonCategory is active and will be proposed: De categorie is actief en zal worden voorgesteld
ActivityReasonCategory is inactive and won't be proposed: De categorie is inactief en zal niet worden voorgesteld
#activity presence admin
ActivityPresence list: Lijst van aanwezigheden bij uitwisselingen
Create a new activity presence: Nieuwe "Aanwezigheid bij uitwisselingen" aanmaken
# activity type type admin
ActivityType list: Types d'échanges
Create a new activity type: Créer un nouveau type d'échange
Persons visible: Visibilité du champ Personnes
Persons label: Libellé du champ Personnes
User visible: Visibilité du champ Utilisateur
User label: Libellé du champ Utilisateur
Date visible: Visibilité du champ Date
Date label: Libellé du champ Date
Location visible: Visibilité du champ Lieu
Location label: Libellé du champ Lieu
Third parties visible: Visibilité du champ Tiers
Third parties label: Libellé du champ Tiers
Duration time visible: Visibilité du champ Durée
Duration time label: Libellé du champ Durée
Travel time visible: Visibilité du champ Durée de déplacement
Travel time label: Libellé du champ Durée de déplacement
Attendee visible: Visibilité du champ Présence de l'usager
Attendee label: Libellé du champ Présence de l'usager
Reasons visible: Visibilité du champ Sujet
Reasons label: Libellé du champ Sujet
Comment visible: Visibilité du champ Commentaire
Comment label: Libellé du champ Commentaire
Emergency visible: Visibilité du champ Urgent
Emergency label: Libellé du champ Urgent
Accompanying period visible: Visibilité du champ Période d'accompagnement
Accompanying period label: Libellé du champ Période d'accompagnement
Social issues visible: Visibilité du champ Problématiques sociales
Social issues label: Libellé du champ Problématiques sociales
Social actions visible: Visibilité du champ Action sociale
Social actions label: Libellé du champ Action sociale
Users visible: Visibilité du champ Utilisateurs
Users label: Libellé du champ Utilisateurs
Sent received visible: Visibilité du champ Entrant / Sortant
Sent received label: Libellé du champ Entrant / Sortant
Documents visible: Visibilité du champ Documents
Documents label: Libellé du champ Documents
ActivityType list: Types uitwisselingen
Create a new activity type: Nieuw type uitwisseling aanmaken
Persons visible: Zichtbaarheid van het veld Gebruikers
Persons label: Label van het veld Gebruikers
User visible: Zichtbaarheid van het veld Gebruiker
User label: Label van het veld Gebruiker
Date visible: Zichtbaarheid van het veld Datum
Date label: Label van het veld Datum
Location visible: Zichtbaarheid van het veld Plaats
Location label: Label van het veld Plaats
Third parties visible: Zichtbaarheid van het veld Derden
Third parties label: Label van het veld Derden
Duration time visible: Zichtbaarheid van het veld Duur
Duration time label: Label van het veld Duur
Travel time visible: Zichtbaarheid van het veld Reisduur
Travel time label: Label van het veld Reisduur
Attendee visible: Zichtbaarheid van het veld Aanwezigheid van de gebruiker
Attendee label: Label van het veld Aanwezigheid van de gebruiker
Reasons visible: Zichtbaarheid van het veld Onderwerp
Reasons label: Label van het veld Onderwerp
Comment visible: Zichtbaarheid van het veld Opmerking
Comment label: Label van het veld Opmerking
Private comment visible: Zichtbaarheid van het veld Privé Opmerking
Private comment label: Label van het veld Privé Opmerking
Emergency visible: Zichtbaarheid van het veld Urgent
Emergency label: Label van het veld Urgent
Accompanying period visible: Zichtbaarheid van het veld begeleidingstraject
Accompanying period label: Label van het veld begeleidingstraject
Social issues visible: Zichtbaarheid van het veld Sociale problematieken
Social issues label: Label van het veld Sociale problematieken
Social actions visible: Zichtbaarheid van het veld Sociale actie
Social actions label: Label van het veld Sociale actie
Users visible: Zichtbaarheid van het veld Gebruikers
Users label: Label van het veld Gebruikers
Sent received visible: Zichtbaarheid van het veld Inkomend / Uitgaand
Sent received label: Label van het veld Inkomend / Uitgaand
Documents visible: Zichtbaarheid van het veld Documenten
Documents label: Label van het veld Documenten
# activity type category admin
ActivityTypeCategory list: Liste des catégories des types d'activité
Create a new activity type category: Créer une nouvelle catégorie de type d'échange
ActivityTypeCategory list: Lijst van categorieën van types uitwisseling
Create a new activity type category: Nieuwe categorie van type uitwisseling aanmaken
Create a new activity in accompanying course: Uitwisseling aanmaken in het traject
# activity delete
Remove activity: Supprimer un échange
Are you sure you want to remove the activity about "%name%" ?: Êtes-vous sûr de vouloir supprimer un échange qui concerne "%name%" ?
The activity has been successfully removed.: L'échange a été supprimée.
Remove activity: Uitwisseling verwijderen
Are you sure you want to remove the activity about "%name%" ?: Weet u zeker dat u een uitwisseling wilt verwijderen die betrekking heeft op "%name%"?
The activity has been successfully removed.: De uitwisseling is verwijderd.
# exports
Count activities: Nombre d'échanges
Count activities by various parameters.: Compte le nombre d'échanges enregistrées en fonction de différents paramètres.
Sum activity duration: Total de la durée des échanges
Sum activities duration by various parameters.: Additionne la durée des échanges en fonction de différents paramètres.
List activities: Liste les échanges
Number of activities: Nombre d'échanges
Exports of activities linked to a person: Exports van uitwisselingen gekoppeld aan een gebruiker
Number of activities linked to a person: Aantal uitwisselingen gekoppeld aan een gebruiker
Count activities linked to a person: Aantal uitwisselingen
Count activities linked to a person by various parameters.: Telt het aantal geregistreerde uitwisselingen gekoppeld aan een gebruiker op basis van verschillende parameters.
Sum activity linked to a person duration: Duur van uitwisselingen
Sum activities linked to a person duration: Duur van uitwisselingen gekoppeld aan een gebruiker
Sum activities linked to a person duration by various parameters.: Telt de duur van uitwisselingen op basis van verschillende parameters.
List activity linked to a person: Uitwisselingen opsommen
List activities linked to a person: Lijst van uitwisselingen gekoppeld aan een gebruiker
List activities linked to a person description: Maakt de lijst van uitwisselingen op basis van verschillende parameters.
Exports of activities linked to an accompanying period: Exports van uitwisselingen gekoppeld aan een traject
Number of activities linked to an accompanying period: Aantal uitwisselingen gekoppeld aan een traject
Count activities linked to an accompanying period: Aantal uitwisselingen
Count activities linked to an accompanying period by various parameters.: Telt het aantal geregistreerde uitwisselingen gekoppeld aan een traject op basis van verschillende parameters.
Sum activity linked to an accompanying period duration: Som van de duur van uitwisselingen
Sum activities linked to an accompanying period duration: Som van de duur van uitwisselingen gekoppeld aan een traject
Sum activities linked to an accompanying period duration by various parameters.: Telt de duur van uitwisselingen op basis van verschillende parameters.
Sum activity linked to an accompanying period visit duration: Som van de reisduur van uitwisselingen
Sum activities linked to an accompanying period visit duration: Som van de reisduur van uitwisselingen gekoppeld aan een traject
Sum activities linked to an accompanying period visit duration by various parameters.: Telt de reisduur van uitwisselingen op basis van verschillende parameters.
Average activity linked to an accompanying period duration: Gemiddelde van de duur van uitwisselingen
Average activities linked to an accompanying period duration: Gemiddelde van de duur van uitwisselingen gekoppeld aan een traject
Average activities linked to an accompanying period duration by various parameters.: Gemiddelde van de duur van uitwisselingen op basis van verschillende parameters.
Average activity linked to an accompanying period visit duration: Gemiddelde van de reisduur van uitwisselingen
Average activities linked to an accompanying period visit duration: Gemiddelde van de reisduur van uitwisselingen gekoppeld aan een traject
Average activities linked to an accompanying period visit duration by various parameters.: Gemiddelde van de reisduur van uitwisselingen op basis van verschillende parameters.
#filters
Filter by reason: Filtrer par sujet d'activité
'Filtered by reasons: only %list%': 'Filtré par sujet: seulement %list%'
'Filtered by activity type: only %list%': "Filtré par type d'activity: uniquement %list%"
Filtered by date activity: Filtrer par date d'activité
Activities after this date: Activités après cette date
Activities before this date: Activités avant cette date
"Filtered by date of activity: only between %date_from% and %date_to%": "Filtré par date de l'activité: uniquement entre %date_from% et %date_to%"
This date should be after the date given in "Implied in an activity after this date" field: Cette date devrait être postérieure à la date donnée dans le champ "activités après cette date"
Filter by reason: Uitwisselingen filteren op onderwerp
'Filtered by reasons: only %list%': 'Gefilterd op onderwerp: alleen %list%'
'Filtered by activity type: only %list%': "Gefilterd op type uitwisseling: alleen %list%"
Filtered by date activity: Uitwisselingen filteren op datum
Activities after this date: Uitwisselingen na deze datum
Activities before this date: Uitwisselingen vóór deze datum
"Filtered by date of activity: only between %date_from% and %date_to%": "Gefilterd op datum van de uitwisseling: alleen tussen %date_from% en %date_to%"
This date should be after the date given in "Implied in an activity after this date" field: Deze datum moet later zijn dan de datum in het veld "uitwisselingen na deze datum"
Filtered by person having an activity in a period: Uniquement les personnes ayant eu une activité dans la période donnée
Implied in an activity after this date: Impliqué dans une activité après cette date
Implied in an activity before this date: Impliqué dans une activité avant cette date
Filtered by person having an activity between %date_from% and %date_to% with reasons %reasons_name%: Filtré par personnes associées à une activité entre %date_from% et %date_to% avec les sujets %reasons_name%
Activity reasons for those activities: Sujets de ces activités
Filter by activity type: Filtrer par type d'activité
Filter by activity type: Uitwisselingen filteren op type
Filter activity by location: Uitwisselingen filteren op locatie
'Filtered activity by location: only %locations%': "Gefilterd op locatie: alleen %locations%"
Filter activity by locationtype: Uitwisselingen filteren op type locatie
'Filtered activity by locationtype: only %types%': "Gefilterd op type locatie: alleen %types%"
Accepted locationtype: Types locatie
Accepted users: TMS(en)
Filter activity by emergency: Uitwisselingen filteren op urgentie
'Filtered activity by emergency: only %emergency%': "Gefilterd op urgentie: alleen als %emergency%"
activity is emergency: de uitwisseling is urgent
activity is not emergency: de uitwisseling is niet urgent
Filter activity by sentreceived: Uitwisselingen filteren op verzonden/ontvangen
'Filtered activity by sentreceived: only %sentreceived%': "Gefilterd op verzonden/ontvangen: alleen %sentreceived%"
Accepted sentreceived: ''
Filter activity by linked socialaction: Uitwisselingen filteren op gekoppelde actie
'Filtered activity by linked socialaction: only %actions%': "Gefilterd op gekoppelde actie: alleen %actions%"
Filter activity by linked socialissue: Uitwisselingen filteren op gekoppelde problematiek
'Filtered activity by linked socialissue: only %issues%': "Gefilterd op gekoppelde problematiek: alleen %issues%"
Filter activity by user: Uitwisselingen filteren op hoofdgebruiker
Filter activity by users: Uitwisselingen filteren op deelnemende gebruiker
Filter activity by creator: Uitwisselingen filteren op aanmaker van de uitwisseling
'Filtered activity by user: only %users%': "Gefilterd op referent: alleen %users%"
'Filtered activity by users: only %users%': "Gefilterd op deelnemende gebruikers: alleen %users%"
'Filtered activity by creator: only %users%': "Gefilterd op aanmaker: alleen %users%"
Creators: Aanmakers
Accepted userscope: Diensten
Filter acp which has no activity: Trajecten filteren die geen uitwisseling hebben
Filtered acp which has no activities: Trajecten zonder gekoppelde uitwisseling filteren
Group acp by activity number: Trajecten groeperen op aantal uitwisselingen
#aggregators
Activity type: Type d'activité
Activity user: Utilisateur lié à l'activity
By reason: Par sujet
By category of reason: Par catégorie de sujet
Reason's level: Niveau du sujet
Group by reasons: Sujet d'activité
Aggregate by activity user: Grouper par utilisateur lié à l'activité
Aggregate by activity type: Grouper par type d'activité
Aggregate by activity reason: Grouper par sujet de l'activité
Activity type: Type uitwisseling
Activity user: Gebruiker gekoppeld aan de uitwisseling
By reason: Op onderwerp
By category of reason: Op categorie van onderwerp
Reason's level: Niveau van het onderwerp
Group by reasons: Onderwerp van uitwisseling
Aggregate by activity user: Uitwisselingen groeperen op referent
Aggregate by activity users: Uitwisselingen groeperen op deelnemende gebruikers
Aggregate by activity type: Uitwisselingen groeperen op type
Aggregate by activity reason: Uitwisselingen groeperen op onderwerp
Last activities: Les dernières activités
Group activity by locationtype: Uitwisselingen groeperen op type locatie
Group activity by date: Uitwisselingen groeperen op datum
Frequency: Frequentie
by month: Per maand
by week: Per week
for week: Week
by year: Per jaar
in year: In
Group activity by creator: Uitwisselingen groeperen op aanmaker van de uitwisseling
Group activity by linked thirdparties: Uitwisselingen groeperen op betrokken derde
Accepted thirdparty: Betrokken derde
Group activity by linked socialaction: Uitwisselingen groeperen op gekoppelde actie
Group activity by linked socialissue: Uitwisselingen groeperen op gekoppelde problematiek
Group activity by userscope: Uitwisselingen groeperen op dienst van de aanmaker
See activity in accompanying course context: Voir l'activité dans le contexte du parcours d'accompagnement
Last activities: De laatste uitwisselingen
You get notified of an activity which does not exists any more: Cette notification ne correspond pas à une activité valide.
you are not allowed to see it details: La notification fait référence à une activité à laquelle vous n'avez pas accès.
This is the minimal activity data: Activité n°
See activity in accompanying course context: Uitwisseling bekijken in de context van het begeleidingstraject
You get notified of an activity which does not exists any more: Deze melding komt niet overeen met een geldige uitwisseling.
you are not allowed to see it details: De melding verwijst naar een uitwisseling waartoe u geen toegang hebt.
This is the minimal activity data: Uitwisseling nr.
docgen:
Activity basic: Echange
A basic context for activity: Contexte pour les activités
Activity basic: Uitwisseling
A basic context for activity: Context voor uitwisselingen
Accompanying period with a list of activities: Begeleidingstraject met lijst van uitwisselingen
Accompanying period with a list of activities description: Deze context neemt de informatie van het traject over, en alle uitwisselingen voor een traject. De uitwisselingen worden niet gefilterd.
myActivitiesOnly: Alleen rekening houden met uitwisselingen waarin ik heb deelgenomen
myWorksOnly: Alleen rekening houden met begeleidingsacties waarvan ik referent ben
export:
export:
count_person_on_activity:
title: Aantal betrokken gebruikers bij uitwisselingen
description: Telt het aantal betrokken gebruikers bij uitwisselingen. Als een gebruiker aanwezig is in meerdere uitwisselingen, wordt hij slechts één keer geteld.
header: Aantal betrokken gebruikers bij uitwisselingen
count_household_on_activity:
title: Aantal betrokken huishoudens bij uitwisselingen
description: Telt het aantal betrokken huishoudens bij uitwisselingen. Als een huishouden aanwezig is in meerdere uitwisselingen, wordt het slechts één keer geteld. Gebruikers zonder huishouden worden niet geteld.
header: Aantal betrokken huishoudens bij uitwisselingen
count_household_on_activity_person:
title: Aantal betrokken huishoudens bij uitwisselingen
description: Telt het aantal betrokken huishoudens bij uitwisselingen. Als een huishouden aanwezig is in meerdere uitwisselingen, wordt het slechts één keer geteld. Gebruikers zonder huishouden worden niet geteld. Wanneer een gebruiker van huishouden verandert, wordt elk huishouden één keer geteld.
header: Aantal betrokken huishoudens bij uitwisselingen
list:
activity:
users name: Naam van de gebruikers
users ids: Identificatie van de gebruikers
third parties ids: Identificatie van de derden
persons ids: Identificatie van de gebruikers
persons name: Naam van de gebruikers
thirds parties: Derden
date: Datum van de uitwisseling
locationName: Locatie
sent received: Verzonden of ontvangen
emergency: Urgentie
accompanying course id: Identificatie van het traject
course circles: Diensten van het traject
travelTime: Reisduur
durationTime: Duur
id: Identificatie
List activities linked to an accompanying course: Somt uitwisselingen op gekoppeld aan een traject op basis van verschillende filters.
List activity linked to a course: Lijst van uitwisselingen gekoppeld aan een traject
commentText: Opmerking
comment_date: Datum van de laatste bewerking van de opmerking
comment_user: Laatste bewerking door
filter:
activity:
by_users_job:
Filter by users job: Uitwisselingen filteren op beroep van ten minste één deelnemende gebruiker
'Filtered activity by users job: only %jobs%': 'Gefilterd op beroep van ten minste één deelnemende gebruiker: alleen %jobs%'
by_users_scope:
Filter by users scope: Uitwisselingen filteren op dienst van ten minste één deelnemende gebruiker
'Filtered activity by users scope: only %scopes%': 'Gefilterd op dienst van ten minste één deelnemende gebruiker: alleen %scopes%'
course_having_activity_between_date:
Title: Trajecten filteren die een uitwisseling hebben ontvangen tussen twee data
Receiving an activity after: Die een uitwisseling hebben ontvangen na
Receiving an activity before: Die een uitwisseling hebben ontvangen vóór
acp_by_activity_type:
'activity after': Uitwisselingen na
activity after help: Indien leeg gelaten, wordt er geen rekening mee gehouden
activity before: Uitwisselingen vóór
activity before help: Indien leeg gelaten, wordt er geen rekening mee gehouden
person_between_dates:
Implied in an activity after this date: Betrokken bij een uitwisseling na deze datum
Implied in an activity before this date: Betrokken bij een uitwisseling vóór deze datum
Activity reasons for those activities: Onderwerpen van deze uitwisselingen
if no reasons: Als geen enkel onderwerp is aangevinkt, worden alle onderwerpen in aanmerking genomen
title: Gebruikers filteren die gekoppeld zijn geweest aan een uitwisseling tijdens de periode
date mismatch: De einddatum van de periode moet later zijn dan de startdatum
by_creator_scope:
Filter activity by user scope: Uitwisselingen filteren op dienst van de aanmaker van de uitwisseling
'Filtered activity by user scope: only %scopes%': "Gefilterd op dienst van de aanmaker van de uitwisseling: alleen %scopes%"
by_creator_job:
job_form_label: Beroepen
Filter activity by user job: Uitwisselingen filteren op beroep van de aanmaker van de uitwisseling
'Filtered activity by user job: only %jobs%': "Gefilterd op beroep van de aanmaker van de uitwisseling: alleen %jobs%"
by_persons:
Filter activity by persons: Uitwisselingen filteren op deelnemende gebruiker
'Filtered activity by persons: only %persons%': 'Uitwisselingen gefilterd op deelnemende gebruikers: alleen %persons%'
persons taking part on the activity: Gebruikers deelnemend aan de uitwisseling
by_sent_received:
Sent or received: Verzonden of ontvangen
is sent: verzonden
is received: ontvangen
by_presence:
Filter activity by activity presence: Uitwisselingen filteren op aanwezigheid van de gebruiker
presences: Aanwezigheden
'Filtered by activity presence: only %presences%': 'Gefilterd op aanwezigheid van de gebruiker: alleen %presences%'
aggregator:
person:
by_person:
title: Uitwisselingen groeperen op gebruiker (gebruikersdossier waarin de uitwisseling is geregistreerd)
person: Gebruiker
by_household:
title: Uitwisselingen groeperen op huishouden
household: Identificatie huishouden
acp:
by_activity_type:
title: Trajecten groeperen op type uitwisseling
after_date: Alleen uitwisselingen na deze datum
before_date: Alleen uitwisselingen vóór deze datum
activity_type: Types uitwisseling
activity:
by_sent_received:
Sent or received: Verzonden of ontvangen
is sent: verzonden
is received: ontvangen
Group activity by sentreceived: Uitwisselingen groeperen op verzonden / ontvangen
by_location:
Activity Location: Locatie van de uitwisseling
Title: Uitwisselingen groeperen op locatie van de uitwisseling
by_user_job:
Users 's job: Beroep van de gebruikers deelnemend aan de uitwisseling
Aggregate by users job: Uitwisselingen groeperen op beroep van de deelnemende gebruikers
by_user_scope:
Users 's scope: Hoofddienst van de gebruikers deelnemend aan de uitwisseling
Aggregate by users scope: Uitwisselingen groeperen op hoofddienst van de gebruiker
by_creator_scope:
Group activity by creator scope: Uitwisselingen groeperen op dienst van de aanmaker van de uitwisseling
Calc date: Berekeningsdatum van de dienst van de aanmaker van de uitwisseling
by_creator_job:
Group activity by creator job: Uitwisselingen groeperen op beroep van de aanmaker van de uitwisseling
Calc date: Berekeningsdatum van het beroep van de aanmaker van de uitwisseling
by_persons:
Group activity by persons: Uitwisselingen groeperen op deelnemende gebruiker
Persons: Deelnemende gebruikers
by_activity_presence:
Group activity by presence: Uitwisselingen groeperen op aanwezigheid van de gebruiker
header: Aanwezigheid van gebruiker(s)
generic_doc:
filter:
keys:
accompanying_period_activity_document: Document van uitwisselingen van trajecten

View File

@@ -25,6 +25,7 @@ final class ChillAsideActivityExtension extends Extension implements PrependExte
$config = $this->processConfiguration($configuration, $configs);
$container->setParameter('chill_aside_activity.form.time_duration', $config['form']['time_duration']);
$container->setParameter('chill_aside_activity.show_concerned_persons_count', 'visible' === $config['show_concerned_persons_count']);
$loader = new Loader\YamlFileLoader($container, new FileLocator(__DIR__.'/../config'));
$loader->load('services.yaml');
@@ -38,6 +39,24 @@ final class ChillAsideActivityExtension extends Extension implements PrependExte
{
$this->prependRoute($container);
$this->prependCruds($container);
$this->prependTwigConfig($container);
}
protected function prependTwigConfig(ContainerBuilder $container)
{
// Get the configuration for this bundle
$chillAsideActivityConfig = $container->getExtensionConfig($this->getAlias());
$config = $this->processConfiguration($this->getConfiguration($chillAsideActivityConfig, $container), $chillAsideActivityConfig);
// Add configuration to twig globals
$twigConfig = [
'globals' => [
'chill_aside_activity_config' => [
'show_concerned_persons_count' => 'visible' === $config['show_concerned_persons_count'],
],
],
];
$container->prependExtensionConfig('twig', $twigConfig);
}
protected function prependCruds(ContainerBuilder $container): void

View File

@@ -141,6 +141,12 @@ class Configuration implements ConfigurationInterface
->end()
->end()
->end()
->end()
->enumNode('show_concerned_persons_count')
->values(['hidden', 'visible'])
->defaultValue('hidden')
->info('Show the concerned persons count field in aside activity forms and views')
->end()
->end();
return $treeBuilder;

View File

@@ -54,6 +54,16 @@ class AsideActivity implements TrackCreationInterface, TrackUpdateInterface
#[ORM\JoinColumn(nullable: false)]
private ?AsideActivityCategory $type = null;
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::DATETIME_MUTABLE, nullable: true)]
private ?\DateTimeInterface $updatedAt = null;
#[ORM\ManyToOne(targetEntity: User::class)]
private User $updatedBy;
#[Assert\GreaterThanOrEqual(0)]
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::INTEGER, nullable: true)]
private ?int $concernedPersonsCount = 0;
public function getAgent(): ?User
{
return $this->agent;
@@ -130,4 +140,30 @@ class AsideActivity implements TrackCreationInterface, TrackUpdateInterface
return $this;
}
public function setUpdatedAt(\DateTimeInterface $updatedAt): self
{
$this->updatedAt = $updatedAt;
return $this;
}
public function setUpdatedBy(?User $updatedBy): self
{
$this->updatedBy = $updatedBy;
return $this;
}
public function getConcernedPersonsCount(): ?int
{
return $this->concernedPersonsCount;
}
public function setConcernedPersonsCount(?int $concernedPersonsCount): self
{
$this->concernedPersonsCount = $concernedPersonsCount;
return $this;
}
}

View File

@@ -0,0 +1,86 @@
<?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\AsideActivityBundle\Export\Aggregator;
use Chill\AsideActivityBundle\Export\Declarations;
use Chill\MainBundle\Export\AggregatorInterface;
use Doctrine\ORM\QueryBuilder;
use Symfony\Component\Form\FormBuilderInterface;
class ByConcernedPersonsCountAggregator implements AggregatorInterface
{
public function addRole(): ?string
{
return null;
}
public function alterQuery(QueryBuilder $qb, $data, \Chill\MainBundle\Export\ExportGenerationContext $exportGenerationContext): void
{
$qb->addSelect('aside.concernedPersonsCount AS by_concerned_persons_count_aggregator')
->addGroupBy('by_concerned_persons_count_aggregator');
}
public function applyOn(): string
{
return Declarations::ASIDE_ACTIVITY_TYPE;
}
public function buildForm(FormBuilderInterface $builder): void
{
// No form needed
}
public function getNormalizationVersion(): int
{
return 1;
}
public function normalizeFormData(array $formData): array
{
return [];
}
public function denormalizeFormData(array $formData, int $fromVersion): array
{
return [];
}
public function getFormDefaultData(): array
{
return [];
}
public function getLabels($key, array $values, $data): callable
{
return function ($value): string {
if ('_header' === $value) {
return 'export.aggregator.Concerned persons count';
}
if (null === $value) {
return 'export.aggregator.No concerned persons count specified';
}
return (string) $value;
};
}
public function getQueryKeys($data): array
{
return ['by_concerned_persons_count_aggregator'];
}
public function getTitle(): string
{
return 'export.aggregator.Group by concerned persons count';
}
}

View File

@@ -0,0 +1,116 @@
<?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\AsideActivityBundle\Export\Export;
use Chill\AsideActivityBundle\Export\Declarations;
use Chill\AsideActivityBundle\Repository\AsideActivityRepository;
use Chill\AsideActivityBundle\Security\AsideActivityVoter;
use Chill\MainBundle\Export\ExportInterface;
use Chill\MainBundle\Export\FormatterInterface;
use Chill\MainBundle\Export\GroupedExportInterface;
use Doctrine\ORM\Query;
use Symfony\Component\Form\FormBuilderInterface;
class SumConcernedPersonsCountAsideActivity implements ExportInterface, GroupedExportInterface
{
public function __construct(private readonly AsideActivityRepository $repository) {}
public function buildForm(FormBuilderInterface $builder) {}
public function getNormalizationVersion(): int
{
return 1;
}
public function normalizeFormData(array $formData): array
{
return [];
}
public function denormalizeFormData(array $formData, int $fromVersion): array
{
return [];
}
public function getFormDefaultData(): array
{
return [];
}
public function getAllowedFormattersTypes(): array
{
return [FormatterInterface::TYPE_TABULAR];
}
public function getDescription(): string
{
return 'export.Sum concerned persons count for aside activities';
}
public function getGroup(): string
{
return 'export.Exports of aside activities';
}
public function getLabels($key, array $values, $data)
{
if ('export_sum_concerned_persons_count' !== $key) {
throw new \LogicException("the key {$key} is not used by this export");
}
$labels = array_combine($values, $values);
$labels['_header'] = $this->getTitle();
return static fn ($value) => $labels[$value];
}
public function getQueryKeys($data): array
{
return ['export_sum_concerned_persons_count'];
}
public function getResult($query, $data, \Chill\MainBundle\Export\ExportGenerationContext $context): array
{
return $query->getQuery()->getResult(Query::HYDRATE_SCALAR);
}
public function getTitle(): string
{
return 'export.Sum concerned persons count for aside activities';
}
public function getType(): string
{
return Declarations::ASIDE_ACTIVITY_TYPE;
}
public function initiateQuery(array $requiredModifiers, array $acl, array $data, \Chill\MainBundle\Export\ExportGenerationContext $context): \Doctrine\ORM\QueryBuilder
{
$qb = $this->repository->createQueryBuilder('aside');
$qb->select('SUM(COALESCE(aside.concernedPersonsCount, 0)) as export_sum_concerned_persons_count');
return $qb;
}
public function requiredRole(): string
{
return AsideActivityVoter::STATS;
}
public function supportsModifiers(): array
{
return [
Declarations::ASIDE_ACTIVITY_TYPE,
];
}
}

View File

@@ -21,6 +21,7 @@ use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\DataTransformer\DateTimeToTimestampTransformer;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\IntegerType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormEvent;
use Symfony\Component\Form\FormEvents;
@@ -29,11 +30,13 @@ use Symfony\Component\OptionsResolver\OptionsResolver;
final class AsideActivityFormType extends AbstractType
{
private readonly array $timeChoices;
private readonly bool $showConcernedPersonsCount;
public function __construct(
ParameterBagInterface $parameterBag,
) {
$this->timeChoices = $parameterBag->get('chill_aside_activity.form.time_duration');
$this->showConcernedPersonsCount = $parameterBag->get('chill_aside_activity.show_concerned_persons_count');
}
public function buildForm(FormBuilderInterface $builder, array $options): void
@@ -76,6 +79,16 @@ final class AsideActivityFormType extends AbstractType
->add('location', PickUserLocationType::class)
;
if ($this->showConcernedPersonsCount) {
$builder->add('concernedPersonsCount', IntegerType::class, [
'label' => 'Concerned persons count',
'required' => false,
'attr' => [
'min' => 0,
],
]);
}
foreach (['duration'] as $fieldName) {
$builder->get($fieldName)
->addModelTransformer($durationTimeTransformer);

View File

@@ -42,6 +42,11 @@
{%- if entity.location.name is defined -%}
<div><i class="fa fa-fw fa-map-marker"></i>{{ entity.location.name }}</div>
{%- endif -%}
{%- if entity.concernedPersonsCount > 0 -%}
<div><i class="fa fa-fw fa-user"></i>{{ entity.concernedPersonsCount }}</div>
{%- endif -%}
</div>
<div class="item-col" style="justify-content: flex-end;">
<div class="box">

View File

@@ -38,6 +38,11 @@
<dt class="inline">{{ 'Duration'|trans }}</dt>
<dd>{{ entity.duration|date('H:i') }}</dd>
{% if chill_aside_activity_config.show_concerned_persons_count == 'visible' %}
<dt class="inline">{{ 'Concerned persons count'|trans }}</dt>
<dd>{{ entity.concernedPersonsCount }}</dd>
{% endif %}
<dt class="inline">{{ 'Remark'|trans }}</dt>
{%- if entity.note is empty -%}
<dd>

View File

@@ -0,0 +1,49 @@
<?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\AsideActivityBundle\Tests\Export\Aggregator;
use Chill\AsideActivityBundle\Entity\AsideActivity;
use Chill\AsideActivityBundle\Export\Aggregator\ByConcernedPersonsCountAggregator;
use Chill\MainBundle\Test\Export\AbstractAggregatorTest;
use Doctrine\ORM\EntityManagerInterface;
/**
* @internal
*
* @coversNothing
*/
class ByConcernedPersonsCountAggregatorTest extends AbstractAggregatorTest
{
public function getAggregator()
{
return new ByConcernedPersonsCountAggregator();
}
public static function getFormData(): array
{
return [
[],
];
}
public static function getQueryBuilders(): iterable
{
self::bootKernel();
$em = self::getContainer()->get(EntityManagerInterface::class);
return [
$em->createQueryBuilder()
->select('count(aside.id)')
->from(AsideActivity::class, 'aside'),
];
}
}

View File

@@ -0,0 +1,50 @@
<?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\AsideActivityBundle\Tests\Export\Export;
use Chill\AsideActivityBundle\Export\Export\SumConcernedPersonsCountAsideActivity;
use Chill\AsideActivityBundle\Repository\AsideActivityRepository;
use Chill\MainBundle\Test\Export\AbstractExportTest;
/**
* @internal
*
* @coversNothing
*/
final class SumConcernedPersonsCountAsideActivityTest extends AbstractExportTest
{
protected function setUp(): void
{
self::bootKernel();
}
public function getExport()
{
$repository = self::getContainer()->get(AsideActivityRepository::class);
yield new SumConcernedPersonsCountAsideActivity($repository);
}
public static function getFormData(): array
{
return [
[],
];
}
public static function getModifiersCombination(): array
{
return [
['aside_activity'],
];
}
}

View File

@@ -20,6 +20,10 @@ services:
tags:
- { name: chill.export, alias: 'avg_aside_activity_duration' }
Chill\AsideActivityBundle\Export\Export\SumConcernedPersonsCountAsideActivity:
tags:
- { name: chill.export, alias: 'sum_aside_activity_concerned_persons_count' }
## Filters
chill.aside_activity.export.date_filter:
class: Chill\AsideActivityBundle\Export\Filter\ByDateFilter
@@ -70,3 +74,7 @@ services:
Chill\AsideActivityBundle\Export\Aggregator\ByLocationAggregator:
tags:
- { name: chill.export_aggregator, alias: 'aside_activity_location_aggregator' }
Chill\AsideActivityBundle\Export\Aggregator\ByConcernedPersonsCountAggregator:
tags:
- { name: chill.export_aggregator, alias: 'aside_activity_concerned_persons_count_aggregator' }

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\Migrations\AsideActivity;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20251006113048 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add concernedPersonsCount property to AsideActivity entity';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE chill_asideactivity.asideactivity ADD concernedPersonsCount INT DEFAULT 0');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE chill_asideactivity.AsideActivity DROP concernedPersonsCount');
}
}

View File

@@ -27,6 +27,7 @@ Emergency: Urgent
by: "Par "
location: Lieu
Asideactivity location: Localisation de l'activité
Concerned persons count: Nombre d'usager concernés
# Crud
crud:
@@ -177,7 +178,7 @@ export:
agent_id: Utilisateur
creator_id: Créateur
main_scope: Service principal de l'utilisateur
main_center: Centre principal de l'utilisateur
main_center: Territoire principal de l'utilisateur
aside_activity_type: Catégorie d'activité annexe
date: Date
duration: Durée
@@ -190,6 +191,7 @@ export:
Count aside activities by various parameters.: Compte le nombre d'activités annexes selon divers critères
Average aside activities duration: Durée moyenne des activités annexes
Sum aside activities duration: Durée des activités annexes
Sum concerned persons count for aside activities: Nombre d'usager concernés par les activités annexes
filter:
Filter by aside activity date: Filtrer les activités annexes par date
Filter by aside activity type: Filtrer les activités annexes par type d'activité
@@ -210,6 +212,8 @@ export:
'Filtered by aside activity location: only %location%': "Filtré par localisation: uniquement %location%"
aggregator:
Group by aside activity type: Grouper les activités annexes par type d'activité
Group by concerned persons count: Grouper les activités annexes par nombre d'usagers conernés
Concerned persons count: Nombre d'usagers concernés
Aside activity type: Type d'activité annexe
by_user_job:
Aggregate by user job: Grouper les activités annexes par métier des utilisateurs

View File

@@ -165,3 +165,60 @@ Phonecall: "Telefoon oproep"
Aside activities: Nevenactiviteiten
Aside activity types: Types nevenactiviteiten
Aside activity type configuration: Configuratie categorieën nevenactiviteiten
# exports
export:
aside_activity:
List of aside activities: Lijst van nevenactiviteiten
createdAt: Aanmaak
updatedAt: Laatste update
agent_id: Gebruiker
creator_id: Aanmaker
main_scope: Hoofddienst van de gebruiker
main_center: Hoofdterritorium van de gebruiker
aside_activity_type: Categorie nevenactiviteit
date: Datum
duration: Duur
note: Notitie
id: Identificatie
location: Locatie
Exports of aside activities: Exports van nevenactiviteiten
Count aside activities: Aantal nevenactiviteiten
Count aside activities by various parameters.: Telt het aantal nevenactiviteiten volgens diverse criteria
Average aside activities duration: Gemiddelde duur van nevenactiviteiten
Sum aside activities duration: Duur van nevenactiviteiten
Sum concerned persons count for aside activities: Aantal betrokken gebruikers bij nevenactiviteiten
filter:
Filter by aside activity date: Nevenactiviteiten filteren op datum
Filter by aside activity type: Nevenactiviteiten filteren op type activiteit
'Filtered by aside activity type: only %type%': "Gefilterd op type nevenactiviteit: alleen %type%"
Filtered by aside activities between %dateFrom% and %dateTo%: Gefilterd op datum van nevenactiviteit, tussen %dateFrom% en %dateTo%
This date should be after the date given in "Implied in an aside activity after this date" field: Deze datum moet later zijn dan de datum in het veld "nevenactiviteiten na deze datum"
Aside activities after this date: Nevenactiviteiten na deze datum
Aside activities before this date: Nevenactiviteiten vóór deze datum
'Filtered aside activity by user: only %users%': "Gefilterd op gebruiker: alleen %users%"
Filter aside activity by user: Filteren op gebruiker
by_user_job:
'Filtered aside activities by user jobs: only %jobs%': "Gefilterd op beroep van gebruikers: alleen %jobs%"
Filter by user jobs: Nevenactiviteiten filteren op beroep van gebruikers
by_user_scope:
'Filtered aside activities by user scope: only %scopes%': "Gefilterd op dienst van gebruikers: alleen %scopes%"
Filter by user scope: Nevenactiviteiten filteren op dienst van gebruiker
Filter by aside activity location: Nevenactiviteiten filteren op locatie
'Filtered by aside activity location: only %location%': "Gefilterd op locatie: alleen %location%"
aggregator:
Group by aside activity type: Nevenactiviteiten groeperen op type activiteit
Group by concerned persons count: Nevenactiviteiten groeperen op aantal betrokken gebruikers
Concerned persons count: Aantal betrokken gebruikers
Aside activity type: Type nevenactiviteit
by_user_job:
Aggregate by user job: Nevenactiviteiten groeperen op beroep van gebruikers
by_user_scope:
Aggregate by user scope: Nevenactiviteiten groeperen op dienst van gebruikers
Aside activity location: Locatie van nevenactiviteiten
Group by aside activity location: Nevenactiviteiten groeperen op locatie
Aside activity localisation: Locatie
# ROLES
CHILL_ASIDE_ACTIVITY_STATS: Statistieken voor nevenactiviteiten

View File

@@ -74,3 +74,42 @@ The balance: Verschil tussen inkomsten en onkosten
Valid since %startDate% until %endDate%: Geldig sinds %startDate% tot %endDate%
Valid since %startDate%: Geldig sinds %startDate%
budget:
admin:
form:
Charge_kind_key: Identificatiesleutel
Resource_kind_key: Identificatiesleutel
This kind must contains only alphabeticals characters, and dashes. This string is in use during document generation. Changes may have side effect on document: Deze sleutel dient om het type last of inkomen te identificeren bij het genereren van documenten. Alleen alfanumerieke tekens zijn toegestaan. Het wijzigen van deze sleutel kan een effect hebben bij het genereren van nieuwe documenten.
# ROLES
Budget elements: Budget
CHILL_BUDGET_ELEMENT_CREATE: Inkomsten/last aanmaken
CHILL_BUDGET_ELEMENT_DELETE: Inkomsten/last verwijderen
CHILL_BUDGET_ELEMENT_SEE: Inkomstenen/lasten bekijken
CHILL_BUDGET_ELEMENT_UPDATE: Inkomsten/last bewerken
## admin
crud:
resource_kind:
title_new: Nieuw type inkomsten
title_edit: Type inkomsten bewerken
charge_kind:
title_new: Nieuw type last
title_edit: Type last bewerken
admin:
menu:
Resource types: Types inkomsten
Charge types: Types last
title:
Charge Type List: Lijst van types last
Resource Type List: Lijst van types inkomsten
Budget configuration: Configuratie van budgetelementen
new:
Create a new charge type: Nieuw type last aanmaken
Create a new resource type: Nieuw type inkomsten aanmaken
form:
Choose the type of resource: Kies een type inkomsten
Choose the type of charge: Kies een type last

View File

@@ -1,2 +1,8 @@
The amount cannot be empty: Le montant ne peut pas être vide ou égal à zéro
The budget element's end date must be after the start date: La date de fin doit être après la date de début
The amount cannot be empty: Het bedrag mag niet nul of leeg zijn
The budget element's end date must be after the start date: De einddatum moet later vallen dan de begindatum
budget:
admin:
form:
kind:
enkel_alphanumeriek

View File

@@ -12,6 +12,7 @@ declare(strict_types=1);
namespace Chill\CalendarBundle\Controller;
use Chill\CalendarBundle\Repository\CalendarRepository;
use Chill\CalendarBundle\Repository\InviteRepository;
use Chill\MainBundle\CRUD\Controller\ApiController;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Serializer\Model\Collection;
@@ -22,7 +23,10 @@ use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
class CalendarAPIController extends ApiController
{
public function __construct(private readonly CalendarRepository $calendarRepository) {}
public function __construct(
private readonly CalendarRepository $calendarRepository,
private readonly InviteRepository $inviteRepository,
) {}
#[\Symfony\Component\Routing\Attribute\Route(path: '/api/1.0/calendar/calendar/by-user/{id}.{_format}', name: 'chill_api_single_calendar_list_by-user', requirements: ['_format' => 'json'])]
public function listByUser(User $user, Request $request, string $_format): JsonResponse
@@ -51,16 +55,37 @@ class CalendarAPIController extends ApiController
throw new BadRequestHttpException('dateTo not parsable');
}
$total = $this->calendarRepository->countByUser($user, $dateFrom, $dateTo);
$paginator = $this->getPaginatorFactory()->create($total);
$ranges = $this->calendarRepository->findByUser(
// Get calendar items where user is the main user
$ownCalendars = $this->calendarRepository->findByUser(
$user,
$dateFrom,
$dateTo,
$paginator->getItemsPerPage(),
$paginator->getCurrentPageFirstItemNumber()
$dateTo
);
// Get calendar items from accepted invites
$acceptedInvites = $this->inviteRepository->findAcceptedInvitesByUserAndDateRange($user, $dateFrom, $dateTo);
$inviteCalendars = array_map(fn ($invite) => $invite->getCalendar(), $acceptedInvites);
// Merge
$allCalendars = array_merge($ownCalendars, $inviteCalendars);
$uniqueCalendars = [];
$seenIds = [];
foreach ($allCalendars as $calendar) {
$id = $calendar->getId();
if (!in_array($id, $seenIds, true)) {
$seenIds[] = $id;
$uniqueCalendars[] = $calendar;
}
}
$total = count($uniqueCalendars);
$paginator = $this->getPaginatorFactory()->create($total);
$offset = $paginator->getCurrentPageFirstItemNumber();
$limit = $paginator->getItemsPerPage();
$ranges = array_slice($uniqueCalendars, $offset, $limit);
$collection = new Collection($ranges, $paginator);
return $this->json($collection, Response::HTTP_OK, [], ['groups' => ['calendar:light']]);

View File

@@ -13,6 +13,7 @@ namespace Chill\CalendarBundle\Controller;
use Chill\CalendarBundle\Entity\Calendar;
use Chill\CalendarBundle\Form\CalendarType;
use Chill\CalendarBundle\Form\CancelType;
use Chill\CalendarBundle\RemoteCalendar\Connector\RemoteCalendarConnectorInterface;
use Chill\CalendarBundle\Repository\CalendarACLAwareRepositoryInterface;
use Chill\CalendarBundle\Security\Voter\CalendarVoter;
@@ -30,6 +31,7 @@ use Chill\PersonBundle\Repository\PersonRepository;
use Chill\PersonBundle\Security\Authorization\AccompanyingPeriodVoter;
use Chill\PersonBundle\Security\Authorization\PersonVoter;
use Chill\ThirdPartyBundle\Entity\ThirdParty;
use Doctrine\ORM\EntityManagerInterface;
use http\Exception\UnexpectedValueException;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
@@ -59,6 +61,7 @@ class CalendarController extends AbstractController
private readonly UserRepositoryInterface $userRepository,
private readonly TranslatorInterface $translator,
private readonly \Doctrine\Persistence\ManagerRegistry $managerRegistry,
private readonly EntityManagerInterface $em,
) {}
/**
@@ -110,6 +113,55 @@ class CalendarController extends AbstractController
]);
}
#[Route(path: '/{_locale}/calendar/calendar/{id}/cancel', name: 'chill_calendar_calendar_cancel')]
public function cancelAction(Calendar $calendar, Request $request): Response
{
// Deal with sms being sent or not
// Communicate cancellation with the remote calendar.
$this->denyAccessUnlessGranted(CalendarVoter::EDIT, $calendar);
[$person, $accompanyingPeriod] = [$calendar->getPerson(), $calendar->getAccompanyingPeriod()];
$form = $this->createForm(CancelType::class, $calendar);
$form->add('submit', SubmitType::class);
if ($accompanyingPeriod instanceof AccompanyingPeriod) {
$view = '@ChillCalendar/Calendar/cancelCalendarByAccompanyingCourse.html.twig';
$redirectRoute = $this->generateUrl('chill_calendar_calendar_list_by_period', ['id' => $accompanyingPeriod->getId()]);
} elseif ($person instanceof Person) {
$view = '@ChillCalendar/Calendar/cancelCalendarByPerson.html.twig';
$redirectRoute = $this->generateUrl('chill_calendar_calendar_list_by_person', ['id' => $person->getId()]);
} else {
throw new \RuntimeException('nor person or accompanying period');
}
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$this->logger->notice('A calendar event has been cancelled', [
'by_user' => $this->getUser()->getUsername(),
'calendar_id' => $calendar->getId(),
]);
$calendar->setStatus($calendar::STATUS_CANCELED);
$calendar->setSmsStatus($calendar::SMS_CANCEL_PENDING);
$this->em->flush();
$this->addFlash('success', $this->translator->trans('chill_calendar.calendar_canceled'));
return new RedirectResponse($redirectRoute);
}
return $this->render($view, [
'calendar' => $calendar,
'form' => $form->createView(),
'accompanyingCourse' => $accompanyingPeriod,
'person' => $person,
]);
}
/**
* Edit a calendar item.
*/
@@ -265,7 +317,7 @@ class CalendarController extends AbstractController
}
if (!$this->getUser() instanceof User) {
throw new UnauthorizedHttpException('you are not an user');
throw new UnauthorizedHttpException('you are not a user');
}
$view = '@ChillCalendar/Calendar/listByUser.html.twig';

View File

@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\CalendarBundle\Controller;
use Chill\CalendarBundle\Entity\Calendar;
use Chill\CalendarBundle\Repository\InviteRepository;
use Chill\DocGeneratorBundle\Repository\DocGeneratorTemplateRepositoryInterface;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Pagination\PaginatorFactory;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
use Symfony\Component\Routing\Annotation\Route;
class MyInvitationsController extends AbstractController
{
public function __construct(private readonly InviteRepository $inviteRepository, private readonly PaginatorFactory $paginator, private readonly DocGeneratorTemplateRepositoryInterface $docGeneratorTemplateRepository) {}
#[Route(path: '/{_locale}/calendar/invitations/my', name: 'chill_calendar_invitations_list_my')]
public function myInvitations(Request $request): Response
{
$this->denyAccessUnlessGranted('ROLE_USER');
$user = $this->getUser();
if (!$user instanceof User) {
throw new UnauthorizedHttpException('you are not a user');
}
$total = count($this->inviteRepository->findBy(['user' => $user]));
$paginator = $this->paginator->create($total);
$invitations = $this->inviteRepository->findBy(
['user' => $user],
['createdAt' => 'DESC'],
$paginator->getItemsPerPage(),
$paginator->getCurrentPageFirstItemNumber()
);
$view = '@ChillCalendar/Invitations/listByUser.html.twig';
return $this->render($view, [
'invitations' => $invitations,
'paginator' => $paginator,
'templates' => $this->docGeneratorTemplateRepository->findByEntity(Calendar::class),
]);
}
}

View File

@@ -35,7 +35,7 @@ class LoadCancelReason extends Fixture implements FixtureGroupInterface
$arr = [
['name' => CancelReason::CANCELEDBY_USER],
['name' => CancelReason::CANCELEDBY_PERSON],
['name' => CancelReason::CANCELEDBY_DONOTCOUNT],
['name' => CancelReason::CANCELEDBY_OTHER],
];
foreach ($arr as $a) {

View File

@@ -47,6 +47,8 @@ class ChillCalendarExtension extends Extension implements PrependExtensionInterf
} else {
$container->setParameter('chill_calendar.short_messages', null);
}
$container->setParameter('chill_calendar.remote_calendar_dsn', $config['remote_calendar_dsn']);
}
public function prepend(ContainerBuilder $container): void

View File

@@ -32,9 +32,10 @@ class Configuration implements ConfigurationInterface
->canBeDisabled()
->children()->end()
->end() // end for short_messages
->scalarNode('remote_calendar_dsn')->defaultValue('null://null')->cannotBeEmpty()->end()
->arrayNode('remote_calendars_sync')->canBeEnabled()
->children()
->arrayNode('microsoft_graph')->canBeEnabled()
->arrayNode('microsoft_graph')->canBeEnabled()->setDeprecated('chill-project/chill-bundles', '4.7.0', 'The child node %node% at path %path% is deprecated: use remote_calendar_dsn instead, with a "msgraph://default" value')
->children()
->end() // end of machine_access_token
->end() // end of microsoft_graph children

View File

@@ -267,6 +267,11 @@ class Calendar implements TrackCreationInterface, TrackUpdateInterface, HasCente
return $this->cancelReason;
}
public function isCanceled(): bool
{
return null !== $this->cancelReason;
}
public function getCenters(): ?iterable
{
return match ($this->getContext()) {

View File

@@ -107,6 +107,11 @@ class CalendarRange implements TrackCreationInterface, TrackUpdateInterface
return $this;
}
public function hasLocation(): bool
{
return null !== $this->location;
}
public function setLocation(?Location $location): self
{
$this->location = $location;

View File

@@ -18,14 +18,14 @@ use Doctrine\ORM\Mapping as ORM;
#[ORM\Table(name: 'chill_calendar.cancel_reason')]
class CancelReason
{
final public const string CANCELEDBY_DONOTCOUNT = 'CANCELEDBY_DONOTCOUNT';
final public const CANCELEDBY_OTHER = 'CANCELEDBY_OTHER';
final public const string CANCELEDBY_PERSON = 'CANCELEDBY_PERSON';
final public const string CANCELEDBY_USER = 'CANCELEDBY_USER';
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::BOOLEAN)]
private ?bool $active = null;
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::BOOLEAN, options: ['default' => true])]
private bool $active = true;
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::STRING, length: 255)]
private ?string $canceledBy = null;

View File

@@ -15,7 +15,7 @@ use Chill\CalendarBundle\Entity\CancelReason;
use Chill\MainBundle\Form\Type\TranslatableStringFormType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
@@ -28,7 +28,14 @@ class CancelReasonType extends AbstractType
->add('active', CheckboxType::class, [
'required' => false,
])
->add('canceledBy', TextType::class);
->add('canceledBy', ChoiceType::class, [
'choices' => [
'chill_calendar.canceled_by.user' => CancelReason::CANCELEDBY_USER,
'chill_calendar.canceled_by.person' => CancelReason::CANCELEDBY_PERSON,
'chill_calendar.canceled_by.other' => CancelReason::CANCELEDBY_OTHER,
],
'required' => true,
]);
}
public function configureOptions(OptionsResolver $resolver): void

View File

@@ -0,0 +1,42 @@
<?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\CalendarBundle\Form;
use Chill\CalendarBundle\Entity\Calendar;
use Chill\CalendarBundle\Entity\CancelReason;
use Chill\MainBundle\Templating\TranslatableStringHelperInterface;
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class CancelType extends AbstractType
{
public function __construct(private readonly TranslatableStringHelperInterface $translatableStringHelper) {}
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder->add('cancelReason', EntityType::class, [
'class' => CancelReason::class,
'required' => true,
'choice_label' => fn (CancelReason $cancelReason) => $this->translatableStringHelper->localize($cancelReason->getName()),
]);
}
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults([
'data_class' => Calendar::class,
]);
}
}

View File

@@ -11,25 +11,46 @@ declare(strict_types=1);
namespace Chill\CalendarBundle\Menu;
use Chill\CalendarBundle\Repository\InviteRepository;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Routing\LocalMenuBuilderInterface;
use Knp\Menu\MenuItem;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Contracts\Translation\TranslatorInterface;
class UserMenuBuilder implements LocalMenuBuilderInterface
final readonly class UserMenuBuilder implements LocalMenuBuilderInterface
{
public function __construct(private readonly Security $security, public TranslatorInterface $translator) {}
public function __construct(
private Security $security,
private TranslatorInterface $translator,
private InviteRepository $inviteRepository,
) {}
public function buildMenu($menuId, MenuItem $menu, array $parameters): void
{
if ($this->security->isGranted('ROLE_USER')) {
$menu->addChild('My calendar list', [
'route' => 'chill_calendar_calendar_list_my',
])
->setExtras([
'order' => 9,
'icon' => 'tasks',
]);
$user = $this->security->getUser();
if ($user instanceof User) {
$invitationsPending = $this->inviteRepository->countPendingInvitesByUser($user);
if ($this->security->isGranted('ROLE_USER')) {
$menu->addChild('My calendar list', [
'route' => 'chill_calendar_calendar_list_my',
])
->setExtras([
'order' => 8,
'icon' => 'tasks',
]);
$menu->addChild(
$this->translator->trans('invite.menu with counter', ['nb' => $invitationsPending]),
['route' => 'chill_calendar_invitations_list_my']
)
->setExtras([
'order' => 9,
'icon' => 'tasks',
'counter' => 0 < $invitationsPending ? $invitationsPending : null,
]);
}
}
}

View File

@@ -21,6 +21,7 @@ namespace Chill\CalendarBundle\Messenger\Doctrine;
use Chill\CalendarBundle\Entity\Calendar;
use Chill\CalendarBundle\Messenger\Message\CalendarMessage;
use Chill\CalendarBundle\Messenger\Message\CalendarRemovedMessage;
use Chill\MainBundle\Entity\User;
use Doctrine\ORM\Event\PostPersistEventArgs;
use Doctrine\ORM\Event\PostRemoveEventArgs;
use Doctrine\ORM\Event\PostUpdateEventArgs;
@@ -31,6 +32,17 @@ class CalendarEntityListener
{
public function __construct(private readonly MessageBusInterface $messageBus, private readonly Security $security) {}
private function getAuthenticatedUser(): User
{
$user = $this->security->getUser();
if (!$user instanceof User) {
throw new \LogicException('Expected an instance of User.');
}
return $user;
}
public function postPersist(Calendar $calendar, PostPersistEventArgs $args): void
{
if (!$calendar->preventEnqueueChanges) {
@@ -38,7 +50,7 @@ class CalendarEntityListener
new CalendarMessage(
$calendar,
CalendarMessage::CALENDAR_PERSIST,
$this->security->getUser()
$this->getAuthenticatedUser()
)
);
}
@@ -50,7 +62,7 @@ class CalendarEntityListener
$this->messageBus->dispatch(
new CalendarRemovedMessage(
$calendar,
$this->security->getUser()
$this->getAuthenticatedUser()
)
);
}
@@ -58,12 +70,19 @@ class CalendarEntityListener
public function postUpdate(Calendar $calendar, PostUpdateEventArgs $args): void
{
if (!$calendar->preventEnqueueChanges) {
if ($calendar->getStatus() === $calendar::STATUS_CANCELED) {
$this->messageBus->dispatch(
new CalendarRemovedMessage(
$calendar,
$this->getAuthenticatedUser()
)
);
} elseif (!$calendar->preventEnqueueChanges) {
$this->messageBus->dispatch(
new CalendarMessage(
$calendar,
CalendarMessage::CALENDAR_UPDATE,
$this->security->getUser()
$this->getAuthenticatedUser()
)
);
}

View File

@@ -22,16 +22,20 @@ use Chill\CalendarBundle\Messenger\Message\CalendarRemovedMessage;
use Chill\CalendarBundle\RemoteCalendar\Connector\RemoteCalendarConnectorInterface;
use Chill\CalendarBundle\Repository\CalendarRangeRepository;
use Chill\MainBundle\Repository\UserRepositoryInterface;
use Doctrine\ORM\EntityManagerInterface;
/**
* Handle the deletion of calendar.
*
* @AsMessageHandler
*/
#[\Symfony\Component\Messenger\Attribute\AsMessageHandler]
class CalendarRemoveHandler
{
public function __construct(private readonly RemoteCalendarConnectorInterface $remoteCalendarConnector, private readonly CalendarRangeRepository $calendarRangeRepository, private readonly UserRepositoryInterface $userRepository) {}
public function __construct(
private readonly RemoteCalendarConnectorInterface $remoteCalendarConnector,
private readonly CalendarRangeRepository $calendarRangeRepository,
private readonly UserRepositoryInterface $userRepository,
private readonly EntityManagerInterface $entityManager,
) {}
public function __invoke(CalendarRemovedMessage $message): void
{
@@ -47,5 +51,7 @@ class CalendarRemoveHandler
$this->userRepository->find($message->getCalendarUserId()),
$associatedRange
);
$this->entityManager->flush();
}
}

View File

@@ -70,6 +70,8 @@ class CalendarRemovedMessage
public function getRemoteId(): string
{
dump($this->remoteId);
return $this->remoteId;
}
}

View File

@@ -21,42 +21,137 @@ namespace Chill\CalendarBundle\RemoteCalendar\Connector;
use Chill\CalendarBundle\Entity\Calendar;
use Chill\CalendarBundle\Entity\CalendarRange;
use Chill\CalendarBundle\Entity\Invite;
use Chill\CalendarBundle\Messenger\Message\CalendarMessage;
use Chill\CalendarBundle\RemoteCalendar\Model\RemoteEvent;
use Chill\MainBundle\Entity\User;
use Symfony\Component\HttpFoundation\Response;
/**
* Contract for connectors that synchronize Chill calendars with a remote
* calendar provider (for example Microsoft 365/Graph, Zimbra, ...).
*
* Implementations act as an adapter between Chill domain objects
* (Calendar, CalendarRange, Invite) and the remote provider API. They must:
* - expose a readiness flow for per-user authorization when applicable
* (see {@see getMakeReadyResponse()} and {@see isReady()});
* - list and count remote events in a time range for a given user;
* - mirror local lifecycle changes to the remote provider for calendars,
* calendar ranges (availability/busy blocks) and invites/attendees.
*
* Use {@see MSGraphRemoteCalendarConnector} as a reference implementation for
* expected behaviours, error handling and parameter semantics.
*/
interface RemoteCalendarConnectorInterface
{
public function countEventsForUser(User $user, \DateTimeImmutable $startDate, \DateTimeImmutable $endDate): int;
/**
* Return a response, more probably a RedirectResponse, where the user
* will be able to fullfill requirements to prepare this connector and
* make it ready.
* Returns a Response (typically a RedirectResponse) that lets the current
* user perform the steps required to make the connector usable (for
* example, OAuth consent or account linking). After completion, the user
* should be redirected back to the given path.
*/
public function getMakeReadyResponse(string $returnPath): Response;
/**
* Return true if the connector is ready to act as a proxy for reading
* remote calendars.
* Returns true when the connector is ready to access the remote provider
* on behalf of the current user (e.g. required tokens/consent exist).
*/
public function isReady(): bool;
/**
* Lists events from the remote provider for the given user and time range.
*
* Implementations should map provider-specific payloads to instances of
* {@see RemoteEvent}.
*
* @return array|RemoteEvent[]
*/
public function listEventsForUser(User $user, \DateTimeImmutable $startDate, \DateTimeImmutable $endDate, ?int $offset = 0, ?int $limit = 50): array;
/**
* Removes a calendar (single event) from the remote provider.
*
* **Note**: calendar (single event) which are canceled will appears in this
* method, and not in syncCalendar method.
*
* Parameters:
* - remoteId: the provider identifier of the remote event to delete. If
* empty, implementations should no-op.
* - remoteAttributes: provider-specific metadata previously stored with the
* local entity (e.g. change keys, etags) that can help perform safe
* concurrency checks when deleting. Implementations may ignore unknown
* keys.
* - user: the user in whose remote calendar the event lives and on whose
* behalf the deletion must be performed.
* - associatedCalendarRange: when provided, the implementation should
* update/synchronize the corresponding remote busy-time block after the
* event removal so that availability stays consistent.
*/
public function removeCalendar(string $remoteId, array $remoteAttributes, User $user, ?CalendarRange $associatedCalendarRange = null): void;
/**
* Removes a remote busy-time block (calendar range) identified by
* provider-specific id and attributes for the given user.
*
* Implementations should no-op if the id is empty.
*/
public function removeCalendarRange(string $remoteId, array $remoteAttributes, User $user): void;
/**
* Synchronizes a Calendar entity to the remote provider.
*
* Typical cases to support (see MSGraph implementation):
* - Creating the event on the remote calendar when it has no remote id.
* - Updating the existing remote event when details or attendees change.
* - Handling main user changes: cancel on the previous user's calendar,
* (re)create associated ranges where needed, then create on the new
* main user's calendar.
* - If the Calendar uses a CalendarRange that already exists remotely,
* implementations should remove/update that remote range when the event
* becomes the source of truth for busy times.
*
* The implementation should not expects to receive calendar which are canceled
* here.
*
* Parameters:
* - calendar: the domain Calendar to mirror remotely.
* - action: a hint about what triggered the sync; implementations should not rely
* solely on this value and must base decisions on the Calendar state.
* - previousCalendarRange: if the Calendar was previously attached to a
* different range, this contains the former range so it can be recreated
* remotely to preserve availability history when applicable.
* - previousMainUser: the former main user, when the main user changed;
* used to cancel the event in the previous user's calendar.
* - oldInvites: the attendee snapshot before the change. Each item is an
* array with keys: inviteId, userId, userEmail, userLabel.
* - newInvites: the attendee snapshot after the change, same shape as
* oldInvites. Implementations can compute diffs to add/remove attendees.
*
* The $action argument is a string tag indicating what happened to the
* calendar. It MUST be one of the constants defined on
* {@see CalendarMessage}:
* - {@see CalendarMessage::CALENDAR_PERSIST}
* - {@see CalendarMessage::CALENDAR_UPDATE}
*
* @param array<array{inviteId: int, userId: int, userEmail: int, userLabel: string}> $oldInvites
*
* @phpstan-param (CalendarMessage::CALENDAR_PERSIST|CalendarMessage::CALENDAR_UPDATE) $action
*/
public function syncCalendar(Calendar $calendar, string $action, ?CalendarRange $previousCalendarRange, ?User $previousMainUser, ?array $oldInvites, ?array $newInvites): void;
/**
* Creates or updates a remote busy-time block representing the provided
* CalendarRange. If the range has a remote id, it should be updated;
* otherwise it should be created remotely, and the range enriched with
* the new id/attributes by the caller.
*/
public function syncCalendarRange(CalendarRange $calendarRange): void;
/**
* Synchronizes a single Invite (attendee) change to the remote provider.
* Implementations may need to lookup the attendee's personal calendar to
* find provider-specific identifiers before patching the main event.
*/
public function syncInvite(Invite $invite): void;
}

View File

@@ -35,25 +35,46 @@ use TheNetworg\OAuth2\Client\Provider\Azure;
class RemoteCalendarCompilerPass implements CompilerPassInterface
{
private const ZIMBRA_CONNECTOR = 'Chill\ZimbraBundle\Calendar\Connector\ZimbraConnector';
private const MS_GRAPH_SERVICES_TO_REMOVE = [
MapAndSubscribeUserCalendarCommand::class,
AzureGrantAdminConsentAndAcquireToken::class,
RemoteCalendarConnectAzureController::class,
MachineTokenStorage::class,
MachineHttpClient::class,
MSGraphRemoteCalendarConnector::class,
MSUserAbsenceReaderInterface::class,
MSUserAbsenceSync::class,
];
public function process(ContainerBuilder $container): void
{
$config = $container->getParameter('chill_calendar');
$config = $container->getParameter('chill_calendar.remote_calendar_dsn');
if (true === $container->getParameter('chill_calendar')['remote_calendars_sync']['microsoft_graph']['enabled']) {
$dsn = 'msgraph://default';
} else {
$dsn = $config;
}
if (true === $config['remote_calendars_sync']['microsoft_graph']['enabled']) {
$scheme = parse_url($dsn, PHP_URL_SCHEME);
if ('msgraph' === $scheme) {
$connector = MSGraphRemoteCalendarConnector::class;
$container->setAlias(HttpClientInterface::class.' $machineHttpClient', MachineHttpClient::class);
} else {
} elseif ('zimbra+http' === $scheme || 'zimbra+https' === $scheme) {
$connector = self::ZIMBRA_CONNECTOR;
foreach (self::MS_GRAPH_SERVICES_TO_REMOVE as $serviceId) {
$container->removeDefinition($serviceId);
}
} elseif ('null' === $scheme) {
$connector = NullRemoteCalendarConnector::class;
// remove services which cannot be loaded
$container->removeDefinition(MapAndSubscribeUserCalendarCommand::class);
$container->removeDefinition(AzureGrantAdminConsentAndAcquireToken::class);
$container->removeDefinition(RemoteCalendarConnectAzureController::class);
$container->removeDefinition(MachineTokenStorage::class);
$container->removeDefinition(MachineHttpClient::class);
$container->removeDefinition(MSGraphRemoteCalendarConnector::class);
$container->removeDefinition(MSUserAbsenceReaderInterface::class);
$container->removeDefinition(MSUserAbsenceSync::class);
foreach (self::MS_GRAPH_SERVICES_TO_REMOVE as $serviceId) {
$container->removeDefinition($serviceId);
}
} else {
throw new \Symfony\Component\DependencyInjection\Exception\InvalidArgumentException('Unsupported remote calendar scheme: '.$scheme);
}
if (!$container->hasAlias(Azure::class) && $container->hasDefinition('knpu.oauth2.client.azure')) {
@@ -62,7 +83,9 @@ class RemoteCalendarCompilerPass implements CompilerPassInterface
foreach ([
NullRemoteCalendarConnector::class,
MSGraphRemoteCalendarConnector::class, ] as $serviceId) {
MSGraphRemoteCalendarConnector::class,
self::ZIMBRA_CONNECTOR,
] as $serviceId) {
if ($connector === $serviceId) {
$container->getDefinition($serviceId)
->setDecoratedService(RemoteCalendarConnectorInterface::class);

View File

@@ -191,6 +191,7 @@ class CalendarRepository implements ObjectRepository
$qb->expr()->eq('c.mainUser', ':user'),
$qb->expr()->gte('c.startDate', ':startDate'),
$qb->expr()->lte('c.endDate', ':endDate'),
$qb->expr()->isNull('c.cancelReason'),
)
)
->setParameters(new \Doctrine\Common\Collections\ArrayCollection([new \Doctrine\ORM\Query\Parameter('user', $user), new \Doctrine\ORM\Query\Parameter('startDate', $from), new \Doctrine\ORM\Query\Parameter('endDate', $to)]));

View File

@@ -12,6 +12,7 @@ declare(strict_types=1);
namespace Chill\CalendarBundle\Repository;
use Chill\CalendarBundle\Entity\Invite;
use Chill\MainBundle\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityRepository;
use Doctrine\Persistence\ObjectRepository;
@@ -41,7 +42,7 @@ class InviteRepository implements ObjectRepository
/**
* @return array|Invite[]
*/
public function findBy(array $criteria, ?array $orderBy = null, ?int $limit = null, ?int $offset = null)
public function findBy(array $criteria, ?array $orderBy = null, ?int $limit = null, ?int $offset = null): array
{
return $this->entityRepository->findBy($criteria, $orderBy, $limit, $offset);
}
@@ -51,6 +52,71 @@ class InviteRepository implements ObjectRepository
return $this->entityRepository->findOneBy($criteria);
}
/**
* Find accepted invites for a user within a date range.
*
* @return array|Invite[]
*/
public function findAcceptedInvitesByUserAndDateRange(User $user, \DateTimeImmutable $from, \DateTimeImmutable $to): array
{
return $this->buildAcceptedInviteByUserAndDateRangeQuery($user, $from, $to)
->getQuery()
->getResult();
}
/**
* Count accepted invites for a user within a date range.
*/
public function countAcceptedInvitesByUserAndDateRange(User $user, \DateTimeImmutable $from, \DateTimeImmutable $to): int
{
return $this->buildAcceptedInviteByUserAndDateRangeQuery($user, $from, $to)
->select('COUNT(c)')
->getQuery()
->getSingleScalarResult();
}
public function countPendingInvitesByUser(User $user): int
{
$qb = $this->entityRepository->createQueryBuilder('i');
$qb->select('COUNT(i)')
->where(
$qb->expr()->andX(
$qb->expr()->eq('i.user', ':user'),
$qb->expr()->eq('i.status', ':status')
)
)
->setParameters([
'user' => $user,
'status' => Invite::PENDING,
]);
return $qb->getQuery()->getSingleScalarResult();
}
public function buildAcceptedInviteByUserAndDateRangeQuery(User $user, \DateTimeImmutable $from, \DateTimeImmutable $to)
{
$qb = $this->entityRepository->createQueryBuilder('i');
return $qb
->join('i.calendar', 'c')
->where(
$qb->expr()->andX(
$qb->expr()->eq('i.user', ':user'),
$qb->expr()->eq('i.status', ':status'),
$qb->expr()->gte('c.startDate', ':startDate'),
$qb->expr()->lte('c.endDate', ':endDate'),
$qb->expr()->isNull('c.cancelReason')
)
)
->setParameters([
'user' => $user,
'status' => Invite::ACCEPTED,
'startDate' => $from,
'endDate' => $to,
]);
}
public function getClassName(): string
{
return Invite::class;

View File

@@ -1,76 +1,74 @@
import { EventInput } from "@fullcalendar/core";
import {
DateTime,
Location,
User,
UserAssociatedInterface,
DateTime,
Location,
User,
UserAssociatedInterface,
} from "../../../ChillMainBundle/Resources/public/types";
import { Person } from "../../../ChillPersonBundle/Resources/public/types";
export interface CalendarRange {
id: number;
endDate: DateTime;
startDate: DateTime;
user: User;
location: Location;
createdAt: DateTime;
createdBy: User;
updatedAt: DateTime;
updatedBy: User;
id: number;
endDate: DateTime;
startDate: DateTime;
user: User;
location: Location;
createdAt: DateTime;
createdBy: User;
updatedAt: DateTime;
updatedBy: User;
}
export interface CalendarRangeCreate {
user: UserAssociatedInterface;
startDate: DateTime;
endDate: DateTime;
location: Location;
user: UserAssociatedInterface;
startDate: DateTime;
endDate: DateTime;
location: Location;
}
export interface CalendarRangeEdit {
startDate?: DateTime;
endDate?: DateTime;
location?: Location;
startDate?: DateTime;
endDate?: DateTime;
location?: Location;
}
export interface Calendar {
id: number;
id: number;
}
export interface CalendarLight {
id: number;
endDate: DateTime;
startDate: DateTime;
mainUser: User;
persons: Person[];
status: "valid" | "moved" | "canceled";
id: number;
endDate: DateTime;
startDate: DateTime;
mainUser: User;
persons: Person[];
status: "valid" | "moved" | "canceled";
}
export interface CalendarRemote {
id: number;
endDate: DateTime;
startDate: DateTime;
title: string;
isAllDay: boolean;
id: number;
endDate: DateTime;
startDate: DateTime;
title: string;
isAllDay: boolean;
}
export type EventInputCalendarRange = EventInput & {
id: string;
userId: number;
userLabel: string;
calendarRangeId: number;
locationId: number;
locationName: string;
start: string;
end: string;
is: "range";
id: string;
userId: number;
userLabel: string;
calendarRangeId: number;
locationId: number;
locationName: string;
start: string;
end: string;
is: "range";
};
export function isEventInputCalendarRange(
toBeDetermined: EventInputCalendarRange | EventInput,
toBeDetermined: EventInputCalendarRange | EventInput,
): toBeDetermined is EventInputCalendarRange {
return (
typeof toBeDetermined.is === "string" && toBeDetermined.is === "range"
);
return typeof toBeDetermined.is === "string" && toBeDetermined.is === "range";
}
export {};

View File

@@ -1,166 +1,148 @@
<template>
<teleport to="#mainUser">
<h2 class="chill-red">Utilisateur principal</h2>
<div>
<div>
<div v-if="null !== this.$store.getters.getMainUser">
<calendar-active :user="this.$store.getters.getMainUser" />
</div>
<pick-entity
:multiple="false"
:types="['user']"
:uniqid="'main_user_calendar'"
:picked="
null !== this.$store.getters.getMainUser
? [this.$store.getters.getMainUser]
: []
"
:removable-if-set="false"
:display-picked="false"
:suggested="this.suggestedUsers"
:label="'main_user'"
@add-new-entity="setMainUser"
/>
</div>
<teleport to="#mainUser">
<h2 class="chill-red">Utilisateur principal</h2>
<div>
<div>
<div v-if="null !== this.$store.getters.getMainUser">
<calendar-active :user="this.$store.getters.getMainUser" />
</div>
</teleport>
<pick-entity
:multiple="false"
:types="['user']"
:uniqid="'main_user_calendar'"
:picked="
null !== this.$store.getters.getMainUser
? [this.$store.getters.getMainUser]
: []
"
:removable-if-set="false"
:display-picked="false"
:suggested="this.suggestedUsers"
:label="'main_user'"
@add-new-entity="setMainUser"
/>
</div>
</div>
</teleport>
<concerned-groups />
<concerned-groups />
<teleport to="#schedule">
<div class="row mb-3" v-if="activity.startDate !== null">
<label class="col-form-label col-sm-4">Date</label>
<div class="col-sm-8">
{{ $d(activity.startDate, "long") }} -
{{ $d(activity.endDate, "hoursOnly") }}
<span v-if="activity.calendarRange === null"
>(Pas de plage de disponibilité sélectionnée)</span
>
<span v-else>(Une plage de disponibilité sélectionnée)</span>
</div>
</div>
</teleport>
<location />
<teleport to="#fullCalendar">
<div class="calendar-actives">
<template v-for="u in getActiveUsers" :key="u.id">
<calendar-active
:user="u"
:invite="this.$store.getters.getInviteForUser(u)"
/>
</template>
</div>
<div
class="display-options row justify-content-between"
style="margin-top: 1rem"
<teleport to="#schedule">
<div class="row mb-3" v-if="activity.startDate !== null">
<label class="col-form-label col-sm-4">Date</label>
<div class="col-sm-8">
{{ $d(activity.startDate, "long") }} -
{{ $d(activity.endDate, "hoursOnly") }}
<span v-if="activity.calendarRange === null"
>(Pas de plage de disponibilité sélectionnée)</span
>
<div class="col-sm-9 col-xs-12">
<div class="input-group mb-3">
<label class="input-group-text" for="slotDuration"
>Durée des créneaux</label
>
<select
v-model="slotDuration"
id="slotDuration"
class="form-select"
>
<option value="00:05:00">5 minutes</option>
<option value="00:10:00">10 minutes</option>
<option value="00:15:00">15 minutes</option>
<option value="00:30:00">30 minutes</option>
<option value="00:45:00">45 minutes</option>
<option value="00:60:00">60 minutes</option>
</select>
<label class="input-group-text" for="slotMinTime">De</label>
<select
v-model="slotMinTime"
id="slotMinTime"
class="form-select"
>
<option value="00:00:00">0h</option>
<option value="01:00:00">1h</option>
<option value="02:00:00">2h</option>
<option value="03:00:00">3h</option>
<option value="04:00:00">4h</option>
<option value="05:00:00">5h</option>
<option value="06:00:00">6h</option>
<option value="07:00:00">7h</option>
<option value="08:00:00">8h</option>
<option value="09:00:00">9h</option>
<option value="10:00:00">10h</option>
<option value="11:00:00">11h</option>
<option value="12:00:00">12h</option>
</select>
<label class="input-group-text" for="slotMaxTime">À</label>
<select
v-model="slotMaxTime"
id="slotMaxTime"
class="form-select"
>
<option value="12:00:00">12h</option>
<option value="13:00:00">13h</option>
<option value="14:00:00">14h</option>
<option value="15:00:00">15h</option>
<option value="16:00:00">16h</option>
<option value="17:00:00">17h</option>
<option value="18:00:00">18h</option>
<option value="19:00:00">19h</option>
<option value="20:00:00">20h</option>
<option value="21:00:00">21h</option>
<option value="22:00:00">22h</option>
<option value="23:00:00">23h</option>
<option value="23:59:59">24h</option>
</select>
</div>
</div>
<div class="col-sm-3 col-xs-12">
<div class="float-end">
<div class="form-check input-group">
<span class="input-group-text">
<input
id="showHideWE"
class="mt-0"
type="checkbox"
v-model="hideWeekends"
/>
</span>
<label
for="showHideWE"
class="form-check-label input-group-text"
>Week-ends</label
>
</div>
</div>
</div>
<span v-else>(Une plage de disponibilité sélectionnée)</span>
</div>
</div>
</teleport>
<location />
<teleport to="#fullCalendar">
<div class="calendar-actives">
<template v-for="u in getActiveUsers" :key="u.id">
<calendar-active
:user="u"
:invite="this.$store.getters.getInviteForUser(u)"
/>
</template>
</div>
<div
class="display-options row justify-content-between"
style="margin-top: 1rem"
>
<div class="col-sm-9 col-xs-12">
<div class="input-group mb-3">
<label class="input-group-text" for="slotDuration"
>Durée des créneaux</label
>
<select v-model="slotDuration" id="slotDuration" class="form-select">
<option value="00:05:00">5 minutes</option>
<option value="00:10:00">10 minutes</option>
<option value="00:15:00">15 minutes</option>
<option value="00:30:00">30 minutes</option>
<option value="00:45:00">45 minutes</option>
<option value="00:60:00">60 minutes</option>
</select>
<label class="input-group-text" for="slotMinTime">De</label>
<select v-model="slotMinTime" id="slotMinTime" class="form-select">
<option value="00:00:00">0h</option>
<option value="01:00:00">1h</option>
<option value="02:00:00">2h</option>
<option value="03:00:00">3h</option>
<option value="04:00:00">4h</option>
<option value="05:00:00">5h</option>
<option value="06:00:00">6h</option>
<option value="07:00:00">7h</option>
<option value="08:00:00">8h</option>
<option value="09:00:00">9h</option>
<option value="10:00:00">10h</option>
<option value="11:00:00">11h</option>
<option value="12:00:00">12h</option>
</select>
<label class="input-group-text" for="slotMaxTime">À</label>
<select v-model="slotMaxTime" id="slotMaxTime" class="form-select">
<option value="12:00:00">12h</option>
<option value="13:00:00">13h</option>
<option value="14:00:00">14h</option>
<option value="15:00:00">15h</option>
<option value="16:00:00">16h</option>
<option value="17:00:00">17h</option>
<option value="18:00:00">18h</option>
<option value="19:00:00">19h</option>
<option value="20:00:00">20h</option>
<option value="21:00:00">21h</option>
<option value="22:00:00">22h</option>
<option value="23:00:00">23h</option>
<option value="23:59:59">24h</option>
</select>
</div>
<FullCalendar ref="fullCalendar" :options="calendarOptions">
<template #eventContent="arg">
<span>
<b v-if="arg.event.extendedProps.is === 'remote'">{{
arg.event.title
}}</b>
<b v-else-if="arg.event.extendedProps.is === 'range'"
>{{ arg.timeText }}
{{ arg.event.extendedProps.locationName }}
<small>{{
arg.event.extendedProps.userLabel
}}</small></b
>
<b v-else-if="arg.event.extendedProps.is === 'current'"
>{{ arg.timeText }} {{ $t("current_selected") }}
</b>
<b v-else-if="arg.event.extendedProps.is === 'local'">{{
arg.event.title
}}</b>
<b v-else
>{{ arg.timeText }} {{ $t("current_selected") }}
</b>
</span>
</template>
</FullCalendar>
</teleport>
</div>
<div class="col-sm-3 col-xs-12">
<div class="float-end">
<div class="form-check input-group">
<span class="input-group-text">
<input
id="showHideWE"
class="mt-0"
type="checkbox"
v-model="hideWeekends"
/>
</span>
<label for="showHideWE" class="form-check-label input-group-text"
>Week-ends</label
>
</div>
</div>
</div>
</div>
<FullCalendar ref="fullCalendar" :options="calendarOptions">
<template #eventContent="arg">
<span>
<b v-if="arg.event.extendedProps.is === 'remote'">{{
arg.event.title
}}</b>
<b v-else-if="arg.event.extendedProps.is === 'range'"
>{{ arg.timeText }}
{{ arg.event.extendedProps.locationName }}
<small>{{ arg.event.extendedProps.userLabel }}</small></b
>
<b v-else-if="arg.event.extendedProps.is === 'current'"
>{{ arg.timeText }} {{ $t("current_selected") }}
</b>
<b v-else-if="arg.event.extendedProps.is === 'local'">{{
arg.event.title
}}</b>
<b v-else>{{ arg.timeText }} {{ $t("current_selected") }} </b>
</span>
</template>
</FullCalendar>
</teleport>
</template>
<script>
@@ -177,219 +159,210 @@ import PickEntity from "ChillMainAssets/vuejs/PickEntity/PickEntity.vue";
import { mapGetters, mapState } from "vuex";
export default {
name: "App",
components: {
ConcernedGroups,
Location,
FullCalendar,
CalendarActive,
PickEntity,
name: "App",
components: {
ConcernedGroups,
Location,
FullCalendar,
CalendarActive,
PickEntity,
},
data() {
return {
errorMsg: [],
showMyCalendar: false,
slotDuration: "00:05:00",
slotMinTime: "09:00:00",
slotMaxTime: "18:00:00",
hideWeekEnds: true,
previousUser: [],
};
},
computed: {
...mapGetters(["getMainUser"]),
...mapState(["activity"]),
events() {
return this.$store.getters.getEventSources;
},
data() {
return {
errorMsg: [],
showMyCalendar: false,
slotDuration: "00:05:00",
slotMinTime: "09:00:00",
slotMaxTime: "18:00:00",
hideWeekEnds: true,
previousUser: [],
};
calendarOptions() {
return {
locale: frLocale,
plugins: [
dayGridPlugin,
interactionPlugin,
timeGridPlugin,
dayGridPlugin,
listPlugin,
],
initialView: "timeGridWeek",
initialDate: this.$store.getters.getInitialDate,
eventSources: this.events,
selectable: true,
slotMinTime: this.slotMinTime,
slotMaxTime: this.slotMaxTime,
scrollTimeReset: false,
datesSet: this.onDatesSet,
select: this.onDateSelect,
eventChange: this.onEventChange,
eventClick: this.onEventClick,
selectMirror: true,
editable: true,
weekends: !this.hideWeekEnds,
headerToolbar: {
left: "prev,next today",
center: "title",
right: "timeGridWeek,timeGridDay,listWeek",
},
views: {
timeGrid: {
slotEventOverlap: false,
slotDuration: this.slotDuration,
},
},
};
},
computed: {
...mapGetters(["getMainUser"]),
...mapState(["activity"]),
events() {
return this.$store.getters.getEventSources;
},
calendarOptions() {
return {
locale: frLocale,
plugins: [
dayGridPlugin,
interactionPlugin,
timeGridPlugin,
dayGridPlugin,
listPlugin,
],
initialView: "timeGridWeek",
initialDate: this.$store.getters.getInitialDate,
eventSources: this.events,
selectable: true,
slotMinTime: this.slotMinTime,
slotMaxTime: this.slotMaxTime,
scrollTimeReset: false,
datesSet: this.onDatesSet,
select: this.onDateSelect,
eventChange: this.onEventChange,
eventClick: this.onEventClick,
selectMirror: true,
editable: true,
weekends: !this.hideWeekEnds,
headerToolbar: {
left: "prev,next today",
center: "title",
right: "timeGridWeek,timeGridDay,listWeek",
},
views: {
timeGrid: {
slotEventOverlap: false,
slotDuration: this.slotDuration,
},
},
};
},
getActiveUsers() {
const users = [];
for (const id of this.$store.state.currentView.users.keys()) {
users.push(this.$store.getters.getUserDataById(id).user);
}
return users;
},
suggestedUsers() {
const suggested = [];
this.$data.previousUser.forEach((u) => {
if (u.id !== this.$store.getters.getMainUser.id) {
suggested.push(u);
}
});
return suggested;
},
getActiveUsers() {
const users = [];
for (const id of this.$store.state.currentView.users.keys()) {
users.push(this.$store.getters.getUserDataById(id).user);
}
return users;
},
methods: {
setMainUser({ entity }) {
const user = entity;
console.log("setMainUser APP", entity);
suggestedUsers() {
const suggested = [];
if (
user.id !== this.$store.getters.getMainUser &&
(this.$store.state.activity.calendarRange !== null ||
this.$store.state.activity.startDate !== null ||
this.$store.state.activity.endDate !== null)
) {
if (
!window.confirm(
this.$t("change_main_user_will_reset_event_data"),
)
) {
return;
}
}
this.$data.previousUser.forEach((u) => {
if (u.id !== this.$store.getters.getMainUser.id) {
suggested.push(u);
}
});
// add the previous user, if any, in the previous user list (in use for suggestion)
if (null !== this.$store.getters.getMainUser) {
const suggestedUids = new Set(
this.$data.previousUser.map((u) => u.id),
);
if (!suggestedUids.has(this.$store.getters.getMainUser.id)) {
this.$data.previousUser.push(
this.$store.getters.getMainUser,
);
}
}
this.$store.dispatch("setMainUser", user);
this.$store.commit("showUserOnCalendar", {
user,
ranges: true,
remotes: true,
});
},
removeMainUser(user) {
console.log("removeMainUser APP", user);
window.alert(this.$t("main_user_is_mandatory"));
return;
},
onDatesSet(event) {
console.log("onDatesSet", event);
this.$store.dispatch("setCurrentDatesView", {
start: event.start,
end: event.end,
});
},
onDateSelect(payload) {
console.log("onDateSelect", payload);
// show an alert if changing mainUser
if (
(this.$store.getters.getMainUser !== null &&
this.$store.state.me.id !==
this.$store.getters.getMainUser.id) ||
this.$store.getters.getMainUser === null
) {
if (!window.confirm(this.$t("will_change_main_user_for_me"))) {
return;
} else {
this.$store.commit("showUserOnCalendar", {
user: this.$store.state.me,
remotes: true,
ranges: true,
});
}
}
this.$store.dispatch("setEventTimes", {
start: payload.start,
end: payload.end,
});
},
onEventChange(payload) {
console.log("onEventChange", payload);
if (this.$store.state.activity.calendarRange !== null) {
throw new Error(
"not allowed to edit a calendar associated with a calendar range",
);
}
this.$store.dispatch("setEventTimes", {
start: payload.event.start,
end: payload.event.end,
});
},
onEventClick(payload) {
if (payload.event.extendedProps.is !== "range") {
// do nothing when clicking on remote
return;
}
// show an alert if changing mainUser
if (
this.$store.getters.getMainUser !== null &&
payload.event.extendedProps.userId !==
this.$store.getters.getMainUser.id
) {
if (
!window.confirm(
this.$t("this_calendar_range_will_change_main_user"),
)
) {
return;
}
}
this.$store.dispatch("associateCalendarToRange", {
range: payload.event,
});
},
return suggested;
},
},
methods: {
setMainUser({ entity }) {
const user = entity;
console.log("setMainUser APP", entity);
if (
user.id !== this.$store.getters.getMainUser &&
(this.$store.state.activity.calendarRange !== null ||
this.$store.state.activity.startDate !== null ||
this.$store.state.activity.endDate !== null)
) {
if (
!window.confirm(this.$t("change_main_user_will_reset_event_data"))
) {
return;
}
}
// add the previous user, if any, in the previous user list (in use for suggestion)
if (null !== this.$store.getters.getMainUser) {
const suggestedUids = new Set(this.$data.previousUser.map((u) => u.id));
if (!suggestedUids.has(this.$store.getters.getMainUser.id)) {
this.$data.previousUser.push(this.$store.getters.getMainUser);
}
}
this.$store.dispatch("setMainUser", user);
this.$store.commit("showUserOnCalendar", {
user,
ranges: true,
remotes: true,
});
},
removeMainUser(user) {
console.log("removeMainUser APP", user);
window.alert(this.$t("main_user_is_mandatory"));
return;
},
onDatesSet(event) {
console.log("onDatesSet", event);
this.$store.dispatch("setCurrentDatesView", {
start: event.start,
end: event.end,
});
},
onDateSelect(payload) {
console.log("onDateSelect", payload);
// show an alert if changing mainUser
if (
(this.$store.getters.getMainUser !== null &&
this.$store.state.me.id !== this.$store.getters.getMainUser.id) ||
this.$store.getters.getMainUser === null
) {
if (!window.confirm(this.$t("will_change_main_user_for_me"))) {
return;
} else {
this.$store.commit("showUserOnCalendar", {
user: this.$store.state.me,
remotes: true,
ranges: true,
});
}
}
this.$store.dispatch("setEventTimes", {
start: payload.start,
end: payload.end,
});
},
onEventChange(payload) {
console.log("onEventChange", payload);
if (this.$store.state.activity.calendarRange !== null) {
throw new Error(
"not allowed to edit a calendar associated with a calendar range",
);
}
this.$store.dispatch("setEventTimes", {
start: payload.event.start,
end: payload.event.end,
});
},
onEventClick(payload) {
if (payload.event.extendedProps.is !== "range") {
// do nothing when clicking on remote
return;
}
// show an alert if changing mainUser
if (
this.$store.getters.getMainUser !== null &&
payload.event.extendedProps.userId !==
this.$store.getters.getMainUser.id
) {
if (
!window.confirm(this.$t("this_calendar_range_will_change_main_user"))
) {
return;
}
}
this.$store.dispatch("associateCalendarToRange", {
range: payload.event,
});
},
},
};
</script>
<style>
.calendar-actives {
display: flex;
flex-direction: row;
flex-wrap: wrap;
display: flex;
flex-direction: row;
flex-wrap: wrap;
}
.display-options {
margin-top: 1rem;
margin-top: 1rem;
}
/* for events which are range */
.fc-event.isrange {
border-width: 3px;
border-width: 3px;
}
</style>

View File

@@ -1,119 +1,105 @@
<template>
<div :style="style" class="calendar-active">
<span class="badge-user">
{{ user.text }}
<template v-if="invite !== null">
<i v-if="invite.status === 'accepted'" class="fa fa-check" />
<i
v-else-if="invite.status === 'declined'"
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>
</template>
</span>
<span class="form-check-inline form-switch">
<input
class="form-check-input"
type="checkbox"
id="flexSwitchCheckDefault"
v-model="rangeShow"
/>
&nbsp;<label
class="form-check-label"
for="flexSwitchCheckDefault"
title="Disponibilités"
><i class="fa fa-calendar-check-o"
/></label>
</span>
<span class="form-check-inline form-switch">
<input
class="form-check-input"
type="checkbox"
id="flexSwitchCheckDefault"
v-model="remoteShow"
/>
&nbsp;<label
class="form-check-label"
for="flexSwitchCheckDefault"
title="Agenda"
><i class="fa fa-calendar"
/></label>
</span>
</div>
<div :style="style" class="calendar-active">
<span class="badge-user">
{{ user.text }}
<template v-if="invite !== null">
<i v-if="invite.status === 'accepted'" class="fa fa-check" />
<i v-else-if="invite.status === 'declined'" 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>
</template>
</span>
<span class="form-check-inline form-switch">
<input
class="form-check-input"
type="checkbox"
id="flexSwitchCheckDefault"
v-model="rangeShow"
/>
&nbsp;<label
class="form-check-label"
for="flexSwitchCheckDefault"
title="Disponibilités"
><i class="fa fa-calendar-check-o"
/></label>
</span>
<span class="form-check-inline form-switch">
<input
class="form-check-input"
type="checkbox"
id="flexSwitchCheckDefault"
v-model="remoteShow"
/>
&nbsp;<label
class="form-check-label"
for="flexSwitchCheckDefault"
title="Agenda"
><i class="fa fa-calendar"
/></label>
</span>
</div>
</template>
<script>
import { mapGetters } from "vuex";
export default {
name: "CalendarActive",
props: {
user: {
type: Object,
required: true,
},
invite: {
type: Object,
required: false,
default: null,
},
name: "CalendarActive",
props: {
user: {
type: Object,
required: true,
},
computed: {
style() {
return {
backgroundColor: this.$store.getters.getUserData(this.user)
.mainColor,
};
},
rangeShow: {
set(value) {
this.$store.commit("showUserOnCalendar", {
user: this.user,
ranges: value,
});
},
get() {
return this.$store.getters.isRangeShownOnCalendarForUser(
this.user,
);
},
},
remoteShow: {
set(value) {
this.$store.commit("showUserOnCalendar", {
user: this.user,
remotes: value,
});
},
get() {
return this.$store.getters.isRemoteShownOnCalendarForUser(
this.user,
);
},
},
invite: {
type: Object,
required: false,
default: null,
},
},
computed: {
style() {
return {
backgroundColor: this.$store.getters.getUserData(this.user).mainColor,
};
},
rangeShow: {
set(value) {
this.$store.commit("showUserOnCalendar", {
user: this.user,
ranges: value,
});
},
get() {
return this.$store.getters.isRangeShownOnCalendarForUser(this.user);
},
},
remoteShow: {
set(value) {
this.$store.commit("showUserOnCalendar", {
user: this.user,
remotes: value,
});
},
get() {
return this.$store.getters.isRemoteShownOnCalendarForUser(this.user);
},
},
},
};
</script>
<style scoped lang="scss">
.calendar-active {
margin: 0 0.25rem 0.25rem 0;
padding: 0.5rem;
margin: 0 0.25rem 0.25rem 0;
padding: 0.5rem;
border-radius: 0.5rem;
border-radius: 0.5rem;
color: var(--bs-blue);
color: var(--bs-blue);
& > .badge-user {
margin-right: 0.5rem;
}
& > .badge-user {
margin-right: 0.5rem;
}
}
</style>

View File

@@ -14,37 +14,37 @@ export { whoami } from "../../../../../ChillMainBundle/Resources/public/lib/api/
* @return Promise
*/
export const fetchCalendarRangeForUser = (
user: User,
start: Date,
end: Date,
user: User,
start: Date,
end: Date,
): Promise<CalendarRange[]> => {
const uri = `/api/1.0/calendar/calendar-range-available/${user.id}.json`;
const dateFrom = datetimeToISO(start);
const dateTo = datetimeToISO(end);
const uri = `/api/1.0/calendar/calendar-range-available/${user.id}.json`;
const dateFrom = datetimeToISO(start);
const dateTo = datetimeToISO(end);
return fetchResults<CalendarRange>(uri, { dateFrom, dateTo });
return fetchResults<CalendarRange>(uri, { dateFrom, dateTo });
};
export const fetchCalendarRemoteForUser = (
user: User,
start: Date,
end: Date,
user: User,
start: Date,
end: Date,
): Promise<CalendarRemote[]> => {
const uri = `/api/1.0/calendar/proxy/calendar/by-user/${user.id}/events`;
const dateFrom = datetimeToISO(start);
const dateTo = datetimeToISO(end);
const uri = `/api/1.0/calendar/proxy/calendar/by-user/${user.id}/events`;
const dateFrom = datetimeToISO(start);
const dateTo = datetimeToISO(end);
return fetchResults<CalendarRemote>(uri, { dateFrom, dateTo });
return fetchResults<CalendarRemote>(uri, { dateFrom, dateTo });
};
export const fetchCalendarLocalForUser = (
user: User,
start: Date,
end: Date,
user: User,
start: Date,
end: Date,
): Promise<CalendarLight[]> => {
const uri = `/api/1.0/calendar/calendar/by-user/${user.id}.json`;
const dateFrom = datetimeToISO(start);
const dateTo = datetimeToISO(end);
const uri = `/api/1.0/calendar/calendar/by-user/${user.id}.json`;
const dateFrom = datetimeToISO(start);
const dateTo = datetimeToISO(end);
return fetchResults<CalendarLight>(uri, { dateFrom, dateTo });
return fetchResults<CalendarLight>(uri, { dateFrom, dateTo });
};

View File

@@ -1,17 +1,17 @@
const COLORS = [
/* from https://colorbrewer2.org/#type=qualitative&scheme=Set3&n=12 */
"#8dd3c7",
"#ffffb3",
"#bebada",
"#fb8072",
"#80b1d3",
"#fdb462",
"#b3de69",
"#fccde5",
"#d9d9d9",
"#bc80bd",
"#ccebc5",
"#ffed6f",
/* from https://colorbrewer2.org/#type=qualitative&scheme=Set3&n=12 */
"#8dd3c7",
"#ffffb3",
"#bebada",
"#fb8072",
"#80b1d3",
"#fdb462",
"#b3de69",
"#fccde5",
"#d9d9d9",
"#bc80bd",
"#ccebc5",
"#ffed6f",
];
export { COLORS };

View File

@@ -1,117 +1,117 @@
import { COLORS } from "../const";
import { ISOToDatetime } from "../../../../../../ChillMainBundle/Resources/public/chill/js/date";
import {
DateTime,
User,
DateTime,
User,
} from "../../../../../../ChillMainBundle/Resources/public/types";
import { CalendarLight, CalendarRange, CalendarRemote } from "../../../types";
import type { EventInputCalendarRange } from "../../../types";
import { EventInput } from "@fullcalendar/core";
export interface UserData {
user: User;
calendarRanges: CalendarRange[];
calendarRangesLoaded: {}[];
remotes: CalendarRemote[];
remotesLoaded: {}[];
locals: CalendarRemote[];
localsLoaded: {}[];
mainColor: string;
user: User;
calendarRanges: CalendarRange[];
calendarRangesLoaded: {}[];
remotes: CalendarRemote[];
remotesLoaded: {}[];
locals: CalendarRemote[];
localsLoaded: {}[];
mainColor: string;
}
export const addIdToValue = (string: string, id: number): string => {
const array = string ? string.split(",") : [];
array.push(id.toString());
const str = array.join();
return str;
const array = string ? string.split(",") : [];
array.push(id.toString());
const str = array.join();
return str;
};
export const removeIdFromValue = (string: string, id: number) => {
let array = string.split(",");
array = array.filter((el) => el !== id.toString());
const str = array.join();
return str;
let array = string.split(",");
array = array.filter((el) => el !== id.toString());
const str = array.join();
return str;
};
/*
* Assign missing keys for the ConcernedGroups component
*/
export const mapEntity = (entity: EventInput): EventInput => {
const calendar = { ...entity };
Object.assign(calendar, { thirdParties: entity.professionals });
const calendar = { ...entity };
Object.assign(calendar, { thirdParties: entity.professionals });
if (entity.startDate !== null) {
calendar.startDate = ISOToDatetime(entity.startDate.datetime);
}
if (entity.endDate !== null) {
calendar.endDate = ISOToDatetime(entity.endDate.datetime);
}
if (entity.startDate !== null) {
calendar.startDate = ISOToDatetime(entity.startDate.datetime);
}
if (entity.endDate !== null) {
calendar.endDate = ISOToDatetime(entity.endDate.datetime);
}
if (entity.calendarRange !== null) {
calendar.calendarRange.calendarRangeId = entity.calendarRange.id;
calendar.calendarRange.id = `range_${entity.calendarRange.id}`;
}
if (entity.calendarRange !== null) {
calendar.calendarRange.calendarRangeId = entity.calendarRange.id;
calendar.calendarRange.id = `range_${entity.calendarRange.id}`;
}
return calendar;
return calendar;
};
export const createUserData = (user: User, colorIndex: number): UserData => {
const colorId = colorIndex % COLORS.length;
const colorId = colorIndex % COLORS.length;
return {
user: user,
calendarRanges: [],
calendarRangesLoaded: [],
remotes: [],
remotesLoaded: [],
locals: [],
localsLoaded: [],
mainColor: COLORS[colorId],
};
return {
user: user,
calendarRanges: [],
calendarRangesLoaded: [],
remotes: [],
remotesLoaded: [],
locals: [],
localsLoaded: [],
mainColor: COLORS[colorId],
};
};
// TODO move this function to a more global namespace, as it is also in use in MyCalendarRange app
export const calendarRangeToFullCalendarEvent = (
entity: CalendarRange,
entity: CalendarRange,
): EventInputCalendarRange => {
return {
id: `range_${entity.id}`,
title: "(" + entity.user.text + ")",
start: entity.startDate.datetime8601,
end: entity.endDate.datetime8601,
allDay: false,
userId: entity.user.id,
userLabel: entity.user.label,
calendarRangeId: entity.id,
locationId: entity.location.id,
locationName: entity.location.name,
is: "range",
};
return {
id: `range_${entity.id}`,
title: "(" + entity.user.text + ")",
start: entity.startDate.datetime8601,
end: entity.endDate.datetime8601,
allDay: false,
userId: entity.user.id,
userLabel: entity.user.label,
calendarRangeId: entity.id,
locationId: entity.location.id,
locationName: entity.location.name,
is: "range",
};
};
export const remoteToFullCalendarEvent = (
entity: CalendarRemote,
entity: CalendarRemote,
): EventInput & { id: string } => {
return {
id: `range_${entity.id}`,
title: entity.title,
start: entity.startDate.datetime8601,
end: entity.endDate.datetime8601,
allDay: entity.isAllDay,
is: "remote",
};
return {
id: `range_${entity.id}`,
title: entity.title,
start: entity.startDate.datetime8601,
end: entity.endDate.datetime8601,
allDay: entity.isAllDay,
is: "remote",
};
};
export const localsToFullCalendarEvent = (
entity: CalendarLight,
entity: CalendarLight,
): EventInput & { id: string; originId: number } => {
return {
id: `local_${entity.id}`,
title: entity.persons.map((p) => p.text).join(", "),
originId: entity.id,
start: entity.startDate.datetime8601,
end: entity.endDate.datetime8601,
allDay: false,
is: "local",
};
return {
id: `local_${entity.id}`,
title: entity.persons.map((p) => p.text).join(", "),
originId: entity.id,
start: entity.startDate.datetime8601,
end: entity.endDate.datetime8601,
allDay: false,
is: "local",
};
};

View File

@@ -1,58 +1,50 @@
<template>
<div class="btn-group" role="group">
<button
id="btnGroupDrop1"
type="button"
class="btn btn-misc dropdown-toggle"
data-bs-toggle="dropdown"
aria-expanded="false"
<div class="btn-group" role="group">
<button
id="btnGroupDrop1"
type="button"
class="btn btn-misc dropdown-toggle"
data-bs-toggle="dropdown"
aria-expanded="false"
>
<template v-if="status === Statuses.PENDING">
<span class="fa fa-hourglass"></span> {{ $t("Give_an_answer") }}
</template>
<template v-else-if="status === Statuses.ACCEPTED">
<span class="fa fa-check"></span> {{ $t("Accepted") }}
</template>
<template v-else-if="status === Statuses.DECLINED">
<span class="fa fa-times"></span> {{ $t("Declined") }}
</template>
<template v-else-if="status === Statuses.TENTATIVELY_ACCEPTED">
<span class="fa fa-question"></span> {{ $t("Tentative") }}
</template>
</button>
<ul class="dropdown-menu" aria-labelledby="btnGroupDrop1">
<li v-if="status !== Statuses.ACCEPTED">
<a class="dropdown-item" @click="changeStatus(Statuses.ACCEPTED)"
><i class="fa fa-check" aria-hidden="true"></i> {{ $t("Accept") }}</a
>
<template v-if="status === Statuses.PENDING">
<span class="fa fa-hourglass"></span> {{ $t("Give_an_answer") }}
</template>
<template v-else-if="status === Statuses.ACCEPTED">
<span class="fa fa-check"></span> {{ $t("Accepted") }}
</template>
<template v-else-if="status === Statuses.DECLINED">
<span class="fa fa-times"></span> {{ $t("Declined") }}
</template>
<template v-else-if="status === Statuses.TENTATIVELY_ACCEPTED">
<span class="fa fa-question"></span> {{ $t("Tentative") }}
</template>
</button>
<ul class="dropdown-menu" aria-labelledby="btnGroupDrop1">
<li v-if="status !== Statuses.ACCEPTED">
<a
class="dropdown-item"
@click="changeStatus(Statuses.ACCEPTED)"
><i class="fa fa-check" aria-hidden="true"></i>
{{ $t("Accept") }}</a
>
</li>
<li v-if="status !== Statuses.DECLINED">
<a
class="dropdown-item"
@click="changeStatus(Statuses.DECLINED)"
><i class="fa fa-times" aria-hidden="true"></i>
{{ $t("Decline") }}</a
>
</li>
<li v-if="status !== Statuses.TENTATIVELY_ACCEPTED">
<a
class="dropdown-item"
@click="changeStatus(Statuses.TENTATIVELY_ACCEPTED)"
><i class="fa fa-question"></i>
{{ $t("Tentatively_accept") }}</a
>
</li>
<li v-if="status !== Statuses.PENDING">
<a class="dropdown-item" @click="changeStatus(Statuses.PENDING)"
><i class="fa fa-hourglass-o"></i>
{{ $t("Set_pending") }}</a
>
</li>
</ul>
</div>
</li>
<li v-if="status !== Statuses.DECLINED">
<a class="dropdown-item" @click="changeStatus(Statuses.DECLINED)"
><i class="fa fa-times" aria-hidden="true"></i> {{ $t("Decline") }}</a
>
</li>
<li v-if="status !== Statuses.TENTATIVELY_ACCEPTED">
<a
class="dropdown-item"
@click="changeStatus(Statuses.TENTATIVELY_ACCEPTED)"
><i class="fa fa-question"></i> {{ $t("Tentatively_accept") }}</a
>
</li>
<li v-if="status !== Statuses.PENDING">
<a class="dropdown-item" @click="changeStatus(Statuses.PENDING)"
><i class="fa fa-hourglass-o"></i> {{ $t("Set_pending") }}</a
>
</li>
</ul>
</div>
</template>
<script lang="ts">
@@ -64,69 +56,67 @@ const PENDING = "pending";
const TENTATIVELY_ACCEPTED = "tentative";
const i18n = {
messages: {
fr: {
Give_an_answer: "Répondre",
Accepted: "Accepté",
Declined: "Refusé",
Tentative: "Accepté provisoirement",
Accept: "Accepter",
Decline: "Refuser",
Tentatively_accept: "Accepter provisoirement",
Set_pending: "Ne pas répondre",
},
messages: {
fr: {
Give_an_answer: "Répondre",
Accepted: "Accepté",
Declined: "Refusé",
Tentative: "Accepté provisoirement",
Accept: "Accepter",
Decline: "Refuser",
Tentatively_accept: "Accepter provisoirement",
Set_pending: "Ne pas répondre",
},
},
};
export default defineComponent({
name: "Answer",
i18n,
props: {
calendarId: { type: Number, required: true },
status: {
type: String as PropType<
"accepted" | "declined" | "pending" | "tentative"
>,
required: true,
},
name: "Answer",
i18n,
props: {
calendarId: { type: Number, required: true },
status: {
type: String as PropType<
"accepted" | "declined" | "pending" | "tentative"
>,
required: true,
},
emits: {
statusChanged(
payload: "accepted" | "declined" | "pending" | "tentative",
) {
return true;
},
},
emits: {
statusChanged(payload: "accepted" | "declined" | "pending" | "tentative") {
return true;
},
data() {
return {
Statuses: {
ACCEPTED,
DECLINED,
PENDING,
TENTATIVELY_ACCEPTED,
},
};
},
methods: {
changeStatus: function (
newStatus: "accepted" | "declined" | "pending" | "tentative",
) {
console.log("changeStatus", newStatus);
const url = `/api/1.0/calendar/calendar/${this.$props.calendarId}/answer/${newStatus}.json`;
window
.fetch(url, {
method: "POST",
})
.then((r: Response) => {
if (!r.ok) {
console.error("could not confirm answer", newStatus);
return;
}
console.log("answer sent", newStatus);
this.$emit("statusChanged", newStatus);
});
},
},
data() {
return {
Statuses: {
ACCEPTED,
DECLINED,
PENDING,
TENTATIVELY_ACCEPTED,
},
};
},
methods: {
changeStatus: function (
newStatus: "accepted" | "declined" | "pending" | "tentative",
) {
console.log("changeStatus", newStatus);
const url = `/api/1.0/calendar/calendar/${this.$props.calendarId}/answer/${newStatus}.json`;
window
.fetch(url, {
method: "POST",
})
.then((r: Response) => {
if (!r.ok) {
console.error("could not confirm answer", newStatus);
return;
}
console.log("answer sent", newStatus);
this.$emit("statusChanged", newStatus);
});
},
},
});
</script>

View File

@@ -1,228 +1,185 @@
<template>
<div class="row">
<div class="col-sm">
<label class="form-label">{{ $t("created_availabilities") }}</label>
<vue-multiselect
v-model="pickedLocation"
:options="locations"
:label="'name'"
:track-by="'id'"
:selectLabel="'Presser \'Entrée\' pour choisir'"
:selectedLabel="'Choisir'"
:deselectLabel="'Presser \'Entrée\' pour enlever'"
:placeholder="'Choisir'"
></vue-multiselect>
</div>
<div class="row">
<div class="col-sm">
<label class="form-label">{{ $t("created_availabilities") }}</label>
<vue-multiselect
v-model="pickedLocation"
:options="locations"
:label="'name'"
:track-by="'id'"
:selectLabel="'Presser \'Entrée\' pour choisir'"
:selectedLabel="'Choisir'"
:deselectLabel="'Presser \'Entrée\' pour enlever'"
:placeholder="'Choisir'"
></vue-multiselect>
</div>
<div
class="display-options row justify-content-between"
style="margin-top: 1rem"
>
<div class="col-sm-9 col-xs-12">
<div class="input-group mb-3">
<label class="input-group-text" for="slotDuration"
>Durée des créneaux</label
>
<select
v-model="slotDuration"
id="slotDuration"
class="form-select"
>
<option value="00:05:00">5 minutes</option>
<option value="00:10:00">10 minutes</option>
<option value="00:15:00">15 minutes</option>
<option value="00:30:00">30 minutes</option>
<option value="00:45:00">45 minutes</option>
<option value="00:60:00">60 minutes</option>
</select>
<label class="input-group-text" for="slotMinTime">De</label>
<select
v-model="slotMinTime"
id="slotMinTime"
class="form-select"
>
<option value="00:00:00">0h</option>
<option value="01:00:00">1h</option>
<option value="02:00:00">2h</option>
<option value="03:00:00">3h</option>
<option value="04:00:00">4h</option>
<option value="05:00:00">5h</option>
<option value="06:00:00">6h</option>
<option value="07:00:00">7h</option>
<option value="08:00:00">8h</option>
<option value="09:00:00">9h</option>
<option value="10:00:00">10h</option>
<option value="11:00:00">11h</option>
<option value="12:00:00">12h</option>
</select>
<label class="input-group-text" for="slotMaxTime">À</label>
<select
v-model="slotMaxTime"
id="slotMaxTime"
class="form-select"
>
<option value="12:00:00">12h</option>
<option value="13:00:00">13h</option>
<option value="14:00:00">14h</option>
<option value="15:00:00">15h</option>
<option value="16:00:00">16h</option>
<option value="17:00:00">17h</option>
<option value="18:00:00">18h</option>
<option value="19:00:00">19h</option>
<option value="20:00:00">20h</option>
<option value="21:00:00">21h</option>
<option value="22:00:00">22h</option>
<option value="23:00:00">23h</option>
<option value="23:59:59">24h</option>
</select>
</div>
</div>
<div class="col-xs-12 col-sm-3">
<div class="float-end">
<div class="form-check input-group">
<span class="input-group-text">
<input
id="showHideWE"
class="mt-0"
type="checkbox"
v-model="showWeekends"
/>
</span>
<label
for="showHideWE"
class="form-check-label input-group-text"
>Week-ends</label
>
</div>
</div>
</div>
</div>
<div
class="display-options row justify-content-between"
style="margin-top: 1rem"
>
<div class="col-sm-9 col-xs-12">
<div class="input-group mb-3">
<label class="input-group-text" for="slotDuration"
>Durée des créneaux</label
>
<select v-model="slotDuration" id="slotDuration" class="form-select">
<option value="00:05:00">5 minutes</option>
<option value="00:10:00">10 minutes</option>
<option value="00:15:00">15 minutes</option>
<option value="00:30:00">30 minutes</option>
<option value="00:45:00">45 minutes</option>
<option value="00:60:00">60 minutes</option>
</select>
<label class="input-group-text" for="slotMinTime">De</label>
<select v-model="slotMinTime" id="slotMinTime" class="form-select">
<option value="00:00:00">0h</option>
<option value="01:00:00">1h</option>
<option value="02:00:00">2h</option>
<option value="03:00:00">3h</option>
<option value="04:00:00">4h</option>
<option value="05:00:00">5h</option>
<option value="06:00:00">6h</option>
<option value="07:00:00">7h</option>
<option value="08:00:00">8h</option>
<option value="09:00:00">9h</option>
<option value="10:00:00">10h</option>
<option value="11:00:00">11h</option>
<option value="12:00:00">12h</option>
</select>
<label class="input-group-text" for="slotMaxTime">À</label>
<select v-model="slotMaxTime" id="slotMaxTime" class="form-select">
<option value="12:00:00">12h</option>
<option value="13:00:00">13h</option>
<option value="14:00:00">14h</option>
<option value="15:00:00">15h</option>
<option value="16:00:00">16h</option>
<option value="17:00:00">17h</option>
<option value="18:00:00">18h</option>
<option value="19:00:00">19h</option>
<option value="20:00:00">20h</option>
<option value="21:00:00">21h</option>
<option value="22:00:00">22h</option>
<option value="23:00:00">23h</option>
<option value="23:59:59">24h</option>
</select>
</div>
</div>
<FullCalendar :options="calendarOptions" ref="calendarRef">
<template v-slot:eventContent="{ event }: { event: EventApi }">
<span :class="eventClasses">
<b v-if="event.extendedProps.is === 'remote'">{{
event.title
}}</b>
<b v-else-if="event.extendedProps.is === 'range'"
>{{ formatDate(event.startStr, "time") }} -
{{ formatDate(event.endStr, "time") }}:
{{ event.extendedProps.locationName }}</b
>
<b v-else-if="event.extendedProps.is === 'local'">{{
event.title
}}</b>
<b v-else>no 'is'</b>
<a
v-if="event.extendedProps.is === 'range'"
class="fa fa-fw fa-times delete"
@click.prevent="onClickDelete(event)"
>
</a>
</span>
<div class="col-xs-12 col-sm-3">
<div class="float-end">
<div class="form-check input-group">
<span class="input-group-text">
<input
id="showHideWE"
class="mt-0"
type="checkbox"
v-model="showWeekends"
/>
</span>
<label for="showHideWE" class="form-check-label input-group-text"
>Week-ends</label
>
</div>
</div>
</div>
</div>
<FullCalendar :options="calendarOptions" ref="calendarRef">
<template v-slot:eventContent="{ event }: { event: EventApi }">
<span :class="eventClasses">
<b v-if="event.extendedProps.is === 'remote'">{{ event.title }}</b>
<b v-else-if="event.extendedProps.is === 'range'"
>{{ formatDate(event.startStr, "time") }} -
{{ formatDate(event.endStr, "time") }}:
{{ event.extendedProps.locationName }}</b
>
<a
:href="calendarLink(event.id)"
v-else-if="event.extendedProps.is === 'local'"
>
<b>{{ event.title }}</b>
</a>
<b v-else>no 'is'</b>
<a
v-if="event.extendedProps.is === 'range'"
class="fa fa-fw fa-times delete"
@click.prevent="onClickDelete(event)"
>
</a>
</span>
</template>
</FullCalendar>
<div id="copy-widget">
<div class="container mt-2 mb-2">
<div class="row justify-content-between align-items-center mb-4">
<div class="col-xs-12 col-sm-3 col-md-2">
<h6 class="chill-red">{{ $t("copy_range_from_to") }}</h6>
</div>
<div class="col-xs-12 col-sm-9 col-md-2">
<select v-model="dayOrWeek" id="dayOrWeek" class="form-select">
<option value="day">{{ $t("from_day_to_day") }}</option>
<option value="week">
{{ $t("from_week_to_week") }}
</option>
</select>
</div>
<template v-if="dayOrWeek === 'day'">
<div class="col-xs-12 col-sm-3 col-md-3">
<input class="form-control" type="date" v-model="copyFrom" />
</div>
<div class="col-xs-12 col-sm-1 col-md-1 copy-chevron">
<i class="fa fa-angle-double-right"></i>
</div>
<div class="col-xs-12 col-sm-3 col-md-3">
<input class="form-control" type="date" v-model="copyTo" />
</div>
<div class="col-xs-12 col-sm-5 col-md-1">
<button class="btn btn-action float-end" @click="copyDay">
{{ $t("copy_range") }}
</button>
</div>
</template>
</FullCalendar>
<div id="copy-widget">
<div class="container mt-2 mb-2">
<div class="row justify-content-between align-items-center mb-4">
<div class="col-xs-12 col-sm-3 col-md-2">
<h6 class="chill-red">{{ $t("copy_range_from_to") }}</h6>
</div>
<div class="col-xs-12 col-sm-9 col-md-2">
<select
v-model="dayOrWeek"
id="dayOrWeek"
class="form-select"
>
<option value="day">{{ $t("from_day_to_day") }}</option>
<option value="week">
{{ $t("from_week_to_week") }}
</option>
</select>
</div>
<template v-if="dayOrWeek === 'day'">
<div class="col-xs-12 col-sm-3 col-md-3">
<input
class="form-control"
type="date"
v-model="copyFrom"
/>
</div>
<div class="col-xs-12 col-sm-1 col-md-1 copy-chevron">
<i class="fa fa-angle-double-right"></i>
</div>
<div class="col-xs-12 col-sm-3 col-md-3">
<input
class="form-control"
type="date"
v-model="copyTo"
/>
</div>
<div class="col-xs-12 col-sm-5 col-md-1">
<button
class="btn btn-action float-end"
@click="copyDay"
>
{{ $t("copy_range") }}
</button>
</div>
</template>
<template v-else>
<div class="col-xs-12 col-sm-3 col-md-3">
<select
v-model="copyFromWeek"
id="copyFromWeek"
class="form-select"
>
<option
v-for="w in lastWeeks"
:value="w.value"
:key="w.value"
>
{{ w.text }}
</option>
</select>
</div>
<div class="col-xs-12 col-sm-1 col-md-1 copy-chevron">
<i class="fa fa-angle-double-right"></i>
</div>
<div class="col-xs-12 col-sm-3 col-md-3">
<select
v-model="copyToWeek"
id="copyToWeek"
class="form-select"
>
<option
v-for="w in nextWeeks"
:value="w.value"
:key="w.value"
>
{{ w.text }}
</option>
</select>
</div>
<div class="col-xs-12 col-sm-5 col-md-1">
<button
class="btn btn-action float-end"
@click="copyWeek"
>
{{ $t("copy_range") }}
</button>
</div>
</template>
</div>
</div>
<template v-else>
<div class="col-xs-12 col-sm-3 col-md-3">
<select
v-model="copyFromWeek"
id="copyFromWeek"
class="form-select"
>
<option v-for="w in lastWeeks" :value="w.value" :key="w.value">
{{ w.text }}
</option>
</select>
</div>
<div class="col-xs-12 col-sm-1 col-md-1 copy-chevron">
<i class="fa fa-angle-double-right"></i>
</div>
<div class="col-xs-12 col-sm-3 col-md-3">
<select v-model="copyToWeek" id="copyToWeek" class="form-select">
<option v-for="w in nextWeeks" :value="w.value" :key="w.value">
{{ w.text }}
</option>
</select>
</div>
<div class="col-xs-12 col-sm-5 col-md-1">
<button class="btn btn-action float-end" @click="copyWeek">
{{ $t("copy_range") }}
</button>
</div>
</template>
</div>
</div>
</div>
<!-- not directly seen, but include in a modal -->
<edit-location ref="editLocation"></edit-location>
<!-- not directly seen, but include in a modal -->
<edit-location ref="editLocation"></edit-location>
</template>
<script setup lang="ts">
import type {
CalendarOptions,
DatesSetArg,
EventInput,
CalendarOptions,
DatesSetArg,
EventInput,
} from "@fullcalendar/core";
import { computed, ref, onMounted } from "vue";
import { useStore } from "vuex";
@@ -230,14 +187,14 @@ import { key } from "./store";
import FullCalendar from "@fullcalendar/vue3";
import frLocale from "@fullcalendar/core/locales/fr";
import interactionPlugin, {
EventResizeDoneArg,
EventResizeDoneArg,
} from "@fullcalendar/interaction";
import timeGridPlugin from "@fullcalendar/timegrid";
import {
EventApi,
DateSelectArg,
EventDropArg,
EventClickArg,
EventApi,
DateSelectArg,
EventDropArg,
EventClickArg,
} from "@fullcalendar/core";
import { dateToISO, ISOToDate } from "ChillMainAssets/chill/js/date";
import VueMultiselect from "vue-multiselect";
@@ -258,113 +215,113 @@ const copyFromWeek = ref<string | null>(null);
const copyToWeek = ref<string | null>(null);
interface Weeks {
value: string | null;
text: string;
value: string | null;
text: string;
}
const getMonday = (week: number): Date => {
const lastMonday = new Date();
lastMonday.setDate(
lastMonday.getDate() - ((lastMonday.getDay() + 6) % 7) + week * 7,
);
return lastMonday;
const lastMonday = new Date();
lastMonday.setDate(
lastMonday.getDate() - ((lastMonday.getDay() + 6) % 7) + week * 7,
);
return lastMonday;
};
const dateOptions: Intl.DateTimeFormatOptions = {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
};
const lastWeeks = computed((): Weeks[] =>
Array.from(Array(30).keys()).map((w) => {
const lastMonday = getMonday(15 - w);
return {
value: dateToISO(lastMonday),
text: `Semaine du ${lastMonday.toLocaleDateString("fr-FR", dateOptions)}`,
};
}),
Array.from(Array(30).keys()).map((w) => {
const lastMonday = getMonday(15 - w);
return {
value: dateToISO(lastMonday),
text: `Semaine du ${lastMonday.toLocaleDateString("fr-FR", dateOptions)}`,
};
}),
);
const nextWeeks = computed((): Weeks[] =>
Array.from(Array(52).keys()).map((w) => {
const nextMonday = getMonday(w + 1);
return {
value: dateToISO(nextMonday),
text: `Semaine du ${nextMonday.toLocaleDateString("fr-FR", dateOptions)}`,
};
}),
Array.from(Array(52).keys()).map((w) => {
const nextMonday = getMonday(w + 1);
return {
value: dateToISO(nextMonday),
text: `Semaine du ${nextMonday.toLocaleDateString("fr-FR", dateOptions)}`,
};
}),
);
const formatDate = (datetime: string, format: null | "time" = null) => {
const date = ISOToDate(datetime);
if (!date) return "";
const date = ISOToDate(datetime);
if (!date) return "";
if (format === "time") {
return date.toLocaleTimeString("fr-FR", {
hour: "2-digit",
minute: "2-digit",
});
}
// French date formatting
return date.toLocaleDateString("fr-FR", {
weekday: "short",
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
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>({
locale: frLocale,
plugins: [interactionPlugin, timeGridPlugin],
initialView: "timeGridWeek",
initialDate: new Date(),
scrollTimeReset: false,
selectable: true,
// when the dates are changes in the fullcalendar view OR when new events are added
datesSet: onDatesSet,
// when a date is selected
select: onDateSelect,
// when a event is resized
eventResize: onEventDropOrResize,
// when an event is moved
eventDrop: onEventDropOrResize,
// when an event si clicked
eventClick: onEventClick,
selectMirror: false,
editable: true,
headerToolbar: {
left: "prev,next today",
center: "title",
right: "timeGridWeek,timeGridDay",
},
locale: frLocale,
plugins: [interactionPlugin, timeGridPlugin],
initialView: "timeGridWeek",
initialDate: new Date(),
scrollTimeReset: false,
selectable: true,
// when the dates are changes in the fullcalendar view OR when new events are added
datesSet: onDatesSet,
// when a date is selected
select: onDateSelect,
// when a event is resized
eventResize: onEventDropOrResize,
// when an event is moved
eventDrop: onEventDropOrResize,
// when an event si clicked
eventClick: onEventClick,
selectMirror: false,
editable: true,
headerToolbar: {
left: "prev,next today",
center: "title",
right: "timeGridWeek,timeGridDay",
},
});
const ranges = computed<EventInput[]>(() => {
return store.state.calendarRanges.ranges;
return store.state.calendarRanges.ranges;
});
const locations = computed<Location[]>(() => {
return store.state.locations.locations;
return store.state.locations.locations;
});
const pickedLocation = computed<Location | null>({
get(): Location | null {
return (
store.state.locations.locationPicked ||
store.state.locations.currentLocation
);
},
set(newLocation: Location | null): void {
store.commit("locations/setLocationPicked", newLocation, {
root: true,
});
},
get(): Location | null {
return (
store.state.locations.locationPicked ||
store.state.locations.currentLocation
);
},
set(newLocation: Location | null): void {
store.commit("locations/setLocationPicked", newLocation, {
root: true,
});
},
});
/**
@@ -393,116 +350,122 @@ const sources = computed<EventSourceInput[]>(() => {
*/
const calendarOptions = computed((): CalendarOptions => {
return {
...baseOptions.value,
weekends: showWeekends.value,
slotDuration: slotDuration.value,
events: ranges.value,
slotMinTime: slotMinTime.value,
slotMaxTime: slotMaxTime.value,
};
return {
...baseOptions.value,
weekends: showWeekends.value,
slotDuration: slotDuration.value,
events: ranges.value,
slotMinTime: slotMinTime.value,
slotMaxTime: slotMaxTime.value,
};
});
/**
* launched when the calendar range date change
*/
function onDatesSet(event: DatesSetArg): void {
store.dispatch("fullCalendar/setCurrentDatesView", {
start: event.start,
end: event.end,
});
store.dispatch("fullCalendar/setCurrentDatesView", {
start: event.start,
end: event.end,
});
}
function onDateSelect(event: DateSelectArg): void {
if (null === pickedLocation.value) {
window.alert(
"Indiquez une localisation avant de créer une période de disponibilité.",
);
return;
}
if (null === pickedLocation.value) {
window.alert(
"Indiquez une localisation avant de créer une période de disponibilité.",
);
return;
}
store.dispatch("calendarRanges/createRange", {
start: event.start,
end: event.end,
location: pickedLocation.value,
});
store.dispatch("calendarRanges/createRange", {
start: event.start,
end: event.end,
location: pickedLocation.value,
});
}
/**
* When a calendar range is deleted
*/
function onClickDelete(event: EventApi): void {
if (event.extendedProps.is !== "range") {
return;
}
if (event.extendedProps.is !== "range") {
return;
}
store.dispatch(
"calendarRanges/deleteRange",
event.extendedProps.calendarRangeId,
);
store.dispatch(
"calendarRanges/deleteRange",
event.extendedProps.calendarRangeId,
);
}
function onEventDropOrResize(payload: EventDropArg | EventResizeDoneArg) {
if (payload.event.extendedProps.is !== "range") {
return;
}
if (payload.event.extendedProps.is !== "range") {
return;
}
store.dispatch("calendarRanges/patchRangeTime", {
calendarRangeId: payload.event.extendedProps.calendarRangeId,
start: payload.event.start,
end: payload.event.end,
});
store.dispatch("calendarRanges/patchRangeTime", {
calendarRangeId: payload.event.extendedProps.calendarRangeId,
start: payload.event.start,
end: payload.event.end,
});
}
function onEventClick(payload: EventClickArg): void {
// @ts-ignore TS does not recognize the target. But it does exists.
if (payload.jsEvent.target.classList.contains("delete")) {
return;
}
if (payload.event.extendedProps.is !== "range") {
return;
}
// @ts-ignore TS does not recognize the target. But it does exists.
if (payload.jsEvent.target.classList.contains("delete")) {
return;
}
if (payload.event.extendedProps.is !== "range") {
return;
}
editLocation.value?.startEdit(payload.event);
editLocation.value?.startEdit(payload.event);
}
function copyDay() {
if (null === copyFrom.value || null === copyTo.value) {
return;
}
store.dispatch("calendarRanges/copyFromDayToAnotherDay", {
from: ISOToDate(copyFrom.value),
to: ISOToDate(copyTo.value),
});
if (null === copyFrom.value || null === copyTo.value) {
return;
}
store.dispatch("calendarRanges/copyFromDayToAnotherDay", {
from: ISOToDate(copyFrom.value),
to: ISOToDate(copyTo.value),
});
}
function copyWeek() {
if (null === copyFromWeek.value || null === copyToWeek.value) {
return;
}
store.dispatch("calendarRanges/copyFromWeekToAnotherWeek", {
fromMonday: ISOToDate(copyFromWeek.value),
toMonday: ISOToDate(copyToWeek.value),
});
if (null === copyFromWeek.value || null === copyToWeek.value) {
return;
}
store.dispatch("calendarRanges/copyFromWeekToAnotherWeek", {
fromMonday: ISOToDate(copyFromWeek.value),
toMonday: ISOToDate(copyToWeek.value),
});
}
const calendarLink = (calendarId: string) => {
const idStr = calendarId.match(/_(\d+)$/)?.[1];
return `/fr/calendar/calendar/${idStr}/edit`;
};
onMounted(() => {
copyFromWeek.value = dateToISO(getMonday(0));
copyToWeek.value = dateToISO(getMonday(1));
copyFromWeek.value = dateToISO(getMonday(0));
copyToWeek.value = dateToISO(getMonday(1));
});
</script>
<style scoped>
#copy-widget {
position: sticky;
bottom: 0px;
background-color: white;
z-index: 9999999999;
padding: 0.25rem 0 0.25rem;
position: sticky;
bottom: 0px;
background-color: white;
z-index: 9999999999;
padding: 0.25rem 0 0.25rem;
}
div.copy-chevron {
text-align: center;
font-size: x-large;
width: 2rem;
text-align: center;
font-size: x-large;
width: 2rem;
}
</style>

View File

@@ -1,28 +1,28 @@
<template>
<component :is="Teleport" to="body">
<modal v-if="showModal" @close="closeModal">
<template v-slot:header>
<h3>{{ "Modifier le lieu" }}</h3>
</template>
<component :is="Teleport" to="body">
<modal v-if="showModal" @close="closeModal">
<template v-slot:header>
<h3>{{ "Modifier le lieu" }}</h3>
</template>
<template v-slot:body>
<div></div>
<label>Localisation</label>
<vue-multiselect
v-model="location"
:options="locations"
:label="'name'"
:track-by="'id'"
></vue-multiselect>
</template>
<template v-slot:body>
<div></div>
<label>Localisation</label>
<vue-multiselect
v-model="location"
:options="locations"
:label="'name'"
:track-by="'id'"
></vue-multiselect>
</template>
<template v-slot:footer>
<button class="btn btn-save" @click="saveAndClose">
{{ "Enregistrer" }}
</button>
</template>
</modal>
</component>
<template v-slot:footer>
<button class="btn btn-save" @click="saveAndClose">
{{ "Enregistrer" }}
</button>
</template>
</modal>
</component>
</template>
<script setup lang="ts">
@@ -39,7 +39,7 @@ import VueMultiselect from "vue-multiselect";
import { Teleport as teleport_, TeleportProps, VNodeProps } from "vue";
const Teleport = teleport_ as new () => {
$props: VNodeProps & TeleportProps;
$props: VNodeProps & TeleportProps;
};
const store = useStore(key);
@@ -50,37 +50,37 @@ const showModal = ref(false);
//const tele = ref<InstanceType<typeof Teleport> | null>(null);
const locations = computed<Location[]>(() => {
return store.state.locations.locations;
return store.state.locations.locations;
});
const startEdit = function (event: EventApi): void {
console.log("startEditing", event);
calendarRangeId.value = event.extendedProps.calendarRangeId;
location.value =
store.getters["locations/getLocationById"](
event.extendedProps.locationId,
) || null;
console.log("startEditing", event);
calendarRangeId.value = event.extendedProps.calendarRangeId;
location.value =
store.getters["locations/getLocationById"](
event.extendedProps.locationId,
) || null;
console.log("new location value", location.value);
console.log("calendar range id", calendarRangeId.value);
showModal.value = true;
console.log("new location value", location.value);
console.log("calendar range id", calendarRangeId.value);
showModal.value = true;
};
const saveAndClose = function (e: Event): void {
console.log("saveEditAndClose", e);
console.log("saveEditAndClose", e);
store
.dispatch("calendarRanges/patchRangeLocation", {
location: location.value,
calendarRangeId: calendarRangeId.value,
})
.then((_) => {
showModal.value = false;
});
store
.dispatch("calendarRanges/patchRangeLocation", {
location: location.value,
calendarRangeId: calendarRangeId.value,
})
.then((_) => {
showModal.value = false;
});
};
const closeModal = function (_: any): void {
showModal.value = false;
showModal.value = false;
};
defineExpose({ startEdit });

View File

@@ -1,27 +1,27 @@
const appMessages = {
fr: {
created_availabilities: "Lieu des plages de disponibilités créées",
edit_your_calendar_range: "Planifiez vos plages de disponibilités",
show_my_calendar: "Afficher mon calendrier",
show_weekends: "Afficher les week-ends",
copy_range: "Copier",
copy_range_from_to: "Copier les plages",
from_day_to_day: "d'un jour à l'autre",
from_week_to_week: "d'une semaine à l'autre",
copy_range_how_to:
"Créez les plages de disponibilités durant une journée et copiez-les facilement au jour suivant avec ce bouton. Si les week-ends sont cachés, le jour suivant un vendredi sera le lundi.",
new_range_to_save: "Nouvelles plages à enregistrer",
update_range_to_save: "Plages à modifier",
delete_range_to_save: "Plages à supprimer",
by: "Par",
main_user_concerned: "Utilisateur concerné",
dateFrom: "De",
dateTo: "à",
day: "Jour",
week: "Semaine",
month: "Mois",
today: "Aujourd'hui",
},
fr: {
created_availabilities: "Lieu des plages de disponibilités créées",
edit_your_calendar_range: "Planifiez vos plages de disponibilités",
show_my_calendar: "Afficher mon calendrier",
show_weekends: "Afficher les week-ends",
copy_range: "Copier",
copy_range_from_to: "Copier les plages",
from_day_to_day: "d'un jour à l'autre",
from_week_to_week: "d'une semaine à l'autre",
copy_range_how_to:
"Créez les plages de disponibilités durant une journée et copiez-les facilement au jour suivant avec ce bouton. Si les week-ends sont cachés, le jour suivant un vendredi sera le lundi.",
new_range_to_save: "Nouvelles plages à enregistrer",
update_range_to_save: "Plages à modifier",
delete_range_to_save: "Plages à supprimer",
by: "Par",
main_user_concerned: "Utilisateur concerné",
dateFrom: "De",
dateTo: "à",
day: "Jour",
week: "Semaine",
month: "Mois",
today: "Aujourd'hui",
},
};
export { appMessages };

View File

@@ -7,13 +7,13 @@ import App2 from "./App2.vue";
import { useI18n } from "vue-i18n";
futureStore().then((store) => {
const i18n = _createI18n(appMessages, false);
const i18n = _createI18n(appMessages, false);
const app = createApp({
template: `<app></app>`,
})
.use(store, key)
.use(i18n)
.component("app", App2)
.mount("#myCalendar");
const app = createApp({
template: `<app></app>`,
})
.use(store, key)
.use(i18n)
.component("app", App2)
.mount("#myCalendar");
});

View File

@@ -5,7 +5,7 @@ import me, { MeState } from "./modules/me";
import fullCalendar, { FullCalendarState } from "./modules/fullcalendar";
import calendarRanges, { CalendarRangesState } from "./modules/calendarRanges";
import calendarRemotes, {
CalendarRemotesState,
CalendarRemotesState,
} from "./modules/calendarRemotes";
import { whoami } from "../../../../../../ChillMainBundle/Resources/public/lib/api/user";
import { User } from "../../../../../../ChillMainBundle/Resources/public/types";
@@ -15,42 +15,40 @@ import calendarLocals, { CalendarLocalsState } from "./modules/calendarLocals";
const debug = process.env.NODE_ENV !== "production";
export interface State {
calendarRanges: CalendarRangesState;
calendarRemotes: CalendarRemotesState;
calendarLocals: CalendarLocalsState;
fullCalendar: FullCalendarState;
me: MeState;
locations: LocationState;
calendarRanges: CalendarRangesState;
calendarRemotes: CalendarRemotesState;
calendarLocals: CalendarLocalsState;
fullCalendar: FullCalendarState;
me: MeState;
locations: LocationState;
}
export const key: InjectionKey<Store<State>> = Symbol();
const futureStore = function (): Promise<Store<State>> {
return whoami().then((user: User) => {
const store = createStore<State>({
strict: debug,
modules: {
me,
fullCalendar,
calendarRanges,
calendarRemotes,
calendarLocals,
locations,
},
mutations: {},
});
store.commit("me/setWhoAmi", user, { root: true });
store
.dispatch("locations/getLocations", null, { root: true })
.then((_) => {
return store.dispatch("locations/getCurrentLocation", null, {
root: true,
});
});
return Promise.resolve(store);
return whoami().then((user: User) => {
const store = createStore<State>({
strict: debug,
modules: {
me,
fullCalendar,
calendarRanges,
calendarRemotes,
calendarLocals,
locations,
},
mutations: {},
});
store.commit("me/setWhoAmi", user, { root: true });
store.dispatch("locations/getLocations", null, { root: true }).then((_) => {
return store.dispatch("locations/getCurrentLocation", null, {
root: true,
});
});
return Promise.resolve(store);
});
};
export default futureStore;

View File

@@ -8,109 +8,99 @@ import { TransportExceptionInterface } from "../../../../../../../ChillMainBundl
import { COLORS } from "../../../Calendar/const";
export interface CalendarLocalsState {
locals: EventInput[];
localsLoaded: { start: number; end: number }[];
localsIndex: Set<string>;
key: number;
locals: EventInput[];
localsLoaded: { start: number; end: number }[];
localsIndex: Set<string>;
key: number;
}
type Context = ActionContext<CalendarLocalsState, State>;
export default {
namespaced: true,
state: (): CalendarLocalsState => ({
locals: [],
localsLoaded: [],
localsIndex: new Set<string>(),
key: 0,
}),
getters: {
isLocalsLoaded:
(state: CalendarLocalsState) =>
({ start, end }: { start: Date; end: Date }): boolean => {
for (const range of state.localsLoaded) {
if (
start.getTime() === range.start &&
end.getTime() === range.end
) {
return true;
}
}
namespaced: true,
state: (): CalendarLocalsState => ({
locals: [],
localsLoaded: [],
localsIndex: new Set<string>(),
key: 0,
}),
getters: {
isLocalsLoaded:
(state: CalendarLocalsState) =>
({ start, end }: { start: Date; end: Date }): boolean => {
for (const range of state.localsLoaded) {
if (start.getTime() === range.start && end.getTime() === range.end) {
return true;
}
}
return false;
},
return false;
},
},
mutations: {
addLocals(state: CalendarLocalsState, ranges: CalendarLight[]) {
console.log("addLocals", ranges);
const toAdd = ranges
.map((cr) => localsToFullCalendarEvent(cr))
.filter((r) => !state.localsIndex.has(r.id));
toAdd.forEach((r) => {
state.localsIndex.add(r.id);
state.locals.push(r);
});
state.key = state.key + toAdd.length;
},
mutations: {
addLocals(state: CalendarLocalsState, ranges: CalendarLight[]) {
console.log("addLocals", ranges);
const toAdd = ranges
.map((cr) => localsToFullCalendarEvent(cr))
.filter((r) => !state.localsIndex.has(r.id));
toAdd.forEach((r) => {
state.localsIndex.add(r.id);
state.locals.push(r);
});
state.key = state.key + toAdd.length;
},
addLoaded(
state: CalendarLocalsState,
payload: { start: Date; end: Date },
) {
state.localsLoaded.push({
start: payload.start.getTime(),
end: payload.end.getTime(),
});
},
addLoaded(state: CalendarLocalsState, payload: { start: Date; end: Date }) {
state.localsLoaded.push({
start: payload.start.getTime(),
end: payload.end.getTime(),
});
},
actions: {
fetchLocals(
ctx: Context,
payload: { start: Date; end: Date },
): Promise<null> {
const start = payload.start;
const end = payload.end;
},
actions: {
fetchLocals(
ctx: Context,
payload: { start: Date; end: Date },
): Promise<null> {
const start = payload.start;
const end = payload.end;
if (ctx.rootGetters["me/getMe"] === null) {
return Promise.resolve(null);
}
if (ctx.rootGetters["me/getMe"] === null) {
return Promise.resolve(null);
}
if (ctx.getters.isLocalsLoaded({ start, end })) {
return Promise.resolve(ctx.getters.getRangeSource);
}
if (ctx.getters.isLocalsLoaded({ start, end })) {
return Promise.resolve(ctx.getters.getRangeSource);
}
ctx.commit("addLoaded", {
start: start,
end: end,
});
ctx.commit("addLoaded", {
start: start,
end: end,
});
return fetchCalendarLocalForUser(
ctx.rootGetters["me/getMe"],
start,
end,
)
.then((remotes: CalendarLight[]) => {
// to be add when reactivity problem will be solve ?
//ctx.commit('addRemotes', remotes);
const inputs = remotes
.map((cr) => localsToFullCalendarEvent(cr))
.map((cr) => ({
...cr,
backgroundColor: COLORS[0],
textColor: "black",
editable: false,
}));
ctx.commit("calendarRanges/addExternals", inputs, {
root: true,
});
return Promise.resolve(null);
})
.catch((e: TransportExceptionInterface) => {
console.error(e);
return fetchCalendarLocalForUser(ctx.rootGetters["me/getMe"], start, end)
.then((remotes: CalendarLight[]) => {
// to be add when reactivity problem will be solve ?
//ctx.commit('addRemotes', remotes);
const inputs = remotes
.map((cr) => localsToFullCalendarEvent(cr))
.map((cr) => ({
...cr,
backgroundColor: COLORS[0],
textColor: "black",
editable: false,
}));
ctx.commit("calendarRanges/addExternals", inputs, {
root: true,
});
return Promise.resolve(null);
})
.catch((e: TransportExceptionInterface) => {
console.error(e);
return Promise.resolve(null);
});
},
return Promise.resolve(null);
});
},
},
} as Module<CalendarLocalsState, State>;

View File

@@ -1,10 +1,10 @@
import { State } from "./../index";
import { ActionContext, Module } from "vuex";
import {
CalendarRange,
CalendarRangeCreate,
CalendarRangeEdit,
isEventInputCalendarRange,
CalendarRange,
CalendarRangeCreate,
CalendarRangeEdit,
isEventInputCalendarRange,
} from "../../../../types";
import { Location } from "../../../../../../../ChillMainBundle/Resources/public/types";
import { fetchCalendarRangeForUser } from "../../../Calendar/api";
@@ -12,369 +12,332 @@ import { calendarRangeToFullCalendarEvent } from "../../../Calendar/store/utils"
import { EventInput } from "@fullcalendar/core";
import { makeFetch } from "../../../../../../../ChillMainBundle/Resources/public/lib/api/apiMethods";
import {
datetimeToISO,
dateToISO,
ISOToDatetime,
datetimeToISO,
dateToISO,
ISOToDatetime,
} from "../../../../../../../ChillMainBundle/Resources/public/chill/js/date";
import type { EventInputCalendarRange } from "../../../../types";
export interface CalendarRangesState {
ranges: (EventInput | EventInputCalendarRange)[];
rangesLoaded: { start: number; end: number }[];
rangesIndex: Set<string>;
key: number;
ranges: (EventInput | EventInputCalendarRange)[];
rangesLoaded: { start: number; end: number }[];
rangesIndex: Set<string>;
key: number;
}
type Context = ActionContext<CalendarRangesState, State>;
export default {
namespaced: true,
state: (): CalendarRangesState => ({
ranges: [],
rangesLoaded: [],
rangesIndex: new Set<string>(),
key: 0,
}),
getters: {
isRangeLoaded:
(state: CalendarRangesState) =>
({ start, end }: { start: Date; end: Date }): boolean => {
for (const range of state.rangesLoaded) {
if (
start.getTime() === range.start &&
end.getTime() === range.end
) {
return true;
}
}
namespaced: true,
state: (): CalendarRangesState => ({
ranges: [],
rangesLoaded: [],
rangesIndex: new Set<string>(),
key: 0,
}),
getters: {
isRangeLoaded:
(state: CalendarRangesState) =>
({ start, end }: { start: Date; end: Date }): boolean => {
for (const range of state.rangesLoaded) {
if (start.getTime() === range.start && end.getTime() === range.end) {
return true;
}
}
return false;
},
getRangesOnDate:
(state: CalendarRangesState) =>
(date: Date): EventInputCalendarRange[] => {
const founds = [];
const dateStr = dateToISO(date) as string;
return false;
},
getRangesOnDate:
(state: CalendarRangesState) =>
(date: Date): EventInputCalendarRange[] => {
const founds = [];
const dateStr = dateToISO(date) as string;
for (const range of state.ranges) {
if (
isEventInputCalendarRange(range) &&
range.start.startsWith(dateStr)
) {
founds.push(range);
}
}
for (const range of state.ranges) {
if (
isEventInputCalendarRange(range) &&
range.start.startsWith(dateStr)
) {
founds.push(range);
}
}
return founds;
},
getRangesOnWeek:
(state: CalendarRangesState) =>
(mondayDate: Date): EventInputCalendarRange[] => {
const founds = [];
for (const d of Array.from(Array(7).keys())) {
const dateOfWeek = new Date(mondayDate);
dateOfWeek.setDate(mondayDate.getDate() + d);
const dateStr = dateToISO(dateOfWeek) as string;
for (const range of state.ranges) {
if (
isEventInputCalendarRange(range) &&
range.start.startsWith(dateStr)
) {
founds.push(range);
}
}
}
return founds;
},
getRangesOnWeek:
(state: CalendarRangesState) =>
(mondayDate: Date): EventInputCalendarRange[] => {
const founds = [];
for (const d of Array.from(Array(7).keys())) {
const dateOfWeek = new Date(mondayDate);
dateOfWeek.setDate(mondayDate.getDate() + d);
const dateStr = dateToISO(dateOfWeek) as string;
for (const range of state.ranges) {
if (
isEventInputCalendarRange(range) &&
range.start.startsWith(dateStr)
) {
founds.push(range);
}
}
}
return founds;
},
return founds;
},
},
mutations: {
addRanges(state: CalendarRangesState, ranges: CalendarRange[]) {
const toAdd = ranges
.map((cr) => calendarRangeToFullCalendarEvent(cr))
.map((cr) => ({
...cr,
backgroundColor: "white",
borderColor: "#3788d8",
textColor: "black",
}))
.filter((r) => !state.rangesIndex.has(r.id));
toAdd.forEach((r) => {
state.rangesIndex.add(r.id);
state.ranges.push(r);
});
state.key = state.key + toAdd.length;
},
mutations: {
addRanges(state: CalendarRangesState, ranges: CalendarRange[]) {
const toAdd = ranges
.map((cr) => calendarRangeToFullCalendarEvent(cr))
.map((cr) => ({
...cr,
backgroundColor: "white",
borderColor: "#3788d8",
textColor: "black",
}))
.filter((r) => !state.rangesIndex.has(r.id));
addExternals(
state: CalendarRangesState,
externalEvents: (EventInput & { id: string })[],
) {
const toAdd = externalEvents.filter((r) => !state.rangesIndex.has(r.id));
toAdd.forEach((r) => {
state.rangesIndex.add(r.id);
state.ranges.push(r);
});
state.key = state.key + toAdd.length;
},
addExternals(
state: CalendarRangesState,
externalEvents: (EventInput & { id: string })[],
) {
const toAdd = externalEvents.filter(
(r) => !state.rangesIndex.has(r.id),
);
toAdd.forEach((r) => {
state.rangesIndex.add(r.id);
state.ranges.push(r);
});
state.key = state.key + toAdd.length;
},
addLoaded(
state: CalendarRangesState,
payload: { start: Date; end: Date },
) {
state.rangesLoaded.push({
start: payload.start.getTime(),
end: payload.end.getTime(),
});
},
addRange(state: CalendarRangesState, payload: CalendarRange) {
const asEvent = calendarRangeToFullCalendarEvent(payload);
state.ranges.push({
...asEvent,
backgroundColor: "white",
borderColor: "#3788d8",
textColor: "black",
});
state.rangesIndex.add(asEvent.id);
state.key = state.key + 1;
},
removeRange(state: CalendarRangesState, calendarRangeId: number) {
const found = state.ranges.find(
(r) =>
r.calendarRangeId === calendarRangeId && r.is === "range",
);
if (found !== undefined) {
state.ranges = state.ranges.filter(
(r) =>
!(
r.calendarRangeId === calendarRangeId &&
r.is === "range"
),
);
if (typeof found.id === "string") {
// should always be true
state.rangesIndex.delete(found.id);
}
state.key = state.key + 1;
}
},
updateRange(state: CalendarRangesState, range: CalendarRange) {
const found = state.ranges.find(
(r) => r.calendarRangeId === range.id && r.is === "range",
);
const newEvent = calendarRangeToFullCalendarEvent(range);
if (found !== undefined) {
found.start = newEvent.start;
found.end = newEvent.end;
found.locationId = range.location.id;
found.locationName = range.location.name;
}
state.key = state.key + 1;
},
toAdd.forEach((r) => {
state.rangesIndex.add(r.id);
state.ranges.push(r);
});
state.key = state.key + toAdd.length;
},
actions: {
fetchRanges(
ctx: Context,
payload: { start: Date; end: Date },
): Promise<null> {
const start = payload.start;
const end = payload.end;
if (ctx.rootGetters["me/getMe"] === null) {
return Promise.resolve(ctx.getters.getRangeSource);
}
if (ctx.getters.isRangeLoaded({ start, end })) {
return Promise.resolve(ctx.getters.getRangeSource);
}
ctx.commit("addLoaded", {
start: start,
end: end,
});
return fetchCalendarRangeForUser(
ctx.rootGetters["me/getMe"],
start,
end,
).then((ranges: CalendarRange[]) => {
ctx.commit("addRanges", ranges);
return Promise.resolve(null);
});
},
createRange(
ctx: Context,
{
start,
end,
location,
}: { start: Date; end: Date; location: Location },
): Promise<null> {
const url = `/api/1.0/calendar/calendar-range.json?`;
if (ctx.rootState.me.me === null) {
throw new Error("user is currently null");
}
const body = {
user: {
id: ctx.rootState.me.me.id,
type: "user",
},
startDate: {
datetime: datetimeToISO(start),
},
endDate: {
datetime: datetimeToISO(end),
},
location: {
id: location.id,
type: "location",
},
} as CalendarRangeCreate;
return makeFetch<CalendarRangeCreate, CalendarRange>(
"POST",
url,
body,
)
.then((newRange) => {
ctx.commit("addRange", newRange);
return Promise.resolve(null);
})
.catch((error) => {
console.error(error);
throw error;
});
},
deleteRange(ctx: Context, calendarRangeId: number) {
const url = `/api/1.0/calendar/calendar-range/${calendarRangeId}.json`;
makeFetch<undefined, never>("DELETE", url).then(() => {
ctx.commit("removeRange", calendarRangeId);
});
},
patchRangeTime(
ctx,
{
calendarRangeId,
start,
end,
}: { calendarRangeId: number; start: Date; end: Date },
): Promise<null> {
const url = `/api/1.0/calendar/calendar-range/${calendarRangeId}.json`;
const body = {
startDate: {
datetime: datetimeToISO(start),
},
endDate: {
datetime: datetimeToISO(end),
},
} as CalendarRangeEdit;
return makeFetch<CalendarRangeEdit, CalendarRange>(
"PATCH",
url,
body,
)
.then((range) => {
ctx.commit("updateRange", range);
return Promise.resolve(null);
})
.catch((error) => {
console.error(error);
return Promise.resolve(null);
});
},
patchRangeLocation(
ctx,
{
location,
calendarRangeId,
}: { location: Location; calendarRangeId: number },
): Promise<null> {
const url = `/api/1.0/calendar/calendar-range/${calendarRangeId}.json`;
const body = {
location: {
id: location.id,
type: "location",
},
} as CalendarRangeEdit;
return makeFetch<CalendarRangeEdit, CalendarRange>(
"PATCH",
url,
body,
)
.then((range) => {
ctx.commit("updateRange", range);
return Promise.resolve(null);
})
.catch((error) => {
console.error(error);
return Promise.resolve(null);
});
},
copyFromDayToAnotherDay(
ctx,
{ from, to }: { from: Date; to: Date },
): Promise<null> {
const rangesToCopy: EventInputCalendarRange[] =
ctx.getters["getRangesOnDate"](from);
const promises = [];
for (const r of rangesToCopy) {
const start = new Date(ISOToDatetime(r.start) as Date);
start.setFullYear(
to.getFullYear(),
to.getMonth(),
to.getDate(),
);
const end = new Date(ISOToDatetime(r.end) as Date);
end.setFullYear(to.getFullYear(), to.getMonth(), to.getDate());
const location = ctx.rootGetters["locations/getLocationById"](
r.locationId,
);
promises.push(
ctx.dispatch("createRange", { start, end, location }),
);
}
return Promise.all(promises).then(() => Promise.resolve(null));
},
copyFromWeekToAnotherWeek(
ctx: Context,
{ fromMonday, toMonday }: { fromMonday: Date; toMonday: Date },
): Promise<null> {
const rangesToCopy: EventInputCalendarRange[] =
ctx.getters["getRangesOnWeek"](fromMonday);
const promises = [];
const diffTime = toMonday.getTime() - fromMonday.getTime();
for (const r of rangesToCopy) {
const start = new Date(ISOToDatetime(r.start) as Date);
const end = new Date(ISOToDatetime(r.end) as Date);
start.setTime(start.getTime() + diffTime);
end.setTime(end.getTime() + diffTime);
const location = ctx.rootGetters["locations/getLocationById"](
r.locationId,
);
promises.push(
ctx.dispatch("createRange", { start, end, location }),
);
}
return Promise.all(promises).then(() => Promise.resolve(null));
},
addLoaded(state: CalendarRangesState, payload: { start: Date; end: Date }) {
state.rangesLoaded.push({
start: payload.start.getTime(),
end: payload.end.getTime(),
});
},
addRange(state: CalendarRangesState, payload: CalendarRange) {
const asEvent = calendarRangeToFullCalendarEvent(payload);
state.ranges.push({
...asEvent,
backgroundColor: "white",
borderColor: "#3788d8",
textColor: "black",
});
state.rangesIndex.add(asEvent.id);
state.key = state.key + 1;
},
removeRange(state: CalendarRangesState, calendarRangeId: number) {
const found = state.ranges.find(
(r) => r.calendarRangeId === calendarRangeId && r.is === "range",
);
if (found !== undefined) {
state.ranges = state.ranges.filter(
(r) => !(r.calendarRangeId === calendarRangeId && r.is === "range"),
);
if (typeof found.id === "string") {
// should always be true
state.rangesIndex.delete(found.id);
}
state.key = state.key + 1;
}
},
updateRange(state: CalendarRangesState, range: CalendarRange) {
const found = state.ranges.find(
(r) => r.calendarRangeId === range.id && r.is === "range",
);
const newEvent = calendarRangeToFullCalendarEvent(range);
if (found !== undefined) {
found.start = newEvent.start;
found.end = newEvent.end;
found.locationId = range.location.id;
found.locationName = range.location.name;
}
state.key = state.key + 1;
},
},
actions: {
fetchRanges(
ctx: Context,
payload: { start: Date; end: Date },
): Promise<null> {
const start = payload.start;
const end = payload.end;
if (ctx.rootGetters["me/getMe"] === null) {
return Promise.resolve(ctx.getters.getRangeSource);
}
if (ctx.getters.isRangeLoaded({ start, end })) {
return Promise.resolve(ctx.getters.getRangeSource);
}
ctx.commit("addLoaded", {
start: start,
end: end,
});
return fetchCalendarRangeForUser(
ctx.rootGetters["me/getMe"],
start,
end,
).then((ranges: CalendarRange[]) => {
ctx.commit("addRanges", ranges);
return Promise.resolve(null);
});
},
createRange(
ctx: Context,
{ start, end, location }: { start: Date; end: Date; location: Location },
): Promise<null> {
const url = `/api/1.0/calendar/calendar-range.json?`;
if (ctx.rootState.me.me === null) {
throw new Error("user is currently null");
}
const body = {
user: {
id: ctx.rootState.me.me.id,
type: "user",
},
startDate: {
datetime: datetimeToISO(start),
},
endDate: {
datetime: datetimeToISO(end),
},
location: {
id: location.id,
type: "location",
},
} as CalendarRangeCreate;
return makeFetch<CalendarRangeCreate, CalendarRange>("POST", url, body)
.then((newRange) => {
ctx.commit("addRange", newRange);
return Promise.resolve(null);
})
.catch((error) => {
console.error(error);
throw error;
});
},
deleteRange(ctx: Context, calendarRangeId: number) {
const url = `/api/1.0/calendar/calendar-range/${calendarRangeId}.json`;
makeFetch<undefined, never>("DELETE", url).then(() => {
ctx.commit("removeRange", calendarRangeId);
});
},
patchRangeTime(
ctx,
{
calendarRangeId,
start,
end,
}: { calendarRangeId: number; start: Date; end: Date },
): Promise<null> {
const url = `/api/1.0/calendar/calendar-range/${calendarRangeId}.json`;
const body = {
startDate: {
datetime: datetimeToISO(start),
},
endDate: {
datetime: datetimeToISO(end),
},
} as CalendarRangeEdit;
return makeFetch<CalendarRangeEdit, CalendarRange>("PATCH", url, body)
.then((range) => {
ctx.commit("updateRange", range);
return Promise.resolve(null);
})
.catch((error) => {
console.error(error);
return Promise.resolve(null);
});
},
patchRangeLocation(
ctx,
{
location,
calendarRangeId,
}: { location: Location; calendarRangeId: number },
): Promise<null> {
const url = `/api/1.0/calendar/calendar-range/${calendarRangeId}.json`;
const body = {
location: {
id: location.id,
type: "location",
},
} as CalendarRangeEdit;
return makeFetch<CalendarRangeEdit, CalendarRange>("PATCH", url, body)
.then((range) => {
ctx.commit("updateRange", range);
return Promise.resolve(null);
})
.catch((error) => {
console.error(error);
return Promise.resolve(null);
});
},
copyFromDayToAnotherDay(
ctx,
{ from, to }: { from: Date; to: Date },
): Promise<null> {
const rangesToCopy: EventInputCalendarRange[] =
ctx.getters["getRangesOnDate"](from);
const promises = [];
for (const r of rangesToCopy) {
const start = new Date(ISOToDatetime(r.start) as Date);
start.setFullYear(to.getFullYear(), to.getMonth(), to.getDate());
const end = new Date(ISOToDatetime(r.end) as Date);
end.setFullYear(to.getFullYear(), to.getMonth(), to.getDate());
const location = ctx.rootGetters["locations/getLocationById"](
r.locationId,
);
promises.push(ctx.dispatch("createRange", { start, end, location }));
}
return Promise.all(promises).then(() => Promise.resolve(null));
},
copyFromWeekToAnotherWeek(
ctx: Context,
{ fromMonday, toMonday }: { fromMonday: Date; toMonday: Date },
): Promise<null> {
const rangesToCopy: EventInputCalendarRange[] =
ctx.getters["getRangesOnWeek"](fromMonday);
const promises = [];
const diffTime = toMonday.getTime() - fromMonday.getTime();
for (const r of rangesToCopy) {
const start = new Date(ISOToDatetime(r.start) as Date);
const end = new Date(ISOToDatetime(r.end) as Date);
start.setTime(start.getTime() + diffTime);
end.setTime(end.getTime() + diffTime);
const location = ctx.rootGetters["locations/getLocationById"](
r.locationId,
);
promises.push(ctx.dispatch("createRange", { start, end, location }));
}
return Promise.all(promises).then(() => Promise.resolve(null));
},
},
} as Module<CalendarRangesState, State>;

View File

@@ -8,109 +8,102 @@ import { TransportExceptionInterface } from "../../../../../../../ChillMainBundl
import { COLORS } from "../../../Calendar/const";
export interface CalendarRemotesState {
remotes: EventInput[];
remotesLoaded: { start: number; end: number }[];
remotesIndex: Set<string>;
key: number;
remotes: EventInput[];
remotesLoaded: { start: number; end: number }[];
remotesIndex: Set<string>;
key: number;
}
type Context = ActionContext<CalendarRemotesState, State>;
export default {
namespaced: true,
state: (): CalendarRemotesState => ({
remotes: [],
remotesLoaded: [],
remotesIndex: new Set<string>(),
key: 0,
}),
getters: {
isRemotesLoaded:
(state: CalendarRemotesState) =>
({ start, end }: { start: Date; end: Date }): boolean => {
for (const range of state.remotesLoaded) {
if (
start.getTime() === range.start &&
end.getTime() === range.end
) {
return true;
}
}
namespaced: true,
state: (): CalendarRemotesState => ({
remotes: [],
remotesLoaded: [],
remotesIndex: new Set<string>(),
key: 0,
}),
getters: {
isRemotesLoaded:
(state: CalendarRemotesState) =>
({ start, end }: { start: Date; end: Date }): boolean => {
for (const range of state.remotesLoaded) {
if (start.getTime() === range.start && end.getTime() === range.end) {
return true;
}
}
return false;
},
return false;
},
},
mutations: {
addRemotes(state: CalendarRemotesState, ranges: CalendarRemote[]) {
console.log("addRemotes", ranges);
const toAdd = ranges
.map((cr) => remoteToFullCalendarEvent(cr))
.filter((r) => !state.remotesIndex.has(r.id));
toAdd.forEach((r) => {
state.remotesIndex.add(r.id);
state.remotes.push(r);
});
state.key = state.key + toAdd.length;
},
mutations: {
addRemotes(state: CalendarRemotesState, ranges: CalendarRemote[]) {
console.log("addRemotes", ranges);
const toAdd = ranges
.map((cr) => remoteToFullCalendarEvent(cr))
.filter((r) => !state.remotesIndex.has(r.id));
toAdd.forEach((r) => {
state.remotesIndex.add(r.id);
state.remotes.push(r);
});
state.key = state.key + toAdd.length;
},
addLoaded(
state: CalendarRemotesState,
payload: { start: Date; end: Date },
) {
state.remotesLoaded.push({
start: payload.start.getTime(),
end: payload.end.getTime(),
});
},
addLoaded(
state: CalendarRemotesState,
payload: { start: Date; end: Date },
) {
state.remotesLoaded.push({
start: payload.start.getTime(),
end: payload.end.getTime(),
});
},
actions: {
fetchRemotes(
ctx: Context,
payload: { start: Date; end: Date },
): Promise<null> {
const start = payload.start;
const end = payload.end;
},
actions: {
fetchRemotes(
ctx: Context,
payload: { start: Date; end: Date },
): Promise<null> {
const start = payload.start;
const end = payload.end;
if (ctx.rootGetters["me/getMe"] === null) {
return Promise.resolve(null);
}
if (ctx.rootGetters["me/getMe"] === null) {
return Promise.resolve(null);
}
if (ctx.getters.isRemotesLoaded({ start, end })) {
return Promise.resolve(ctx.getters.getRangeSource);
}
if (ctx.getters.isRemotesLoaded({ start, end })) {
return Promise.resolve(ctx.getters.getRangeSource);
}
ctx.commit("addLoaded", {
start: start,
end: end,
});
ctx.commit("addLoaded", {
start: start,
end: end,
});
return fetchCalendarRemoteForUser(
ctx.rootGetters["me/getMe"],
start,
end,
)
.then((remotes: CalendarRemote[]) => {
// to be add when reactivity problem will be solve ?
//ctx.commit('addRemotes', remotes);
const inputs = remotes
.map((cr) => remoteToFullCalendarEvent(cr))
.map((cr) => ({
...cr,
backgroundColor: COLORS[0],
textColor: "black",
editable: false,
}));
ctx.commit("calendarRanges/addExternals", inputs, {
root: true,
});
return Promise.resolve(null);
})
.catch((e: TransportExceptionInterface) => {
console.error(e);
return fetchCalendarRemoteForUser(ctx.rootGetters["me/getMe"], start, end)
.then((remotes: CalendarRemote[]) => {
// to be add when reactivity problem will be solve ?
//ctx.commit('addRemotes', remotes);
const inputs = remotes
.map((cr) => remoteToFullCalendarEvent(cr))
.map((cr) => ({
...cr,
backgroundColor: COLORS[0],
textColor: "black",
editable: false,
}));
ctx.commit("calendarRanges/addExternals", inputs, {
root: true,
});
return Promise.resolve(null);
})
.catch((e: TransportExceptionInterface) => {
console.error(e);
return Promise.resolve(null);
});
},
return Promise.resolve(null);
});
},
},
} as Module<CalendarRemotesState, State>;

View File

@@ -2,77 +2,77 @@ import { State } from "./../index";
import { ActionContext } from "vuex";
export interface FullCalendarState {
currentView: {
start: Date | null;
end: Date | null;
};
key: number;
currentView: {
start: Date | null;
end: Date | null;
};
key: number;
}
type Context = ActionContext<FullCalendarState, State>;
export default {
namespaced: true,
state: (): FullCalendarState => ({
currentView: {
start: null,
end: null,
},
key: 0,
}),
mutations: {
setCurrentDatesView: function (
state: FullCalendarState,
payload: { start: Date; end: Date },
): void {
state.currentView.start = payload.start;
state.currentView.end = payload.end;
},
increaseKey: function (state: FullCalendarState): void {
state.key = state.key + 1;
},
namespaced: true,
state: (): FullCalendarState => ({
currentView: {
start: null,
end: null,
},
actions: {
setCurrentDatesView(
ctx: Context,
{ start, end }: { start: Date | null; end: Date | null },
): Promise<null> {
console.log("dispatch setCurrentDatesView", { start, end });
if (
ctx.state.currentView.start !== start ||
ctx.state.currentView.end !== end
) {
ctx.commit("setCurrentDatesView", { start, end });
}
if (start !== null && end !== null) {
return Promise.all([
ctx
.dispatch(
"calendarRanges/fetchRanges",
{ start, end },
{ root: true },
)
.then((_) => Promise.resolve(null)),
ctx
.dispatch(
"calendarRemotes/fetchRemotes",
{ start, end },
{ root: true },
)
.then((_) => Promise.resolve(null)),
ctx
.dispatch(
"calendarLocals/fetchLocals",
{ start, end },
{ root: true },
)
.then((_) => Promise.resolve(null)),
]).then((_) => Promise.resolve(null));
} else {
return Promise.resolve(null);
}
},
key: 0,
}),
mutations: {
setCurrentDatesView: function (
state: FullCalendarState,
payload: { start: Date; end: Date },
): void {
state.currentView.start = payload.start;
state.currentView.end = payload.end;
},
increaseKey: function (state: FullCalendarState): void {
state.key = state.key + 1;
},
},
actions: {
setCurrentDatesView(
ctx: Context,
{ start, end }: { start: Date | null; end: Date | null },
): Promise<null> {
console.log("dispatch setCurrentDatesView", { start, end });
if (
ctx.state.currentView.start !== start ||
ctx.state.currentView.end !== end
) {
ctx.commit("setCurrentDatesView", { start, end });
}
if (start !== null && end !== null) {
return Promise.all([
ctx
.dispatch(
"calendarRanges/fetchRanges",
{ start, end },
{ root: true },
)
.then((_) => Promise.resolve(null)),
ctx
.dispatch(
"calendarRemotes/fetchRemotes",
{ start, end },
{ root: true },
)
.then((_) => Promise.resolve(null)),
ctx
.dispatch(
"calendarLocals/fetchLocals",
{ start, end },
{ root: true },
)
.then((_) => Promise.resolve(null)),
]).then((_) => Promise.resolve(null));
} else {
return Promise.resolve(null);
}
},
},
};

View File

@@ -5,61 +5,61 @@ import { getLocations } from "../../../../../../../ChillMainBundle/Resources/pub
import { whereami } from "../../../../../../../ChillMainBundle/Resources/public/lib/api/user";
export interface LocationState {
locations: Location[];
locationPicked: Location | null;
currentLocation: Location | null;
locations: Location[];
locationPicked: Location | null;
currentLocation: Location | null;
}
export default {
namespaced: true,
state: (): LocationState => {
return {
locations: [],
locationPicked: null,
currentLocation: null,
};
namespaced: true,
state: (): LocationState => {
return {
locations: [],
locationPicked: null,
currentLocation: null,
};
},
getters: {
getLocationById:
(state) =>
(id: number): Location | undefined => {
return state.locations.find((l) => l.id === id);
},
},
mutations: {
setLocations(state, locations): void {
state.locations = locations;
},
getters: {
getLocationById:
(state) =>
(id: number): Location | undefined => {
return state.locations.find((l) => l.id === id);
},
},
mutations: {
setLocations(state, locations): void {
state.locations = locations;
},
setLocationPicked(state, location: Location | null): void {
if (null === location) {
state.locationPicked = null;
return;
}
setLocationPicked(state, location: Location | null): void {
if (null === location) {
state.locationPicked = null;
return;
}
state.locationPicked =
state.locations.find((l) => l.id === location.id) || null;
},
setCurrentLocation(state, location: Location | null): void {
if (null === location) {
state.currentLocation = null;
return;
}
state.locationPicked =
state.locations.find((l) => l.id === location.id) || null;
},
setCurrentLocation(state, location: Location | null): void {
if (null === location) {
state.currentLocation = null;
return;
}
state.currentLocation =
state.locations.find((l) => l.id === location.id) || null;
},
state.currentLocation =
state.locations.find((l) => l.id === location.id) || null;
},
actions: {
getLocations(ctx): Promise<void> {
return getLocations().then((locations) => {
ctx.commit("setLocations", locations);
return Promise.resolve();
});
},
getCurrentLocation(ctx): Promise<void> {
return whereami().then((location) => {
ctx.commit("setCurrentLocation", location);
});
},
},
actions: {
getLocations(ctx): Promise<void> {
return getLocations().then((locations) => {
ctx.commit("setLocations", locations);
return Promise.resolve();
});
},
getCurrentLocation(ctx): Promise<void> {
return whereami().then((location) => {
ctx.commit("setCurrentLocation", location);
});
},
},
} as Module<LocationState, State>;

View File

@@ -3,24 +3,24 @@ import { User } from "../../../../../../../ChillMainBundle/Resources/public/type
import { ActionContext } from "vuex";
export interface MeState {
me: User | null;
me: User | null;
}
type Context = ActionContext<MeState, State>;
export default {
namespaced: true,
state: (): MeState => ({
me: null,
}),
getters: {
getMe: function (state: MeState): User | null {
return state.me;
},
namespaced: true,
state: (): MeState => ({
me: null,
}),
getters: {
getMe: function (state: MeState): User | null {
return state.me;
},
mutations: {
setWhoAmi(state: MeState, me: User) {
state.me = me;
},
},
mutations: {
setWhoAmi(state: MeState, me: User) {
state.me = me;
},
},
};

View File

@@ -1,51 +1,51 @@
<template>
<div>
<h2 class="chill-red">
{{ $t("choose_your_calendar_user") }}
</h2>
<VueMultiselect
name="field"
id="calendarUserSelector"
v-model="value"
track-by="id"
label="value"
:custom-label="transName"
:placeholder="$t('select_user')"
:multiple="true"
:close-on-select="false"
:allow-empty="true"
:model-value="value"
:select-label="$t('multiselect.select_label')"
:deselect-label="$t('multiselect.deselect_label')"
:selected-label="$t('multiselect.selected_label')"
@select="selectUsers"
@remove="unSelectUsers"
@close="coloriseSelectedValues"
:options="options"
/>
</div>
<div class="form-check">
<input
type="checkbox"
id="myCalendar"
class="form-check-input"
v-model="showMyCalendarWidget"
/>
<label class="form-check-label" for="myCalendar">{{
$t("show_my_calendar")
}}</label>
</div>
<div class="form-check">
<input
type="checkbox"
id="weekends"
class="form-check-input"
@click="toggleWeekends"
/>
<label class="form-check-label" for="weekends">{{
$t("show_weekends")
}}</label>
</div>
<div>
<h2 class="chill-red">
{{ $t("choose_your_calendar_user") }}
</h2>
<VueMultiselect
name="field"
id="calendarUserSelector"
v-model="value"
track-by="id"
label="value"
:custom-label="transName"
:placeholder="$t('select_user')"
:multiple="true"
:close-on-select="false"
:allow-empty="true"
:model-value="value"
:select-label="$t('multiselect.select_label')"
:deselect-label="$t('multiselect.deselect_label')"
:selected-label="$t('multiselect.selected_label')"
@select="selectUsers"
@remove="unSelectUsers"
@close="coloriseSelectedValues"
:options="options"
/>
</div>
<div class="form-check">
<input
type="checkbox"
id="myCalendar"
class="form-check-input"
v-model="showMyCalendarWidget"
/>
<label class="form-check-label" for="myCalendar">{{
$t("show_my_calendar")
}}</label>
</div>
<div class="form-check">
<input
type="checkbox"
id="weekends"
class="form-check-input"
@click="toggleWeekends"
/>
<label class="form-check-label" for="weekends">{{
$t("show_weekends")
}}</label>
</div>
</template>
<script>
import { fetchCalendarRanges, fetchCalendar } from "../../_api/api";
@@ -53,206 +53,183 @@ import VueMultiselect from "vue-multiselect";
import { whoami } from "ChillPersonAssets/vuejs/AccompanyingCourse/api";
const COLORS = [
/* from https://colorbrewer2.org/#type=qualitative&scheme=Set3&n=12 */
"#8dd3c7",
"#ffffb3",
"#bebada",
"#fb8072",
"#80b1d3",
"#fdb462",
"#b3de69",
"#fccde5",
"#d9d9d9",
"#bc80bd",
"#ccebc5",
"#ffed6f",
/* from https://colorbrewer2.org/#type=qualitative&scheme=Set3&n=12 */
"#8dd3c7",
"#ffffb3",
"#bebada",
"#fb8072",
"#80b1d3",
"#fdb462",
"#b3de69",
"#fccde5",
"#d9d9d9",
"#bc80bd",
"#ccebc5",
"#ffed6f",
];
export default {
name: "CalendarUserSelector",
components: { VueMultiselect },
props: [
"users",
"updateEventsSource",
"calendarEvents",
"showMyCalendar",
"toggleMyCalendar",
"toggleWeekends",
],
data() {
return {
errorMsg: [],
value: [],
options: [],
};
name: "CalendarUserSelector",
components: { VueMultiselect },
props: [
"users",
"updateEventsSource",
"calendarEvents",
"showMyCalendar",
"toggleMyCalendar",
"toggleWeekends",
],
data() {
return {
errorMsg: [],
value: [],
options: [],
};
},
computed: {
showMyCalendarWidget: {
set(value) {
this.toggleMyCalendar(value);
this.updateEventsSource();
},
get() {
return this.showMyCalendar;
},
},
computed: {
showMyCalendarWidget: {
set(value) {
this.toggleMyCalendar(value);
this.updateEventsSource();
},
get() {
return this.showMyCalendar;
},
},
},
methods: {
init() {
this.fetchData();
},
methods: {
init() {
this.fetchData();
},
fetchData() {
fetchCalendarRanges()
.then(
(calendarRanges) =>
new Promise((resolve, reject) => {
let results = calendarRanges.results;
fetchData() {
fetchCalendarRanges()
.then(
(calendarRanges) =>
new Promise((resolve, reject) => {
let results = calendarRanges.results;
let users = [];
let users = [];
results.forEach((i) => {
if (!users.some((j) => i.user.id === j.id)) {
let ratio = Math.floor(
users.length / COLORS.length,
);
let colorIndex =
users.length - ratio * COLORS.length;
users.push({
id: i.user.id,
username: i.user.username,
color: COLORS[colorIndex],
});
}
});
let calendarEvents = [];
users.forEach((u) => {
let arr = results
.filter((i) => i.user.id === u.id)
.map((i) => ({
start: i.startDate.datetime,
end: i.endDate.datetime,
calendarRangeId: i.id,
sourceColor: u.color,
//display: 'background' // can be an option for the disponibility
}));
calendarEvents.push({
events: arr,
color: u.color,
textColor: "#444444",
editable: false,
id: u.id,
});
});
this.users.loaded = users;
this.options = users;
this.calendarEvents.loaded = calendarEvents;
whoami().then(
(me) =>
new Promise((resolve, reject) => {
this.users.logged = me;
let currentUser = users.find(
(u) => u.id === me.id,
);
this.value = currentUser;
fetchCalendar(currentUser.id).then(
(calendar) =>
new Promise(
(resolve, reject) => {
let results =
calendar.results;
let events =
results.map(
(i) => ({
start: i
.startDate
.datetime,
end: i
.endDate
.datetime,
}),
);
let calendarEventsCurrentUser =
{
events: events,
color: "darkblue",
id: 1000,
editable: false,
};
this.calendarEvents.user =
calendarEventsCurrentUser;
this.selectUsers(
currentUser,
);
resolve();
},
),
);
resolve();
}),
);
resolve();
}),
)
.catch((error) => {
this.errorMsg.push(error.message);
});
},
transName(value) {
return `${value.username}`;
},
coloriseSelectedValues() {
let tags = document.querySelectorAll(
"div.multiselect__tags-wrap",
)[0];
if (tags.hasChildNodes()) {
let children = tags.childNodes;
for (let i = 0; i < children.length; i++) {
let child = children[i];
if (child.nodeType === Node.ELEMENT_NODE) {
this.users.selected.forEach((u) => {
if (child.hasChildNodes()) {
if (child.firstChild.innerText == u.username) {
child.style.background = u.color;
child.firstChild.style.color = "#444444";
}
}
});
}
results.forEach((i) => {
if (!users.some((j) => i.user.id === j.id)) {
let ratio = Math.floor(users.length / COLORS.length);
let colorIndex = users.length - ratio * COLORS.length;
users.push({
id: i.user.id,
username: i.user.username,
color: COLORS[colorIndex],
});
}
}
},
selectEvents() {
let selectedUsersId = this.users.selected.map((a) => a.id);
this.calendarEvents.selected = this.calendarEvents.loaded.filter(
(a) => selectedUsersId.includes(a.id),
);
},
selectUsers(value) {
this.users.selected.push(value);
this.coloriseSelectedValues();
this.selectEvents();
this.updateEventsSource();
},
unSelectUsers(value) {
this.users.selected = this.users.selected.filter(
(a) => a.id != value.id,
);
this.selectEvents();
this.updateEventsSource();
},
});
let calendarEvents = [];
users.forEach((u) => {
let arr = results
.filter((i) => i.user.id === u.id)
.map((i) => ({
start: i.startDate.datetime,
end: i.endDate.datetime,
calendarRangeId: i.id,
sourceColor: u.color,
//display: 'background' // can be an option for the disponibility
}));
calendarEvents.push({
events: arr,
color: u.color,
textColor: "#444444",
editable: false,
id: u.id,
});
});
this.users.loaded = users;
this.options = users;
this.calendarEvents.loaded = calendarEvents;
whoami().then(
(me) =>
new Promise((resolve, reject) => {
this.users.logged = me;
let currentUser = users.find((u) => u.id === me.id);
this.value = currentUser;
fetchCalendar(currentUser.id).then(
(calendar) =>
new Promise((resolve, reject) => {
let results = calendar.results;
let events = results.map((i) => ({
start: i.startDate.datetime,
end: i.endDate.datetime,
}));
let calendarEventsCurrentUser = {
events: events,
color: "darkblue",
id: 1000,
editable: false,
};
this.calendarEvents.user = calendarEventsCurrentUser;
this.selectUsers(currentUser);
resolve();
}),
);
resolve();
}),
);
resolve();
}),
)
.catch((error) => {
this.errorMsg.push(error.message);
});
},
mounted() {
this.init();
transName(value) {
return `${value.username}`;
},
coloriseSelectedValues() {
let tags = document.querySelectorAll("div.multiselect__tags-wrap")[0];
if (tags.hasChildNodes()) {
let children = tags.childNodes;
for (let i = 0; i < children.length; i++) {
let child = children[i];
if (child.nodeType === Node.ELEMENT_NODE) {
this.users.selected.forEach((u) => {
if (child.hasChildNodes()) {
if (child.firstChild.innerText == u.username) {
child.style.background = u.color;
child.firstChild.style.color = "#444444";
}
}
});
}
}
}
},
selectEvents() {
let selectedUsersId = this.users.selected.map((a) => a.id);
this.calendarEvents.selected = this.calendarEvents.loaded.filter((a) =>
selectedUsersId.includes(a.id),
);
},
selectUsers(value) {
this.users.selected.push(value);
this.coloriseSelectedValues();
this.selectEvents();
this.updateEventsSource();
},
unSelectUsers(value) {
this.users.selected = this.users.selected.filter((a) => a.id != value.id);
this.selectEvents();
this.updateEventsSource();
},
},
mounted() {
this.init();
},
};
</script>

View File

@@ -1,17 +1,23 @@
{# list used in context of person or accompanyingPeriod #}
{# list used in context of person, accompanyingPeriod or user #}
{% if calendarItems|length > 0 %}
<div class="flex-table list-records context-accompanyingCourse">
{% for calendar in calendarItems %}
<div class="item-bloc">
<div class="item-row main">
<div class="item-col">
<div class="wrap-header">
<div class="item-bloc">
<div class="item-row main">
<div class="item-col">
<div class="wrap-header">
<div class="wl-row">
{% if calendar.status == 'canceled' %}
<div class="badge rounded-pill bg-danger">
<span>{{ 'chill_calendar.canceled'|trans }}: </span>
<span>{{ calendar.cancelReason.name|localize_translatable_string }}</span>
</div>
{% endif %}
</div>
<div class="wl-row">
<div class="wl-col title">
<p class="date-label">
{% if calendar.status == 'canceled' %}
<del>
{% endif %}
{% if context == 'person' and calendar.context == 'accompanying_period' %}
<a href="{{ chill_path_add_return_path('chill_person_accompanying_course_index', {'accompanying_period_id': calendar.accompanyingPeriod.id}) }}" style="text-decoration: none;">
<span class="badge bg-primary">
@@ -19,6 +25,12 @@
</span>
</a>
{% endif %}
{% if calendar.status == 'canceled' %}
<del>
{% endif %}
{% if calendar.status == 'canceled' %}
<del>
{% endif %}
{% if calendar.endDate.diff(calendar.startDate).days >= 1 %}
{{ calendar.startDate|format_datetime('short', 'short') }}
- {{ calendar.endDate|format_datetime('short', 'short') }}
@@ -26,44 +38,46 @@
{{ calendar.startDate|format_datetime('short', 'short') }}
- {{ calendar.endDate|format_datetime('none', 'short') }}
{% endif %}
</p>
<div class="duration short-message">
<i class="fa fa-fw fa-hourglass-end"></i>
{{ calendar.duration|date('%H:%I') }}
{% if false == calendar.sendSMS or null == calendar.sendSMS %}
<!-- no sms will be send -->
{% else %}
{% if calendar.smsStatus == 'sms_sent' %}
<span title="{{ 'SMS already sent'|trans }}" class="badge bg-info">
<i class="fa fa-check "></i>
<i class="fa fa-envelope "></i>
</span>
{% else %}
<span title="{{ 'Will send SMS'|trans }}" class="badge bg-info">
<i class="fa fa-envelope "></i>
<i class="fa fa-hourglass-end "></i>
</span>
{% endif %}
{% if calendar.status == 'canceled' %}
</del>
{% endif %}
</div>
</div>
</div>
</div>
<div class="item-col">
<ul class="list-content">
{% if calendar.mainUser is not empty %}
<span class="badge-user">{{ calendar.mainUser|chill_entity_render_box({'at_date': calendar.startDate}) }}</span>
<div class="duration short-message">
<i class="fa fa-fw fa-hourglass-end"></i>
{{ calendar.duration|date('%H:%I') }}
{% if false == calendar.sendSMS or null == calendar.sendSMS %}
<!-- no sms will be sent -->
{% else %}
{% if calendar.smsStatus == 'sms_sent' %}
<span title="{{ 'SMS already sent'|trans }}" class="badge bg-info">
<i class="fa fa-check "></i>
<i class="fa fa-envelope "></i>
</span>
{% else %}
<span title="{{ 'Will send SMS'|trans }}" class="badge bg-info">
<i class="fa fa-envelope "></i>
<i class="fa fa-hourglass-end "></i>
</span>
{% endif %}
</ul>
{% endif %}
</div>
</div>
</div>
</div>
{% if calendar.comment.comment is not empty
<div class="item-col">
<ul class="list-content">
{% if calendar.mainUser is not empty %}
<span class="badge-user">{{ calendar.mainUser|chill_entity_render_box({'at_date': calendar.startDate}) }}</span>
{% endif %}
</ul>
</div>
</div>
</div>
{% if calendar.comment.comment is not empty
or calendar.users|length > 0
or calendar.thirdParties|length > 0
or calendar.users|length > 0 %}
@@ -76,131 +90,134 @@
} %}
</div>
</div>
{% endif %}
</div>
{% endif %}
{% if calendar.comment.comment is not empty %}
<div class="item-row details separator">
<div class="item-col comment">
{{ calendar.comment|chill_entity_render_box( { 'limit_lines': 3, 'metadata': false } ) }}
</div>
</div>
{% endif %}
{% if calendar.location is not empty %}
<div class="item-row separator">
<div>
{% if calendar.location.address is not same as(null) and calendar.location.name is not empty %}
<i class="fa fa-map-marker"></i>{% endif %}
{% if calendar.location.name is not empty %}{{ calendar.location.name }}{% endif %}
{% if calendar.location.address is not same as(null) %}{{ calendar.location.address|chill_entity_render_box({'multiline': false, 'with_picto': (calendar.location.name is empty)}) }}{% else %}
<i class="fa fa-map-marker"></i>{% endif %}
{% if calendar.location.phonenumber1 is not empty %}<i
class="fa fa-phone"></i> {{ calendar.location.phonenumber1|chill_format_phonenumber }}{% endif %}
{% if calendar.location.phonenumber2 is not empty %}<i
class="fa fa-phone"></i> {{ calendar.location.phonenumber2|chill_format_phonenumber }}{% endif %}
</div>
</div>
{% endif %}
{% if calendar.documents is not empty %}
<div class="item-row separator column">
<div>
{{ include('@ChillCalendar/Calendar/_documents.twig.html') }}
</div>
</div>
{% endif %}
{% if calendar.activity is not null %}
<div class="item-row separator">
<div class="item-col">
<div class="wrap-list">
<div class="wl-row">
<div class="wl-col title"><h3>{{ 'Activity'|trans }}</h3></div>
<div class="wl-col list activity-linked">
<h2 class="badge-title">
<span class="title_label"></span>
<span class="title_action">
{{ calendar.activity.type.name | localize_translatable_string }}
{% if calendar.activity.emergency %}
<span class="badge bg-danger rounded-pill fs-6 float-end">{{ 'Emergency'|trans|upper }}</span>
{% endif %}
</span>
</h2>
<ul class="record_actions">
<li class="cancel">
<span class="createdBy">
{{ 'Created by'|trans }}
<b>{{ calendar.activity.createdBy|chill_entity_render_string({'at_date': calendar.activity.createdAt}) }}</b>, {{ 'on'|trans }} {{ calendar.activity.createdAt|format_datetime('short', 'short') }}
</span>
</li>
{% if is_granted('CHILL_ACTIVITY_SEE', calendar.activity) %}
<li>
<a href="{{ chill_path_add_return_path('chill_activity_activity_show', {'id': calendar.activity.id}) }}" class="btn btn-sm btn-show" ></a>
</li>
{% endif %}
</ul>
{% if calendar.comment.comment is not empty %}
<div class="item-row details separator">
<div class="item-col comment">
{{ calendar.comment|chill_entity_render_box( { 'limit_lines': 3, 'metadata': false } ) }}
</div>
</div>
{% endif %}
{% if calendar.location is not empty %}
<div class="item-row separator">
<div>
{% if calendar.location.address is not same as(null) and calendar.location.name is not empty %}
<i class="fa fa-map-marker"></i>{% endif %}
{% if calendar.location.name is not empty %}{{ calendar.location.name }}{% endif %}
{% if calendar.location.address is not same as(null) %}{{ calendar.location.address|chill_entity_render_box({'multiline': false, 'with_picto': (calendar.location.name is empty)}) }}{% else %}
<i class="fa fa-map-marker"></i>{% endif %}
{% if calendar.location.phonenumber1 is not empty %}<i
class="fa fa-phone"></i> {{ calendar.location.phonenumber1|chill_format_phonenumber }}{% endif %}
{% if calendar.location.phonenumber2 is not empty %}<i
class="fa fa-phone"></i> {{ calendar.location.phonenumber2|chill_format_phonenumber }}{% endif %}
</div>
</div>
{% endif %}
<div class="item-row separator column">
<div>
{{ include('@ChillCalendar/Calendar/_documents.twig.html') }}
</div>
</div>
</div>
</div>
{% endif %}
{% if calendar.activity is not null %}
<div class="item-row separator">
<div class="item-col">
<div class="wrap-list">
<div class="wl-row">
<div class="wl-col title"><h3>{{ 'Activity'|trans }}</h3></div>
<div class="wl-col list activity-linked">
<h2 class="badge-title">
<span class="title_label"></span>
<span class="title_action">
{{ calendar.activity.type.name | localize_translatable_string }}
{% if calendar.activity.emergency %}
<span class="badge bg-danger rounded-pill fs-6 float-end">{{ 'Emergency'|trans|upper }}</span>
{% endif %}
</span>
</h2>
<ul class="record_actions">
<li class="cancel">
<span class="createdBy">
{{ 'Created by'|trans }}
<b>{{ calendar.activity.createdBy|chill_entity_render_string({'at_date': calendar.activity.createdAt}) }}</b>, {{ 'on'|trans }} {{ calendar.activity.createdAt|format_datetime('short', 'short') }}
</span>
</li>
{% if is_granted('CHILL_ACTIVITY_SEE', calendar.activity) %}
<li>
<a href="{{ chill_path_add_return_path('chill_activity_activity_show', {'id': calendar.activity.id}) }}" class="btn btn-sm btn-show" ></a>
</li>
{% endif %}
</ul>
</div>
</div>
<div class="item-row separator">
{% if show_record_actions is not defined or show_record_actions %}
<ul class="record_actions">
{% if is_granted('CHILL_CALENDAR_DOC_EDIT', calendar) and calendar.status is not constant('STATUS_CANCELED', calendar) %}
{% if templates|length == 0 %}
<li>
<a class="btn btn-create"
href="{{ chill_path_add_return_path('chill_calendar_calendardoc_new', {'id': calendar.id }) }}">
{{ 'chill_calendar.Add a document'|trans }}
</a>
</li>
{% else %}
<li>
<div class="dropdown">
<button class="btn btn-create dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
{{ 'chill_calendar.Add a document'|trans }}
</button>
<ul class="dropdown-menu">
<li>
<a class="dropdown-item"
href="{{ chill_path_add_return_path('chill_calendar_calendardoc_new', {'id': calendar.id }) }}">
{{ 'chill_calendar.Upload a document'|trans }}
</a>
</li>
{% for template in templates %}
<li>
<a class="dropdown-item"
href="{{ chill_path_add_return_path('chill_docgenerator_generate_from_template', {'template': template.id, 'entityClassName': 'Chill\\CalendarBundle\\Entity\\Calendar', 'entityId': calendar.id}) }}"
>
{{ template.name|localize_translatable_string }}
</a>
</li>
{% endfor %}
</ul>
</div>
</div>
</div>
</li>
{% endif %}
{% endif %}
{% if calendar.activity is null and (
(calendar.context == 'accompanying_period' and is_granted('CHILL_ACTIVITY_CREATE', calendar.accompanyingPeriod))
or
(calendar.context == 'person' and is_granted('CHILL_ACTIVITY_CREATE', calendar.person))
)
and calendar.status is not constant('STATUS_CANCELED', calendar)
%}
<li>
<a class="btn btn-create"
href="{{ chill_path_add_return_path('chill_calendar_calendar_to_activity', { 'id': calendar.id }) }}">
{{ 'Transform to activity'|trans }}
</a>
</li>
{% endif %}
<div class="item-row separator">
<ul class="record_actions">
{% if is_granted('CHILL_CALENDAR_DOC_EDIT', calendar) %}
{% if templates|length == 0 %}
<li>
<a class="btn btn-create"
href="{{ chill_path_add_return_path('chill_calendar_calendardoc_new', {'id': calendar.id }) }}">
{{ 'chill_calendar.Add a document'|trans }}
</a>
</li>
{% else %}
<li>
<div class="dropdown">
<button class="btn btn-create dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
{{ 'chill_calendar.Add a document'|trans }}
</button>
<ul class="dropdown-menu">
<li>
<a class="dropdown-item"
href="{{ chill_path_add_return_path('chill_calendar_calendardoc_new', {'id': calendar.id }) }}">
{{ 'chill_calendar.Upload a document'|trans }}
</a>
</li>
{% for template in templates %}
<li>
<a class="dropdown-item"
href="{{ chill_path_add_return_path('chill_docgenerator_generate_from_template', {'template': template.id, 'entityClassName': 'Chill\\CalendarBundle\\Entity\\Calendar', 'entityId': calendar.id}) }}"
>
{{ template.name|localize_translatable_string }}
</a>
</li>
{% endfor %}
</ul>
</div>
</li>
{% endif %}
{% endif %}
{% if calendar.activity is null and (
(calendar.context == 'accompanying_period' and is_granted('CHILL_ACTIVITY_CREATE', calendar.accompanyingPeriod))
or
(calendar.context == 'person' and is_granted('CHILL_ACTIVITY_CREATE', calendar.person))
)
%}
<li>
<a class="btn btn-create"
href="{{ chill_path_add_return_path('chill_calendar_calendar_to_activity', { 'id': calendar.id }) }}">
{{ 'Transform to activity'|trans }}
</a>
</li>
{% endif %}
{% if (calendar.isInvited(app.user)) %}
{% if calendar.isInvited(app.user) and not calendar.isCanceled %}
{% set invite = calendar.inviteForUser(app.user) %}
<li>
<div invite-answer data-status="{{ invite.status|e('html_attr') }}"
@@ -213,12 +230,22 @@
class="btn btn-show "></a>
</li>
{% endif %}
{% if is_granted('CHILL_CALENDAR_CALENDAR_EDIT', calendar) %}
{% if is_granted('CHILL_CALENDAR_CALENDAR_EDIT', calendar) and calendar.status is not constant('STATUS_CANCELED', calendar) %}
<li>
<a href="{{ chill_path_add_return_path('chill_calendar_calendar_edit', { 'id': calendar.id }) }}"
class="btn btn-update "></a>
</li>
<li>
<a href="{{ chill_path_add_return_path('chill_calendar_calendar_cancel', { 'id': calendar.id } ) }}"
class="btn btn-action"><i class="bi bi-x-circle"></i> {{ 'Cancel'|trans }}</a>
</li>
<li>
<a href="{{ chill_path_add_return_path('chill_calendar_calendar_cancel', { 'id': calendar.id } ) }}"
class="btn btn-action"><i class="bi bi-x-circle"></i> {{ 'Cancel'|trans }}</a>
</li>
{% endif %}
{% if is_granted('CHILL_CALENDAR_CALENDAR_DELETE', calendar) %}
<li>
<a href="{{ chill_path_add_return_path('chill_calendar_calendar_delete', { 'id': calendar.id } ) }}"
@@ -226,15 +253,9 @@
</li>
{% endif %}
</ul>
</div>
</div>
{% endfor %}
{% if calendarItems|length < paginator.getTotalItems %}
{{ chill_pagination(paginator) }}
{% endif %}
</div>
{% endif %}
</div>

View File

@@ -0,0 +1,33 @@
{% extends "@ChillPerson/AccompanyingCourse/layout.html.twig" %}
{% set activeRouteKey = 'chill_calendar_calendar_list' %}
{% block title 'chill_calendar.cancel_calendar_item'|trans %}
{% block content %}
<div class="flex-table list-records context-accompanyingCourse">
{{ include('@ChillCalendar/Calendar/_list.html.twig', {context: 'accompanying_course', show_record_actions: false}) }}
</div>
{{ form_start(form) }}
{{ form_row(form.cancelReason) }}
<ul class="record_actions sticky-form-buttons">
<li class="cancel">
<a
class="btn btn-cancel"
href="{{ chill_return_path_or('chill_calendar_calendar_list', { 'id': accompanyingCourse.id } )}}"
>
{{ 'Cancel'|trans|chill_return_path_label }}
</a>
</li>
<li>
{{ form_widget(form.submit, { 'attr' : { 'class': 'btn btn-save' }, 'label': 'Save' } ) }}
</li>
</ul>
{{ form_end(form) }}
{% endblock %}

View File

@@ -0,0 +1,33 @@
{% extends "@ChillPerson/Person/layout.html.twig" %}
{% set activeRouteKey = 'chill_calendar_calendar_list' %}
{% block title 'chill_calendar.cancel_calendar_item'|trans %}
{% block content %}
<div class="flex-table list-records context-person">
{{ include('@ChillCalendar/Calendar/_list.html.twig', {context: 'person', show_record_actions: false}) }}
</div>
{{ form_start(form) }}
{{ form_row(form.cancelReason) }}
<ul class="record_actions sticky-form-buttons">
<li class="cancel">
<a
class="btn btn-cancel"
href="{{ chill_return_path_or('chill_calendar_calendar_list', { 'id': person.id } )}}"
>
{{ 'Cancel'|trans|chill_return_path_label }}
</a>
</li>
<li>
{{ form_widget(form.submit, { 'attr' : { 'class': 'btn btn-save' }, 'label': 'Save' } ) }}
</li>
</ul>
{{ form_end(form) }}
{% endblock %}

View File

@@ -34,7 +34,18 @@
{% endif %}
</p>
{% else %}
{{ include('@ChillCalendar/Calendar/_list.html.twig', {context: 'accompanying_course'}) }}
{% if calendarItems|length > 0 %}
<div class="flex-table list-records context-accompanyingCourse">
{% for calendar in calendarItems %}
{{ include('@ChillCalendar/Calendar/_list.html.twig', {context: 'accompanying_course'}) }}
{% endfor %}
</div>
{% if calendarItems|length < paginator.getTotalItems %}
{{ chill_pagination(paginator) }}
{% endif %}
{% endif %}
{% endif %}
<ul class="record_actions sticky-form-buttons">

View File

@@ -33,7 +33,17 @@
{% endif %}
</p>
{% else %}
{{ include ('@ChillCalendar/Calendar/_list.html.twig', {context: 'person'}) }}
{% if calendarItems|length > 0 %}
<div class="flex-table list-records context-person">
{% for calendar in calendarItems %}
{{ include ('@ChillCalendar/Calendar/_list.html.twig', {context: 'person'}) }}
{% endfor %}
</div>
{% if calendarItems|length < paginator.getTotalItems %}
{{ chill_pagination(paginator) }}
{% endif %}
{% endif %}
{% endif %}
<ul class="record_actions sticky-form-buttons">

View File

@@ -5,7 +5,7 @@
{% block table_entities_thead_tr %}
<th>{{ 'Id'|trans }}</th>
<th>{{ 'Name'|trans }}</th>
<th>{{ 'canceledBy'|trans }}</th>
<th>{{ 'Canceled by'|trans }}</th>
<th>{{ 'active'|trans }}</th>
<th>&nbsp;</th>
{% endblock %}
@@ -40,4 +40,4 @@
</li>
{% endblock %}
{% endembed %}
{% endblock %}
{% endblock %}

View File

@@ -0,0 +1,40 @@
{% extends "@ChillMain/layout.html.twig" %}
{% set activeRouteKey = 'chill_calendar_invitations_list' %}
{% block title %}{{ 'invite.list.title'|trans }}{% endblock title %}
{% block content %}
<h1>{{ 'invite.list.title'|trans }}</h1>
{% if invitations|length == 0 %}
<p class="chill-no-data-statement">
{{ "invite.list.none"|trans }}
</p>
{% else %}
<div class="flex-table list-records">
{% for invitation in invitations %}
{% set calendar = invitation.getCalendar %}
{{ include('@ChillCalendar/Calendar/_list.html.twig', {context: 'user'}) }}
{% endfor %}
</div>
{% if invitations|length < paginator.getTotalItems %}
{{ chill_pagination(paginator) }}
{% endif %}
{% endif %}
{% endblock %}
{% block js %}
{{ parent() }}
{{ encore_entry_script_tags('mod_answer') }}
{{ encore_entry_script_tags('mod_document_action_buttons_group') }}
{% endblock %}
{% block css %}
{{ parent() }}
{{ encore_entry_link_tags('mod_answer') }}
{{ encore_entry_link_tags('mod_document_action_buttons_group') }}
{% endblock %}

View File

@@ -19,6 +19,7 @@ declare(strict_types=1);
namespace Chill\CalendarBundle\Service\ShortMessageNotification;
use Chill\CalendarBundle\Entity\Calendar;
use Chill\CalendarBundle\Entity\CancelReason;
use libphonenumber\PhoneNumberFormat;
use libphonenumber\PhoneNumberUtil;
use Symfony\Component\Notifier\Message\SmsMessage;
@@ -57,7 +58,7 @@ class DefaultShortMessageForCalendarBuilder implements ShortMessageForCalendarBu
$this->phoneUtil->format($person->getMobilenumber(), PhoneNumberFormat::E164),
$this->engine->render('@ChillCalendar/CalendarShortMessage/short_message.txt.twig', ['calendar' => $calendar]),
);
} elseif (Calendar::SMS_CANCEL_PENDING === $calendar->getSmsStatus()) {
} elseif (Calendar::SMS_CANCEL_PENDING === $calendar->getSmsStatus() && (null === $calendar->getCancelReason() || CancelReason::CANCELEDBY_PERSON !== $calendar->getCancelReason()->getCanceledBy())) {
$toUsers[] = new SmsMessage(
$this->phoneUtil->format($person->getMobilenumber(), PhoneNumberFormat::E164),
$this->engine->render('@ChillCalendar/CalendarShortMessage/short_message_canceled.txt.twig', ['calendar' => $calendar]),

View File

@@ -0,0 +1,292 @@
<?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\CalendarBundle\Tests\Controller;
use Chill\CalendarBundle\Controller\MyInvitationsController;
use Chill\CalendarBundle\Entity\Calendar;
use Chill\CalendarBundle\Entity\Invite;
use Chill\CalendarBundle\Repository\InviteRepository;
use Chill\DocGeneratorBundle\Repository\DocGeneratorTemplateRepositoryInterface;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Pagination\PaginatorFactory;
use Chill\MainBundle\Pagination\PaginatorInterface;
use PHPUnit\Framework\TestCase;
use Prophecy\PhpUnit\ProphecyTrait;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
use Twig\Environment;
/**
* @internal
*
* @coversNothing
*/
final class MyInvitationsControllerTest extends TestCase
{
use ProphecyTrait;
private MyInvitationsController $controller;
protected function setUp(): void
{
// Create prophecies for dependencies
$inviteRepository = $this->prophesize(InviteRepository::class);
$paginatorFactory = $this->prophesize(PaginatorFactory::class);
$docGeneratorTemplateRepository = $this->prophesize(DocGeneratorTemplateRepositoryInterface::class);
// Create controller instance
$this->controller = new MyInvitationsController(
$inviteRepository->reveal(),
$paginatorFactory->reveal(),
$docGeneratorTemplateRepository->reveal()
);
// Set up necessary services for AbstractController
$authorizationChecker = $this->prophesize(AuthorizationCheckerInterface::class);
$tokenStorage = $this->prophesize(TokenStorageInterface::class);
$twig = $this->prophesize(Environment::class);
// Use reflection to set the container
$reflection = new \ReflectionClass($this->controller);
$containerProperty = $reflection->getParentClass()->getProperty('container');
$containerProperty->setAccessible(true);
// Create a mock container
$container = $this->prophesize(\Psr\Container\ContainerInterface::class);
$container->has('security.authorization_checker')->willReturn(true);
$container->get('security.authorization_checker')->willReturn($authorizationChecker->reveal());
$container->has('security.token_storage')->willReturn(true);
$container->get('security.token_storage')->willReturn($tokenStorage->reveal());
$container->has('twig')->willReturn(true);
$container->get('twig')->willReturn($twig->reveal());
$containerProperty->setValue($this->controller, $container->reveal());
}
public function testMyInvitationsReturnsCorrectAmountOfInvitations(): void
{
// Create test user
$user = new User();
$user->setUsername('testuser');
// Create test invitations
$invite1 = new Invite();
$invite1->setUser($user);
$invite1->setStatus(Invite::PENDING);
$invite2 = new Invite();
$invite2->setUser($user);
$invite2->setStatus(Invite::ACCEPTED);
$invite3 = new Invite();
$invite3->setUser($user);
$invite3->setStatus(Invite::DECLINED);
$allInvitations = [$invite1, $invite2, $invite3];
$paginatedInvitations = [$invite1, $invite2]; // First page with 2 items per page
// Set up repository prophecies
$inviteRepository = $this->prophesize(InviteRepository::class);
$inviteRepository->findBy(['user' => $user])->willReturn($allInvitations);
$inviteRepository->findBy(
['user' => $user],
['createdAt' => 'DESC'],
2, // items per page
0 // offset
)->willReturn($paginatedInvitations);
// Set up paginator prophecies
$paginator = $this->prophesize(PaginatorInterface::class);
$paginator->getItemsPerPage()->willReturn(2);
$paginator->getCurrentPageFirstItemNumber()->willReturn(0);
$paginatorFactory = $this->prophesize(PaginatorFactory::class);
$paginatorFactory->create(3)->willReturn($paginator->reveal());
// Set up doc generator repository
$docGeneratorTemplateRepository = $this->prophesize(DocGeneratorTemplateRepositoryInterface::class);
$docGeneratorTemplateRepository->findByEntity(Calendar::class)->willReturn([]);
// Create controller with mocked dependencies
$controller = new MyInvitationsController(
$inviteRepository->reveal(),
$paginatorFactory->reveal(),
$docGeneratorTemplateRepository->reveal()
);
// Set up authorization checker to return true for ROLE_USER
$authorizationChecker = $this->prophesize(AuthorizationCheckerInterface::class);
$authorizationChecker->isGranted('ROLE_USER', null)->willReturn(true);
// Set up token storage to return user
$token = $this->prophesize(TokenInterface::class);
$token->getUser()->willReturn($user);
$tokenStorage = $this->prophesize(TokenStorageInterface::class);
$tokenStorage->getToken()->willReturn($token->reveal());
// Set up twig to return a response
$twig = $this->prophesize(Environment::class);
$twig->render('@ChillCalendar/Invitations/listByUser.html.twig', [
'invitations' => $paginatedInvitations,
'paginator' => $paginator->reveal(),
'templates' => [],
])->willReturn('rendered content');
// Set up container
$container = $this->prophesize(\Psr\Container\ContainerInterface::class);
$container->has('security.authorization_checker')->willReturn(true);
$container->get('security.authorization_checker')->willReturn($authorizationChecker->reveal());
$container->has('security.token_storage')->willReturn(true);
$container->get('security.token_storage')->willReturn($tokenStorage->reveal());
$container->has('twig')->willReturn(true);
$container->get('twig')->willReturn($twig->reveal());
// Use reflection to set the container
$reflection = new \ReflectionClass($controller);
$containerProperty = $reflection->getParentClass()->getProperty('container');
$containerProperty->setAccessible(true);
$containerProperty->setValue($controller, $container->reveal());
// Create request
$request = new Request();
// Execute the action
$response = $controller->myInvitations($request);
// Assert that response is successful
self::assertInstanceOf(Response::class, $response);
self::assertSame(200, $response->getStatusCode());
self::assertSame('rendered content', $response->getContent());
}
public function testMyInvitationsPageLoads(): void
{
// Create test user
$user = new User();
$user->setUsername('testuser');
// Set up repository prophecies - no invitations
$inviteRepository = $this->prophesize(InviteRepository::class);
$inviteRepository->findBy(['user' => $user])->willReturn([]);
$inviteRepository->findBy(
['user' => $user],
['createdAt' => 'DESC'],
20, // default items per page
0 // offset
)->willReturn([]);
// Set up paginator prophecies
$paginator = $this->prophesize(PaginatorInterface::class);
$paginator->getItemsPerPage()->willReturn(20);
$paginator->getCurrentPageFirstItemNumber()->willReturn(0);
$paginatorFactory = $this->prophesize(PaginatorFactory::class);
$paginatorFactory->create(0)->willReturn($paginator->reveal());
// Set up doc generator repository
$docGeneratorTemplateRepository = $this->prophesize(DocGeneratorTemplateRepositoryInterface::class);
$docGeneratorTemplateRepository->findByEntity(Calendar::class)->willReturn([]);
// Create controller with mocked dependencies
$controller = new MyInvitationsController(
$inviteRepository->reveal(),
$paginatorFactory->reveal(),
$docGeneratorTemplateRepository->reveal()
);
// Set up authorization checker to return true for ROLE_USER
$authorizationChecker = $this->prophesize(AuthorizationCheckerInterface::class);
$authorizationChecker->isGranted('ROLE_USER', null)->willReturn(true);
// Set up token storage to return user
$token = $this->prophesize(TokenInterface::class);
$token->getUser()->willReturn($user);
$tokenStorage = $this->prophesize(TokenStorageInterface::class);
$tokenStorage->getToken()->willReturn($token->reveal());
// Set up twig to return a response
$twig = $this->prophesize(Environment::class);
$twig->render('@ChillCalendar/Invitations/listByUser.html.twig', [
'invitations' => [],
'paginator' => $paginator->reveal(),
'templates' => [],
])->willReturn('empty page content');
// Set up container
$container = $this->prophesize(\Psr\Container\ContainerInterface::class);
$container->has('security.authorization_checker')->willReturn(true);
$container->get('security.authorization_checker')->willReturn($authorizationChecker->reveal());
$container->has('security.token_storage')->willReturn(true);
$container->get('security.token_storage')->willReturn($tokenStorage->reveal());
$container->has('twig')->willReturn(true);
$container->get('twig')->willReturn($twig->reveal());
// Use reflection to set the container
$reflection = new \ReflectionClass($controller);
$containerProperty = $reflection->getParentClass()->getProperty('container');
$containerProperty->setAccessible(true);
$containerProperty->setValue($controller, $container->reveal());
// Create request
$request = new Request();
// Execute the action
$response = $controller->myInvitations($request);
// Assert that page loads successfully
self::assertInstanceOf(Response::class, $response);
self::assertSame(200, $response->getStatusCode());
self::assertSame('empty page content', $response->getContent());
}
public function testMyInvitationsRequiresAuthentication(): void
{
// Create controller with minimal dependencies
$inviteRepository = $this->prophesize(InviteRepository::class);
$paginatorFactory = $this->prophesize(PaginatorFactory::class);
$docGeneratorTemplateRepository = $this->prophesize(DocGeneratorTemplateRepositoryInterface::class);
$controller = new MyInvitationsController(
$inviteRepository->reveal(),
$paginatorFactory->reveal(),
$docGeneratorTemplateRepository->reveal()
);
// Set up authorization checker to return false for ROLE_USER
$authorizationChecker = $this->prophesize(AuthorizationCheckerInterface::class);
$authorizationChecker->isGranted('ROLE_USER')->willReturn(false);
$authorizationChecker->isGranted('ROLE_USER', null)->willReturn(false);
// Set up container
$container = $this->prophesize(\Psr\Container\ContainerInterface::class);
$container->has('security.authorization_checker')->willReturn(true);
$container->get('security.authorization_checker')->willReturn($authorizationChecker->reveal());
// Use reflection to set the container
$reflection = new \ReflectionClass($controller);
$containerProperty = $reflection->getParentClass()->getProperty('container');
$containerProperty->setAccessible(true);
$containerProperty->setValue($controller, $container->reveal());
// Create request
$request = new Request();
// Expect AccessDeniedException
$this->expectException(\Symfony\Component\Security\Core\Exception\AccessDeniedException::class);
// Execute the action
$controller->myInvitations($request);
}
}

View File

@@ -6,3 +6,11 @@ chill_calendar:
few {# rendez-vous sont ignorés par le filtre de date. Modifiez le filtre de date pour les voir apparaitre.}
other {# rendez-vous sont ignorés par le filtre de date. Modifiez le filtre de date pour les voir apparaitre.}
}
invite:
menu with counter: >-
{nb, plural,
=0 {Mes invitations}
one {# invitation}
few {# invitations}
other {# invitations}
}

View File

@@ -0,0 +1,8 @@
chill_calendar:
There are count ignored calendars by date filter: >-
{nbIgnored, plural,
=0 {Er zijn geen afspraken genegeerd door de datumfilter.}
one {Er is een afspraak genegeerd door de datumfilter. Wijzig de datumfilter om deze te laten verschijnen.}
few {# afspraken zijn genegeerd door de datumfilter. Wijzig de datumfilter om deze te laten verschijnen.}
other {# afspraken zijn genegeerd door de datumfilter. Wijzig de datumfilter om deze te laten verschijnen.}
}

View File

@@ -31,8 +31,7 @@ Will send SMS: Un SMS de rappel sera envoyé
Will not send SMS: Aucun SMS de rappel ne sera envoyé
SMS already sent: Un SMS a été envoyé
canceledBy: supprimé par
Canceled by: supprimé par
Canceled by: Annulé par
Calendar configuration: Gestion des rendez-vous
crud:
@@ -44,6 +43,14 @@ crud:
title_edit: Modifier le motif d'annulation
chill_calendar:
canceled: Annulé
cancel_reason: Raison d'annulation
cancel_calendar_item: Annuler rendez-vous
calendar_canceled: Le rendez-vous a été annulé
canceled_by:
user: Utilisateur
person: Usager
other: Autre
Document: Document d'un rendez-vous
form:
The main user is mandatory. He will organize the appointment.: L'utilisateur principal est obligatoire. Il est l'organisateur de l'événement.
@@ -80,12 +87,18 @@ remote_ms_graph:
remote_calendar:
calendar_range_title: Plage de disponibilité Chill
# small type-hint in remote calendar to says that the appointment is created through an invitation, and not as main referrer
calendar_invite_statement_in_calendar: Par invitation
invite:
accepted: Accepté
declined: Refusé
pending: En attente
tentative: Accepté provisoirement
list:
none: Il n'y aucun invitation
title: Mes invitations
number of invitations waiting: Invitations en attente
# exports
Exports of calendar: Exports des rendez-vous

View File

@@ -26,3 +26,149 @@ The calendar item has been successfully removed.: De afspraak is verwijdert
From the day: Vanaf
to the day: tot
Transform to activity: In activiteit omzetten
Create a new calendar in accompanying course: Afspraak aanmaken in het traject
Will send SMS: Er zal een herinnerings-sms worden verzonden
Will not send SMS: Er wordt geen herinnerings-sms verzonden
SMS already sent: Er is een sms verzonden
Canceled by: Geannuleerd door
Calendar configuration: Beheer van afspraken
crud:
calendar_cancel-reason:
index:
title: Lijst van annuleringsredenen
add_new: Nieuwe toevoegen
title_new: Nieuwe annuleringsreden
title_edit: Annuleringsreden bewerken
chill_calendar:
canceled: Geannuleerd
cancel_reason: Reden van annulering
cancel_calendar_item: Afspraak annuleren
calendar_canceled: De afspraak is geannuleerd
canceled_by:
user: Gebruiker
person: Gebruiker
other: Andere
Document: Document van een afspraak
form:
The main user is mandatory. He will organize the appointment.: De hoofdgebruiker is verplicht. Hij is de organisator van de gebeurtenis.
Create for referrer: Aanmaken voor de referent
start date filter: Begin van de afspraak
From: Van
To: Tot
Next calendars: Volgende afspraken
Add a document: Document toevoegen
Documents: Documenten
Create and add a document: Aanmaken en document toevoegen
Save and add a document: Opslaan en document toevoegen
Create for me: Afspraak aanmaken voor mezelf
Edit a document: Document bewerken
Document title: Titel
Document object: Document
Add a document from template: Document toevoegen vanaf sjabloon
Upload a document: Document uploaden
Remove a calendar document: Document van een afspraak verwijderen
Are you sure you want to remove the doc?: Weet u zeker dat u het gekoppelde document wilt verwijderen?
Document outdated: De datum en tijd van de afspraak zijn gewijzigd na het aanmaken van het document
remote_ms_graph:
freebusy_statuses:
busy: Bezet
free: Vrij
tentative: In afwachting van bevestiging
oof: Buiten kantoor
workingElsewhere: Werkt elders
unknown: Onbekend
cancel_event_because_main_user_is_%label%: De gebeurtenis is overgedragen aan gebruiker %label%
remote_calendar:
calendar_range_title: Chill-beschikbaarheidsperiode
invite:
accepted: Geaccepteerd
declined: Geweigerd
pending: In afwachting
tentative: Voorlopig geaccepteerd
list:
none: Er is geen uitnodiging
title: Mijn uitnodigingen
# exports
Exports of calendar: Exports van afspraken
Count calendars: Aantal afspraken
Count calendars by various parameters.: Telt het aantal afspraken op basis van verschillende parameters.
Average appointment duration: Gemiddelde duur van afspraken
Get the average of appointment duration according to various filters: Berekent het gemiddelde van de duur van afspraken op basis van verschillende parameters.
Sum of appointment durations: Som van de duur van afspraken
Get the sum of appointment durations according to various filters: Berekent de som van de duur van afspraken op basis van verschillende parameters.
'Filtered by agent: only %agents%': "Gefilterd op agenten: alleen %agents%"
Filter calendars by agent: Afspraken filteren op agenten
Filter calendars between certain dates: Afspraken filteren op datum van de afspraak
'Filtered by calendars between %dateFrom% and %dateTo%': 'Gefilterd op afspraken tussen %dateFrom% en %dateTo%'
'Filtered by calendar range: only %calendarRange%': 'Gefilterd op afspraken per beschikbaarheidsperiode: alleen de %calendarRange%'
Filter by calendar range: Filteren op afspraken binnen een beschikbaarheidsperiode of niet
Group calendars by agent: Afspraken groeperen op agent
Group calendars by location type: Afspraken groeperen op type locatie
Group calendars by location: Afspraken groeperen op afspraaklocatie
Group calendars by cancel reason: Afspraken groeperen op annuleringsreden
Group calendars by month and year: Afspraken groeperen op maand en jaar
Group calendars by urgency: Afspraken groeperen op urgent of niet
export:
aggregator.calendar:
agent_job:
Group calendars by agent job: Afspraken groeperen op beroep van de agent
agent_scope:
Group calendars by agent scope: Afspraken groeperen op dienst van de agent
filter.calendar:
agent_job:
Filter calendars by agent job: Afspraken filteren op beroepen van de agenten (hoofdgebruikers)
'Filtered by agent job: only %jobs%': 'Gefilterd op beroepen van de agenten (hoofdgebruikers): alleen de %jobs%'
agent_scope:
Filter calendars by agent scope: Afspraken filteren op diensten van de agenten (hoofdgebruikers)
'Filtered by agent scope: only %scopes%': 'Gefilterd op diensten van de agenten (hoofdgebruikers): alleen de diensten %scopes%'
Scope: Dienst
Job: Beroep
Location type: Type locatie
Location: Afspraaklocatie
by month and year: Per maand en jaar
is urgent: Urgent
is not urgent: Niet urgent
has calendar range: Binnen een beschikbaarheidsperiode?
Not made within a calendar range: Afspraak binnen een beschikbaarheidsperiode
Made within a calendar range: Afspraak buiten een beschikbaarheidsperiode
docgen:
calendar:
Base context for calendar: 'Afspraak: basiscontext'
A base context for generating document on calendar: Context voor het genereren van documenten op basis van afspraken
Track changes on datetime and warn user if date time is updated after the doc generation: Wijzigingen in het document volgen en gebruikers waarschuwen dat de datum en tijd zijn gewijzigd na het genereren van het document
Ask main person: Vragen om een gebruiker te kiezen uit de deelnemers aan de afspraken
Main person label: Label om de gebruiker te kiezen
Ask third party: Vragen om een derde te kiezen uit de deelnemers aan de afspraken
Third party label: Label om de derde te kiezen
Destinee: Geadresseerde
None: Geen keuze
title of the generated document: Titel van het gegenereerde document
CHILL_CALENDAR_CALENDAR_CREATE: Afspraken aanmaken
CHILL_CALENDAR_CALENDAR_EDIT: Afspraken bewerken
CHILL_CALENDAR_CALENDAR_DELETE: Afspraken verwijderen
CHILL_CALENDAR_CALENDAR_SEE: Afspraken bekijken
generic_doc:
filter:
keys:
accompanying_period_calendar_document: Document van afspraken van trajecten
person_calendar_document: Document van afspraken van de gebruiker

View File

@@ -0,0 +1,6 @@
calendar:
At least {{ limit }} person is required.: Minimaal {{ limit }} persoon is vereist voor deze afspraak
An end date is required: Geef een einddatum en -tijd op
A start date is required: Geef een startdatum en -tijd op
A location is required: Geef een locatie op
A main user is mandator: Geef een hoofdgebruiker op

View File

@@ -1,3 +1,103 @@
'Not available in your language': 'Vertaling niet mogelijk in het Nederlands'
'Other value': 'Andere mogelijkheid'
'None': 'Niet gespecifieerd'
'None': 'Niet gespecifieerd'
#customfieldsgroup rendering
Empty data: Lege gegevens
No data to show: Geen waarden om te tonen
#customfieldsgroup administration
CustomFieldsGroup list: Groepen van aangepaste velden
CustomFieldsGroup creation: Nieuwe groep van aangepaste velden
Entity: Entiteit
"Is default ?": "Standaard?"
"Some module select default groups for some usage. Example: the default person group is shown under person page.": "Sommige modules selecteren standaardgroepen voor bepaald gebruik. Voorbeeld: de standaard persoonsgroep wordt getoond op de persoonspagina"
Make default: Groep standaard maken
Create a new group: Nieuwe groep aanmaken
CustomFieldsGroup details: Details van de groep aangepaste velden
Fields associated with this group: Velden geassocieerd met deze groep
Any field is currently associated with this group: Geen veld is momenteel geassocieerd met deze groep
Create a new field: Nieuw aangepast veld aanmaken
Add a new field: Aangepast veld toevoegen
ordering: volgorde
label_field: label van het veld
active: actief
No value defined for this option: Geen waarde gedefinieerd voor deze optie
CustomFieldsGroup edit: Bewerken van een groep aangepaste velden
type: type
The custom fields group has been created: De groep aangepaste velden is aangemaakt
The custom fields group has been updated: De groep aangepaste velden is bijgewerkt
The custom fields group form contains errors: Het formulier bevat fouten
The default custom fields group has been changed: De standaardgroep is gewijzigd
#menu entries
Custom fields configuration: Aangepaste velden
CustomFields List: Lijst van aangepaste velden
CustomFields Groups: Groep van aangepaste velden
#customfield administration
Custom fields: Aangepaste velden
CustomFields configuration: Beheer van aangepaste velden
CustomField edit: Wijziging van een aangepast veld
CustomField creation: Nieuw aangepast veld
General informations: Algemene informatie
Options: Opties
Custom fields group: Groep van aangepaste velden
Ordering: Volgorde van verschijning
Required field: Verplicht veld
An answer is required: Een antwoord is vereist
Any answer is required: Geen antwoord is vereist
Back to the group: Terug naar de groep aangepaste velden
Slug: Tekstuele identificatie
The custom field has been created: Het aangepaste veld is aangemaakt
The custom field form contains errors: Het formulier bevat fouten
The custom field has been updated: Het aangepaste veld is bijgewerkt
#custom field name
choice: keuze
Title: Titel
text: tekst
Text field: Tekstveld
Date field: Datumveld
#custom field choice
Multiplicity: Multipliciteit
Multiple: Meerdere
Unique: Slechts één keuze mogelijk
Choice display: Weergave van keuzes
Expanded: Uitgebreide keuzes (radioknoppen)
Non expanded: Gegroepeerde keuzes
Allow other: Andere waarde toestaan
No: Nee
Yes: Ja
Other value label (empty if use by default): Label van het veld "andere waarde"
Choices: Keuzes
Add an element: Element toevoegen
#custom field text
Max length: Maximale lengte
Box appearance: Verschijning van het veld
Multiple boxes on the line: Meerdere velden op de lijn
One box on the line: Slechts één veld op de lijn
#custom field title
Title level: Titelniveau
Main title: Hoofdtitel
Subtitle: Ondertitel
#custom field number
Greater or equal than: Groter dan of gelijk aan
Lesser or equal than: Kleiner dan of gelijk aan
Precision: Precisie
Text after the field: Tekst na het veld
Number field: Nummerveld
#custom field long choice
Options key: Sleutel van opties
Choose a value: Kies een waarde
Long choice field: Veld met vooraf geregistreerde keuzes
#Custom field date
Greater or equal than (expression like 1 day ago, 2 years ago, +1 month, today, tomorrow, or date with format YYYY-mm-dd): Datum na (geef een PHP-expressie op zoals '1 day ago', '2 years ago', '+1 month', 'today', 'tomorrow', ...)
Lesser or equal than (expression like 1 day ago, 2 years ago, +1 month, today, tomorrow, or date with format YYYY-mm-dd): Datum voor (geef een PHP-expressie op zoals '1 day ago', '2 years ago', '+1 month', 'today', 'tomorrow', ...)

View File

@@ -0,0 +1,3 @@
Characters not allowed. Only lowercase letters, numbers and "-" are allowed.: Tekens niet toegestaan. Alleen kleine letters, cijfers en "-" zijn toegestaan.
'This date must be after or equal to %date%': Deze datum moet gelijk zijn aan of na %date%
'This date must be before or equal to %date%': Deze datum moet voor of gelijk zijn aan %date%

View File

@@ -90,7 +90,7 @@ class AdminDocGeneratorTemplateController extends CRUDController
#[\Override]
protected function orderQuery(string $action, $query, Request $request, PaginatorInterface $paginator): QueryBuilder
{
return $query->addSelect('JSON_EXTRACT(e.name, :lang) AS HIDDEN name_lang')
return $query->addSelect('REPLACE(JSON_EXTRACT(e.name, :lang), \' \', \'~\') AS HIDDEN name_lang')
->setParameter('lang', $request->getLocale())
->addOrderBy('name_lang', 'ASC');
}

View File

@@ -20,4 +20,9 @@ use Doctrine\Persistence\ObjectRepository;
interface DocGeneratorTemplateRepositoryInterface extends ObjectRepository
{
public function countByEntity(string $entity): int;
/**
* @return array|DocGeneratorTemplate[]
*/
public function findByEntity(string $entity, ?int $start = 0, ?int $limit = 50): array;
}

View File

@@ -1,59 +1,54 @@
<template>
<div>
<template v-if="templates.length > 0">
<slot name="title">
<h2>{{ $t("generate_document") }}</h2>
</slot>
<div>
<template v-if="templates.length > 0">
<slot name="title">
<h2>{{ $t("generate_document") }}</h2>
</slot>
<div class="container">
<div class="row">
<div class="col-md-4">
<slot name="label">
<label>{{ $t("select_a_template") }}</label>
</slot>
</div>
<div class="col-md-8">
<div class="input-group mb-3">
<select class="form-select" v-model="template">
<option disabled selected value="">
{{ $t("choose_a_template") }}
</option>
<template v-for="t in templates" :key="t.id">
<option :value="t.id">
{{
localizeString(t.name) ||
"Aucun nom défini"
}}
</option>
</template>
</select>
<a
v-if="canGenerate"
class="btn btn-update btn-sm change-icon"
:href="buildUrlGenerate"
@click.prevent="
clickGenerate($event, buildUrlGenerate)
"
><i class="fa fa-fw fa-cog"
/></a>
<a
v-else
class="btn btn-update btn-sm change-icon"
href="#"
disabled
><i class="fa fa-fw fa-cog"
/></a>
</div>
</div>
</div>
<div class="row" v-if="hasDescription">
<div class="col-md-8 align-self-end">
<p>{{ getDescription }}</p>
</div>
</div>
<div class="container">
<div class="row">
<div class="col-md-4">
<slot name="label">
<label>{{ $t("select_a_template") }}</label>
</slot>
</div>
<div class="col-md-8">
<div class="input-group mb-3">
<select class="form-select" v-model="template">
<option disabled selected value="">
{{ $t("choose_a_template") }}
</option>
<template v-for="t in templates" :key="t.id">
<option :value="t.id">
{{ localizeString(t.name) || "Aucun nom défini" }}
</option>
</template>
</select>
<a
v-if="canGenerate"
class="btn btn-update btn-sm change-icon"
:href="buildUrlGenerate"
@click.prevent="clickGenerate($event, buildUrlGenerate)"
><i class="fa fa-fw fa-cog"
/></a>
<a
v-else
class="btn btn-update btn-sm change-icon"
href="#"
disabled
><i class="fa fa-fw fa-cog"
/></a>
</div>
</template>
</div>
</div>
</div>
<div class="row" v-if="hasDescription">
<div class="col-md-8 align-self-end">
<p>{{ getDescription }}</p>
</div>
</div>
</div>
</template>
</div>
</template>
<script>
@@ -61,83 +56,83 @@ import { buildLink } from "ChillDocGeneratorAssets/lib/document-generator";
import { localizeString } from "ChillMainAssets/lib/localizationHelper/localizationHelper";
export default {
name: "PickTemplate",
props: {
entityId: [String, Number],
entityClass: {
type: String,
required: false,
},
templates: {
type: Array,
required: true,
},
preventDefaultMoveToGenerate: {
type: Boolean,
required: false,
default: false,
},
name: "PickTemplate",
props: {
entityId: [String, Number],
entityClass: {
type: String,
required: false,
},
emits: ["goToGenerateDocument"],
data() {
return {
template: null,
};
templates: {
type: Array,
required: true,
},
computed: {
canGenerate() {
return this.template != null;
},
hasDescription() {
if (this.template == null) {
return false;
}
preventDefaultMoveToGenerate: {
type: Boolean,
required: false,
default: false,
},
},
emits: ["goToGenerateDocument"],
data() {
return {
template: null,
};
},
computed: {
canGenerate() {
return this.template != null;
},
hasDescription() {
if (this.template == null) {
return false;
}
return true;
},
getDescription() {
if (null === this.template) {
return "";
}
let desc = this.templates.find((t) => t.id === this.template);
if (null === desc) {
return "";
}
return desc.description || "";
},
buildUrlGenerate() {
if (null === this.template) {
return "#";
}
return true;
},
getDescription() {
if (null === this.template) {
return "";
}
let desc = this.templates.find((t) => t.id === this.template);
if (null === desc) {
return "";
}
return desc.description || "";
},
buildUrlGenerate() {
if (null === this.template) {
return "#";
}
return buildLink(this.template, this.entityId, this.entityClass);
},
return buildLink(this.template, this.entityId, this.entityClass);
},
methods: {
localizeString(str) {
return localizeString(str);
},
clickGenerate(event, link) {
if (!this.preventDefaultMoveToGenerate) {
window.location.assign(link);
}
},
methods: {
localizeString(str) {
return localizeString(str);
},
clickGenerate(event, link) {
if (!this.preventDefaultMoveToGenerate) {
window.location.assign(link);
}
this.$emit("goToGenerateDocument", {
event,
link,
template: this.template,
});
},
this.$emit("goToGenerateDocument", {
event,
link,
template: this.template,
});
},
i18n: {
messages: {
fr: {
generate_document: "Générer un document",
select_a_template: "Choisir un modèle",
choose_a_template: "Choisir",
},
},
},
i18n: {
messages: {
fr: {
generate_document: "Générer un document",
select_a_template: "Choisir un modèle",
choose_a_template: "Choisir",
},
},
},
};
</script>

View File

@@ -25,12 +25,24 @@
<div class="item-bloc">
<div class="item-row">
<div class="item-col" style="flex-basis:100%;">
<h2>{{ entity.name|localize_translatable_string }}</h2>
<h2>{{ entity.name|localize_translatable_string }} </h2>
<p style="margin-left: 1rem;"><span class="badge bg-chill-gray">
{% if entity.active %}
{{ 'admin.active'|trans }}
{% else %}
{{ 'admin.not active'|trans }}
{% endif %}
</span></p>
</div>
</div>
<div class="item-row">
<p><span class="badge bg-chill-green-dark">{{ contextManager.getContextByKey(entity.context).name|trans }}</span></p>
</div>
{# <div class="item-row">#}
{# <div class="item-col" style="flex-basis:100%;">#}
{##}
{# </div>#}
{# </div>#}
<div class="item-row">
<div class="item-col"></div>
<ul class="record_actions item-col flex-shrink-1">

View File

@@ -25,6 +25,8 @@ use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Messenger\Event\WorkerMessageFailedEvent;
use Symfony\Contracts\Translation\TranslatorInterface;
// use Symfony\Component\Translation\LocaleSwitcher;
/**
* @see OnGenerationFailsTest for test suite
*/
@@ -40,6 +42,7 @@ final readonly class OnGenerationFails implements EventSubscriberInterface
private StoredObjectRepositoryInterface $storedObjectRepository,
private TranslatorInterface $translator,
private UserRepositoryInterface $userRepository,
// private LocaleSwitcher $localeSwitcher,
) {}
public static function getSubscribedEvents(): array
@@ -118,7 +121,26 @@ final readonly class OnGenerationFails implements EventSubscriberInterface
return;
}
$email = new TemplatedEmail()
// Implementation with LocaleSwitcher (commented out - to be activated after migration to sf7.2):
/*
$this->localeSwitcher->runWithLocale($creator->getLocale(), function () use ($message, $errors, $template, $creator) {
$email = (new TemplatedEmail())
->to($message->getSendResultToEmail())
->subject($this->translator->trans('docgen.failure_email.The generation of a document failed'))
->textTemplate('@ChillDocGenerator/Email/on_generation_failed_email.txt.twig')
->context([
'errors' => $errors,
'template' => $template,
'creator' => $creator,
'stored_object_id' => $message->getDestinationStoredObjectId(),
]);
$this->mailer->send($email);
});
*/
// Current implementation:
$email = (new TemplatedEmail())
->to($message->getSendResultToEmail())
->subject($this->translator->trans('docgen.failure_email.The generation of a document failed'))
->textTemplate('@ChillDocGenerator/Email/on_generation_failed_email.txt.twig')

View File

@@ -26,6 +26,8 @@ use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Messenger\Exception\UnrecoverableMessageHandlingException;
use Symfony\Contracts\Translation\TranslatorInterface;
// use Symfony\Component\Translation\LocaleSwitcher;
/**
* Handle the request of document generation.
*/
@@ -45,6 +47,7 @@ class RequestGenerationHandler
private readonly MailerInterface $mailer,
private readonly TranslatorInterface $translator,
private readonly StoredObjectManagerInterface $storedObjectManager,
// private readonly LocaleSwitcher $localeSwitcher,
) {}
public function __invoke(RequestGenerationMessage $message): void
@@ -121,6 +124,30 @@ class RequestGenerationHandler
private function sendDataDump(StoredObject $destinationStoredObject, RequestGenerationMessage $message): void
{
// Implementation with LocaleSwitcher (commented out - to be activated after migration to sf7.2):
// Note: This method sends emails to admin addresses, not user addresses, so locale switching may not be needed
/*
$this->localeSwitcher->runWithLocale('fr', function () use ($destinationStoredObject, $message) {
// Get the content of the document
$content = $this->storedObjectManager->read($destinationStoredObject);
$filename = $destinationStoredObject->getFilename();
$contentType = $destinationStoredObject->getType();
// Create the email with the document as an attachment
$email = (new TemplatedEmail())
->to($message->getSendResultToEmail())
->textTemplate('@ChillDocGenerator/Email/send_data_dump_to_admin.txt.twig')
->context([
'filename' => $filename,
])
->subject($this->translator->trans('docgen.data_dump_email.subject'))
->attach($content, $filename, $contentType);
$this->mailer->send($email);
});
*/
// Current implementation:
// Get the content of the document
$content = $this->storedObjectManager->read($destinationStoredObject);
$filename = $destinationStoredObject->getFilename();

View File

@@ -0,0 +1,2 @@
docgen:
# Geen ICU berichten nodig voor data_dump_email meer

View File

@@ -49,3 +49,7 @@ crud:
Template file: Fichier modèle
admin:
active: Actif
not active: Non-actif

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