<template> <b-container> <template v-if="selectedShow"> <auth-wall> <show-selector ref="showSelector" :title="$t('navigation.calendar')" :calendar="true" :callback="showHasSwitched" @set-view="changeView" /> <hr> <b-alert :variant="conflictCount > 0 ? 'danger' : 'success'" :show="conflictMode" > <div v-if="conflictMode" > <h4>{{ $t('conflictResolution.title') }}</h4> <p :class="{ 'tw-mb-4': resolveData.schedule.rrule === 1, 'tw-mb-0': resolveData.schedule.rrule !== 1 }" v-html="$t('conflictResolution.newSchedule', { firstDate: prettyDate(resolveData.schedule.first_date), startTime: resolveData.schedule.start_time, endTime: resolveData.schedule.end_time, })" /> <p v-if="resolveData.schedule.rrule !== 1" v-html="$t('conflictResolution.recurringSchedule', { rrule: rruleRender(resolveData.schedule.rrule), lastDate: prettyDate(resolveData.schedule.last_date) })" /> <div v-if="submitting"> <b-row> <b-col align="center"> <img src="/assets/radio.gif" :alt="$t('loading')" > </b-col> </b-row> </div> <div v-else> <div v-if="conflictCount > 0"> <p>{{ $t('conflictResolution.leftToResolve', {smart_count: conflictCount}) }}</p> <b-button variant="danger" size="sm" @click="resolveCancel" > {{ $t('cancel') }} </b-button> </div> <div v-else> <p>{{ $t('conflictResolution.noneLeftToResolve') }}</p> <b-button variant="success" @click="resolveSubmit" > {{ $t('conflictResolution.applySolution') }} </b-button> <b-button variant="danger" @click="resolveCancel" > {{ $t('cancel') }} </b-button> </div> </div> </div> </b-alert> <server-errors :errors="serverErrors" /> <div class="tw-flex"> <div :class="{ 'tw-hidden': view !== 'week'}"> <full-calendar ref="calendar" default-view="agendaWeek" :events="calendarSlots" :config="calendarConfig" @view-render="renderView" @event-selected="eventSelected" @event-drop="eventDrop" @event-resize="eventResize" @event-created="eventCreated" /> </div> <div :class="{ 'schedule-panel tw-w-full': true, 'tw-hidden': view !== 'day' }" > <div class="tw-flex tw-items-center tw-justify-between tw-mb-4"> <h3>{{ prettyDate(selectedDay) }}</h3> <b-button-group> <b-button type="info" @click="changeDay(-1)" > <svg class="tw-w-4 tw-h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" ><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" /></svg> </b-button> <b-button type="info" @click="changeDay(1)" > <svg class="tw-w-4 tw-h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" > <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" /> </svg> </b-button> </b-button-group> </div> <div v-for="timeslot in timeslotsForDay" :key="timeslot.id" :class="{ 'timeslot tw-w-full tw-py-2 tw-px-3 tw-rounded hover:tw-bg-gray-200 hover:tw-text-gray-900 tw-cursor-pointer tw-border tw-border-solid tw-border-gray-200 tw-mb-2': true, 'tw-bg-gray-900 tw-text-white': new Date() >= new Date(timeslot.start) && new Date() <= new Date(timeslot.end), 'tw-bg-gray-200 tw-text-gray-600 tw-opacity-75': new Date() >= new Date(timeslot.end), }" @click="() => timeslotClicked(timeslot)" > <div class="tw-flex tw-justify-between tw-items-center"> <div> <p class="tw-mb-0 tw-leading-tight tw-font-bold"> {{ timeslot.show.name }} </p> <span class="tw-text-sm"> {{ prettyTime(timeslot.start) }} - {{ prettyTime(timeslot.end) }} <span v-if="timeslot.is_repetition" class="tw-text-gray-400" >{{ $t('calendar.repetition') }}</span> </span> </div> <div v-if="loaded.playlists"> <div v-if="!timeslot.playlist_id"> <span v-if="timeslot.show.default_playlist_id" class="tw-leading-none" > <span class="tw-block"><strong>{{ $t('emissionTable.playlist') }}:</strong> {{ getPlaylistById(timeslot.show.default_playlist_id).description || timeslot.show.default_playlist_id }}</span> <span class="tw-text-xs tw-text-red-500">{{ $t('calendar.fallback') }}</span> </span> <span v-else> {{ $t('calendar.empty') }} </span> </div> <div v-else class="tw-leading-none" > <span class="tw-block"><strong>{{ $t('emissionTable.playlist') }}:</strong> {{ timeslot.playlist.description || timeslot.playlist_id }}</span> <span class="tw-text-sm"> <strong>{{ $t('emissionTable.duration') }}:</strong> {{ playlistDuration(timeslot.playlist) }} <span v-if="isMismatchedLength(timeslot.playlist, timeslot)" class="is-mismatched" > {{ $t('calendar.mismatchedLength') }} </span> </span> </div> </div> </div> </div> </div> </div> </auth-wall> <app-modalEmissionManagerCreate ref="appModalEmissionManagerCreate" /> <app-modalEmissionManagerResolve ref="appModalEmissionManagerResolve" /> <app-modalEmissionManagerEdit ref="appModalEmissionManagerEdit" /> </template> <div v-else-if="loaded.shows && !selectedShow" class="tw-text-center" v-html="$t('noAssignedShows', { adminUrl })" /> </b-container> </template> <script> import {mapGetters} from 'vuex' // We need the fullcalendar import because it has side-effects without which // Vite would throw an error when loading the FullCalendar vue component. import 'fullcalendar' import {FullCalendar} from 'vue-full-calendar' import 'fullcalendar/dist/fullcalendar.css' import showSelector from '@/components/ShowSelector.vue' import modalEmissionManagerCreate from '@/components/emissions/ModalCreate.vue' import modalEmissionManagerResolve from '@/components/emissions/ModalResolve.vue' import modalEmissionManagerEdit from '@/components/emissions/ModalEdit.vue' import AuthWall from '@/components/AuthWall.vue' import rrules from '@/mixins/rrules' import prettyDate from '@/mixins/prettyDate' import playlist from '@/mixins/playlist' import ServerErrors from '@/components/ServerErrors.vue' import {getISODateString} from '@/util' export default { components: { ServerErrors, FullCalendar, AuthWall, 'show-selector': showSelector, 'app-modalEmissionManagerCreate': modalEmissionManagerCreate, 'app-modalEmissionManagerResolve': modalEmissionManagerResolve, 'app-modalEmissionManagerEdit': modalEmissionManagerEdit, }, mixins: [rrules, prettyDate, playlist], data() { return { view: 'week', adminUrl: `${import.meta.env.VUE_APP_BASEURI_STEERING}/admin`, calendarSlots: [], selectedDay: new Date(), // flag for when submitting resolve data submitting: false, // this flag signifies if we are in conflict resolution mode conflictMode: false, // when conflict mode is activated, this should hold the steering response // from schedule creation, with all the conflicts and solutions resolveData: null, conflictCount: 0, conflictSolutions: [], serverErrors: [], } }, computed: { ...mapGetters({ getShow: 'shows/getShowByDataParam' }), loaded() { return { shows: this.$store.state.shows.loaded['shows'], timeslots: this.$store.state.shows.loaded['timeslots'], playlists: this.$store.state.playlists.loaded['playlists'] } }, timeslotsForDay() { return this.timeslots .filter(timeslot => { const dateFormat = new Intl.DateTimeFormat('en', {year: 'numeric', month: '2-digit', day: '2-digit'}) return dateFormat.format(new Date(timeslot.start)) === dateFormat.format(this.selectedDay) }) .map(timeslot => { const id = timeslot.show const show = this.getShow({id}) return { ...timeslot, playlist: timeslot.playlist_id ? this.getPlaylistById(timeslot.playlist_id) : null, show: { ...show } } }) }, // this is the whole configuration for our schedule calendar, including // simple event handlers that do not need the whole components scope calendarConfig() { const selectDay = this.selectDay.bind(this) return { locale: this.$activeLocale(), height: 600, firstDay: 1, navLinks: true, buttonText: { today: this.$t('calendar.today') }, header: { left: 'title', center: '', right: 'today prev,next' }, views: { agendaWeek: { columnHeaderFormat: 'ddd D.M.', timeFormat: 'k:mm', slotLabelFormat: 'k:mm', allDaySlot: false, editable: false, }, }, // here we add a simple tooltip to every event, so that the full title // of a show can be viewed eventRender: function (event, element) { element.attr('title', event.title) }, navLinkDayClick(selectedDate) { selectDay(selectedDate.toDate()) document.querySelectorAll('.fc-day-header[data-date]').forEach(el => el.classList.remove('fc-day-selected')) document.querySelector(`.fc-day-header[data-date="${selectedDate.format('YYYY-MM-DD')}"]`).classList.add('fc-day-selected') } } }, ...mapGetters({ shows: 'shows/shows', playlists: 'playlists/playlists', selectedShow: 'shows/selectedShow', timeslots: 'shows/timeslots', getPlaylistById: 'playlists/getPlaylistById', files: 'files/files', getFileById: 'files/getFileById', }) }, created() { this.$store.dispatch('shows/fetchShows', { callback: () => { if (!this.selectedShow) { return } this.$nextTick(() => { this.showHasSwitched() this.$refs.showSelector.updateInputSelector() }) } }) }, mounted() { this.$nextTick(() => { try { document.querySelectorAll('.fc-day-header[data-date]').forEach(el => el.classList.remove('fc-day-selected')) document.querySelector(`.fc-day-header[data-date="${getISODateString()}"]`).classList.add('fc-day-selected') } catch (e) { // Full Calendar might not be initialized yet. } }) }, methods: { changeView(view) { this.view = view }, changeDay(delta) { this.selectedDay = new Date(this.selectedDay.setDate(this.selectedDay.getDate() + delta)) }, selectDay(date) { this.selectedDay = date }, switchShow(index) { this.$store.commit('shows/switchShow', index) this.loadCalendarSlots() }, // This is the callback function that is activated by the show-selector // component, whenever the user switches to a different show showHasSwitched() { this.$store.dispatch('playlists/fetch', { slug: this.selectedShow.slug, callback: () => console.log(this.playlists) }) this.$log.debug('show has switched to', this.selectedShow.name) let start = this.$refs.calendar.fireMethod('getView').start.format() let end = this.$refs.calendar.fireMethod('getView').end.format() this.loadTimeslots(start, end) }, getShowTitleById(id) { let i = this.shows.findIndex(show => show.id === id) if (i >= 0) { return this.shows[i].name } else { return 'Error: no show found for this timeslot' } }, getShowIndexById(id) { let i = this.shows.findIndex(show => show.id === id) if (i >= 0) { return i } else { this.$log.error('No show found for id ' + id) return 0 } }, timeslotClicked(slot) { const timeslot = {...slot} timeslot.show = slot.show.id if (timeslot.show !== this.selectedShow.id) { this.switchShow(this.getShowIndexById(timeslot.show)) } else { this.$refs.appModalEmissionManagerEdit.open(timeslot) } }, // this handler will be called whenever the user clicks on one of the // displayed timeslots eventSelected(event) { // in conflict resolution mode only the newly generated events are // clickable. if there is no conflict for an event, only a modal // with a short notice should be opend, otherwise the resolution modal if (this.conflictMode) { if (event.hash === undefined) { return } else if (this.conflictSolutions[event.hash] === undefined) { this.$refs.appModalEmissionManagerResolve.openNotNeeded() } else { this.$refs.appModalEmissionManagerResolve.open(event) } } // standard mode only those events are clickable that belong to the // currently selected show. else { let timeslot = this.timeslots.find(slot => slot.id === event.id) if (timeslot.show !== this.selectedShow.id) { this.switchShow(this.getShowIndexById(timeslot.show)) } else { this.$refs.appModalEmissionManagerEdit.open(timeslot) } } }, // currently this will not be called, as our events are not editable // if editable is set to true in the calendar config, this handler will // be called if a timeslot was dragged somewhere else eventDrop(event) { this.$log.debug('eventDrop', event) this.notYetImplemented() }, // currently this will not be called, as our events are not editable // if editable is set to true in the calendar config, this handler will // be called if a timeslot was resized eventResize(event) { this.$log.debug('eventResize', event) this.notYetImplemented() }, // this handler is called when the user creates a new timeslot eventCreated(event) { if (!this.conflictMode) { this.$refs.appModalEmissionManagerCreate.open(event.start, event.end) } }, // this is called when the user changes the calendar view, so we just // refetch the timeslots with the updated visible date range renderView(view) { if (this.loaded.shows && this.loaded.playlists) { let start = null let end = null // in case it gets called from a modal, we use the current view // otherwise we use the new dates from the view received by the renderView event if (view === null) { start = this.$refs.calendar.fireMethod('getView').start.format() end = this.$refs.calendar.fireMethod('getView').end.format() } else { start = view.start.format() end = view.end.format() } // we only load new timeslots, if we are not in conflict mode if (!this.conflictMode) { this.loadTimeslots(start, end) } } }, resolve(data) { this.resolveData = data this.conflictMode = true this.conflictCount = 0 this.conflictSolutions = data.solutions this.calendarSlots = [] try { for (let i in data.projected) { let newSlot = { // we need a numeric ID for the event for later selection by the user. // with converting the hash to a number (in this case a float), we // do not risk using a number that is already used by a timeslot id // of a conflicting timeslot id: Number(data.projected[i].hash), // the hash is needed to compare against solutions and conflicts hash: data.projected[i].hash, start: data.projected[i].start, end: data.projected[i].end, title: this.$t('new'), collisions: [], solutionChoices: [], className: 'noconflict', editable: false, } if (data.projected[i].collisions.length > 0) { newSlot.className = 'conflict' newSlot.solutionChoices = data.projected[i].solution_choices for (let col of data.projected[i].collisions) { let conflictingSlot = { id: col.id, start: col.start, end: col.end, title: col.show_name, className: 'otherShow', editable: false, } this.calendarSlots.push(conflictingSlot) this.conflictCount++ newSlot.collisions.push(col) } } this.calendarSlots.push(newSlot) } } catch (err) { this.$log.error(err) } }, resolveEvent(toResolve, mode) { let calendarSlot = this.calendarSlots.find(s => s.id === toResolve.id) let originalSlot = this.resolveData.projected.find(s => s.hash === toResolve.hash) // we only need the conflicting slot specifically for theirs-both mode, where there should be only one collision let conflictingSlot = this.calendarSlots.find(s => s.id === toResolve.collisions[0].id) // we only reduce the conflict count, if there was no other solution set already if (this.conflictSolutions[toResolve.hash] === '') { this.conflictCount -= toResolve.collisions.length } // if there already was a resolution chosen before, that added a second timeslot // because either the "ours" or "theirs" was split, we have to clean it up before // in the calendar before setting a new resolution let oldResolutionSlot = this.calendarSlots.findIndex(s => s.id === calendarSlot.id * 10) if (oldResolutionSlot > -1) { this.calendarSlots.splice(oldResolutionSlot, 1) } // and in case their was a ours-* choice before, we have to reset the (one) // conflicting slot to its original start and end time conflictingSlot.start = toResolve.collisions[0].start conflictingSlot.end = toResolve.collisions[0].end // for a detailed description of the resolution modes, see https://docs.aura.radio/en/latest/user/timeslot-collision-detection.html // and https://docs.aura.radio/en/latest/developer/misc/conflict-resolution.html switch (mode) { case 'theirs': this.conflictSolutions[toResolve.hash] = mode calendarSlot.className = 'timeslot-discarded' calendarSlot.title = this.$t('new') calendarSlot.start = originalSlot.start calendarSlot.end = originalSlot.end for (let theirs of toResolve.collisions) { this.calendarSlots.find(s => s.id === theirs.id).className = 'timeslot-accepted' } this.renderView(null) break case 'ours': this.conflictSolutions[toResolve.hash] = mode calendarSlot.className = 'timeslot-accepted' calendarSlot.title = this.$t('new') calendarSlot.start = originalSlot.start calendarSlot.end = originalSlot.end for (let theirs of toResolve.collisions) { this.calendarSlots.find(s => s.id === theirs.id).className = 'timeslot-discarded' } this.renderView(null) break case 'theirs-start': case 'theirs-end': case 'theirs-both': this.conflictSolutions[toResolve.hash] = mode calendarSlot.className = 'timeslot-partly' calendarSlot.title = 'new [' + mode + ']' if (mode === 'theirs-start') { calendarSlot.start = toResolve.collisions[0].end calendarSlot.end = originalSlot.end } else if (mode === 'theirs-end') { calendarSlot.start = originalSlot.start calendarSlot.end = toResolve.collisions[0].start } else { calendarSlot.start = originalSlot.start calendarSlot.end = toResolve.collisions[0].start this.calendarSlots.push({ id: calendarSlot.id * 10, start: toResolve.collisions[0].end, end: originalSlot.end, title: 'new [theirs-both]', className: 'timeslot-partly', editable: false, }) } for (let theirs of toResolve.collisions) { this.calendarSlots.find(s => s.id === theirs.id).className = 'timeslot-accepted' } this.renderView(null) break case 'ours-start': case 'ours-end': case 'ours-both': this.conflictSolutions[toResolve.hash] = mode calendarSlot.className = 'timeslot-accepted' calendarSlot.title = 'new [' + mode + ']' if (mode === 'ours-start') { conflictingSlot.start = toResolve.collisions[0].start conflictingSlot.end = originalSlot.start } else if (mode === 'ours-end') { conflictingSlot.start = originalSlot.end conflictingSlot.end = toResolve.collisions[0].end } else { conflictingSlot.start = toResolve.collisions[0].start conflictingSlot.end = originalSlot.start this.calendarSlots.push({ id: calendarSlot.id * 10, start: originalSlot.end, end: toResolve.collisions[0].end, title: conflictingSlot.title, className: 'timeslot-partly', editable: false, }) } for (let theirs of toResolve.collisions) { this.calendarSlots.find(s => s.id === theirs.id).className = 'timeslot-partly' } this.renderView(null) break default: this.$log.error('EmissionManager.resolveEvent') this.$log.error('toResolve:', toResolve) this.$log.error('mode:', mode) alert('Error: an undefined conflict resolution mode was chosen. See console for details') break } }, resolveCancel() { this.conflictMode = false this.renderView(null) }, // submit a conflict-resolved schedule to steering async resolveSubmit() { // TODO: check why steering returns undefined and null values here if (this.resolveData.schedule.add_business_days_only === undefined) { this.resolveData.schedule.add_business_days_only = false } if (this.resolveData.schedule.add_days_no === null) { this.resolveData.schedule.add_days_no = 0 } if (this.resolveData.schedule.is_repetition === undefined) { this.resolveData.schedule.is_repetition = false } if (this.resolveData.schedule.default_playlist_id === null) { this.resolveData.schedule.default_playlist_id = 0 } if (this.resolveData.schedule.automation_id === null) { this.resolveData.schedule.automation_id = 0 } if (this.resolveData.schedule.by_weekday === undefined) { this.resolveData.schedule.by_weekday = 0 } // create the resolved schedule object including solutions let resolvedSchedule = { schedule: this.resolveData.schedule, solutions: this.resolveData.solutions, } this.$log.debug('resolveSubmit: schedule:', resolvedSchedule) this.submitting = true this.serverErrors = [] try { await this.$store.dispatch('shows/submitSchedule', { showId: this.selectedShow.id, schedule: resolvedSchedule, }) this.conflictMode = false this.renderView(null) } catch (e) { if (e.response?.status === 409) { this.resolve(e.response.data) } else { this.serverErrors = e.errors } } finally { this.submitting = false } }, loadCalendarSlots() { this.calendarSlots = [] for (let i in this.timeslots) { const isEmpty = !this.timeslots[i].playlist_id let emptyText = '' let highlighting = 'otherShow' if (this.timeslots[i].show === this.selectedShow.id) { highlighting = 'currentShow ' highlighting += isEmpty ? 'emptySlot' : '' emptyText = isEmpty ? this.$t('calendar.empty') : '' } this.calendarSlots.push({ id: this.timeslots[i].id, start: this.timeslots[i].start, end: this.timeslots[i].end, title: this.getShowTitleById(this.timeslots[i].show) + `\n${emptyText}`, className: highlighting }) } }, loadTimeslots(start, end) { this.$store.dispatch('shows/fetchTimeslots', { start: start, end: end, callback: () => { this.loadCalendarSlots() } }) }, isMismatchedLength(playlist, timeslot) { const timeslotDuration = this.minutesToNanoseconds( this.prettyDuration(timeslot.start, timeslot.end).minutes ) const playlistDuration = this.hmsToNanoseconds( this.playlistDuration(playlist) ) let delta = 0 const unknowns = playlist.entries.filter(entry => !entry.duration) if (unknowns.length === 1) { delta = timeslotDuration - playlistDuration } console.log(timeslotDuration, playlistDuration + delta) return timeslotDuration !== playlistDuration + delta }, notYetImplemented: function () { alert(this.$t('unimplemented')) }, }, } </script>