Skip to content
Snippets Groups Projects
EmissionManager.vue 20.3 KiB
Newer Older
  <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>
    <b-alert
      variant="danger"
      :show="conflictMode"
    >
      <div
        v-if="conflictMode"
        align="center"
      >
        <h4>Conflict Resolution</h4>
        <p>
          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>
            &nbsp;
            <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>
            &nbsp;
            <b-button
              variant="danger"
              @click="resolveCancel"
            >
              Cancel
            </b-button>
          </p>
        </div>
    <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"
    />
</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,
      showChangedAlert: {
        countdown: 0,
        seconds: 3
      },

      // flags for loading data
      loaded: {
        shows: false,
        timeslots: false,
        calendarSlots: 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,
        // 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()
    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
      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)
      }
      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
          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/'
      axios.post(uri, resolvedSchedule, {
        withCredentials: true,
        headers: { 'Authorization': 'Bearer ' + this.$parent.user.access_token }
      }).then(response => {
        this.$log.debug('resolveSubmit: response:', response)
        // 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.$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;
}
  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;
}