Merge branch 'issue123_duplicate_calendar_range_by_week' into 'master'

Add a button to duplicate calendar ranges from a week to another one

See merge request Chill-Projet/chill-bundles!706
This commit is contained in:
Julien Fastré 2024-07-05 08:07:49 +00:00
commit fa91e9494d
4 changed files with 265 additions and 83 deletions

View File

@ -0,0 +1,5 @@
kind: Feature
body: Add a button to duplicate calendar ranges from a week to another one
time: 2024-07-01T15:32:23.602091234+02:00
custom:
Issue: "123"

View File

@ -1,7 +1,7 @@
<template> <template>
<div class="row"> <div class="row">
<div class="col-sm"> <div class="col-sm">
<label class="form-label">{{ $t('created_availabilities') }}</label> <label class="form-label">{{ $t("created_availabilities") }}</label>
<vue-multiselect <vue-multiselect
v-model="pickedLocation" v-model="pickedLocation"
:options="locations" :options="locations"
@ -14,10 +14,15 @@
></vue-multiselect> ></vue-multiselect>
</div> </div>
</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="col-sm-9 col-xs-12">
<div class="input-group mb-3"> <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"> <select v-model="slotDuration" id="slotDuration" class="form-select">
<option value="00:05:00">5 minutes</option> <option value="00:05:00">5 minutes</option>
<option value="00:10:00">10 minutes</option> <option value="00:10:00">10 minutes</option>
@ -58,13 +63,20 @@
</select> </select>
</div> </div>
</div> </div>
<div class="col-sm-3 col-xs-12"> <div class="col-xs-12 col-sm-3">
<div class="float-end"> <div class="float-end">
<div class="form-check input-group"> <div class="form-check input-group">
<span class="input-group-text"> <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> </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> </div>
</div> </div>
@ -72,39 +84,86 @@
<FullCalendar :options="calendarOptions" ref="calendarRef"> <FullCalendar :options="calendarOptions" ref="calendarRef">
<template v-slot:eventContent="arg: EventApi"> <template v-slot:eventContent="arg: EventApi">
<span :class="eventClasses(arg.event)"> <span :class="eventClasses(arg.event)">
<b v-if="arg.event.extendedProps.is === 'remote'">{{ arg.event.title}}</b> <b v-if="arg.event.extendedProps.is === 'remote'">{{
<b v-else-if="arg.event.extendedProps.is === 'range'">{{ arg.timeText }} - {{ arg.event.extendedProps.locationName }}</b> arg.event.title
<b v-else-if="arg.event.extendedProps.is === 'local'">{{ arg.event.title}}</b> }}</b>
<b v-else >no 'is'</b> <b v-else-if="arg.event.extendedProps.is === 'range'"
<a v-if="arg.event.extendedProps.is === 'range'" class="fa fa-fw fa-times delete" >{{ arg.timeText }} - {{ arg.event.extendedProps.locationName }}</b
@click.prevent="onClickDelete(arg.event)"> >
</a> <b v-else-if="arg.event.extendedProps.is === 'local'">{{
</span> 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>
</span>
</template> </template>
</FullCalendar> </FullCalendar>
<div id="copy-widget"> <div id="copy-widget">
<div class="container"> <div class="container mt-2 mb-2">
<div class="row align-items-center">
<div class="col-sm-4 col-xs-12"> <div class="row justify-content-between align-items-center mb-4">
<h6 class="chill-red">{{ $t('copy_range_from_to') }}</h6> <div class="col-xs-12 col-sm-3 col-md-2">
</div> <h6 class="chill-red">{{ $t("copy_range_from_to") }}</h6>
<div class="col-sm-3 col-xs-12"> </div>
<input class="form-control" type="date" v-model="copyFrom" /> <div class="col-xs-12 col-sm-9 col-md-2">
</div> <select v-model="dayOrWeek" id="dayOrWeek" class="form-select">
<div class="col-sm-1 col-xs-12" style="text-align: center; font-size: x-large;"> <option value="day">{{ $t("from_day_to_day") }}</option>
<i class="fa fa-angle-double-right"></i> <option value="week">{{ $t("from_week_to_week") }}</option>
</div> </select>
<div class="col-sm-3 col-xs-12" > </div>
<input class="form-control" type="date" v-model="copyTo" /> <template v-if="dayOrWeek === 'day'">
</div> <div class="col-xs-12 col-sm-3 col-md-3">
<div class="col-sm-1"> <input class="form-control" type="date" v-model="copyFrom" />
<button class="btn btn-action" @click="copyDay"> </div>
{{ $t('copy_range') }} <div class="col-xs-12 col-sm-1 col-md-1 copy-chevron">
</button> <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>
</div> </div>
</div>
</div> </div>
<!-- not directly seen, but include in a modal --> <!-- not directly seen, but include in a modal -->
@ -112,42 +171,95 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { import type {
CalendarOptions, CalendarOptions,
DatesSetArg, DatesSetArg,
EventInput EventInput,
} from '@fullcalendar/core'; } from "@fullcalendar/core";
import {reactive, computed, ref} from "vue"; import { reactive, computed, ref, onMounted } from "vue";
import {useStore} from "vuex"; import { useStore } from "vuex";
import {key} from './store'; import { key } from "./store";
import FullCalendar from '@fullcalendar/vue3'; import FullCalendar from "@fullcalendar/vue3";
import frLocale from '@fullcalendar/core/locales/fr'; import frLocale from "@fullcalendar/core/locales/fr";
import interactionPlugin, {DropArg, EventResizeDoneArg} from "@fullcalendar/interaction"; import interactionPlugin, {
DropArg,
EventResizeDoneArg,
} from "@fullcalendar/interaction";
import timeGridPlugin from "@fullcalendar/timegrid"; import timeGridPlugin from "@fullcalendar/timegrid";
import {EventApi, DateSelectArg, EventDropArg, EventClickArg} from "@fullcalendar/core"; import {
import {ISOToDate} from "../../../../../ChillMainBundle/Resources/public/chill/js/date"; EventApi,
DateSelectArg,
EventDropArg,
EventClickArg,
} from "@fullcalendar/core";
import {
dateToISO,
ISOToDate,
} from "../../../../../ChillMainBundle/Resources/public/chill/js/date";
import VueMultiselect from "vue-multiselect"; import VueMultiselect from "vue-multiselect";
import {Location} from "../../../../../ChillMainBundle/Resources/public/types"; import { Location } from "../../../../../ChillMainBundle/Resources/public/types";
import EditLocation from "./Components/EditLocation.vue"; import EditLocation from "./Components/EditLocation.vue";
import {useI18n} from "vue-i18n"; import { useI18n } from "vue-i18n";
const store = useStore(key); const store = useStore(key);
const {t} = useI18n(); const { t } = useI18n();
const showWeekends = ref(false); const showWeekends = ref(false);
const slotDuration = ref('00:05:00'); const slotDuration = ref("00:15:00");
const slotMinTime = ref('09:00:00'); const slotMinTime = ref("09:00:00");
const slotMaxTime = ref('18:00:00'); const slotMaxTime = ref("18:00:00");
const copyFrom = ref<string | null>(null); const copyFrom = ref<string | null>(null);
const copyTo = 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>({ const baseOptions = ref<CalendarOptions>({
locale: frLocale, locale: frLocale,
plugins: [interactionPlugin, timeGridPlugin], plugins: [interactionPlugin, timeGridPlugin],
initialView: 'timeGridWeek', initialView: "timeGridWeek",
initialDate: new Date(), initialDate: new Date(),
scrollTimeReset: false, scrollTimeReset: false,
selectable: true, selectable: true,
@ -164,9 +276,9 @@ const baseOptions = ref<CalendarOptions>({
selectMirror: false, selectMirror: false,
editable: true, editable: true,
headerToolbar: { headerToolbar: {
left: 'prev,next today', left: "prev,next today",
center: 'title', center: "title",
right: 'timeGridWeek,timeGridDay' right: "timeGridWeek,timeGridDay",
}, },
}); });
@ -180,20 +292,23 @@ const locations = computed<Location[]>(() => {
const pickedLocation = computed<Location | null>({ const pickedLocation = computed<Location | null>({
get(): 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 { 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 * return the show classes for the event
* @param arg * @param arg
*/ */
const eventClasses = function(arg: EventApi): object { const eventClasses = function (arg: EventApi): object {
return {'calendarRangeItems': true}; return { calendarRangeItems: true };
} };
/* /*
// currently, all events are stored into calendarRanges, due to reactivity bug // 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 * launched when the calendar range date change
*/ */
function onDatesSet(event: DatesSetArg): void { 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 { function onDateSelect(event: DateSelectArg): void {
if (null === pickedLocation.value) { 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; 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 * When a calendar range is deleted
*/ */
function onClickDelete(event: EventApi): void { function onClickDelete(event: EventApi): void {
console.log('onClickDelete', event); if (event.extendedProps.is !== "range") {
if (event.extendedProps.is !== 'range') {
return; return;
} }
store.dispatch('calendarRanges/deleteRange', event.extendedProps.calendarRangeId); store.dispatch(
"calendarRanges/deleteRange",
event.extendedProps.calendarRangeId
);
} }
function onEventDropOrResize(payload: EventDropArg | EventResizeDoneArg) { function onEventDropOrResize(payload: EventDropArg | EventResizeDoneArg) {
if (payload.event.extendedProps.is !== 'range') { if (payload.event.extendedProps.is !== "range") {
return; return;
} }
const changedEvent = payload.event; const changedEvent = payload.event;
store.dispatch('calendarRanges/patchRangeTime', { store.dispatch("calendarRanges/patchRangeTime", {
calendarRangeId: payload.event.extendedProps.calendarRangeId, calendarRangeId: payload.event.extendedProps.calendarRangeId,
start: payload.event.start, start: payload.event.start,
end: payload.event.end, end: payload.event.end,
}); });
}; }
function onEventClick(payload: EventClickArg): void { function onEventClick(payload: EventClickArg): void {
// @ts-ignore TS does not recognize the target. But it does exists. // @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; return;
} }
if (payload.event.extendedProps.is !== 'range') { if (payload.event.extendedProps.is !== "range") {
return; return;
} }
@ -285,10 +409,26 @@ function copyDay() {
if (null === copyFrom.value || null === copyTo.value) { if (null === copyFrom.value || null === copyTo.value) {
return; return;
} }
store.dispatch("calendarRanges/copyFromDayToAnotherDay", {
store.dispatch('calendarRanges/copyFromDayToAnotherDay', {from: ISOToDate(copyFrom.value), to: ISOToDate(copyTo.value)}) 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> </script>
<style scoped> <style scoped>
@ -299,4 +439,9 @@ function copyDay() {
z-index: 9999999999; z-index: 9999999999;
padding: 0.25rem 0 0.25rem; padding: 0.25rem 0 0.25rem;
} }
div.copy-chevron {
text-align: center;
font-size: x-large;
width: 2rem;
}
</style> </style>

View File

@ -5,11 +5,9 @@ const appMessages = {
show_my_calendar: "Afficher mon calendrier", show_my_calendar: "Afficher mon calendrier",
show_weekends: "Afficher les week-ends", show_weekends: "Afficher les week-ends",
copy_range: "Copier", copy_range: "Copier",
copy_range_from_to: "Copier les plages d'un jour à l'autre", copy_range_from_to: "Copier les plages",
copy_range_to_next_day: "Copier les plages du jour au jour suivant", from_day_to_day: "d'un jour à l'autre",
copy_range_from_day: "Copier les plages du ", from_week_to_week: "d'une semaine à l'autre",
to_the_next_day: " au jour suivant",
copy_range_to_next_week: "Copier les plages de la semaine à la semaine suivante",
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.", 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", new_range_to_save: "Nouvelles plages à enregistrer",
update_range_to_save: "Plages à modifier", 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; return founds;
}, },
}, },
@ -238,7 +255,7 @@ export default <Module<CalendarRangesState, State>>{
for (let r of rangesToCopy) { for (let r of rangesToCopy) {
let start = new Date(<Date>ISOToDatetime(r.start)); 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)); let end = new Date(<Date>ISOToDatetime(r.end));
end.setFullYear(to.getFullYear(), to.getMonth(), to.getDate()); end.setFullYear(to.getFullYear(), to.getMonth(), to.getDate());
let location = ctx.rootGetters['locations/getLocationById'](r.locationId); 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})); 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)); return Promise.all(promises).then(_ => Promise.resolve(null));
} }
} }