Merge remote-tracking branch 'origin/master' into upgrade-sf5

This commit is contained in:
Julien Fastré 2024-09-12 13:28:08 +02:00
commit bc34d84d63
Signed by: julienfastre
GPG Key ID: BDE2190974723FCB
32 changed files with 1483 additions and 620 deletions

View File

@ -1,4 +1,4 @@
## v2.23.0 - 2024-07-23
## v2.23.0 - 2024-07-23 & 2024-07-19
### Feature
* ([#221](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/221)) [DX] move async-upload-bundle features into chill-bundles
* Add job bundle (module emploi)
@ -6,6 +6,25 @@
* Upgrade CKEditor and refactor configuration with use of typescript
* ([#123](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/123)) Add a button to duplicate calendar ranges from a week to another one
* [admin] filter users by active / inactive in the admin user's list
* ([#273](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/273)) Add the possibility to mark all notifications as read
* Handle duplicate reference id in the import of reference addresses
* Do not update the "createdAt" column when importing postal code which does not change
* Display filename on file upload within the UI interface
### Fixed
* Fix resolving of centers for an household, which will fix in turn the access control
* Resolved type hinting error in activity list export
* ([#271](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/271)) Take into account the acp closing date in the acp works date filter
### Traduction française des principaux changements
- Ajout d'un bouton pour dupliquer les périodes de disponibilités d'une semaine à une autre;
- dans l'interface d'administration, filtre sur les utilisateurs actifs. Par défaut, seul les utilisateurs
actifs sont affichés;
- Nouveau bouton pour indiquer toutes les notifications comme lues;
- Améliorations sur l'import des adresses et des codes postaux;
- Affiche le nom du fichier déposé quand on téléverse un fichier depuis le poste de travail local;
- Agrandit l'icône du type de fichier dans l'interface de dépôt de fichier;
- correction: tient compte de la date de fermeture du parcours dans les filtres sur les actions d'accompagnement.

3
.changes/v2.24.0.md Normal file
View File

@ -0,0 +1,3 @@
## v2.24.0 - 2024-09-11
### Feature
* ([#306](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/306)) When a document is converted or downloaded in the browser, this document is removed from the browser memory after 45s. Future click on the button re-download the document.

View File

@ -16,8 +16,33 @@ and is generated by [Changie](https://github.com/miniscruff/changie).
* Fix connection to azure when making an calendar event in chill
* CollectionType js fixes for remove button and adding multiple entries
## v2.23.0 - 2024-07-23
## v2.24.0 - 2024-09-11
### Feature
* ([#306](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/306)) When a document is converted or downloaded in the browser, this document is removed from the browser memory after 45s. Future click on the button re-download the document.
## v2.23.0 - 2024-07-19 & 2024-07-23
### Feature
* ([#123](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/123)) Add a button to duplicate calendar ranges from a week to another one
* [admin] filter users by active / inactive in the admin user's list
* ([#273](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/273)) Add the possibility to mark all notifications as read
* Handle duplicate reference id in the import of reference addresses
* Do not update the "createdAt" column when importing postal code which does not change
* Display filename on file upload within the UI interface
### Fixed
* ([#271](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/271)) Take into account the acp closing date in the acp works date filter
### Traduction française des principaux changements
- Ajout d'un bouton pour dupliquer les périodes de disponibilités d'une semaine à une autre;
- dans l'interface d'administration, filtre sur les utilisateurs actifs. Par défaut, seul les utilisateurs
actifs sont affichés;
- Nouveau bouton pour indiquer toutes les notifications comme lues;
- Améliorations sur l'import des adresses et des codes postaux;
- Affiche le nom du fichier déposé quand on téléverse un fichier depuis le poste de travail local;
- Agrandit l'icône du type de fichier dans l'interface de dépôt de fichier;
- correction: tient compte de la date de fermeture du parcours dans les filtres sur les actions d'accompagnement.
* ([#221](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/221)) [DX] move async-upload-bundle features into chill-bundles
* Add job bundle (module emploi)
* Upgrade import of address list to the last version of compiled addresses of belgian-best-address

View File

@ -39,9 +39,12 @@ Implements a :code:`Chill\MainBundle\Cron\CronJobInterface`. Here is an example:
use Chill\MainBundle\Entity\CronJobExecution;
use DateInterval;
use DateTimeImmutable;
use Symfony\Component\Clock\ClockInterface;
class MyCronJob implements CronJobInterface
{
function __construct(private ClockInterface $clock) {}
public function canRun(?CronJobExecution $cronJobExecution): bool
{
// the parameter $cronJobExecution contains data about the last execution of the cronjob
@ -56,7 +59,7 @@ Implements a :code:`Chill\MainBundle\Cron\CronJobInterface`. Here is an example:
// this cron job should be executed if the last execution is greater than one day, but only during the night
$now = new DateTimeImmutable('now');
$now = $clock->now();
return $cronJobExecution->getLastStart() < $now->sub(new DateInterval('P1D'))
&& in_array($now->format('H'), self::ACCEPTED_HOURS, true)
@ -69,9 +72,14 @@ Implements a :code:`Chill\MainBundle\Cron\CronJobInterface`. Here is an example:
return 'arbitrary-and-unique-key';
}
public function run(): void
public function run(array $lastExecutionData): void
{
// here, we execute the command
// we return execution data, which will be served for next execution
// this data should be easily serializable in a json column: it should contains
// only int, string, etc. Avoid storing object
return ['last-execution-id' => 0];
}
}

View File

@ -1,7 +1,7 @@
<template>
<div class="row">
<div class="col-sm">
<label class="form-label">{{ $t('created_availabilities') }}</label>
<label class="form-label">{{ $t("created_availabilities") }}</label>
<vue-multiselect
v-model="pickedLocation"
:options="locations"
@ -14,10 +14,15 @@
></vue-multiselect>
</div>
</div>
<div class="display-options row justify-content-between" style="margin-top: 1rem;">
<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>
<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>
@ -58,13 +63,20 @@
</select>
</div>
</div>
<div class="col-sm-3 col-xs-12">
<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">
<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>
<label for="showHideWE" class="form-check-label input-group-text"
>Week-ends</label
>
</div>
</div>
</div>
@ -72,39 +84,86 @@
<FullCalendar :options="calendarOptions" ref="calendarRef">
<template v-slot:eventContent="arg: EventApi">
<span :class="eventClasses(arg.event)">
<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 }}</b>
<b v-else-if="arg.event.extendedProps.is === 'local'">{{ arg.event.title}}</b>
<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 }}</b
>
<b v-else-if="arg.event.extendedProps.is === 'local'">{{
arg.event.title
}}</b>
<b v-else>no 'is'</b>
<a v-if="arg.event.extendedProps.is === 'range'" class="fa fa-fw fa-times delete"
@click.prevent="onClickDelete(arg.event)">
<a
v-if="arg.event.extendedProps.is === 'range'"
class="fa fa-fw fa-times delete"
@click.prevent="onClickDelete(arg.event)"
>
</a>
</span>
</template>
</FullCalendar>
<div id="copy-widget">
<div class="container">
<div class="row align-items-center">
<div class="col-sm-4 col-xs-12">
<h6 class="chill-red">{{ $t('copy_range_from_to') }}</h6>
<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-sm-3 col-xs-12">
<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-sm-1 col-xs-12" style="text-align: center; font-size: x-large;">
<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-sm-3 col-xs-12" >
<div class="col-xs-12 col-sm-3 col-md-3">
<input class="form-control" type="date" v-model="copyTo" />
</div>
<div class="col-sm-1">
<button class="btn btn-action" @click="copyDay">
{{ $t('copy_range') }}
<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>
</div>
<!-- not directly seen, but include in a modal -->
@ -112,21 +171,31 @@
</template>
<script setup lang="ts">
import type {
CalendarOptions,
DatesSetArg,
EventInput
} from '@fullcalendar/core';
import {reactive, computed, ref} from "vue";
EventInput,
} from "@fullcalendar/core";
import { reactive, computed, ref, onMounted } from "vue";
import { useStore } from "vuex";
import {key} from './store';
import FullCalendar from '@fullcalendar/vue3';
import frLocale from '@fullcalendar/core/locales/fr';
import interactionPlugin, {DropArg, EventResizeDoneArg} from "@fullcalendar/interaction";
import { key } from "./store";
import FullCalendar from "@fullcalendar/vue3";
import frLocale from "@fullcalendar/core/locales/fr";
import interactionPlugin, {
DropArg,
EventResizeDoneArg,
} from "@fullcalendar/interaction";
import timeGridPlugin from "@fullcalendar/timegrid";
import {EventApi, DateSelectArg, EventDropArg, EventClickArg} from "@fullcalendar/core";
import {ISOToDate} from "../../../../../ChillMainBundle/Resources/public/chill/js/date";
import {
EventApi,
DateSelectArg,
EventDropArg,
EventClickArg,
} from "@fullcalendar/core";
import {
dateToISO,
ISOToDate,
} from "../../../../../ChillMainBundle/Resources/public/chill/js/date";
import VueMultiselect from "vue-multiselect";
import { Location } from "../../../../../ChillMainBundle/Resources/public/types";
import EditLocation from "./Components/EditLocation.vue";
@ -137,17 +206,60 @@ const store = useStore(key);
const { t } = useI18n();
const showWeekends = ref(false);
const slotDuration = ref('00:05:00');
const slotMinTime = ref('09:00:00');
const slotMaxTime = ref('18:00:00');
const slotDuration = ref("00:15:00");
const slotMinTime = ref("09:00:00");
const slotMaxTime = ref("18:00:00");
const copyFrom = ref<string | null>(null);
const copyTo = ref<string | null>(null);
const editLocation = ref<InstanceType<typeof EditLocation> | null>(null)
const editLocation = ref<InstanceType<typeof EditLocation> | null>(null);
const dayOrWeek = ref("day");
const copyFromWeek = ref<string | null>(null);
const copyToWeek = ref<string | null>(null);
interface Weeks {
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 dateOptions: Intl.DateTimeFormatOptions = {
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)}`,
};
})
);
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)}`,
};
})
);
const baseOptions = ref<CalendarOptions>({
locale: frLocale,
plugins: [interactionPlugin, timeGridPlugin],
initialView: 'timeGridWeek',
initialView: "timeGridWeek",
initialDate: new Date(),
scrollTimeReset: false,
selectable: true,
@ -164,9 +276,9 @@ const baseOptions = ref<CalendarOptions>({
selectMirror: false,
editable: true,
headerToolbar: {
left: 'prev,next today',
center: 'title',
right: 'timeGridWeek,timeGridDay'
left: "prev,next today",
center: "title",
right: "timeGridWeek,timeGridDay",
},
});
@ -180,20 +292,23 @@ const locations = computed<Location[]>(() => {
const pickedLocation = computed<Location | null>({
get(): Location | null {
return store.state.locations.locationPicked || store.state.locations.currentLocation;
return (
store.state.locations.locationPicked ||
store.state.locations.currentLocation
);
},
set(newLocation: Location | null): void {
store.commit('locations/setLocationPicked', newLocation, {root: true});
}
})
store.commit("locations/setLocationPicked", newLocation, { root: true });
},
});
/**
* return the show classes for the event
* @param arg
*/
const eventClasses = function (arg: EventApi): object {
return {'calendarRangeItems': true};
}
return { calendarRangeItems: true };
};
/*
// currently, all events are stored into calendarRanges, due to reactivity bug
@ -230,51 +345,60 @@ const calendarOptions = computed((): CalendarOptions => {
* 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é.");
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 {
console.log('onClickDelete', event);
if (event.extendedProps.is !== 'range') {
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') {
if (payload.event.extendedProps.is !== "range") {
return;
}
const changedEvent = payload.event;
store.dispatch('calendarRanges/patchRangeTime', {
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')) {
if (payload.jsEvent.target.classList.contains("delete")) {
return;
}
if (payload.event.extendedProps.is !== 'range') {
if (payload.event.extendedProps.is !== "range") {
return;
}
@ -285,10 +409,26 @@ function copyDay() {
if (null === copyFrom.value || null === copyTo.value) {
return;
}
store.dispatch('calendarRanges/copyFromDayToAnotherDay', {from: ISOToDate(copyFrom.value), to: ISOToDate(copyTo.value)})
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),
});
}
onMounted(() => {
copyFromWeek.value = dateToISO(getMonday(0));
copyToWeek.value = dateToISO(getMonday(1));
});
</script>
<style scoped>
@ -299,4 +439,9 @@ function copyDay() {
z-index: 9999999999;
padding: 0.25rem 0 0.25rem;
}
div.copy-chevron {
text-align: center;
font-size: x-large;
width: 2rem;
}
</style>

View File

@ -5,11 +5,9 @@ const appMessages = {
show_my_calendar: "Afficher mon calendrier",
show_weekends: "Afficher les week-ends",
copy_range: "Copier",
copy_range_from_to: "Copier les plages d'un jour à l'autre",
copy_range_to_next_day: "Copier les plages du jour au jour suivant",
copy_range_from_day: "Copier les plages du ",
to_the_next_day: " au jour suivant",
copy_range_to_next_week: "Copier les plages de la semaine à la semaine suivante",
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",

View File

@ -52,6 +52,23 @@ export default <Module<CalendarRangesState, State>>{
}
}
return founds;
},
getRangesOnWeek: (state: CalendarRangesState) => (mondayDate: Date): EventInputCalendarRange[] => {
const founds = [];
for (let d of Array.from(Array(7).keys())) {
const dateOfWeek = new Date(mondayDate);
dateOfWeek.setDate(mondayDate.getDate() + d);
const dateStr = <string>dateToISO(dateOfWeek);
for (let range of state.ranges) {
if (isEventInputCalendarRange(range)
&& range.start.startsWith(dateStr)
) {
founds.push(range);
}
}
}
return founds;
},
},
@ -238,7 +255,7 @@ export default <Module<CalendarRangesState, State>>{
for (let r of rangesToCopy) {
let start = new Date(<Date>ISOToDatetime(r.start));
start.setFullYear(to.getFullYear(), to.getMonth(), to.getDate())
start.setFullYear(to.getFullYear(), to.getMonth(), to.getDate());
let end = new Date(<Date>ISOToDatetime(r.end));
end.setFullYear(to.getFullYear(), to.getMonth(), to.getDate());
let location = ctx.rootGetters['locations/getLocationById'](r.locationId);
@ -246,6 +263,23 @@ export default <Module<CalendarRangesState, State>>{
promises.push(ctx.dispatch('createRange', {start, end, location}));
}
return Promise.all(promises).then(_ => Promise.resolve(null));
},
copyFromWeekToAnotherWeek(ctx, {fromMonday, toMonday}: {fromMonday: Date, toMonday: Date}): Promise<null> {
const rangesToCopy: EventInputCalendarRange[] = ctx.getters['getRangesOnWeek'](fromMonday);
const promises = [];
const diffTime = toMonday.getTime() - fromMonday.getTime();
for (let r of rangesToCopy) {
let start = new Date(<Date>ISOToDatetime(r.start));
let end = new Date(<Date>ISOToDatetime(r.end));
start.setTime(start.getTime() + diffTime);
end.setTime(end.getTime() + diffTime);
let location = ctx.rootGetters['locations/getLocationById'](r.locationId);
promises.push(ctx.dispatch('createRange', {start, end, location}));
}
return Promise.all(promises).then(_ => Promise.resolve(null));
}
}

View File

@ -16,6 +16,7 @@ const emit = defineEmits<{
const is_dragging: Ref<boolean> = ref(false);
const uploading: Ref<boolean> = ref(false);
const display_filename: Ref<string|null> = ref(null);
const has_existing_doc = computed<boolean>(() => {
return props.existingDoc !== undefined && props.existingDoc !== null;
@ -79,6 +80,7 @@ const onFileChange = async (event: Event): Promise<void> => {
const handleFile = async (file: File): Promise<void> => {
uploading.value = true;
display_filename.value = file.name;
const type = file.type;
const buffer = await file.arrayBuffer();
const [encrypted, iv, jsonWebKey] = await encryptFile(buffer);
@ -103,7 +105,7 @@ const handleFile = async (file: File): Promise<void> => {
<template>
<div class="drop-file">
<div v-if="!uploading" :class="{ area: true, dragging: is_dragging}" @click="onZoneClick" @dragover="onDragOver" @dragleave="onDragLeave" @drop="onDrop">
<p v-if="has_existing_doc">
<p v-if="has_existing_doc" class="file-icon">
<i class="fa fa-file-pdf-o" v-if="props.existingDoc?.type === 'application/pdf'"></i>
<i class="fa fa-file-word-o" v-else-if="props.existingDoc?.type === 'application/vnd.oasis.opendocument.text'"></i>
<i class="fa fa-file-word-o" v-else-if="props.existingDoc?.type === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'"></i>
@ -115,6 +117,8 @@ const handleFile = async (file: File): Promise<void> => {
<i class="fa fa-file-archive-o" v-else-if="props.existingDoc?.type === 'application/x-zip-compressed'"></i>
<i class="fa fa-file-code-o" v-else ></i>
</p>
<p v-if="display_filename !== null" class="display-filename">{{ display_filename }}</p>
<!-- todo i18n -->
<p v-if="has_existing_doc">Déposez un document ou cliquez ici pour remplacer le document existant</p>
<p v-else>Déposez un document ou cliquez ici pour ouvrir le navigateur de fichier</p>
@ -130,9 +134,18 @@ const handleFile = async (file: File): Promise<void> => {
.drop-file {
width: 100%;
.file-icon {
font-size: xx-large;
}
.display-filename {
font-variant: small-caps;
font-weight: 200;
}
& > .area, & > .waiting {
width: 100%;
height: 8rem;
height: 10rem;
display: flex;
flex-direction: column;
@ -149,7 +162,4 @@ const handleFile = async (file: File): Promise<void> => {
}
}
div.chill-collection ul.list-entry li.entry:nth-child(2n) {
}
</style>

View File

@ -1,5 +1,5 @@
<template>
<a :class="props.classes" @click="download_and_open($event)">
<a :class="props.classes" @click="download_and_open($event)" ref="btn">
<i class="fa fa-file-pdf-o"></i>
Télécharger en pdf
</a>
@ -9,7 +9,7 @@
import {build_convert_link, download_and_decrypt_doc, download_doc} from "./helpers";
import mime from "mime";
import {reactive} from "vue";
import {reactive, ref} from "vue";
import {StoredObject, StoredObjectCreated} from "../../types";
interface ConvertButtonConfig {
@ -24,6 +24,7 @@ interface DownloadButtonState {
const props = defineProps<ConvertButtonConfig>();
const state: DownloadButtonState = reactive({content: null});
const btn = ref<HTMLAnchorElement | null>(null);
async function download_and_open(event: Event): Promise<void> {
const button = event.target as HTMLAnchorElement;
@ -41,6 +42,14 @@ async function download_and_open(event: Event): Promise<void> {
}
button.click();
const reset_pending = setTimeout(reset_state, 45000);
}
function reset_state(): void {
state.content = null;
btn.value?.removeAttribute('download');
btn.value?.removeAttribute('href');
btn.value?.removeAttribute('type');
}
</script>

View File

@ -86,6 +86,14 @@ async function download_and_open(event: Event): Promise<void> {
console.log('openbutton after next tick', open_button.value);
open_button.value?.click();
console.log('open button should have been clicked');
const timer = setTimeout(reset_state, 45000);
}
function reset_state(): void {
state.href_url = '#';
state.is_ready = false;
state.is_running = false;
}
</script>

View File

@ -94,4 +94,38 @@ class NotificationApiController
return new JsonResponse(null, JsonResponse::HTTP_ACCEPTED, [], false);
}
/**
* @Route("/mark/allread", name="chill_api_main_notification_mark_allread", methods={"POST"})
*/
public function markAllRead(): JsonResponse
{
$user = $this->security->getUser();
if (!$user instanceof User) {
throw new \RuntimeException('Invalid user');
}
$modifiedNotificationIds = $this->notificationRepository->markAllNotificationAsReadForUser($user);
return new JsonResponse($modifiedNotificationIds);
}
/**
* @Route("/mark/undoallread", name="chill_api_main_notification_mark_undoallread", methods={"POST"})
*/
public function undoAllRead(Request $request): JsonResponse
{
$user = $this->security->getUser();
if (!$user instanceof User) {
throw new \RuntimeException('Invalid user');
}
$ids = json_decode($request->getContent(), true, 512, JSON_THROW_ON_ERROR);
$touchedIds = $this->notificationRepository->markAllNotificationAsUnreadForUser($user, $ids);
return new JsonResponse($touchedIds);
}
}

View File

@ -169,7 +169,7 @@ class NotificationController extends AbstractController
#[Route(path: '/inbox', name: 'chill_main_notification_my')]
public function inboxAction(): Response
{
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_REMEMBERED');
$this->denyAccessUnlessGranted('ROLE_USER');
$currentUser = $this->security->getUser();
$notificationsNbr = $this->notificationRepository->countAllForAttendee($currentUser);
@ -177,8 +177,8 @@ class NotificationController extends AbstractController
$notifications = $this->notificationRepository->findAllForAttendee(
$currentUser,
$limit = $paginator->getItemsPerPage(),
$offset = $paginator->getCurrentPage()->getFirstItemNumber()
$paginator->getItemsPerPage(),
$paginator->getCurrentPage()->getFirstItemNumber()
);
return $this->render('@ChillMain/Notification/list.html.twig', [

View File

@ -278,7 +278,7 @@ final class PasswordController extends AbstractController
}
/**
* @return \Symfony\Component\Form\Form
* @return \Symfony\Component\Form\FormInterface
*/
private function passwordForm(User $user)
{

View File

@ -264,6 +264,7 @@ class UserController extends CRUDController
return $this->getFilterOrderHelperFactory()
->create(self::class)
->addSearchBox(['label'])
->addCheckbox('activeFilter', [true => 'Active', false => 'Inactive'], ['Active'])
->build();
}
@ -273,11 +274,7 @@ class UserController extends CRUDController
return parent::countEntities($action, $request, $filterOrder);
}
if (null === $filterOrder->getQueryString()) {
return parent::countEntities($action, $request, $filterOrder);
}
return $this->userRepository->countByUsernameOrEmail($filterOrder->getQueryString());
return $this->userRepository->countFilteredUsers($filterOrder->getQueryString(), $filterOrder->getCheckboxData('activeFilter'));
}
protected function createFormFor(string $action, $entity, ?string $formClass = null, array $formOptions = []): FormInterface
@ -334,16 +331,13 @@ class UserController extends CRUDController
return parent::getQueryResult($action, $request, $totalItems, $paginator, $filterOrder);
}
if (null === $filterOrder->getQueryString()) {
return parent::getQueryResult($action, $request, $totalItems, $paginator, $filterOrder);
}
$queryString = $filterOrder->getQueryString();
$activeFilter = $filterOrder->getCheckboxData('activeFilter');
$nb = $this->userRepository->countFilteredUsers($queryString, $activeFilter);
return $this->userRepository->findByUsernameOrEmail(
$filterOrder->getQueryString(),
['usernameCanonical' => 'ASC'],
$paginator->getItemsPerPage(),
$paginator->getCurrentPageFirstItemNumber()
);
$paginator = $this->getPaginatorFactory()->create($nb);
return $this->userRepository->findFilteredUsers($queryString, $activeFilter, $paginator->getCurrentPageFirstItemNumber(), $paginator->getItemsPerPage());
}
protected function onPrePersist(string $action, $entity, FormInterface $form, Request $request)
@ -374,10 +368,12 @@ class UserController extends CRUDController
$returnPathParams = $request->query->has('returnPath') ? ['returnPath' => $request->query->get('returnPath')] : [];
return $this->createFormBuilder()
->setAction($this->generateUrl(
->setAction(
$this->generateUrl(
'admin_user_add_groupcenter',
array_merge($returnPathParams, ['uid' => $user->getId()])
))
)
)
->setMethod('POST')
->add(self::FORM_GROUP_CENTER_COMPOSED, ComposedGroupCenterType::class)
->add('submit', SubmitType::class, ['label' => 'Add a new groupCenter'])
@ -392,10 +388,12 @@ class UserController extends CRUDController
$returnPathParams = $request->query->has('returnPath') ? ['returnPath' => $request->query->get('returnPath')] : [];
return $this->createFormBuilder()
->setAction($this->generateUrl(
->setAction(
$this->generateUrl(
'admin_user_delete_groupcenter',
array_merge($returnPathParams, ['uid' => $user->getId(), 'gcid' => $groupCenter->getId()])
))
)
)
->setMethod('DELETE')
->add('submit', SubmitType::class, ['label' => 'Delete'])
->getForm();

View File

@ -24,9 +24,9 @@ interface CronJobInterface
*
* If data is returned, this data is passed as argument on the next execution
*
* @param array $lastExecutionData the data which was returned from the previous execution
* @param array<string|int, int|float|string|bool|array<int|string, int|float|string|bool>> $lastExecutionData the data which was returned from the previous execution
*
* @return array|null optionally return an array with the same data than the previous execution
* @return array<string|int, int|float|string|bool|array<int|string, int|float|string|bool>>|null optionally return an array with the same data than the previous execution
*/
public function run(array $lastExecutionData): ?array;
}

View File

@ -13,6 +13,8 @@ namespace Chill\MainBundle\Repository;
use Chill\MainBundle\Entity\Notification;
use Chill\MainBundle\Entity\User;
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\Result;
use Doctrine\DBAL\Statement;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\EntityManagerInterface;
@ -81,10 +83,7 @@ final class NotificationRepository implements ObjectRepository
$results->free();
} else {
$wheres = [];
foreach ([
['relatedEntityClass' => $relatedEntityClass, 'relatedEntityId' => $relatedEntityId],
...$more,
] as $k => ['relatedEntityClass' => $relClass, 'relatedEntityId' => $relId]) {
foreach ([['relatedEntityClass' => $relatedEntityClass, 'relatedEntityId' => $relatedEntityId], ...$more] as $k => ['relatedEntityClass' => $relClass, 'relatedEntityId' => $relId]) {
$wheres[] = "(relatedEntityClass = :relatedEntityClass_{$k} AND relatedEntityId = :relatedEntityId_{$k})";
$sqlParams["relatedEntityClass_{$k}"] = $relClass;
$sqlParams["relatedEntityId_{$k}"] = $relId;
@ -255,10 +254,12 @@ final class NotificationRepository implements ObjectRepository
$qb = $this->repository->createQueryBuilder('n');
// add condition for related entity (in main arguments, and in more)
$or = $qb->expr()->orX($qb->expr()->andX(
$or = $qb->expr()->orX(
$qb->expr()->andX(
$qb->expr()->eq('n.relatedEntityClass', ':relatedEntityClass'),
$qb->expr()->eq('n.relatedEntityId', ':relatedEntityId')
));
)
);
$qb
->setParameter('relatedEntityClass', $relatedEntityClass)
->setParameter('relatedEntityId', $relatedEntityId);
@ -310,4 +311,86 @@ final class NotificationRepository implements ObjectRepository
return $qb;
}
/**
* @return list<int> the ids of the notifications marked as unread
*/
public function markAllNotificationAsReadForUser(User $user): array
{
// Get the database connection from the entity manager
$connection = $this->em->getConnection();
/** @var Result $results */
$results = $connection->transactional(function (Connection $connection) use ($user) {
// Define the SQL query
$sql = <<<'SQL'
DELETE FROM chill_main_notification_addresses_unread
WHERE user_id = :user_id
RETURNING notification_id
SQL;
return $connection->executeQuery($sql, ['user_id' => $user->getId()]);
});
$notificationIdsTouched = [];
foreach ($results->iterateAssociative() as $row) {
$notificationIdsTouched[] = $row['notification_id'];
}
return array_values($notificationIdsTouched);
}
/**
* @param list<int> $notificationIds
*/
public function markAllNotificationAsUnreadForUser(User $user, array $notificationIds): array
{
// Get the database connection from the entity manager
$connection = $this->em->getConnection();
/** @var Result $results */
$results = $connection->transactional(function (Connection $connection) use ($user, $notificationIds) {
// This query double-check that the user is one of the addresses of the notification or the sender,
// if the notification is already marked as unread, this query does not fails.
// this query return the list of notification id which are affected
$sql = <<<'SQL'
INSERT INTO chill_main_notification_addresses_unread (user_id, notification_id)
SELECT ?, chill_main_notification_addresses_user.notification_id
FROM chill_main_notification_addresses_user JOIN chill_main_notification ON chill_main_notification_addresses_user.notification_id = chill_main_notification.id
WHERE (chill_main_notification_addresses_user.user_id = ? OR chill_main_notification.sender_id = ?)
AND chill_main_notification_addresses_user.notification_id IN ({ notification_ids })
ON CONFLICT (user_id, notification_id) DO NOTHING
RETURNING notification_id
SQL;
$params = [$user->getId(), $user->getId(), $user->getId(), ...array_values($notificationIds)];
$sql = strtr($sql, ['{ notification_ids }' => implode(', ', array_fill(0, count($notificationIds), '?'))]);
return $connection->executeQuery($sql, $params);
});
$notificationIdsTouched = [];
foreach ($results->iterateAssociative() as $row) {
$notificationIdsTouched[] = $row['notification_id'];
}
return array_values($notificationIdsTouched);
}
public function findAllUnreadByUser(User $user): array
{
$rsm = new Query\ResultSetMappingBuilder($this->em);
$rsm->addRootEntityFromClassMetadata(Notification::class, 'cmn');
$sql = 'SELECT '.$rsm->generateSelectClause(['cmn' => 'cmn']).' '.
'FROM chill_main_notification cmn '.
'WHERE '.
'EXISTS (SELECT 1 FROM chill_main_notification_addresses_unread cmnau WHERE cmnau.user_id = :userId AND cmnau.notification_id = cmn.id) '.
'ORDER BY cmn.date DESC';
$nq = $this->em->createNativeQuery($sql, $rsm)
->setParameter('userId', $user->getId());
return $nq->getResult();
}
}

View File

@ -17,6 +17,7 @@ use Doctrine\DBAL\Connection;
use Doctrine\DBAL\Exception;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\NonUniqueResultException;
use Doctrine\ORM\NoResultException;
use Doctrine\ORM\Query\ResultSetMapping;
use Doctrine\ORM\Query\ResultSetMappingBuilder;
@ -26,9 +27,25 @@ final readonly class UserRepository implements UserRepositoryInterface
{
private EntityRepository $repository;
private const FIELDS = ['id', 'email', 'enabled', 'civility_id', 'civility_abbreviation', 'civility_name', 'label', 'mainCenter_id',
'mainCenter_name', 'mainScope_id', 'mainScope_name', 'userJob_id', 'userJob_name', 'currentLocation_id', 'currentLocation_name',
'mainLocation_id', 'mainLocation_name'];
private const FIELDS = [
'id',
'email',
'enabled',
'civility_id',
'civility_abbreviation',
'civility_name',
'label',
'mainCenter_id',
'mainCenter_name',
'mainScope_id',
'mainScope_name',
'userJob_id',
'userJob_name',
'currentLocation_id',
'currentLocation_name',
'mainLocation_id',
'mainLocation_name',
];
public function __construct(private EntityManagerInterface $entityManager, private Connection $connection)
{
@ -296,6 +313,25 @@ final readonly class UserRepository implements UserRepositoryInterface
return User::class;
}
public function getResult(
QueryBuilder $qb,
?int $start = 0,
?int $limit = 50,
?array $orderBy = [],
): array {
$qb->select('u');
$qb
->setFirstResult($start)
->setMaxResults($limit);
foreach ($orderBy as $field => $direction) {
$qb->addOrderBy('u.'.$field, $direction);
}
return $qb->getQuery()->getResult();
}
private function queryByUsernameOrEmail(string $pattern): QueryBuilder
{
$qb = $this->entityManager->createQueryBuilder()->from(User::class, 'u');
@ -312,4 +348,49 @@ final readonly class UserRepository implements UserRepositoryInterface
return $qb;
}
public function buildFilterBaseQuery(?string $queryString, array $isActive)
{
if (null !== $queryString) {
$qb = $this->queryByUsernameOrEmail($queryString);
} else {
$qb = $this->entityManager->createQueryBuilder()->from(User::class, 'u');
}
// Add condition based on active/inactive status
if (in_array('Active', $isActive, true) && !in_array('Inactive', $isActive, true)) {
$qb->andWhere('u.enabled = true');
} elseif (in_array('Inactive', $isActive, true) && !in_array('Active', $isActive, true)) {
$qb->andWhere('u.enabled = false');
}
return $qb;
}
public function findFilteredUsers(
?string $queryString = null,
array $isActive = ['active'],
?int $start = 0,
?int $limit = 50,
?array $orderBy = ['username' => 'ASC'],
): array {
$qb = $this->buildFilterBaseQuery($queryString, $isActive);
return $this->getResult($qb, $start, $limit, $orderBy);
}
public function countFilteredUsers(
?string $queryString = null,
array $isActive = ['active'],
): int {
$qb = $this->buildFilterBaseQuery($queryString, $isActive);
try {
return $qb
->select('COUNT(u)')
->getQuery()->getSingleScalarResult();
} catch (NoResultException|NonUniqueResultException $e) {
throw new \LogicException('a count query should return one result', previous: $e);
}
}
}

View File

@ -1,11 +1,13 @@
import { createApp } from "vue";
import NotificationReadToggle from "ChillMainAssets/vuejs/_components/Notification/NotificationReadToggle.vue";
import { _createI18n } from "ChillMainAssets/vuejs/_js/i18n";
import NotificationReadAllToggle from "ChillMainAssets/vuejs/_components/Notification/NotificationReadAllToggle.vue";
const i18n = _createI18n({});
window.addEventListener('DOMContentLoaded', function (e) {
document.querySelectorAll('.notification_toggle_read_status')
window.addEventListener("DOMContentLoaded", function (e) {
document
.querySelectorAll(".notification_toggle_read_status")
.forEach(function (el, i) {
createApp({
template: `<notification-read-toggle
@ -22,35 +24,40 @@ window.addEventListener('DOMContentLoaded', function (e) {
},
data() {
return {
notificationId: el.dataset.notificationId,
notificationId: parseInt(el.dataset.notificationId),
buttonClass: el.dataset.buttonClass,
buttonNoText: 'false' === el.dataset.buttonText,
buttonNoText: "false" === el.dataset.buttonText,
showUrl: el.dataset.showButtonUrl,
isRead: 1 === Number.parseInt(el.dataset.notificationCurrentIsRead),
container: el.dataset.container
}
container: el.dataset.container,
};
},
computed: {
getContainer() {
return document.querySelectorAll(`div.${this.container}`);
}
},
},
methods: {
onMarkRead() {
if (typeof this.getContainer[i] !== 'undefined') {
this.getContainer[i].classList.replace('read', 'unread');
} else { throw 'data-container attribute is missing' }
if (typeof this.getContainer[i] !== "undefined") {
this.getContainer[i].classList.replace("read", "unread");
} else {
throw "data-container attribute is missing";
}
this.isRead = false;
},
onMarkUnread() {
if (typeof this.getContainer[i] !== 'undefined') {
this.getContainer[i].classList.replace('unread', 'read');
} else { throw 'data-container attribute is missing' }
if (typeof this.getContainer[i] !== "undefined") {
this.getContainer[i].classList.replace("unread", "read");
} else {
throw "data-container attribute is missing";
}
this.isRead = true;
},
}
},
})
.use(i18n)
.mount(el);
});
});

View File

@ -0,0 +1,39 @@
import { createApp } from "vue";
import { _createI18n } from "../../vuejs/_js/i18n";
import NotificationReadAllToggle from "../../vuejs/_components/Notification/NotificationReadAllToggle.vue";
const i18n = _createI18n({});
document.addEventListener("DOMContentLoaded", function () {
const elements = document.querySelectorAll(".notification_all_read");
elements.forEach((element) => {
console.log('launch');
createApp({
template: `<notification-read-all-toggle @markAsRead="markAsRead" @markAsUnRead="markAsUnread"></notification-read-all-toggle>`,
components: {
NotificationReadAllToggle,
},
methods: {
markAsRead(id: number) {
const el = document.querySelector<HTMLDivElement>(`div.notification-status[data-notification-id="${id}"]`);
if (el === null) {
return;
}
el.classList.add('read');
el.classList.remove('unread');
},
markAsUnread(id: number) {
const el = document.querySelector<HTMLDivElement>(`div.notification-status[data-notification-id="${id}"]`);
if (el === null) {
return;
}
el.classList.remove('read');
el.classList.add('unread');
},
}
})
.use(i18n)
.mount(element);
});
});

View File

@ -0,0 +1,50 @@
<template>
<div>
<button v-if="idsMarkedAsRead.length === 0"
class="btn btn-primary"
type="button"
@click="markAllRead"
>
<i class="fa fa-sm fa-envelope-open-o"></i> Marquer tout comme lu
</button>
<button v-else
class="btn btn-primary"
type="button"
@click="undo"
>
<i class="fa fa-sm fa-envelope-open-o"></i> Annuler
</button>
</div>
</template>
<script lang="ts" setup>
import { makeFetch } from "../../../lib/api/apiMethods";
import { ref } from "vue";
const emit = defineEmits<{
(e: 'markAsRead', id: number): void,
(e: 'markAsUnRead', id: number): void,
}>();
const idsMarkedAsRead = ref([] as number[]);
async function markAllRead() {
const ids: number[] = await makeFetch("POST", `/api/1.0/main/notification/mark/allread`, null);
for (let i of ids) {
idsMarkedAsRead.value.push(i);
emit('markAsRead', i);
}
}
async function undo() {
const touched: number[] = await makeFetch("POST", `/api/1.0/main/notification/mark/undoallread`, idsMarkedAsRead.value);
while (idsMarkedAsRead.value.length > 0) {
idsMarkedAsRead.value.pop();
}
for (let t of touched) {
emit('markAsUnRead', t);
}
};
</script>
<style lang="scss" scoped></style>

View File

@ -1,8 +1,11 @@
<template>
<div :class="{'btn-group btn-group-sm float-end': isButtonGroup }"
role="group" aria-label="Notification actions">
<button v-if="isRead"
<div
:class="{ 'btn-group btn-group-sm float-end': isButtonGroup }"
role="group"
aria-label="Notification actions"
>
<button
v-if="isRead"
class="btn"
:class="overrideClass"
type="button"
@ -11,11 +14,12 @@
>
<i class="fa fa-sm fa-envelope-o"></i>
<span v-if="!buttonNoText" class="ps-2">
{{ $t('markAsUnread') }}
{{ $t("markAsUnread") }}
</span>
</button>
<button v-if="!isRead"
<button
v-if="!isRead"
class="btn"
:class="overrideClass"
type="button"
@ -24,11 +28,12 @@
>
<i class="fa fa-sm fa-envelope-open-o"></i>
<span v-if="!buttonNoText" class="ps-2">
{{ $t('markAsRead') }}
{{ $t("markAsRead") }}
</span>
</button>
<a v-if="isButtonGroup"
<a
v-if="isButtonGroup"
type="button"
class="btn btn-outline-primary"
:href="showUrl"
@ -37,11 +42,25 @@
<i class="fa fa-sm fa-comment-o"></i>
</a>
<!-- "Mark All Read" button -->
<button
v-if="showMarkAllButton"
class="btn"
:class="overrideClass"
type="button"
:title="$t('markAllRead')"
@click="markAllRead"
>
<i class="fa fa-sm fa-envelope-o"></i>
<span v-if="!buttonNoText" class="ps-2">
{{ $t("markAllRead") }}
</span>
</button>
</div>
</template>
<script>
import { makeFetch } from 'ChillMainAssets/lib/api/apiMethods.ts';
import { makeFetch } from "ChillMainAssets/lib/api/apiMethods.ts";
export default {
name: "NotificationReadToggle",
@ -57,7 +76,7 @@ export default {
// Optional
buttonClass: {
required: false,
type: String
type: String,
},
buttonNoText: {
required: false,
@ -65,14 +84,14 @@ export default {
},
showUrl: {
required: false,
type: String
}
type: String,
},
emits: ['markRead', 'markUnread'],
},
emits: ["markRead", "markUnread"],
computed: {
/// [Option] override default button appearance (btn-misc)
overrideClass() {
return this.buttonClass ? this.buttonClass : 'btn-misc'
return this.buttonClass ? this.buttonClass : "btn-misc";
},
/// [Option] don't display text on button
buttonHideText() {
@ -82,31 +101,48 @@ export default {
// When passed, the component return a button-group with 2 buttons.
isButtonGroup() {
return this.showUrl;
}
},
},
methods: {
markAsUnread() {
makeFetch('POST', `/api/1.0/main/notification/${this.notificationId}/mark/unread`, []).then(response => {
this.$emit('markRead', { notificationId: this.notificationId });
})
makeFetch(
"POST",
`/api/1.0/main/notification/${this.notificationId}/mark/unread`,
[]
).then((response) => {
this.$emit("markRead", {notificationId: this.notificationId});
});
},
markAsRead() {
makeFetch('POST', `/api/1.0/main/notification/${this.notificationId}/mark/read`, []).then(response => {
this.$emit('markUnread', { notificationId: this.notificationId });
})
makeFetch(
"POST",
`/api/1.0/main/notification/${this.notificationId}/mark/read`,
[]
).then((response) => {
this.$emit("markUnread", {
notificationId: this.notificationId,
});
});
},
markAllRead() {
makeFetch(
"POST",
`/api/1.0/main/notification/markallread`,
[]
).then((response) => {
this.$emit("markAllRead");
});
},
},
i18n: {
messages: {
fr: {
markAsUnread: 'Marquer comme non-lu',
markAsRead: 'Marquer comme lu'
}
}
}
}
markAsUnread: "Marquer comme non-lu",
markAsRead: "Marquer comme lu",
},
},
},
};
</script>
<style lang="scss">
</style>
<style lang="scss"></style>

View File

@ -1,13 +1,16 @@
{% macro title(c) %}
<div class="item-row title">
<h2 class="notification-title">
<a href="{{ chill_path_add_return_path('chill_main_notification_show', {'id': c.notification.id}) }}">
<a
href="{{ chill_path_add_return_path('chill_main_notification_show', {
id: c.notification.id
}) }}"
>
{{ c.notification.title }}
</a>
</h2>
</div>
{% endmacro %}
{% macro header(c) %}
<div class="item-row notification-header mt-2">
<div class="item-col">
@ -16,7 +19,7 @@
<li class="notification-from">
<span class="item-key">
<abbr title="{{ 'notification.received_from' | trans }}">
{{ 'notification.from'|trans }} :
{{ "notification.from" | trans }} :
</abbr>
</span>
{% if not c.notification.isSystem %}
@ -24,7 +27,7 @@
{{ c.notification.sender | chill_entity_render_string({'at_date': c.notification.date}) }}
</span>
{% else %}
<span class="badge-user system">{{ 'notification.is_system'|trans }}</span>
<span class="badge-user system">{{ "notification.is_system" | trans }}</span>
{% endif %}
</li>
{% endif %}
@ -34,20 +37,20 @@
{% if c.notification_cc %}
<span class="item-key">
<abbr title="{{ 'notification.sent_cc' | trans }}">
{{ 'notification.cc'|trans }} :
{{ "notification.cc" | trans }} :
</abbr>
</span>
{% else %}
<span class="item-key">
<abbr title="{{ 'notification.sent_to' | trans }}">
{{ 'notification.to'|trans }} :
{{ "notification.to" | trans }} :
</abbr>
</span>
{% endif %}
{% else %}
<span class="item-key">
<abbr title="{{ 'notification.sent_to' | trans }}">
{{ 'notification.to'|trans }} :
{{ "notification.to" | trans }} :
</abbr>
</span>
{% endif %}
@ -57,7 +60,10 @@
</span>
{% endfor %}
{% for a in c.notification.addressesEmails %}
<span class="badge-user" title="{{ 'notification.Email with access link'|trans|e('html_attr') }}">
<span
class="badge-user"
title="{{ 'notification.Email with access link'|trans|e('html_attr') }}"
>
{{ a }}
</span>
{% endfor %}
@ -70,7 +76,6 @@
</div>
</div>
{% endmacro %}
{% macro content(c) %}
<div class="item-row separator">
{% if c.data is defined %}
@ -85,25 +90,29 @@
{% if c.notification.message is not empty %}
{{ c.notification.message | chill_markdown_to_html }}
{% else %}
<p class="chill-no-data-statement">{{ 'Any comment'|trans }}</p>
<p class="chill-no-data-statement">{{ "Any comment" | trans }}</p>
{% endif %}
{% else %}
{% if c.notification.message is not empty %}
{{ c.notification.message|u.truncate(250, '…', false)|chill_markdown_to_html }}
<p class="read-more"><a href="{{ chill_path_add_return_path('chill_main_notification_show', {'id': c.notification.id}) }}">{{ 'Read more'|trans }}</a></p>
<p class="read-more">
<a
href="{{ chill_path_add_return_path('chill_main_notification_show', {
id: c.notification.id
}) }}"
>{{ "Read more" | trans }}</a>
</p>
{% else %}
<p class="chill-no-data-statement">{{ 'Any comment'|trans }}</p>
<p class="chill-no-data-statement">{{ "Any comment" | trans }}</p>
{% endif %}
{% endif %}
</div>
</div>
{% endmacro %}
{% macro actions(c) %}
{% if c.action_button is not defined or c.action_button != false %}
<div class="item-row separator">
<div class="item-col item-meta">
{% if c.notification.comments|length > 0 %}
<div class="comment-counter">
<span class="counter">
@ -111,13 +120,13 @@
</span>
</div>
{% endif %}
</div>
<div class="item-col">
<ul class="record_actions">
<li>
{# Vue component #}
<span class="notification_toggle_read_status"
<span
class="notification_toggle_read_status"
data-notification-id="{{ c.notification.id }}"
data-notification-current-is-read="{{ c.notification.isReadBy(app.user) }}"
data-container="notification-status"
@ -125,18 +134,31 @@
</li>
{% if is_granted('CHILL_MAIN_NOTIFICATION_UPDATE', c.notification) %}
<li>
<a href="{{ chill_path_add_return_path('chill_main_notification_edit', {'id': c.notification.id}) }}"
class="btn btn-edit" title="{{ 'Edit'|trans }}"></a>
<a
href="{{ chill_path_add_return_path(
'chill_main_notification_edit',
{ id: c.notification.id }
) }}"
class="btn btn-edit"
title="{{ 'Edit' | trans }}"
></a>
</li>
{% endif %}
{% if is_granted('CHILL_MAIN_NOTIFICATION_SEE', c.notification) %}
{% if is_granted('CHILL_MAIN_NOTIFICATION_SEE',
c.notification) %}
<li>
<a href="{{ chill_path_add_return_path('chill_main_notification_show', {'id': c.notification.id}) }}"
class="btn {% if not c.notification.isSystem %}btn-show change-icon{% else %}btn-misc{% endif %}" title="{{ 'notification.see_comments_thread'|trans }}">
<a
href="{{ chill_path_add_return_path(
'chill_main_notification_show',
{ id: c.notification.id }
) }}"
class="btn {% if not c.notification.isSystem %}btn-show change-icon{% else %}btn-misc{% endif %}"
title="{{ 'notification.see_comments_thread' | trans }}"
>
{% if not c.notification.isSystem() %}
<i class="fa fa-comment"></i>
{% else %}
{{ 'Read more'|trans }}
{{ "Read more" | trans }}
{% endif %}
</a>
</li>
@ -147,24 +169,30 @@
{% endif %}
{% endmacro %}
<div class="item-bloc notification-status {% if notification.isReadBy(app.user) %}read{% else %}unread{% endif %}">
<div
class="item-bloc notification-status {% if notification.isReadBy(app.user) %}read{% else %}unread{% endif %}"
data-notification-id="{{ notification.id|escape('html_attr') }}"
>
{% if fold_item is defined and fold_item != false %}
<div class="accordion-header" id="flush-heading-{{ notification.id }}">
<button type="button" class="accordion-button collapsed"
data-bs-toggle="collapse" data-bs-target="#flush-collapse-{{ notification.id }}"
aria-expanded="false" aria-controls="flush-collapse-{{ notification.id }}">
<button
type="button"
class="accordion-button collapsed"
data-bs-toggle="collapse"
data-bs-target="#flush-collapse-{{ notification.id }}"
aria-expanded="false"
aria-controls="flush-collapse-{{ notification.id }}"
>
{{ _self.title(_context) }}
</button>
{{ _self.header(_context) }}
</div>
<div id="flush-collapse-{{ notification.id }}"
<div
id="flush-collapse-{{ notification.id }}"
class="accordion-collapse collapse"
aria-labelledby="flush-heading-{{ notification.id }}"
data-bs-parent="#notification-fold">
data-bs-parent="#notification-fold"
>
{{ _self.content(_context) }}
</div>
{{ _self.actions(_context) }}
@ -174,5 +202,4 @@
{{ _self.content(_context) }}
{{ _self.actions(_context) }}
{% endif %}
</div>

View File

@ -4,59 +4,75 @@
{% block js %}
{{ parent() }}
{{ encore_entry_script_tags('mod_notification_toggle_read_status') }}
{{ encore_entry_script_tags("mod_notification_toggle_read_status") }}
{{ encore_entry_script_tags("mod_notification_toggle_read_all_status") }}
{% endblock %}
{% block css %}
{{ parent() }}
{{ encore_entry_link_tags('mod_notification_toggle_read_status') }}
{{ encore_entry_link_tags("mod_notification_toggle_read_status") }}
{{ encore_entry_link_tags("mod_notification_toggle_read_all_status") }}
{% endblock %}
{% block content %}
<div class="col-10 notification notification-list">
<h1>{{ block('title') }}</h1>
<h1>{{ block("title") }}</h1>
<ul class="nav nav-pills justify-content-center">
<li class="nav-item">
<a class="nav-link {% if step == 'inbox' %}active{% endif %}" href="{{ path('chill_main_notification_my') }}">
{{ 'notification.Notifications received'|trans }}
<a
class="nav-link {% if step == 'inbox' %}active{% endif %}"
href="{{ path('chill_main_notification_my') }}"
>
{{ "notification.Notifications received" | trans }}
{% if unreads['inbox'] > 0 %}
<span class="badge rounded-pill bg-danger">
{{ unreads['inbox'] }}
{{ unreads["inbox"] }}
</span>
{% endif %}
</a>
</li>
<li class="nav-item">
<a class="nav-link {% if step == 'sent' %}active{% endif %}" href="{{ path('chill_main_notification_sent') }}">
{{ 'notification.Notifications sent'|trans }}
<a
class="nav-link {% if step == 'sent' %}active{% endif %}"
href="{{ path('chill_main_notification_sent') }}"
>
{{ "notification.Notifications sent" | trans }}
{% if unreads['sent'] > 0 %}
<span class="badge rounded-pill bg-danger">
{{ unreads['sent'] }}
{{ unreads["sent"] }}
</span>
{% endif %}
</a>
</li>
</ul>
{% if datas|length == 0 %}
{% if step == 'inbox' %}
<p class="chill-no-data-statement">{{ 'notification.Any notification received'|trans }}</p>
{% if datas|length == 0 %} {% if step == 'inbox' %}
<p class="chill-no-data-statement">
{{ "notification.Any notification received" | trans }}
</p>
{% else %}
<p class="chill-no-data-statement">{{ 'notification.Any notification sent'|trans }}</p>
<p class="chill-no-data-statement">
{{ "notification.Any notification sent" | trans }}
</p>
{% endif %}
{% else %}
<div class="flex-table accordion accordion-flush" id="notification-fold">
{% for data in datas %}
{% set notification = data.notification %}
{% include '@ChillMain/Notification/_list_item.html.twig' with {
'fold_item': true,
'notification_cc': data.template_data.notificationCc is defined ? data.template_data.notificationCc : false
} %}
'fold_item': true, 'notification_cc': data.template_data.notificationCc
is defined ? data.template_data.notificationCc : false } %}
{% endfor %}
</div>
{{ chill_pagination(paginator) }}
{% endif %}
<ul class="record_actions sticky-form-buttons justify-content-end">
<li class="ml-auto d-flex align-items-center gap-2">
<span class="notification_all_read"></span>
</li>
</ul>
</div>
{% endblock content %}

View File

@ -65,7 +65,7 @@ class PasswordRecoverLocker
if (0 === $this->chillRedis->exists($key)) {
$this->chillRedis->set($key, 1);
$this->chillRedis->setTimeout($key, $ttl);
$this->chillRedis->expire($key, $ttl);
break;
}

View File

@ -15,6 +15,12 @@ use Doctrine\DBAL\Connection;
use Doctrine\DBAL\Statement;
use Psr\Log\LoggerInterface;
/**
* Import addresses into the database.
*
* This importer do some optimization about the import, ensuring that adresses are inserted and reconciled with
* the existing one on a optimized way.
*/
final class AddressReferenceBaseImporter
{
private const INSERT = <<<'SQL'
@ -47,11 +53,18 @@ final class AddressReferenceBaseImporter
public function __construct(private readonly Connection $defaultConnection, private readonly LoggerInterface $logger) {}
public function finalize(): void
/**
* Finalize the import process and make reconciliation with addresses.
*
* @param bool $allowRemoveDoubleRefId if true, allow the importer to remove automatically addresses with same refid
*
* @throws \Exception
*/
public function finalize(bool $allowRemoveDoubleRefId = false): void
{
$this->doInsertPending();
$this->updateAddressReferenceTable();
$this->updateAddressReferenceTable($allowRemoveDoubleRefId);
$this->deleteTemporaryTable();
@ -59,6 +72,11 @@ final class AddressReferenceBaseImporter
$this->isInitialized = false;
}
/**
* Do import a single address.
*
* @throws \Exception
*/
public function importAddress(
string $refAddress,
?string $refPostalCode,
@ -167,15 +185,48 @@ final class AddressReferenceBaseImporter
$this->isInitialized = true;
}
private function updateAddressReferenceTable(): void
private function updateAddressReferenceTable(bool $allowRemoveDoubleRefId): void
{
$this->defaultConnection->executeStatement(
'CREATE INDEX idx_ref_add_temp ON reference_address_temp (refid)'
);
// 0) detect for doublon in current temporary table
$results = $this->defaultConnection->executeQuery(
'SELECT COUNT(*) AS nb_appearance, refid FROM reference_address_temp GROUP BY refid HAVING count(*) > 1'
);
$hasDouble = false;
foreach ($results->iterateAssociative() as $result) {
$this->logger->error(self::LOG_PREFIX.'Some reference id are present more than one time', ['nb_apparearance' => $result['nb_appearance'], 'refid' => $result['refid']]);
$hasDouble = true;
}
if ($hasDouble) {
if ($allowRemoveDoubleRefId) {
$this->logger->alert(self::LOG_PREFIX.'We are going to remove the addresses which are present more than once in the table');
$this->defaultConnection->executeStatement('ALTER TABLE reference_address_temp ADD COLUMN gid SERIAL');
$removed = $this->defaultConnection->executeStatement(<<<'SQL'
WITH ordering AS (
SELECT gid, rank() over (PARTITION BY refid ORDER BY gid DESC) AS ranking
FROM reference_address_temp
WHERE refid IN (SELECT refid FROM reference_address_temp group by refid having count(*) > 1)
),
keep_last AS (
SELECT gid, ranking FROM ordering where ranking > 1
)
DELETE FROM reference_address_temp WHERE gid IN (SELECT gid FROM keep_last);
SQL);
$this->logger->alert(self::LOG_PREFIX.'addresses with same refid present twice, we removed some double', ['nb_removed', $removed]);
} else {
throw new \RuntimeException('Some addresses are present twice in the database, we cannot process them');
}
}
$this->defaultConnection->transactional(function ($connection): void {
// 1) Add new addresses
$this->logger->info(self::LOG_PREFIX.'upsert new addresses');
$affected = $this->defaultConnection->executeStatement("INSERT INTO chill_main_address_reference
$affected = $connection->executeStatement("INSERT INTO chill_main_address_reference
(id, postcode_id, refid, street, streetnumber, municipalitycode, source, point, createdat, deletedat, updatedat)
SELECT
nextval('chill_main_address_reference_id_seq'),
@ -197,12 +248,13 @@ final class AddressReferenceBaseImporter
// 3) Delete addresses
$this->logger->info(self::LOG_PREFIX.'soft delete adresses');
$affected = $this->defaultConnection->executeStatement('UPDATE chill_main_address_reference
$affected = $connection->executeStatement('UPDATE chill_main_address_reference
SET deletedat = NOW()
WHERE
chill_main_address_reference.refid NOT IN (SELECT refid FROM reference_address_temp WHERE source LIKE ?)
AND chill_main_address_reference.source LIKE ?
', [$this->currentSource, $this->currentSource]);
$this->logger->info(self::LOG_PREFIX.'addresses deleted', ['deleted' => $affected]);
});
}
}

View File

@ -42,7 +42,8 @@ class PostalCodeBaseImporter
NOW(),
NOW()
FROM g
ON CONFLICT (code, refpostalcodeid, postalcodeSource) WHERE refpostalcodeid IS NOT NULL DO UPDATE SET label = excluded.label, center = excluded.center, updatedAt = NOW()
ON CONFLICT (code, refpostalcodeid, postalcodeSource) WHERE refpostalcodeid IS NOT NULL DO UPDATE
SET label = excluded.label, center = excluded.center, updatedAt = CASE WHEN NOT st_equals(excluded.center, chill_main_postal_code.center) OR excluded.label != chill_main_postal_code.label THEN NOW() ELSE chill_main_postal_code.updatedAt END
SQL;
private const VALUE = '(?, ?, ?, ?, ?, ?, ?, ?)';

View File

@ -9,7 +9,7 @@ declare(strict_types=1);
* the LICENSE file that was distributed with this source code.
*/
namespace Repository;
namespace Chill\MainBundle\Tests\Repository;
use Chill\MainBundle\Entity\NewsItem;
use Chill\MainBundle\Repository\NewsItemRepository;

View File

@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Tests\Repository;
use Chill\MainBundle\Entity\Notification;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Repository\NotificationRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
/**
* @internal
*
* @coversNothing
*/
class NotificationRepositoryTest extends KernelTestCase
{
private EntityManagerInterface $entityManager;
private NotificationRepository $repository;
protected function setUp(): void
{
self::bootKernel();
$this->entityManager = static::$kernel->getContainer()->get('doctrine.orm.entity_manager');
$this->repository = new NotificationRepository($this->entityManager);
}
public function testMarkAllNotificationAsReadForUser(): void
{
$user = $this->entityManager->createQuery('SELECT u FROM '.User::class.' u')
->setMaxResults(1)->getSingleResult();
$notification = (new Notification())
->setRelatedEntityClass('\Dummy')
->setRelatedEntityId(0)
;
$notification->addAddressee($user)->markAsUnreadBy($user);
$this->entityManager->persist($notification);
$this->entityManager->flush();
$notification->markAsUnreadBy($user);
$this->entityManager->flush();
$this->entityManager->refresh($notification);
if ($notification->isReadBy($user)) {
throw new \LogicException('Notification should not be marked as read');
}
$notificationsIds = $this->repository->markAllNotificationAsReadForUser($user);
self::assertContains($notification->getId(), $notificationsIds);
$this->entityManager->clear();
$notification = $this->entityManager->find(Notification::class, $notification->getId());
self::assertTrue($notification->isReadBy($user));
}
public function testMarkAllNotificationAsUnreadForUser(): void
{
$user = $this->entityManager->createQuery('SELECT u FROM '.User::class.' u')
->setMaxResults(1)->getSingleResult();
$notification = (new Notification())
->setRelatedEntityClass('\Dummy')
->setRelatedEntityId(0)
;
$notification->addAddressee($user); // we do not mark the notification as unread by the user
$this->entityManager->persist($notification);
$this->entityManager->flush();
$notification->markAsReadBy($user);
$this->entityManager->flush();
$this->entityManager->refresh($notification);
if (!$notification->isReadBy($user)) {
throw new \LogicException('Notification should be marked as read');
}
$notificationsIds = $this->repository->markAllNotificationAsUnreadForUser($user, [$notification->getId()]);
self::assertContains($notification->getId(), $notificationsIds);
}
}

View File

@ -0,0 +1,55 @@
<?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 ChillMainBundle\Tests\Repository;
use Chill\MainBundle\Repository\UserRepository;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
/**
* @internal
*
* @coversNothing
*/
class UserRepositoryTest extends KernelTestCase
{
private UserRepository $userRepository;
protected function setUp(): void
{
self::bootKernel();
$entityManager = static::$kernel->getContainer()->get('doctrine.orm.entity_manager');
$connection = $entityManager->getConnection();
$this->userRepository = new UserRepository($entityManager, $connection);
}
public function testCountFilteredUsers(): void
{
self::assertIsInt($this->userRepository->countFilteredUsers(null, ['Active']));
self::assertIsInt($this->userRepository->countFilteredUsers(null, ['Active', 'Inactive']));
self::assertIsInt($this->userRepository->countFilteredUsers(null, ['Inactive']));
self::assertIsInt($this->userRepository->countFilteredUsers('center', ['Active']));
self::assertIsInt($this->userRepository->countFilteredUsers('center', ['Active', 'Inactive']));
self::assertIsInt($this->userRepository->countFilteredUsers('center', ['Inactive']));
self::assertIsInt($this->userRepository->countFilteredUsers('center'));
}
public function testFindByFilteredUsers(): void
{
self::assertIsArray($this->userRepository->findFilteredUsers(null, ['Active']));
self::assertIsArray($this->userRepository->findFilteredUsers(null, ['Active', 'Inactive']));
self::assertIsArray($this->userRepository->findFilteredUsers(null, ['Inactive']));
self::assertIsArray($this->userRepository->findFilteredUsers('center', ['Active']));
self::assertIsArray($this->userRepository->findFilteredUsers('center', ['Active', 'Inactive']));
self::assertIsArray($this->userRepository->findFilteredUsers('center', ['Inactive']));
self::assertIsArray($this->userRepository->findFilteredUsers('center'));
}
}

View File

@ -165,7 +165,6 @@ components:
endDate:
$ref: "#/components/schemas/Date"
paths:
/1.0/search.json:
get:
@ -237,7 +236,7 @@ paths:
minItems: 2
maxItems: 2
postcode:
$ref: '#/components/schemas/PostalCode'
$ref: "#/components/schemas/PostalCode"
steps:
type: string
street:
@ -275,7 +274,7 @@ paths:
content:
application/json:
schema:
$ref: '#/components/schemas/Address'
$ref: "#/components/schemas/Address"
404:
description: "not found"
401:
@ -321,7 +320,7 @@ paths:
minItems: 2
maxItems: 2
postcode:
$ref: '#/components/schemas/PostalCode'
$ref: "#/components/schemas/PostalCode"
steps:
type: string
street:
@ -344,7 +343,6 @@ paths:
400:
description: "transition cannot be applyed"
/1.0/main/address/{id}/duplicate.json:
post:
tags:
@ -365,7 +363,7 @@ paths:
content:
application/json:
schema:
$ref: '#/components/schemas/Address'
$ref: "#/components/schemas/Address"
404:
description: "not found"
401:
@ -406,7 +404,7 @@ paths:
content:
application/json:
schema:
$ref: '#/components/schemas/AddressReference'
$ref: "#/components/schemas/AddressReference"
404:
description: "not found"
401:
@ -439,7 +437,7 @@ paths:
content:
application/json:
schema:
$ref: '#/components/schemas/AddressReference'
$ref: "#/components/schemas/AddressReference"
404:
description: "not found"
401:
@ -477,7 +475,7 @@ paths:
code:
type: string
country:
$ref: '#/components/schemas/Country'
$ref: "#/components/schemas/Country"
responses:
401:
description: "Unauthorized"
@ -510,7 +508,7 @@ paths:
content:
application/json:
schema:
$ref: '#/components/schemas/PostalCode'
$ref: "#/components/schemas/PostalCode"
404:
description: "not found"
401:
@ -541,7 +539,7 @@ paths:
content:
application/json:
schema:
$ref: '#/components/schemas/PostalCode'
$ref: "#/components/schemas/PostalCode"
404:
description: "not found"
400:
@ -575,13 +573,12 @@ paths:
content:
application/json:
schema:
$ref: '#/components/schemas/Country'
$ref: "#/components/schemas/Country"
404:
description: "not found"
401:
description: "Unauthorized"
/1.0/main/user.json:
get:
tags:
@ -626,7 +623,7 @@ paths:
content:
application/json:
schema:
$ref: '#/components/schemas/User'
$ref: "#/components/schemas/User"
404:
description: "not found"
401:
@ -784,7 +781,7 @@ paths:
id: 1
class: 'Chill\PersonBundle\Entity\AccompanyingPeriod'
roles:
- 'CHILL_PERSON_ACCOMPANYING_PERIOD_SEE'
- "CHILL_PERSON_ACCOMPANYING_PERIOD_SEE"
/1.0/main/notification/{id}/mark/read:
post:
tags:
@ -823,6 +820,38 @@ paths:
description: "accepted"
403:
description: "unauthorized"
/1.0/main/notification/mark/allread:
post:
tags:
- notification
summary: Mark all notifications as read
responses:
202:
description: "accepted"
403:
description: "unauthorized"
/1.0/main/notification/mark/undoallread:
post: # Use POST method for creating resources
tags:
- notification
summary: Mark notifications as unread
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
ids:
type: array
items:
type: integer
example: [1, 2, 3] # Example array of IDs
responses:
"202":
description: Notifications marked as unread successfully
"403":
description: Unauthorized
/1.0/main/civility.json:
get:
tags:
@ -844,7 +873,7 @@ paths:
content:
application/json:
schema:
$ref: '#/components/schemas/UserJob'
$ref: "#/components/schemas/UserJob"
/1.0/main/workflow/my:
get:
tags:
@ -858,7 +887,7 @@ paths:
schema:
type: array
items:
$ref: '#/components/schemas/Workflow'
$ref: "#/components/schemas/Workflow"
403:
description: "Unauthorized"
/1.0/main/workflow/my-cc:
@ -874,7 +903,7 @@ paths:
schema:
type: array
items:
$ref: '#/components/schemas/Workflow'
$ref: "#/components/schemas/Workflow"
403:
description: "Unauthorized"
/1.0/main/dashboard-config-item.json:
@ -888,7 +917,7 @@ paths:
content:
application/json:
schema:
$ref: '#/components/schemas/DashboardConfigItem'
$ref: "#/components/schemas/DashboardConfigItem"
403:
description: "Unauthorized"
@ -905,6 +934,6 @@ paths:
schema:
type: array
items:
$ref: '#/components/schemas/NewsItem'
$ref: "#/components/schemas/NewsItem"
403:
description: "Unauthorized"

View File

@ -72,6 +72,7 @@ module.exports = function(encore, entries)
encore.addEntry('mod_blur', __dirname + '/Resources/public/module/blur/index.js');
encore.addEntry('mod_input_address', __dirname + '/Resources/public/vuejs/Address/mod_input_address_index.js');
encore.addEntry('mod_notification_toggle_read_status', __dirname + '/Resources/public/module/notification/toggle_read.js');
encore.addEntry('mod_notification_toggle_read_all_status', __dirname + '/Resources/public/module/notification/toggle_read_all.ts');
encore.addEntry('mod_pickentity_type', __dirname + '/Resources/public/module/pick-entity/index.js');
encore.addEntry('mod_entity_workflow_subscribe', __dirname + '/Resources/public/module/entity-workflow-subscribe/index.js');
encore.addEntry('mod_entity_workflow_pick', __dirname + '/Resources/public/module/entity-workflow-pick/index.js');

View File

@ -85,11 +85,11 @@ final readonly class AccompanyingPeriodWorkEndDateBetweenDateFilter implements F
};
$end = match ($data['keep_null']) {
true => $qb->expr()->orX(
$qb->expr()->gt('acpw.endDate', ':'.$as),
$qb->expr()->gt('COALESCE(acp.closingDate, acpw.endDate)', ':'.$as),
$qb->expr()->isNull('acpw.endDate')
),
false => $qb->expr()->andX(
$qb->expr()->gt('acpw.endDate', ':'.$as),
$qb->expr()->gt('COALESCE(acp.closingDate, acpw.endDate)', ':'.$as),
$qb->expr()->isNotNull('acpw.endDate')
),
default => throw new \LogicException('This value is not supported'),