<template> <b-container> <template v-if="selectedShow"> <auth-wall> <show-selector ref="showSelector" :title="$t('navigation.calendar')" :callback="showHasSwitched" /> <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', { dstart: this.prettyDate(resolveData.schedule.dstart), tstart: resolveData.schedule.tstart, tend: resolveData.schedule.tend, })" /> <p v-if="resolveData.schedule.rrule !== 1" v-html="$t('conflictResolution.recurringSchedule', { rrule: this.rruleRender(resolveData.schedule.rrule), until: this.prettyDate(resolveData.schedule.until) })" /> <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> <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" /> </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="this.$t('noAssignedShows', { adminUrl })" /> </b-container> </template> <script> import {mapGetters} from 'vuex' 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"; export default { components: { FullCalendar, AuthWall, 'show-selector': showSelector, 'app-modalEmissionManagerCreate': modalEmissionManagerCreate, 'app-modalEmissionManagerResolve': modalEmissionManagerResolve, 'app-modalEmissionManagerEdit': modalEmissionManagerEdit, }, mixins: [rrules, prettyDate], data() { return { adminUrl: `${process.env.VUE_APP_BASEURI_STEERING}/admin`, calendarSlots: [], // 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: [], } }, computed: { loaded() { return { shows: this.$store.state.shows.loaded['shows'], timeslots: this.$store.state.shows.loaded['timeslots'], } }, // this is the whole configuration for our schedule calendar, including // simple event handlers that do not need the whole components scope calendarConfig() { return { locale: this.$activeLocale(), height: 600, firstDay: 1, 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); }, } }, ...mapGetters({ shows: 'shows/shows', 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() }) } }) }, methods: { switchShow() { 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.$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 } }, // 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) { 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 conflict-resolution.md // and conflict-resolution.pdf at https://gitlab.servus.at/autoradio/meta 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 resolveSubmit() { // TODO: check why steering retourns 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_id === null) { this.resolveData.schedule.default_id = 0 } if (this.resolveData.schedule.automation_id === null) { this.resolveData.schedule.automation_id = 0 } if (this.resolveData.schedule.byweekday === undefined) { this.resolveData.schedule.byweekday = 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.$store.dispatch('shows/submitSchedule', { showId: this.selectedShow.id, schedule: resolvedSchedule, callback: (response) => { this.submitting = false if (response.data.projected === undefined) { this.conflictMode = false this.renderView(null) } else { this.resolve(response.data) } }, callbackCancel: () => { 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() } }) }, notYetImplemented: function () { alert(this.$t('unimplemented')) }, }, } </script> <style> </style>