Skip to content
Snippets Groups Projects
EmissionManager.vue 25.8 KiB
Newer Older
        <template v-if="selectedShow">
            <auth-wall>
                <show-selector
                        ref="showSelector"
                        :title="$t('navigation.calendar')"
                        :callback="showHasSwitched"
                />
                <hr>

                <b-alert
                        :variant="conflictCount > 0 ? 'danger' : 'success'"
                        <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')"
                            <div v-if="conflictCount > 0">
                                <p>{{ $t('conflictResolution.leftToResolve', { smart_count: conflictCount }) }}</p>

                                <b-button
                                        variant="danger"
                                        size="sm"
                                        @click="resolveCancel"
                                >
                                    {{ $t('cancel') }}
                            </div>
                            <div v-else>
                                <p>{{ $t('conflictResolution.noneLeftToResolve') }}</p>
                                    {{ $t('conflictResolution.applySolution') }}
                                </b-button>
                                &nbsp;
                                <b-button
                                        variant="danger"
                                        @click="resolveCancel"
                                >
                                    {{ $t('cancel') }}
                    </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"
Richard Blechinger's avatar
Richard Blechinger committed
                v-else-if="loaded.shows && !selectedShow"
                v-html="this.$t('noAssignedShows', { adminUrl })"
    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,
Richard Blechinger's avatar
Richard Blechinger committed
                            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'
Richard Blechinger's avatar
Richard Blechinger committed
                        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'
Richard Blechinger's avatar
Richard Blechinger committed
                        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'))