<template> <b-container> <b-row v-if="loaded.shows"> <b-col> <b-alert :show="showChangedAlert.countdown" @dismiss-count-down="showChangedAlertCountdown" > Selected new show: <b>{{ shows[currentShow].name }}</b> <b-progress variant="info" animated :max="showChangedAlert.seconds" :value="showChangedAlert.countdown" height="3px" /> </b-alert> <b-alert :show="!showChangedAlert.countdown" variant="light" > Currently selected show: <b>{{ shows[currentShow].name }}</b> </b-alert> </b-col> <b-col align="right"> <b-dropdown id="ddshows" text="Sendereihe auswählen" variant="outline-info" > <b-dropdown-item v-for="(show, index) in shows" :key="show.id" @click="switchShow(index)" > {{ show.name }} </b-dropdown-item> </b-dropdown> </b-col> </b-row> <b-row v-else> <b-col cols="12"> <div align="center"> ... loading show data ... </div> </b-col> </b-row> <hr> <b-alert variant="danger" :show="conflictMode" > <div v-if="conflictMode" align="center" > <h4>Conflict Resolution</h4> <p> for new schedule from <b>{{ resolveData.schedule.dstart }}, {{ resolveData.schedule.tstart }}</b> to <b>{{ resolveData.schedule.tend }}</b>. </p> <p v-if="resolveData.schedule.rrule !== 1"> This is a recurring event: <b>{{ rruleRender(resolveData.schedule.rrule) }}</b>, until: <b>{{ resolveData.schedule.until }}</b>. </p> <div v-if="submitting"> <b-row> <b-col align="center"> <img src="../assets/radio.gif" alt="submitting resolve data" > </b-col> </b-row> </div> <div v-else> <p v-if="conflictCount > 0"> Conflicts left to resolve: <b-badge variant="danger"> {{ conflictCount }} </b-badge> <b-button variant="danger" size="sm" @click="resolveCancel" > Cancel </b-button> </p> <p v-else> <b-button variant="success" @click="resolveSubmit" > 0 conflicts left! Submit this solution. </b-button> <b-button variant="danger" @click="resolveCancel" > Cancel </b-button> </p> </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" /> <app-modalEmissionManagerCreate ref="appModalEmissionManagerCreate" /> <app-modalEmissionManagerResolve ref="appModalEmissionManagerResolve" /> <app-modalEmissionManagerEdit ref="appModalEmissionManagerEdit" /> </b-container> </template> <script> import axios from 'axios' import { FullCalendar } from 'vue-full-calendar' import 'fullcalendar/dist/fullcalendar.css' import modalEmissionManagerCreate from './EmissionManagerModalCreate.vue' import modalEmissionManagerResolve from './EmissionManagerModalResolve.vue' import modalEmissionManagerEdit from './EmissionManagerModalEdit.vue' import rrules from '../mixins/rrules' export default { components: { FullCalendar, 'app-modalEmissionManagerCreate': modalEmissionManagerCreate, 'app-modalEmissionManagerResolve': modalEmissionManagerResolve, 'app-modalEmissionManagerEdit': modalEmissionManagerEdit, }, mixins: [ rrules ], data () { return { currentShow: 0, calendarSlots: [], showChangedAlert: { countdown: 0, seconds: 3 }, // flags for loading data loaded: { shows: false, timeslots: false, calendarSlots: false, }, 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: [], // this is the whole configuration for our schedule calendar, including // simple event handlers that do not need the whole components scope calendarConfig: { height: 600, firstDay: 1, 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); }, }, } }, computed: { shows () { return this.$store.state.shows.shows }, timeslots () { return this.$store.state.shows.timeslots }, }, created () { if (this.$route.query.show) { this.currentShow = this.$route.query.show } else { this.currentShow = 0 } this.loadShows() }, methods: { switchShow (index) { this.currentShow = index this.loadCalendarSlots() this.showChangedAlert.countdown = this.showChangedAlert.seconds }, showChangedAlertCountdown(countdown) { this.showChangedAlert.countdown = countdown }, 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.shows[this.currentShow].id) { this.switchShow(this.getShowIndexById(timeslot.show)) } else { this.$refs.appModalEmissionManagerEdit.open(timeslot, this.shows[this.currentShow]) } } }, // 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: '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 = '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 = '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.fallback_id === null) { this.resolveData.schedule.fallback_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) // now generate the URL and POST it to steering let uri = process.env.VUE_APP_API_STEERING_SHOWS + this.shows[this.currentShow].id + '/schedules/' this.submitting = true axios.post(uri, resolvedSchedule, { withCredentials: true, headers: { 'Authorization': 'Bearer ' + this.$parent.user.access_token } }).then(response => { this.$log.debug('resolveSubmit: response:', response) this.submitting = false // if for some reason a new conflict arose, e.g. because in the meantime // someone else inserted a conflicting schedule, we have to resolve. if (response.data.projected === undefined) { this.conflictMode = false this.renderView(null) } else { this.resolve(response.data) } }).catch(error => { this.submitting = false this.$log.error(error.response.status + ' ' + error.response.statusText) this.$log.error(error.response) alert('Error: could not submit final schedule. See console for details.') // and we leave the modal open, so no call to its .hide function here }) }, loadCalendarSlots () { this.loaded.calendarSlots = false this.calendarSlots = [] for (let i in this.timeslots) { let highlighting = 'otherShow' if (this.timeslots[i].show === this.shows[this.currentShow].id) { highlighting = 'currentShow' } 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), className: highlighting }) } this.loaded.calendarSlots = true }, loadTimeslots (start, end) { this.loaded.timeslots = false this.$store.dispatch('shows/fetchTimeslots', { start: start, end: end, callback: () => { this.$log.debug('loadTimeslots callback executed') this.loaded.timeslots = true this.loadCalendarSlots() } }) }, loadShows () { this.loaded.shows = false this.$store.dispatch('shows/fetchShows', { callback: () => { this.$log.debug('firing calendar method getView') this.loaded.shows = true let start = this.$refs.calendar.fireMethod('getView').start.format() let end = this.$refs.calendar.fireMethod('getView').end.format() this.loadTimeslots(start, end) } }) }, notYetImplemented: function () { alert('By the mighty witchcraftry of the mother of time!\n\nThis feature is not implemented yet.') }, }, } </script> <style> .otherShow { background-color: #eee; } a.currentShow { background-color: #17a2b8; } .conflict { background-color: #b00; } .noconflict { background-color: #17a2b8; } .timeslot-discarded { background-color: #b00; opacity: 0.5; text-decoration: line-through !important; } .timeslot-accepted { background-color: #17a2b8; } .timeslot-partly { background-color: #17a2b8; opacity: 0.5; font-weight: bold; } </style>