implemented coodle survey calendar

This commit is contained in:
adisposkofh
2026-06-24 13:56:42 +02:00
parent d8a25dc0f3
commit 1bfd24313a
10 changed files with 663 additions and 72 deletions
+8
View File
@@ -33,5 +33,13 @@ Events::on('loadRenderers', function ($renderers) {
);
});
Events::on('loadRenderers', function ($renderers) {
$fhc_core_renderers =& $renderers();
$fhc_core_renderers["coodle"] = array(
'calendarEvent' => absoluteJsImportUrl('public/js/components/Cis/Renderer/Coodle/calendarEvent.js'),
'calendarEventStyles' => APP_ROOT . 'public/css/Cis4/CoreCalendarEvents.css'
);
});
+8
View File
@@ -872,4 +872,12 @@ html {
}
.coodleParticipantSearchResult:hover {
opacity: 80%;
}
.coodleCalendarTimeslotCard:hover {
background-color: var(--fhc-background);
}
.coodleCalendarTimeslotCardIcon:hover {
color: var(--fhc-primary);
}
+1
View File
@@ -290,6 +290,7 @@ export default {
:is="modes ? modes[cMode] : null || 'div'"
ref="mode"
v-model:current-date="cDate"
@emptyCellClicked="$emit('emptyCellClicked', $event)"
@update:range="$emit('update:range', $event)"
@request-modal-open="showEventModal"
@request-modal-close="hideEventModal"
+1 -1
View File
@@ -399,7 +399,7 @@ export default {
style="position:relative"
:style="'grid-' + axisCol + ':' + (1+index) + ';grid-' + axisRow + ':ps_' + i + '/pe_' + i"
>
<slot name="part-body" v-bind="{ index, part }" />
<slot name="part-body" v-bind="{ index, part, date }" />
<div
v-if="snapToGrid && dragging"
style="position:absolute;inset:0;z-index:1"
+36 -19
View File
@@ -1,7 +1,7 @@
import CalendarGrid from '../../Base/Grid.js';
import LabelDay from '../../Base/Label/Day.js';
import LabelDow from '../../Base/Label/Dow.js';
import LabelTime from '../../Base/Label/Time.js';
import CalendarGrid from "../../Base/Grid.js";
import LabelDay from "../../Base/Label/Day.js";
import LabelDow from "../../Base/Label/Dow.js";
import LabelTime from "../../Base/Label/Time.js";
export default {
name: "WeekView",
@@ -9,47 +9,54 @@ export default {
CalendarGrid,
LabelDay,
LabelDow,
LabelTime
LabelTime,
},
inject: {
timeGrid: "timeGrid",
timezone: "timezone"
timezone: "timezone",
},
props: {
day: {
type: luxon.DateTime,
required: true
required: true,
},
collapseEmptyDays: Boolean
collapseEmptyDays: Boolean,
},
computed: {
start() {
return this.day.startOf('week', { useLocaleWeeks: true });
return this.day.startOf("week", { useLocaleWeeks: true });
},
axisMain() {
return Array.from({ length: 7 }, (e, i) => this.start.plus({ days: i }));
return Array.from({ length: 7 }, (e, i) =>
this.start.plus({ days: i }),
);
},
axisParts() {
if (this.timeGrid) {
// create {start, end} array
return this.timeGrid.map(tu => {
return this.timeGrid.map((tu) => {
return {
start: luxon.Duration.fromISOTime(tu.start),
end: luxon.Duration.fromISOTime(tu.end)
end: luxon.Duration.fromISOTime(tu.end),
};
});
} else {
// create 07:00-23:00
return Array.from({ length: 17 }, (e, i) => luxon.Duration.fromObject({ hours: i + 7 }));
return Array.from({ length: 17 }, (e, i) =>
luxon.Duration.fromObject({ hours: i + 7 }),
);
}
}
},
},
methods: {
isToday(date) {
return date.hasSame(luxon.DateTime.now().setZone(this.timezone), 'day');
}
return date.hasSame(
luxon.DateTime.now().setZone(this.timezone),
"day",
);
},
},
template: /* html */`
template: /* html */ `
<div class="fhc-calendar-mode-week-view h-100">
<calendar-grid
ref="grid"
@@ -73,6 +80,16 @@ export default {
<template #part-header="{ part }">
<label-time v-bind="{ part }" />
</template>
<template #part-body="params">
<slot name="part-body">
<div
@click="($event) => {
$event.params = params;
$emit('emptyCellClicked', $event)
}"
class="position-absolute h-100 w-100" style="z-index:1"></div>
</slot>
</template>
<template #event="slot">
<div v-if="slot.event.type == 'loading'" class="placeholder-glow h-100 opacity-50">
<span class="placeholder w-100 h-100" />
@@ -81,5 +98,5 @@ export default {
</template>
</calendar-grid>
</div>
`
}
`,
};
@@ -25,7 +25,8 @@ export default {
data() {
return {
surveyFormData: null,
isEditInProgress: false,
// todo: revert to default false
isEditInProgress: true,
weekdays: [
"Sunday",
"Monday",
@@ -179,46 +180,10 @@ export default {
</div>
<div class="card-body">
<div class="d-flex flex-column gap-3">
<span
v-if="isEditInProgress"
class="fst-italic"
>
{{ "Required inputs are marked with *." }}
</span>
<coodle-survey-basic-info
v-model:surveyFormDataModelValue="surveyFormData"
:survey="$props.survey"
:parsedSelectedTimeslot="parsedSelectedTimeslot"
:isEditInProgress="isEditInProgress"
/>
<hr>
<div v-if="!isEditInProgress" class="d-flex flex-column gap-2">
<coodle-survey-voting-table
v-if="$props.survey"
:uid="$props.uid"
:timeslots="parsedTimeslotsForVotingTable"
:survey="$props.survey"
/>
</div>
<div v-else class="d-flex flex-column gap-3">
<div class="row">
<div class="col-12 col-xl-4">
<coodle-survey-participants
v-if="surveyFormData?.participants"
v-model:participantsModelValue="surveyFormData.participants"
/>
</div>
<div class="col-12 col-xl-8">
<div class="d-flex flex-column gap-3">
<coodle-survey-duration-selector
v-if="surveyFormData?.timeslotDuration"
v-model:durationModelValue="surveyFormData.timeslotDuration"
:survey="$props.survey"
/>
<coodle-survey-calendar />
</div>
</div>
</div>
<div v-if="isEditInProgress" class="d-flex flex-row justify-content-between">
<span class="fst-italic">
{{ "Required inputs are marked with *." }}
</span>
<div class="d-flex flex-row gap-2 justify-content-end align-items-start px-0">
<div>
<div
@@ -241,6 +206,42 @@ export default {
</div>
</div>
</div>
<coodle-survey-basic-info
v-model:surveyFormDataModelValue="surveyFormData"
:survey="$props.survey"
:parsedSelectedTimeslot="parsedSelectedTimeslot"
:isEditInProgress="isEditInProgress"
/>
<hr>
<div v-if="!isEditInProgress" class="d-flex flex-column gap-2">
<coodle-survey-voting-table
v-if="$props.survey"
:uid="$props.uid"
:timeslots="parsedTimeslotsForVotingTable"
:survey="$props.survey"
/>
</div>
<div v-else class="d-flex flex-column gap-3">
<div class="row">
<div class="col-12 col-xxl-4 pb-3">
<coodle-survey-participants
v-if="surveyFormData?.participants"
v-model:participantsModelValue="surveyFormData.participants"
/>
</div>
<div class="col-12 col-xxl-8">
<div class="d-flex flex-column gap-4">
<coodle-survey-duration-selector
v-if="surveyFormData?.timeslotDuration"
v-model:durationModelValue="surveyFormData.timeslotDuration"
:survey="$props.survey"
/>
<coodle-survey-calendar :surveyFormData="surveyFormData" />
</div>
</div>
</div>
</div>
</div>
</div>
</div>`,
};
@@ -1,18 +1,324 @@
import CoodleSurveyCalendarTimeslotCard from "./CoodleSurveyCalendar/CoodleSurveyCalendarTimeslotCard.js";
import FhcCalendar from "../../../../Calendar/Base.js";
import ModeWeek from "../../../../Calendar/Mode/Week.js";
import { numberPadding } from "../../../../../helpers/DateHelpers.js";
export default {
name: "CoodleSurveyCalendar",
components: {},
props: {},
components: { CoodleSurveyCalendarTimeslotCard, FhcCalendar },
inject: ["renderers"],
props: {
surveyFormData: Object | null,
},
emits: [],
data() {
return {}
return {
mode: "Week",
modes: {
week: Vue.markRaw(ModeWeek),
},
modeOptions: {
week: {
collapseEmptyDays: false,
},
},
timeslots: [
{
type: "coodle",
datum: "2026-06-24",
beginn: "10:45:00",
ende: "12:00:00",
isostart: "2026-06-24T08:45:00.000Z",
isoend: "2026-06-24T10:00:00.000Z",
farbe: "FFFFFF",
},
{
type: "coodle",
datum: "2026-06-25",
beginn: "14:55:00",
ende: "16:10:00",
isostart: "2026-06-25T12:55:00.000Z",
isoend: "2026-06-25T14:10:00.000Z",
farbe: "FFFFFF",
},
{
type: "coodle",
datum: "2026-06-25",
beginn: "12:05:00",
ende: "13:20:00",
isostart: "2026-06-25T10:05:00.000Z",
isoend: "2026-06-25T11:20:00.000Z",
farbe: "FFFFFF",
},
{
type: "coodle",
datum: "2026-06-26",
beginn: "14:20:00",
ende: "15:35:00",
isostart: "2026-06-26T12:20:00.000Z",
isoend: "2026-06-26T13:35:00.000Z",
farbe: "FFFFFF",
},
{
type: "coodle",
datum: "2026-06-24",
beginn: "14:35:00",
ende: "15:50:00",
isostart: "2026-06-24T12:35:00.000Z",
isoend: "2026-06-24T13:50:00.000Z",
farbe: "FFFFFF",
},
{
type: "coodle",
datum: "2026-06-24",
beginn: "17:20:00",
ende: "18:35:00",
isostart: "2026-06-24T15:20:00.000Z",
isoend: "2026-06-24T16:35:00.000Z",
farbe: "FFFFFF",
},
{
type: "coodle",
datum: "2026-06-25",
beginn: "17:30:00",
ende: "18:45:00",
isostart: "2026-06-25T15:30:00.000Z",
isoend: "2026-06-25T16:45:00.000Z",
farbe: "FFFFFF",
},
{
type: "coodle",
datum: "2026-06-26",
beginn: "18:40:00",
ende: "19:55:00",
isostart: "2026-06-26T16:40:00.000Z",
isoend: "2026-06-26T17:55:00.000Z",
farbe: "FFFFFF",
},
],
isTimeslotCardEditInProgress: false,
};
},
computed: {
timezone() {
return FHC_JS_DATA_STORAGE_OBJECT.timezone;
},
backgrounds() {
let now = luxon.DateTime.now().setZone(this.timezone);
return [
{
class: "background-past",
end: now,
label: now.startOf("minute").toISOTime({
suppressSeconds: true,
includeOffset: false,
}),
},
];
},
events() {
// todo
return this.timeslots;
},
roundedTimeslotDuration() {
return (
this.$props.surveyFormData.timeslotDuration -
(this.$props.surveyFormData.timeslotDuration % 5)
);
},
},
watch: {
"surveyFormData.timeslotDuration": {
handler() {
this.updateDurationOfExistingTimeslots();
},
},
timeslots: {
handler() {
// todo: update form data
},
deep: true,
},
},
methods: {
addTimeslotOnCalendarClick(clickEvent) {
if (this.formatTimeslotForCalendarBasedOnStart.length >= 50) {
return;
}
const elementRectangle = clickEvent.target.getBoundingClientRect();
const relativeClickLocation = clickEvent.y - elementRectangle.top;
let minutes =
Math.floor(
(relativeClickLocation / elementRectangle.height) * 12,
) * 5;
if (minutes < 0 || minutes > 55) {
minutes = 0;
}
let hours = clickEvent.params?.part?.values?.hours ?? 12;
if (hours < 7 || hours > 22) {
hours = 12;
}
let start = new Date(clickEvent.params.date.ts);
start.setHours(hours);
start.setMinutes(minutes);
let newTimeslot = this.formatTimeslotForCalendarBasedOnStart(start);
if (
this.timeslots.some(
(timeslot) => timeslot.isostart === newTimeslot.isostart,
)
) {
return;
}
this.timeslots.push(newTimeslot);
this.sortTimeslots();
},
formatTimeslotForCalendarBasedOnStart(start) {
let end = new Date(start.getTime());
end.setMinutes(end.getMinutes() + this.roundedTimeslotDuration);
return {
type: "coodle",
datum: start.toISOString().slice(0, 10),
beginn:
numberPadding(start.getHours()) +
":" +
numberPadding(start.getMinutes()) +
":00",
ende:
numberPadding(end.getHours()) +
":" +
numberPadding(end.getMinutes()) +
":00",
isostart: start.toISOString(),
isoend: end.toISOString(),
farbe: "FFFFFF",
};
},
updateDurationOfExistingTimeslots() {
this.timeslots = this.timeslots.map((timeslot) => {
let start = new Date(timeslot.isostart);
return this.formatTimeslotForCalendarBasedOnStart(start);
});
this.sortTimeslots();
},
createTimeslotFromCard(startDate) {
if (
this.timeslots.some(
(existingTimeslot) =>
existingTimeslot.isostart ===
startDate.toISOString(),
)
) {
window.alert("You cannot create duplicates!");
return;
}
this.timeslots.push(this.formatTimeslotForCalendarBasedOnStart(startDate));
this.sortTimeslots();
this.isTimeslotCardEditInProgress = false;
},
updateTimeslotFromCard(timeslot, newStartDate) {
if (
this.timeslots.some(
(existingTimeslot) =>
existingTimeslot.isostart ===
newStartDate.toISOString(),
)
) {
window.alert("You cannot create duplicates!");
return;
}
this.timeslots = this.timeslots.map((existingTimeslot) => {
if (existingTimeslot.isostart !== timeslot.isostart) {
return existingTimeslot;
}
return this.formatTimeslotForCalendarBasedOnStart(newStartDate);
});
this.sortTimeslots();
this.isTimeslotCardEditInProgress = false;
},
deleteTimeslot(timeslot) {
this.timeslots = this.timeslots.filter((existingTimeslot) => {
return existingTimeslot.isostart !== timeslot.isostart;
});
this.sortTimeslots();
this.isTimeslotCardEditInProgress = false;
},
sortTimeslots() {
this.timeslots = this.timeslots.sort((timeslotA, timeslotB) => {
let timeslotAStartDateTime = timeslotA.datum + timeslotA.beginn;
let timeslotBStartDateTime = timeslotB.datum + timeslotB.beginn;
if (timeslotAStartDateTime > timeslotBStartDateTime) {
return 1;
} else {
return -1;
}
});
},
},
created() {
// todo: set timeslots
this.sortTimeslots();
},
computed: {},
watch: {},
methods: {},
created() {},
template: /*html*/ `
<div>
calendar placeholder
<div class="d-flex flex-column gap-3">
<div class="d-flex flex-column">
<span class="fw-bold">{{ "Timeslot selection" }}</span>
<span class="fst-italic">{{ "You can add timeslots directly to the calendar or from the list down below." }}</span>
</div>
<div style="height:800px;">
<fhc-calendar
@emptyCellClicked="addTimeslotOnCalendarClick($event)"
:ref="'coodleCalendar'"
:timezone="timezone"
:modes="modes"
:modeOptions="modeOptions"
:mode="'Week'"
:timeGrid="null"
:showBtns="false"
:locale="$p.user_locale.value"
:events="events"
:backgrounds="backgrounds"
:draggableEvents="true"
:droppableEvents="true"
>
<template v-slot="{ event, mode }">
<div
:class="'event-type-' + event.type + ' ' + mode + 'PageContainer'"
:type="mode == 'day' ? 'button' : undefined"
>
<component
@deleteCoodleTimeslot="deleteTimeslot($event.timeslot)"
:is="renderers[event.type]?.calendarEvent"
:event="event"
></component>
</div>
</template>
</fhc-calendar>
</div>
<div class="row">
<div v-for="timeslot in timeslots.concat([null])" class="col-12 col-md-6 col-xl-4">
<coodle-survey-calendar-timeslot-card
@createTimeslot="createTimeslotFromCard($event.startDate)"
@updateTimeslot="updateTimeslotFromCard(timeslot, $event.newStartDate)"
@deleteTimeslot="deleteTimeslot(timeslot)"
@setIsTimeslotCardEditInProgress="isTimeslotCardEditInProgress = $event.value"
:timeslot="timeslot"
:isTimeslotCardEditInProgress="isTimeslotCardEditInProgress"
/>
</div>
</div>
</div>
`,
}
};
@@ -0,0 +1,192 @@
import { numberPadding } from "../../../../../../helpers/DateHelpers.js";
export default {
name: "CoodleSurveyCalendarTimeslotCard",
props: {
timeslot: Object | null,
isTimeslotCardEditInProgress: Boolean,
},
emits: [
"deleteTimeslot",
"updateTimeslot",
"setIsTimeslotCardEditInProgress",
],
data() {
return {
isEditInProgress: false,
dateInput: null,
startTimeInput: null,
minStartTimeInput: "07:00",
maxStartTimeInput: "22:55",
};
},
computed: {
formattedDate() {
let dateFragments = this.$props.timeslot.datum.split("-");
return dateFragments[2] + "." + dateFragments[1] + ".";
},
formattedStartEnd() {
return (
this.$props.timeslot?.beginn?.slice(0, 5) +
"-" +
this.$props.timeslot?.ende?.slice(0, 5)
);
},
},
watch: {
timeslot: {
handler() {
this.isEditInProgress = false;
this.setInputs();
},
deep: true,
},
isEditInProgress() {
this.setInputs();
},
isTimeslotCardEditInProgress() {
this.isEditInProgress = false;
},
},
methods: {
setInputs() {
this.dateInput = this.$props.timeslot?.datum;
this.startTimeInput = this.$props.timeslot?.beginn?.slice(0, 5);
},
async startEdit() {
if (this.$props.isTimeslotCardEditInProgress) {
this.$emit("setIsTimeslotCardEditInProgress", { value: false });
}
await this.$nextTick();
this.$emit("setIsTimeslotCardEditInProgress", { value: true });
await this.$nextTick();
this.isEditInProgress = true;
},
cancelEdit() {
this.isEditInProgress = false;
this.$emit("setIsTimeslotCardEditInProgress", { value: false });
},
saveChanges() {
if (!this.dateInput || !this.startTimeInput) {
window.alert("Check your inputs!");
return;
}
if (
this.dateInput === this.$props.timeslot?.datum &&
this.startTimeInput ===
this.$props.timeslot?.beginn?.slice(0, 5)
) {
this.isEditInProgress = false;
return;
}
if (
this.startTimeInput < this.minStartTimeInput ||
this.startTimeInput > this.maxStartTimeInput
) {
window.alert(
"Appointment start time must be between 07:00 and 22:55!",
);
return;
}
let minutes = parseInt(this.startTimeInput.slice(3));
if (minutes % 5) {
minutes = minutes - (minutes % 5);
this.startTimeInput =
this.startTimeInput.slice(0, 3) + numberPadding(minutes);
}
let startDate = new Date(
this.dateInput + " " + this.startTimeInput,
);
if (this.$props.timeslot) {
this.$emit("updateTimeslot", { newStartDate: startDate });
} else {
this.$emit("createTimeslot", { startDate });
}
},
},
created() {
this.setInputs();
},
template: /*html*/ `
<div class="w-100 p-2" style="height: 120px;">
<div class="coodleCalendarTimeslotCard h-100 d-flex flex-row justify-content-between px-3 py-2 border border-2 rounded-2">
<template v-if="$props.timeslot || isEditInProgress">
<div
v-if="!isEditInProgress"
class="d-flex flex-column gap-2 justify-content-center align-items-center flex-grow-1"
>
<span class="fs-5 fw-bold">{{ formattedDate }}</span>
<span>{{ formattedStartEnd }}</span>
</div>
<div v-else class="d-flex flex-column gap-2 flex-grow-1 justify-content-center pe-3">
<div class="d-flex flex-row gap-2">
<label :for="'dateInput-' + $props.timeslot?.isostart" class="fw-bold">{{ "Date: " }}</label>
<input v-model="dateInput" type="date" class="flex-grow-1" />
</div>
<div class="d-flex flex-row gap-2">
<label :for="'startTimeInput-' + $props.timeslot?.isostart" class="fw-bold">{{ "Start time: " }}</label>
<input
v-model="startTimeInput"
type="time"
step="300"
:min="minStartTimeInput"
:max="maxStartTimeInput"
class="flex-grow-1"
/>
</div>
</div>
<div class="d-flex flex-row align-items-start justify-content-end">
<template v-if="!isEditInProgress">
<div
@click="startEdit()"
:title="'Edit'"
type="button"
class="p-1 coodleCalendarTimeslotCardIcon"
>
<i class="fa-solid fa-pen-to-square fa-lg"></i>
</div>
<div
@click="$emit('deleteTimeslot')"
:title="'Delete'"
type="button"
class="p-1 coodleCalendarTimeslotCardIcon"
>
<i class="fa-solid fa-trash-can fa-lg"></i>
</div>
</template>
<template v-else>
<div
@click="cancelEdit()"
:title="'Cancel'"
type="button"
class="p-1 coodleCalendarTimeslotCardIcon"
>
<i class="fa-regular fa-circle-left fa-lg"></i>
</div>
<div
@click="saveChanges()"
:title="'Save'"
type="button"
class="p-1 coodleCalendarTimeslotCardIcon"
>
<i class="fa-solid fa-floppy-disk fa-lg"></i>
</div>
</template>
</div>
</template>
<div v-else class="flex-grow-1 d-flex flex-row justify-content-center align-items-center">
<i
@click="startEdit()"
:title="'Add'"
type="button"
class="coodleCalendarTimeslotCardIcon fa-solid fa-circle-plus fa-2xl"
></i>
</div>
</div>
</div>
`,
};
@@ -57,7 +57,7 @@ export default {
},
template: /*html*/ `
<div class="d-flex flex-column gap-2">
<label class="fw-bold">{{ "* " + "Appointment duration (in minutes)" }}</label>
<span class="fw-bold">{{ "* " + "Appointment duration (in minutes)" }}</span>
<div class="overflow-x-auto">
<div class="d-flex flex-row align-items-center gap-2">
<div
@@ -0,0 +1,58 @@
export default {
props: {
event: {
type: Object,
required: true,
},
},
emits: ["deleteCoodleTimeslot"],
computed: {
tooltipString() {
const tooltipArray = [];
const dateFragments = this.$props.event.datum.split("-");
tooltipArray.push(
dateFragments[2] +
"." +
dateFragments[1] +
"." +
dateFragments[0] +
".",
);
tooltipArray.push(this.start + "-" + this.end);
return tooltipArray.join("\n");
},
start() {
return luxon.Duration.fromISOTime(this.event.beginn).toISOTime({
suppressSeconds: true,
});
},
end() {
return luxon.Duration.fromISOTime(this.event.ende).toISOTime({
suppressSeconds: true,
});
},
},
template: /* html */ `
<div
@click.stop=""
class="calendar-event-default h-100 w-100 p-1 d-flex flex-row"
>
<div
v-if="event?.beginn && event?.ende"
class="d-flex flex-column justify-content-center align-items-center flex-grow-1"
>
<span>{{ start }}</span>
<span>{{ end }}</span>
</div>
<div
@click="$emit('deleteCoodleTimeslot', { timeslot: $props.event })"
class="d-flex justify-content-end align-items-start pt-2 pe-1"
v-tooltip="tooltipString"
>
<i class="fa-solid fa-xmark fa-lg"></i>
</div>
</div>
`,
};