diff --git a/InvenTree/InvenTree/fields.py b/InvenTree/InvenTree/fields.py
index 884bd0c49f..c0e1633ac7 100644
--- a/InvenTree/InvenTree/fields.py
+++ b/InvenTree/InvenTree/fields.py
@@ -5,10 +5,13 @@ from __future__ import unicode_literals
 
 from .validators import allowable_url_schemes
 
+from django.utils.translation import ugettext as _
+
 from django.forms.fields import URLField as FormURLField
 from django.db import models as models
 from django.core import validators
 from django import forms
+
 from decimal import Decimal
 
 import InvenTree.helpers
@@ -31,6 +34,32 @@ class InvenTreeURLField(models.URLField):
         })
 
 
+class DatePickerFormField(forms.DateField):
+    """
+    Custom date-picker field
+    """
+
+    def __init__(self, **kwargs):
+
+        help_text = kwargs.get('help_text', _('Enter date'))
+        required = kwargs.get('required', False)
+        initial = kwargs.get('initial', None)
+
+        widget = forms.DateInput(
+            attrs={
+                'type': 'date',
+            }
+        )
+
+        forms.DateField.__init__(
+            self,
+            required=required,
+            initial=initial,
+            help_text=help_text,
+            widget=widget
+        )
+
+
 def round_decimal(value, places):
     """
     Round value to the specified number of places.
diff --git a/InvenTree/InvenTree/static/css/inventree.css b/InvenTree/InvenTree/static/css/inventree.css
index 92568eeb11..78c1808adf 100644
--- a/InvenTree/InvenTree/static/css/inventree.css
+++ b/InvenTree/InvenTree/static/css/inventree.css
@@ -748,3 +748,6 @@ input[type="submit"] {
     to { transform: scale(1) rotate(360deg);}
 }
 
+input[type="date"].form-control, input[type="time"].form-control, input[type="datetime-local"].form-control, input[type="month"].form-control {
+	line-height: unset;
+}
\ No newline at end of file
diff --git a/InvenTree/InvenTree/static/fullcalendar/locales-all.js b/InvenTree/InvenTree/static/fullcalendar/locales-all.js
new file mode 100644
index 0000000000..81eaf9e99e
--- /dev/null
+++ b/InvenTree/InvenTree/static/fullcalendar/locales-all.js
@@ -0,0 +1,1515 @@
+[].push.apply(FullCalendar.globalLocales, function () {
+  'use strict';
+
+  var l0 = {
+    code: 'af',
+    week: {
+      dow: 1, // Maandag is die eerste dag van die week.
+      doy: 4, // Die week wat die 4de Januarie bevat is die eerste week van die jaar.
+    },
+    buttonText: {
+      prev: 'Vorige',
+      next: 'Volgende',
+      today: 'Vandag',
+      year: 'Jaar',
+      month: 'Maand',
+      week: 'Week',
+      day: 'Dag',
+      list: 'Agenda',
+    },
+    allDayText: 'Heeldag',
+    moreLinkText: 'Addisionele',
+    noEventsText: 'Daar is geen gebeurtenisse nie',
+  };
+
+  var l1 = {
+    code: 'ar-dz',
+    week: {
+      dow: 0, // Sunday is the first day of the week.
+      doy: 4, // The week that contains Jan 1st is the first week of the year.
+    },
+    direction: 'rtl',
+    buttonText: {
+      prev: 'السابق',
+      next: 'التالي',
+      today: 'اليوم',
+      month: 'شهر',
+      week: 'أسبوع',
+      day: 'يوم',
+      list: 'أجندة',
+    },
+    weekText: 'أسبوع',
+    allDayText: 'اليوم كله',
+    moreLinkText: 'أخرى',
+    noEventsText: 'أي أحداث لعرض',
+  };
+
+  var l2 = {
+    code: 'ar-kw',
+    week: {
+      dow: 0, // Sunday is the first day of the week.
+      doy: 12, // The week that contains Jan 1st is the first week of the year.
+    },
+    direction: 'rtl',
+    buttonText: {
+      prev: 'السابق',
+      next: 'التالي',
+      today: 'اليوم',
+      month: 'شهر',
+      week: 'أسبوع',
+      day: 'يوم',
+      list: 'أجندة',
+    },
+    weekText: 'أسبوع',
+    allDayText: 'اليوم كله',
+    moreLinkText: 'أخرى',
+    noEventsText: 'أي أحداث لعرض',
+  };
+
+  var l3 = {
+    code: 'ar-ly',
+    week: {
+      dow: 6, // Saturday is the first day of the week.
+      doy: 12, // The week that contains Jan 1st is the first week of the year.
+    },
+    direction: 'rtl',
+    buttonText: {
+      prev: 'السابق',
+      next: 'التالي',
+      today: 'اليوم',
+      month: 'شهر',
+      week: 'أسبوع',
+      day: 'يوم',
+      list: 'أجندة',
+    },
+    weekText: 'أسبوع',
+    allDayText: 'اليوم كله',
+    moreLinkText: 'أخرى',
+    noEventsText: 'أي أحداث لعرض',
+  };
+
+  var l4 = {
+    code: 'ar-ma',
+    week: {
+      dow: 6, // Saturday is the first day of the week.
+      doy: 12, // The week that contains Jan 1st is the first week of the year.
+    },
+    direction: 'rtl',
+    buttonText: {
+      prev: 'السابق',
+      next: 'التالي',
+      today: 'اليوم',
+      month: 'شهر',
+      week: 'أسبوع',
+      day: 'يوم',
+      list: 'أجندة',
+    },
+    weekText: 'أسبوع',
+    allDayText: 'اليوم كله',
+    moreLinkText: 'أخرى',
+    noEventsText: 'أي أحداث لعرض',
+  };
+
+  var l5 = {
+    code: 'ar-sa',
+    week: {
+      dow: 0, // Sunday is the first day of the week.
+      doy: 6, // The week that contains Jan 1st is the first week of the year.
+    },
+    direction: 'rtl',
+    buttonText: {
+      prev: 'السابق',
+      next: 'التالي',
+      today: 'اليوم',
+      month: 'شهر',
+      week: 'أسبوع',
+      day: 'يوم',
+      list: 'أجندة',
+    },
+    weekText: 'أسبوع',
+    allDayText: 'اليوم كله',
+    moreLinkText: 'أخرى',
+    noEventsText: 'أي أحداث لعرض',
+  };
+
+  var l6 = {
+    code: 'ar-tn',
+    week: {
+      dow: 1, // Monday is the first day of the week.
+      doy: 4, // The week that contains Jan 4th is the first week of the year.
+    },
+    direction: 'rtl',
+    buttonText: {
+      prev: 'السابق',
+      next: 'التالي',
+      today: 'اليوم',
+      month: 'شهر',
+      week: 'أسبوع',
+      day: 'يوم',
+      list: 'أجندة',
+    },
+    weekText: 'أسبوع',
+    allDayText: 'اليوم كله',
+    moreLinkText: 'أخرى',
+    noEventsText: 'أي أحداث لعرض',
+  };
+
+  var l7 = {
+    code: 'ar',
+    week: {
+      dow: 6, // Saturday is the first day of the week.
+      doy: 12, // The week that contains Jan 1st is the first week of the year.
+    },
+    direction: 'rtl',
+    buttonText: {
+      prev: 'السابق',
+      next: 'التالي',
+      today: 'اليوم',
+      month: 'شهر',
+      week: 'أسبوع',
+      day: 'يوم',
+      list: 'أجندة',
+    },
+    weekText: 'أسبوع',
+    allDayText: 'اليوم كله',
+    moreLinkText: 'أخرى',
+    noEventsText: 'أي أحداث لعرض',
+  };
+
+  var l8 = {
+    code: 'az',
+    week: {
+      dow: 1, // Monday is the first day of the week.
+      doy: 4, // The week that contains Jan 4th is the first week of the year.
+    },
+    buttonText: {
+      prev: 'Əvvəl',
+      next: 'Sonra',
+      today: 'Bu Gün',
+      month: 'Ay',
+      week: 'Həftə',
+      day: 'Gün',
+      list: 'Gündəm',
+    },
+    weekText: 'Həftə',
+    allDayText: 'Bütün Gün',
+    moreLinkText: function(n) {
+      return '+ daha çox ' + n
+    },
+    noEventsText: 'Göstərmək üçün hadisə yoxdur',
+  };
+
+  var l9 = {
+    code: 'bg',
+    week: {
+      dow: 1, // Monday is the first day of the week.
+      doy: 7, // The week that contains Jan 1st is the first week of the year.
+    },
+    buttonText: {
+      prev: 'назад',
+      next: 'напред',
+      today: 'днес',
+      month: 'Месец',
+      week: 'Седмица',
+      day: 'Ден',
+      list: 'График',
+    },
+    allDayText: 'Цял ден',
+    moreLinkText: function(n) {
+      return '+още ' + n
+    },
+    noEventsText: 'Няма събития за показване',
+  };
+
+  var l10 = {
+    code: 'bs',
+    week: {
+      dow: 1, // Monday is the first day of the week.
+      doy: 7, // The week that contains Jan 1st is the first week of the year.
+    },
+    buttonText: {
+      prev: 'Prošli',
+      next: 'Sljedeći',
+      today: 'Danas',
+      month: 'Mjesec',
+      week: 'Sedmica',
+      day: 'Dan',
+      list: 'Raspored',
+    },
+    weekText: 'Sed',
+    allDayText: 'Cijeli dan',
+    moreLinkText: function(n) {
+      return '+ još ' + n
+    },
+    noEventsText: 'Nema događaja za prikazivanje',
+  };
+
+  var l11 = {
+    code: 'ca',
+    week: {
+      dow: 1, // Monday is the first day of the week.
+      doy: 4, // The week that contains Jan 4th is the first week of the year.
+    },
+    buttonText: {
+      prev: 'Anterior',
+      next: 'Següent',
+      today: 'Avui',
+      month: 'Mes',
+      week: 'Setmana',
+      day: 'Dia',
+      list: 'Agenda',
+    },
+    weekText: 'Set',
+    allDayText: 'Tot el dia',
+    moreLinkText: 'més',
+    noEventsText: 'No hi ha esdeveniments per mostrar',
+  };
+
+  var l12 = {
+    code: 'cs',
+    week: {
+      dow: 1, // Monday is the first day of the week.
+      doy: 4, // The week that contains Jan 4th is the first week of the year.
+    },
+    buttonText: {
+      prev: 'Dříve',
+      next: 'Později',
+      today: 'Nyní',
+      month: 'Měsíc',
+      week: 'Týden',
+      day: 'Den',
+      list: 'Agenda',
+    },
+    weekText: 'Týd',
+    allDayText: 'Celý den',
+    moreLinkText: function(n) {
+      return '+další: ' + n
+    },
+    noEventsText: 'Žádné akce k zobrazení',
+  };
+
+  var l13 = {
+    code: 'cy',
+    week: {
+      dow: 1, // Monday is the first day of the week.
+      doy: 4, // The week that contains Jan 4th is the first week of the year.
+    },
+    buttonText: {
+      prev: 'Blaenorol',
+      next: 'Nesaf',
+      today: 'Heddiw',
+      year: 'Blwyddyn',
+      month: 'Mis',
+      week: 'Wythnos',
+      day: 'Dydd',
+      list: 'Rhestr',
+    },
+    weekText: 'Wythnos',
+    allDayText: 'Trwy\'r dydd',
+    moreLinkText: 'Mwy',
+    noEventsText: 'Dim digwyddiadau',
+  };
+
+  var l14 = {
+    code: 'da',
+    week: {
+      dow: 1, // Monday is the first day of the week.
+      doy: 4, // The week that contains Jan 4th is the first week of the year.
+    },
+    buttonText: {
+      prev: 'Forrige',
+      next: 'Næste',
+      today: 'I dag',
+      month: 'Måned',
+      week: 'Uge',
+      day: 'Dag',
+      list: 'Agenda',
+    },
+    weekText: 'Uge',
+    allDayText: 'Hele dagen',
+    moreLinkText: 'flere',
+    noEventsText: 'Ingen arrangementer at vise',
+  };
+
+  var l15 = {
+    code: 'de-at',
+    week: {
+      dow: 1, // Monday is the first day of the week.
+      doy: 4, // The week that contains Jan 4th is the first week of the year.
+    },
+    buttonText: {
+      prev: 'Zurück',
+      next: 'Vor',
+      today: 'Heute',
+      year: 'Jahr',
+      month: 'Monat',
+      week: 'Woche',
+      day: 'Tag',
+      list: 'Terminübersicht',
+    },
+    weekText: 'KW',
+    allDayText: 'Ganztägig',
+    moreLinkText: function(n) {
+      return '+ weitere ' + n
+    },
+    noEventsText: 'Keine Ereignisse anzuzeigen',
+  };
+
+  var l16 = {
+    code: 'de',
+    week: {
+      dow: 1, // Monday is the first day of the week.
+      doy: 4, // The week that contains Jan 4th is the first week of the year.
+    },
+    buttonText: {
+      prev: 'Zurück',
+      next: 'Vor',
+      today: 'Heute',
+      year: 'Jahr',
+      month: 'Monat',
+      week: 'Woche',
+      day: 'Tag',
+      list: 'Terminübersicht',
+    },
+    weekText: 'KW',
+    allDayText: 'Ganztägig',
+    moreLinkText: function(n) {
+      return '+ weitere ' + n
+    },
+    noEventsText: 'Keine Ereignisse anzuzeigen',
+  };
+
+  var l17 = {
+    code: 'el',
+    week: {
+      dow: 1, // Monday is the first day of the week.
+      doy: 4, // The week that contains Jan 4st is the first week of the year.
+    },
+    buttonText: {
+      prev: 'Προηγούμενος',
+      next: 'Επόμενος',
+      today: 'Σήμερα',
+      month: 'Μήνας',
+      week: 'Εβδομάδα',
+      day: 'Ημέρα',
+      list: 'Ατζέντα',
+    },
+    weekText: 'Εβδ',
+    allDayText: 'Ολοήμερο',
+    moreLinkText: 'περισσότερα',
+    noEventsText: 'Δεν υπάρχουν γεγονότα προς εμφάνιση',
+  };
+
+  var l18 = {
+    code: 'en-au',
+    week: {
+      dow: 1, // Monday is the first day of the week.
+      doy: 4, // The week that contains Jan 4th is the first week of the year.
+    },
+  };
+
+  var l19 = {
+    code: 'en-gb',
+    week: {
+      dow: 1, // Monday is the first day of the week.
+      doy: 4, // The week that contains Jan 4th is the first week of the year.
+    },
+  };
+
+  var l20 = {
+    code: 'en-nz',
+    week: {
+      dow: 1, // Monday is the first day of the week.
+      doy: 4, // The week that contains Jan 4th is the first week of the year.
+    },
+  };
+
+  var l21 = {
+    code: 'eo',
+    week: {
+      dow: 1, // Monday is the first day of the week.
+      doy: 4, // The week that contains Jan 4th is the first week of the year.
+    },
+    buttonText: {
+      prev: 'Antaŭa',
+      next: 'Sekva',
+      today: 'Hodiaŭ',
+      month: 'Monato',
+      week: 'Semajno',
+      day: 'Tago',
+      list: 'Tagordo',
+    },
+    weekText: 'Sm',
+    allDayText: 'Tuta tago',
+    moreLinkText: 'pli',
+    noEventsText: 'Neniuj eventoj por montri',
+  };
+
+  var l22 = {
+    code: 'es',
+    week: {
+      dow: 0, // Sunday is the first day of the week.
+      doy: 6, // The week that contains Jan 1st is the first week of the year.
+    },
+    buttonText: {
+      prev: 'Ant',
+      next: 'Sig',
+      today: 'Hoy',
+      month: 'Mes',
+      week: 'Semana',
+      day: 'Día',
+      list: 'Agenda',
+    },
+    weekText: 'Sm',
+    allDayText: 'Todo el día',
+    moreLinkText: 'más',
+    noEventsText: 'No hay eventos para mostrar',
+  };
+
+  var l23 = {
+    code: 'es',
+    week: {
+      dow: 1, // Monday is the first day of the week.
+      doy: 4, // The week that contains Jan 4th is the first week of the year.
+    },
+    buttonText: {
+      prev: 'Ant',
+      next: 'Sig',
+      today: 'Hoy',
+      month: 'Mes',
+      week: 'Semana',
+      day: 'Día',
+      list: 'Agenda',
+    },
+    weekText: 'Sm',
+    allDayText: 'Todo el día',
+    moreLinkText: 'más',
+    noEventsText: 'No hay eventos para mostrar',
+  };
+
+  var l24 = {
+    code: 'et',
+    week: {
+      dow: 1, // Monday is the first day of the week.
+      doy: 4, // The week that contains Jan 4th is the first week of the year.
+    },
+    buttonText: {
+      prev: 'Eelnev',
+      next: 'Järgnev',
+      today: 'Täna',
+      month: 'Kuu',
+      week: 'Nädal',
+      day: 'Päev',
+      list: 'Päevakord',
+    },
+    weekText: 'näd',
+    allDayText: 'Kogu päev',
+    moreLinkText: function(n) {
+      return '+ veel ' + n
+    },
+    noEventsText: 'Kuvamiseks puuduvad sündmused',
+  };
+
+  var l25 = {
+    code: 'eu',
+    week: {
+      dow: 1, // Monday is the first day of the week.
+      doy: 7, // The week that contains Jan 1st is the first week of the year.
+    },
+    buttonText: {
+      prev: 'Aur',
+      next: 'Hur',
+      today: 'Gaur',
+      month: 'Hilabetea',
+      week: 'Astea',
+      day: 'Eguna',
+      list: 'Agenda',
+    },
+    weekText: 'As',
+    allDayText: 'Egun osoa',
+    moreLinkText: 'gehiago',
+    noEventsText: 'Ez dago ekitaldirik erakusteko',
+  };
+
+  var l26 = {
+    code: 'fa',
+    week: {
+      dow: 6, // Saturday is the first day of the week.
+      doy: 12, // The week that contains Jan 1st is the first week of the year.
+    },
+    direction: 'rtl',
+    buttonText: {
+      prev: 'قبلی',
+      next: 'بعدی',
+      today: 'امروز',
+      month: 'ماه',
+      week: 'هفته',
+      day: 'روز',
+      list: 'برنامه',
+    },
+    weekText: 'هف',
+    allDayText: 'تمام روز',
+    moreLinkText: function(n) {
+      return 'بیش از ' + n
+    },
+    noEventsText: 'هیچ رویدادی به نمایش',
+  };
+
+  var l27 = {
+    code: 'fi',
+    week: {
+      dow: 1, // Monday is the first day of the week.
+      doy: 4, // The week that contains Jan 4th is the first week of the year.
+    },
+    buttonText: {
+      prev: 'Edellinen',
+      next: 'Seuraava',
+      today: 'Tänään',
+      month: 'Kuukausi',
+      week: 'Viikko',
+      day: 'Päivä',
+      list: 'Tapahtumat',
+    },
+    weekText: 'Vk',
+    allDayText: 'Koko päivä',
+    moreLinkText: 'lisää',
+    noEventsText: 'Ei näytettäviä tapahtumia',
+  };
+
+  var l28 = {
+    code: 'fr',
+    buttonText: {
+      prev: 'Précédent',
+      next: 'Suivant',
+      today: "Aujourd'hui",
+      year: 'Année',
+      month: 'Mois',
+      week: 'Semaine',
+      day: 'Jour',
+      list: 'Mon planning',
+    },
+    weekText: 'Sem.',
+    allDayText: 'Toute la journée',
+    moreLinkText: 'en plus',
+    noEventsText: 'Aucun événement à afficher',
+  };
+
+  var l29 = {
+    code: 'fr-ch',
+    week: {
+      dow: 1, // Monday is the first day of the week.
+      doy: 4, // The week that contains Jan 4th is the first week of the year.
+    },
+    buttonText: {
+      prev: 'Précédent',
+      next: 'Suivant',
+      today: 'Courant',
+      year: 'Année',
+      month: 'Mois',
+      week: 'Semaine',
+      day: 'Jour',
+      list: 'Mon planning',
+    },
+    weekText: 'Sm',
+    allDayText: 'Toute la journée',
+    moreLinkText: 'en plus',
+    noEventsText: 'Aucun événement à afficher',
+  };
+
+  var l30 = {
+    code: 'fr',
+    week: {
+      dow: 1, // Monday is the first day of the week.
+      doy: 4, // The week that contains Jan 4th is the first week of the year.
+    },
+    buttonText: {
+      prev: 'Précédent',
+      next: 'Suivant',
+      today: "Aujourd'hui",
+      year: 'Année',
+      month: 'Mois',
+      week: 'Semaine',
+      day: 'Jour',
+      list: 'Planning',
+    },
+    weekText: 'Sem.',
+    allDayText: 'Toute la journée',
+    moreLinkText: 'en plus',
+    noEventsText: 'Aucun événement à afficher',
+  };
+
+  var l31 = {
+    code: 'gl',
+    week: {
+      dow: 1, // Monday is the first day of the week.
+      doy: 4, // The week that contains Jan 4th is the first week of the year.
+    },
+    buttonText: {
+      prev: 'Ant',
+      next: 'Seg',
+      today: 'Hoxe',
+      month: 'Mes',
+      week: 'Semana',
+      day: 'Día',
+      list: 'Axenda',
+    },
+    weekText: 'Sm',
+    allDayText: 'Todo o día',
+    moreLinkText: 'máis',
+    noEventsText: 'Non hai eventos para amosar',
+  };
+
+  var l32 = {
+    code: 'he',
+    direction: 'rtl',
+    buttonText: {
+      prev: 'הקודם',
+      next: 'הבא',
+      today: 'היום',
+      month: 'חודש',
+      week: 'שבוע',
+      day: 'יום',
+      list: 'סדר יום',
+    },
+    allDayText: 'כל היום',
+    moreLinkText: 'אחר',
+    noEventsText: 'אין אירועים להצגה',
+    weekText: 'שבוע',
+  };
+
+  var l33 = {
+    code: 'hi',
+    week: {
+      dow: 0, // Sunday is the first day of the week.
+      doy: 6, // The week that contains Jan 1st is the first week of the year.
+    },
+    buttonText: {
+      prev: 'पिछला',
+      next: 'अगला',
+      today: 'आज',
+      month: 'महीना',
+      week: 'सप्ताह',
+      day: 'दिन',
+      list: 'कार्यसूची',
+    },
+    weekText: 'हफ्ता',
+    allDayText: 'सभी दिन',
+    moreLinkText: function(n) {
+      return '+अधिक ' + n
+    },
+    noEventsText: 'कोई घटनाओं को प्रदर्शित करने के लिए',
+  };
+
+  var l34 = {
+    code: 'hr',
+    week: {
+      dow: 1, // Monday is the first day of the week.
+      doy: 7, // The week that contains Jan 1st is the first week of the year.
+    },
+    buttonText: {
+      prev: 'Prijašnji',
+      next: 'Sljedeći',
+      today: 'Danas',
+      month: 'Mjesec',
+      week: 'Tjedan',
+      day: 'Dan',
+      list: 'Raspored',
+    },
+    weekText: 'Tje',
+    allDayText: 'Cijeli dan',
+    moreLinkText: function(n) {
+      return '+ još ' + n
+    },
+    noEventsText: 'Nema događaja za prikaz',
+  };
+
+  var l35 = {
+    code: 'hu',
+    week: {
+      dow: 1, // Monday is the first day of the week.
+      doy: 4, // The week that contains Jan 4th is the first week of the year.
+    },
+    buttonText: {
+      prev: 'vissza',
+      next: 'előre',
+      today: 'ma',
+      month: 'Hónap',
+      week: 'Hét',
+      day: 'Nap',
+      list: 'Napló',
+    },
+    weekText: 'Hét',
+    allDayText: 'Egész nap',
+    moreLinkText: 'további',
+    noEventsText: 'Nincs megjeleníthető esemény',
+  };
+
+  var l36 = {
+    code: 'hy-am',
+    week: {
+      dow: 1, // Monday is the first day of the week.
+      doy: 4, // The week that contains Jan 4th is the first week of the year.
+    },
+    buttonText: {
+      prev: 'Նախորդ',
+      next: 'Հաջորդ',
+      today: 'Այսօր',
+      month: 'Ամիս',
+      week: 'Շաբաթ',
+      day: 'Օր',
+      list: 'Օրվա ցուցակ',
+    },
+    weekText: 'Շաբ',
+    allDayText: 'Ամբողջ օր',
+    moreLinkText: function(n) {
+      return '+ ևս ' + n
+    },
+    noEventsText: 'Բացակայում է իրադարձությունը ցուցադրելու',
+  };
+
+  var l37 = {
+    code: 'id',
+    week: {
+      dow: 1, // Monday is the first day of the week.
+      doy: 7, // The week that contains Jan 1st is the first week of the year.
+    },
+    buttonText: {
+      prev: 'mundur',
+      next: 'maju',
+      today: 'hari ini',
+      month: 'Bulan',
+      week: 'Minggu',
+      day: 'Hari',
+      list: 'Agenda',
+    },
+    weekText: 'Mg',
+    allDayText: 'Sehari penuh',
+    moreLinkText: 'lebih',
+    noEventsText: 'Tidak ada acara untuk ditampilkan',
+  };
+
+  var l38 = {
+    code: 'is',
+    week: {
+      dow: 1, // Monday is the first day of the week.
+      doy: 4, // The week that contains Jan 4th is the first week of the year.
+    },
+    buttonText: {
+      prev: 'Fyrri',
+      next: 'Næsti',
+      today: 'Í dag',
+      month: 'Mánuður',
+      week: 'Vika',
+      day: 'Dagur',
+      list: 'Dagskrá',
+    },
+    weekText: 'Vika',
+    allDayText: 'Allan daginn',
+    moreLinkText: 'meira',
+    noEventsText: 'Engir viðburðir til að sýna',
+  };
+
+  var l39 = {
+    code: 'it',
+    week: {
+      dow: 1, // Monday is the first day of the week.
+      doy: 4, // The week that contains Jan 4th is the first week of the year.
+    },
+    buttonText: {
+      prev: 'Prec',
+      next: 'Succ',
+      today: 'Oggi',
+      month: 'Mese',
+      week: 'Settimana',
+      day: 'Giorno',
+      list: 'Agenda',
+    },
+    weekText: 'Sm',
+    allDayText: 'Tutto il giorno',
+    moreLinkText: function(n) {
+      return '+altri ' + n
+    },
+    noEventsText: 'Non ci sono eventi da visualizzare',
+  };
+
+  var l40 = {
+    code: 'ja',
+    buttonText: {
+      prev: '前',
+      next: '次',
+      today: '今日',
+      month: '月',
+      week: '週',
+      day: '日',
+      list: '予定リスト',
+    },
+    weekText: '週',
+    allDayText: '終日',
+    moreLinkText: function(n) {
+      return '他 ' + n + ' 件'
+    },
+    noEventsText: '表示する予定はありません',
+  };
+
+  var l41 = {
+    code: 'ka',
+    week: {
+      dow: 1,
+      doy: 7,
+    },
+    buttonText: {
+      prev: 'წინა',
+      next: 'შემდეგი',
+      today: 'დღეს',
+      month: 'თვე',
+      week: 'კვირა',
+      day: 'დღე',
+      list: 'დღის წესრიგი',
+    },
+    weekText: 'კვ',
+    allDayText: 'მთელი დღე',
+    moreLinkText: function(n) {
+      return '+ კიდევ ' + n
+    },
+    noEventsText: 'ღონისძიებები არ არის',
+  };
+
+  var l42 = {
+    code: 'kk',
+    week: {
+      dow: 1, // Monday is the first day of the week.
+      doy: 7, // The week that contains Jan 1st is the first week of the year.
+    },
+    buttonText: {
+      prev: 'Алдыңғы',
+      next: 'Келесі',
+      today: 'Бүгін',
+      month: 'Ай',
+      week: 'Апта',
+      day: 'Күн',
+      list: 'Күн тәртібі',
+    },
+    weekText: 'Не',
+    allDayText: 'Күні бойы',
+    moreLinkText: function(n) {
+      return '+ тағы ' + n
+    },
+    noEventsText: 'Көрсету үшін оқиғалар жоқ',
+  };
+
+  var l43 = {
+    code: 'ko',
+    buttonText: {
+      prev: '이전달',
+      next: '다음달',
+      today: '오늘',
+      month: '월',
+      week: '주',
+      day: '일',
+      list: '일정목록',
+    },
+    weekText: '주',
+    allDayText: '종일',
+    moreLinkText: '개',
+    noEventsText: '일정이 없습니다',
+  };
+
+  var l44 = {
+    code: 'lb',
+    week: {
+      dow: 1, // Monday is the first day of the week.
+      doy: 4, // The week that contains Jan 4th is the first week of the year.
+    },
+    buttonText: {
+      prev: 'Zréck',
+      next: 'Weider',
+      today: 'Haut',
+      month: 'Mount',
+      week: 'Woch',
+      day: 'Dag',
+      list: 'Terminiwwersiicht',
+    },
+    weekText: 'W',
+    allDayText: 'Ganzen Dag',
+    moreLinkText: 'méi',
+    noEventsText: 'Nee Evenementer ze affichéieren',
+  };
+
+  var l45 = {
+    code: 'lt',
+    week: {
+      dow: 1, // Monday is the first day of the week.
+      doy: 4, // The week that contains Jan 4th is the first week of the year.
+    },
+    buttonText: {
+      prev: 'Atgal',
+      next: 'Pirmyn',
+      today: 'Šiandien',
+      month: 'Mėnuo',
+      week: 'Savaitė',
+      day: 'Diena',
+      list: 'Darbotvarkė',
+    },
+    weekText: 'SAV',
+    allDayText: 'Visą dieną',
+    moreLinkText: 'daugiau',
+    noEventsText: 'Nėra įvykių rodyti',
+  };
+
+  var l46 = {
+    code: 'lv',
+    week: {
+      dow: 1, // Monday is the first day of the week.
+      doy: 4, // The week that contains Jan 4th is the first week of the year.
+    },
+    buttonText: {
+      prev: 'Iepr.',
+      next: 'Nāk.',
+      today: 'Šodien',
+      month: 'Mēnesis',
+      week: 'Nedēļa',
+      day: 'Diena',
+      list: 'Dienas kārtība',
+    },
+    weekText: 'Ned.',
+    allDayText: 'Visu dienu',
+    moreLinkText: function(n) {
+      return '+vēl ' + n
+    },
+    noEventsText: 'Nav notikumu',
+  };
+
+  var l47 = {
+    code: 'mk',
+    buttonText: {
+      prev: 'претходно',
+      next: 'следно',
+      today: 'Денес',
+      month: 'Месец',
+      week: 'Недела',
+      day: 'Ден',
+      list: 'График',
+    },
+    weekText: 'Сед',
+    allDayText: 'Цел ден',
+    moreLinkText: function(n) {
+      return '+повеќе ' + n
+    },
+    noEventsText: 'Нема настани за прикажување',
+  };
+
+  var l48 = {
+    code: 'ms',
+    week: {
+      dow: 1, // Monday is the first day of the week.
+      doy: 7, // The week that contains Jan 1st is the first week of the year.
+    },
+    buttonText: {
+      prev: 'Sebelum',
+      next: 'Selepas',
+      today: 'hari ini',
+      month: 'Bulan',
+      week: 'Minggu',
+      day: 'Hari',
+      list: 'Agenda',
+    },
+    weekText: 'Mg',
+    allDayText: 'Sepanjang hari',
+    moreLinkText: function(n) {
+      return 'masih ada ' + n + ' acara'
+    },
+    noEventsText: 'Tiada peristiwa untuk dipaparkan',
+  };
+
+  var l49 = {
+    code: 'nb',
+    week: {
+      dow: 1, // Monday is the first day of the week.
+      doy: 4, // The week that contains Jan 4th is the first week of the year.
+    },
+    buttonText: {
+      prev: 'Forrige',
+      next: 'Neste',
+      today: 'I dag',
+      month: 'Måned',
+      week: 'Uke',
+      day: 'Dag',
+      list: 'Agenda',
+    },
+    weekText: 'Uke',
+    allDayText: 'Hele dagen',
+    moreLinkText: 'til',
+    noEventsText: 'Ingen hendelser å vise',
+  };
+
+  var l50 = {
+    code: 'ne', // code for nepal
+    week: {
+      dow: 7, // Sunday is the first day of the week.
+      doy: 1, // The week that contains Jan 1st is the first week of the year.
+    },
+    buttonText: {
+      prev: 'अघिल्लो',
+      next: 'अर्को',
+      today: 'आज',
+      month: 'महिना',
+      week: 'हप्ता',
+      day: 'दिन',
+      list: 'सूची',
+    },
+    weekText: 'हप्ता',
+    allDayText: 'दिनभरि',
+    moreLinkText: 'थप लिंक',
+    noEventsText: 'देखाउनको लागि कुनै घटनाहरू छैनन्',
+  };
+
+  var l51 = {
+    code: 'nl',
+    week: {
+      dow: 1, // Monday is the first day of the week.
+      doy: 4, // The week that contains Jan 4th is the first week of the year.
+    },
+    buttonText: {
+      prev: 'Vorige',
+      next: 'Volgende',
+      today: 'Vandaag',
+      year: 'Jaar',
+      month: 'Maand',
+      week: 'Week',
+      day: 'Dag',
+      list: 'Agenda',
+    },
+    allDayText: 'Hele dag',
+    moreLinkText: 'extra',
+    noEventsText: 'Geen evenementen om te laten zien',
+  };
+
+  var l52 = {
+    code: 'nn',
+    week: {
+      dow: 1, // Monday is the first day of the week.
+      doy: 4, // The week that contains Jan 4th is the first week of the year.
+    },
+    buttonText: {
+      prev: 'Førre',
+      next: 'Neste',
+      today: 'I dag',
+      month: 'Månad',
+      week: 'Veke',
+      day: 'Dag',
+      list: 'Agenda',
+    },
+    weekText: 'Veke',
+    allDayText: 'Heile dagen',
+    moreLinkText: 'til',
+    noEventsText: 'Ingen hendelser å vise',
+  };
+
+  var l53 = {
+    code: 'pl',
+    week: {
+      dow: 1, // Monday is the first day of the week.
+      doy: 4, // The week that contains Jan 4th is the first week of the year.
+    },
+    buttonText: {
+      prev: 'Poprzedni',
+      next: 'Następny',
+      today: 'Dziś',
+      month: 'Miesiąc',
+      week: 'Tydzień',
+      day: 'Dzień',
+      list: 'Plan dnia',
+    },
+    weekText: 'Tydz',
+    allDayText: 'Cały dzień',
+    moreLinkText: 'więcej',
+    noEventsText: 'Brak wydarzeń do wyświetlenia',
+  };
+
+  var l54 = {
+    code: 'pt-br',
+    buttonText: {
+      prev: 'Anterior',
+      next: 'Próximo',
+      today: 'Hoje',
+      month: 'Mês',
+      week: 'Semana',
+      day: 'Dia',
+      list: 'Lista',
+    },
+    weekText: 'Sm',
+    allDayText: 'dia inteiro',
+    moreLinkText: function(n) {
+      return 'mais +' + n
+    },
+    noEventsText: 'Não há eventos para mostrar',
+  };
+
+  var l55 = {
+    code: 'pt',
+    week: {
+      dow: 1, // Monday is the first day of the week.
+      doy: 4, // The week that contains Jan 4th is the first week of the year.
+    },
+    buttonText: {
+      prev: 'Anterior',
+      next: 'Seguinte',
+      today: 'Hoje',
+      month: 'Mês',
+      week: 'Semana',
+      day: 'Dia',
+      list: 'Agenda',
+    },
+    weekText: 'Sem',
+    allDayText: 'Todo o dia',
+    moreLinkText: 'mais',
+    noEventsText: 'Não há eventos para mostrar',
+  };
+
+  var l56 = {
+    code: 'ro',
+    week: {
+      dow: 1, // Monday is the first day of the week.
+      doy: 7, // The week that contains Jan 1st is the first week of the year.
+    },
+    buttonText: {
+      prev: 'precedentă',
+      next: 'următoare',
+      today: 'Azi',
+      month: 'Lună',
+      week: 'Săptămână',
+      day: 'Zi',
+      list: 'Agendă',
+    },
+    weekText: 'Săpt',
+    allDayText: 'Toată ziua',
+    moreLinkText: function(n) {
+      return '+alte ' + n
+    },
+    noEventsText: 'Nu există evenimente de afișat',
+  };
+
+  var l57 = {
+    code: 'ru',
+    week: {
+      dow: 1, // Monday is the first day of the week.
+      doy: 4, // The week that contains Jan 4th is the first week of the year.
+    },
+    buttonText: {
+      prev: 'Пред',
+      next: 'След',
+      today: 'Сегодня',
+      month: 'Месяц',
+      week: 'Неделя',
+      day: 'День',
+      list: 'Повестка дня',
+    },
+    weekText: 'Нед',
+    allDayText: 'Весь день',
+    moreLinkText: function(n) {
+      return '+ ещё ' + n
+    },
+    noEventsText: 'Нет событий для отображения',
+  };
+
+  var l58 = {
+    code: 'sk',
+    week: {
+      dow: 1, // Monday is the first day of the week.
+      doy: 4, // The week that contains Jan 4th is the first week of the year.
+    },
+    buttonText: {
+      prev: 'Predchádzajúci',
+      next: 'Nasledujúci',
+      today: 'Dnes',
+      month: 'Mesiac',
+      week: 'Týždeň',
+      day: 'Deň',
+      list: 'Rozvrh',
+    },
+    weekText: 'Ty',
+    allDayText: 'Celý deň',
+    moreLinkText: function(n) {
+      return '+ďalšie: ' + n
+    },
+    noEventsText: 'Žiadne akcie na zobrazenie',
+  };
+
+  var l59 = {
+    code: 'sl',
+    week: {
+      dow: 1, // Monday is the first day of the week.
+      doy: 7, // The week that contains Jan 1st is the first week of the year.
+    },
+    buttonText: {
+      prev: 'Prejšnji',
+      next: 'Naslednji',
+      today: 'Trenutni',
+      month: 'Mesec',
+      week: 'Teden',
+      day: 'Dan',
+      list: 'Dnevni red',
+    },
+    weekText: 'Teden',
+    allDayText: 'Ves dan',
+    moreLinkText: 'več',
+    noEventsText: 'Ni dogodkov za prikaz',
+  };
+
+  var l60 = {
+    code: 'sq',
+    week: {
+      dow: 1, // Monday is the first day of the week.
+      doy: 4, // The week that contains Jan 4th is the first week of the year.
+    },
+    buttonText: {
+      prev: 'mbrapa',
+      next: 'Përpara',
+      today: 'sot',
+      month: 'Muaj',
+      week: 'Javë',
+      day: 'Ditë',
+      list: 'Listë',
+    },
+    weekText: 'Ja',
+    allDayText: 'Gjithë ditën',
+    moreLinkText: function(n) {
+      return '+më tepër ' + n
+    },
+    noEventsText: 'Nuk ka evente për të shfaqur',
+  };
+
+  var l61 = {
+    code: 'sr-cyrl',
+    week: {
+      dow: 1, // Monday is the first day of the week.
+      doy: 7, // The week that contains Jan 1st is the first week of the year.
+    },
+    buttonText: {
+      prev: 'Претходна',
+      next: 'следећи',
+      today: 'Данас',
+      month: 'Месец',
+      week: 'Недеља',
+      day: 'Дан',
+      list: 'Планер',
+    },
+    weekText: 'Сед',
+    allDayText: 'Цео дан',
+    moreLinkText: function(n) {
+      return '+ још ' + n
+    },
+    noEventsText: 'Нема догађаја за приказ',
+  };
+
+  var l62 = {
+    code: 'sr',
+    week: {
+      dow: 1, // Monday is the first day of the week.
+      doy: 7, // The week that contains Jan 1st is the first week of the year.
+    },
+    buttonText: {
+      prev: 'Prethodna',
+      next: 'Sledeći',
+      today: 'Danas',
+      month: 'Mеsеc',
+      week: 'Nеdеlja',
+      day: 'Dan',
+      list: 'Planеr',
+    },
+    weekText: 'Sed',
+    allDayText: 'Cеo dan',
+    moreLinkText: function(n) {
+      return '+ još ' + n
+    },
+    noEventsText: 'Nеma događaja za prikaz',
+  };
+
+  var l63 = {
+    code: 'sv',
+    week: {
+      dow: 1, // Monday is the first day of the week.
+      doy: 4, // The week that contains Jan 4th is the first week of the year.
+    },
+    buttonText: {
+      prev: 'Förra',
+      next: 'Nästa',
+      today: 'Idag',
+      month: 'Månad',
+      week: 'Vecka',
+      day: 'Dag',
+      list: 'Program',
+    },
+    weekText: 'v.',
+    allDayText: 'Heldag',
+    moreLinkText: 'till',
+    noEventsText: 'Inga händelser att visa',
+  };
+
+  var l64 = {
+    code: 'th',
+    week: {
+      dow: 1, // Monday is the first day of the week.
+      doy: 4, // The week that contains Jan 4th is the first week of the year.
+    },
+    buttonText: {
+      prev: 'ก่อนหน้า',
+      next: 'ถัดไป',
+      prevYear: 'ปีก่อนหน้า',
+      nextYear: 'ปีถัดไป',
+      year: 'ปี',
+      today: 'วันนี้',
+      month: 'เดือน',
+      week: 'สัปดาห์',
+      day: 'วัน',
+      list: 'กำหนดการ',
+    },
+    weekText: 'สัปดาห์',
+    allDayText: 'ตลอดวัน',
+    moreLinkText: 'เพิ่มเติม',
+    noEventsText: 'ไม่มีกิจกรรมที่จะแสดง',
+  };
+
+  var l65 = {
+    code: 'tr',
+    week: {
+      dow: 1, // Monday is the first day of the week.
+      doy: 7, // The week that contains Jan 1st is the first week of the year.
+    },
+    buttonText: {
+      prev: 'geri',
+      next: 'ileri',
+      today: 'bugün',
+      month: 'Ay',
+      week: 'Hafta',
+      day: 'Gün',
+      list: 'Ajanda',
+    },
+    weekText: 'Hf',
+    allDayText: 'Tüm gün',
+    moreLinkText: 'daha fazla',
+    noEventsText: 'Gösterilecek etkinlik yok',
+  };
+
+  var l66 = {
+    code: 'ug',
+    buttonText: {
+      month: 'ئاي',
+      week: 'ھەپتە',
+      day: 'كۈن',
+      list: 'كۈنتەرتىپ',
+    },
+    allDayText: 'پۈتۈن كۈن',
+  };
+
+  var l67 = {
+    code: 'uk',
+    week: {
+      dow: 1, // Monday is the first day of the week.
+      doy: 7, // The week that contains Jan 1st is the first week of the year.
+    },
+    buttonText: {
+      prev: 'Попередній',
+      next: 'далі',
+      today: 'Сьогодні',
+      month: 'Місяць',
+      week: 'Тиждень',
+      day: 'День',
+      list: 'Порядок денний',
+    },
+    weekText: 'Тиж',
+    allDayText: 'Увесь день',
+    moreLinkText: function(n) {
+      return '+ще ' + n + '...'
+    },
+    noEventsText: 'Немає подій для відображення',
+  };
+
+  var l68 = {
+    code: 'uz',
+    buttonText: {
+      month: 'Oy',
+      week: 'Xafta',
+      day: 'Kun',
+      list: 'Kun tartibi',
+    },
+    allDayText: "Kun bo'yi",
+    moreLinkText: function(n) {
+      return '+ yana ' + n
+    },
+    noEventsText: "Ko'rsatish uchun voqealar yo'q",
+  };
+
+  var l69 = {
+    code: 'vi',
+    week: {
+      dow: 1, // Monday is the first day of the week.
+      doy: 4, // The week that contains Jan 4th is the first week of the year.
+    },
+    buttonText: {
+      prev: 'Trước',
+      next: 'Tiếp',
+      today: 'Hôm nay',
+      month: 'Tháng',
+      week: 'Tuần',
+      day: 'Ngày',
+      list: 'Lịch biểu',
+    },
+    weekText: 'Tu',
+    allDayText: 'Cả ngày',
+    moreLinkText: function(n) {
+      return '+ thêm ' + n
+    },
+    noEventsText: 'Không có sự kiện để hiển thị',
+  };
+
+  var l70 = {
+    code: 'zh-cn',
+    week: {
+      // GB/T 7408-1994《数据元和交换格式·信息交换·日期和时间表示法》与ISO 8601:1988等效
+      dow: 1, // Monday is the first day of the week.
+      doy: 4, // The week that contains Jan 4th is the first week of the year.
+    },
+    buttonText: {
+      prev: '上月',
+      next: '下月',
+      today: '今天',
+      month: '月',
+      week: '周',
+      day: '日',
+      list: '日程',
+    },
+    weekText: '周',
+    allDayText: '全天',
+    moreLinkText: function(n) {
+      return '另外 ' + n + ' 个'
+    },
+    noEventsText: '没有事件显示',
+  };
+
+  var l71 = {
+    code: 'zh-tw',
+    buttonText: {
+      prev: '上月',
+      next: '下月',
+      today: '今天',
+      month: '月',
+      week: '週',
+      day: '天',
+      list: '活動列表',
+    },
+    weekText: '周',
+    allDayText: '整天',
+    moreLinkText: '顯示更多',
+    noEventsText: '没有任何活動',
+  };
+
+  /* eslint max-len: off */
+
+  var localesAll = [
+    l0, l1, l2, l3, l4, l5, l6, l7, l8, l9, l10, l11, l12, l13, l14, l15, l16, l17, l18, l19, l20, l21, l22, l23, l24, l25, l26, l27, l28, l29, l30, l31, l32, l33, l34, l35, l36, l37, l38, l39, l40, l41, l42, l43, l44, l45, l46, l47, l48, l49, l50, l51, l52, l53, l54, l55, l56, l57, l58, l59, l60, l61, l62, l63, l64, l65, l66, l67, l68, l69, l70, l71, 
+  ];
+
+  return localesAll;
+
+}());
diff --git a/InvenTree/InvenTree/static/fullcalendar/locales/af.js b/InvenTree/InvenTree/static/fullcalendar/locales/af.js
new file mode 100644
index 0000000000..9441707651
--- /dev/null
+++ b/InvenTree/InvenTree/static/fullcalendar/locales/af.js
@@ -0,0 +1,27 @@
+FullCalendar.globalLocales.push(function () {
+  'use strict';
+
+  var af = {
+    code: 'af',
+    week: {
+      dow: 1, // Maandag is die eerste dag van die week.
+      doy: 4, // Die week wat die 4de Januarie bevat is die eerste week van die jaar.
+    },
+    buttonText: {
+      prev: 'Vorige',
+      next: 'Volgende',
+      today: 'Vandag',
+      year: 'Jaar',
+      month: 'Maand',
+      week: 'Week',
+      day: 'Dag',
+      list: 'Agenda',
+    },
+    allDayText: 'Heeldag',
+    moreLinkText: 'Addisionele',
+    noEventsText: 'Daar is geen gebeurtenisse nie',
+  };
+
+  return af;
+
+}());
diff --git a/InvenTree/InvenTree/static/fullcalendar/locales/ar-dz.js b/InvenTree/InvenTree/static/fullcalendar/locales/ar-dz.js
new file mode 100644
index 0000000000..15e8a03a5a
--- /dev/null
+++ b/InvenTree/InvenTree/static/fullcalendar/locales/ar-dz.js
@@ -0,0 +1,28 @@
+FullCalendar.globalLocales.push(function () {
+  'use strict';
+
+  var arDz = {
+    code: 'ar-dz',
+    week: {
+      dow: 0, // Sunday is the first day of the week.
+      doy: 4, // The week that contains Jan 1st is the first week of the year.
+    },
+    direction: 'rtl',
+    buttonText: {
+      prev: 'السابق',
+      next: 'التالي',
+      today: 'اليوم',
+      month: 'شهر',
+      week: 'أسبوع',
+      day: 'يوم',
+      list: 'أجندة',
+    },
+    weekText: 'أسبوع',
+    allDayText: 'اليوم كله',
+    moreLinkText: 'أخرى',
+    noEventsText: 'أي أحداث لعرض',
+  };
+
+  return arDz;
+
+}());
diff --git a/InvenTree/InvenTree/static/fullcalendar/locales/ar-kw.js b/InvenTree/InvenTree/static/fullcalendar/locales/ar-kw.js
new file mode 100644
index 0000000000..daf9221167
--- /dev/null
+++ b/InvenTree/InvenTree/static/fullcalendar/locales/ar-kw.js
@@ -0,0 +1,28 @@
+FullCalendar.globalLocales.push(function () {
+  'use strict';
+
+  var arKw = {
+    code: 'ar-kw',
+    week: {
+      dow: 0, // Sunday is the first day of the week.
+      doy: 12, // The week that contains Jan 1st is the first week of the year.
+    },
+    direction: 'rtl',
+    buttonText: {
+      prev: 'السابق',
+      next: 'التالي',
+      today: 'اليوم',
+      month: 'شهر',
+      week: 'أسبوع',
+      day: 'يوم',
+      list: 'أجندة',
+    },
+    weekText: 'أسبوع',
+    allDayText: 'اليوم كله',
+    moreLinkText: 'أخرى',
+    noEventsText: 'أي أحداث لعرض',
+  };
+
+  return arKw;
+
+}());
diff --git a/InvenTree/InvenTree/static/fullcalendar/locales/ar-ly.js b/InvenTree/InvenTree/static/fullcalendar/locales/ar-ly.js
new file mode 100644
index 0000000000..2ff0986fb1
--- /dev/null
+++ b/InvenTree/InvenTree/static/fullcalendar/locales/ar-ly.js
@@ -0,0 +1,28 @@
+FullCalendar.globalLocales.push(function () {
+  'use strict';
+
+  var arLy = {
+    code: 'ar-ly',
+    week: {
+      dow: 6, // Saturday is the first day of the week.
+      doy: 12, // The week that contains Jan 1st is the first week of the year.
+    },
+    direction: 'rtl',
+    buttonText: {
+      prev: 'السابق',
+      next: 'التالي',
+      today: 'اليوم',
+      month: 'شهر',
+      week: 'أسبوع',
+      day: 'يوم',
+      list: 'أجندة',
+    },
+    weekText: 'أسبوع',
+    allDayText: 'اليوم كله',
+    moreLinkText: 'أخرى',
+    noEventsText: 'أي أحداث لعرض',
+  };
+
+  return arLy;
+
+}());
diff --git a/InvenTree/InvenTree/static/fullcalendar/locales/ar-ma.js b/InvenTree/InvenTree/static/fullcalendar/locales/ar-ma.js
new file mode 100644
index 0000000000..e19ae1d7a9
--- /dev/null
+++ b/InvenTree/InvenTree/static/fullcalendar/locales/ar-ma.js
@@ -0,0 +1,28 @@
+FullCalendar.globalLocales.push(function () {
+  'use strict';
+
+  var arMa = {
+    code: 'ar-ma',
+    week: {
+      dow: 6, // Saturday is the first day of the week.
+      doy: 12, // The week that contains Jan 1st is the first week of the year.
+    },
+    direction: 'rtl',
+    buttonText: {
+      prev: 'السابق',
+      next: 'التالي',
+      today: 'اليوم',
+      month: 'شهر',
+      week: 'أسبوع',
+      day: 'يوم',
+      list: 'أجندة',
+    },
+    weekText: 'أسبوع',
+    allDayText: 'اليوم كله',
+    moreLinkText: 'أخرى',
+    noEventsText: 'أي أحداث لعرض',
+  };
+
+  return arMa;
+
+}());
diff --git a/InvenTree/InvenTree/static/fullcalendar/locales/ar-sa.js b/InvenTree/InvenTree/static/fullcalendar/locales/ar-sa.js
new file mode 100644
index 0000000000..0e1d4fbca0
--- /dev/null
+++ b/InvenTree/InvenTree/static/fullcalendar/locales/ar-sa.js
@@ -0,0 +1,28 @@
+FullCalendar.globalLocales.push(function () {
+  'use strict';
+
+  var arSa = {
+    code: 'ar-sa',
+    week: {
+      dow: 0, // Sunday is the first day of the week.
+      doy: 6, // The week that contains Jan 1st is the first week of the year.
+    },
+    direction: 'rtl',
+    buttonText: {
+      prev: 'السابق',
+      next: 'التالي',
+      today: 'اليوم',
+      month: 'شهر',
+      week: 'أسبوع',
+      day: 'يوم',
+      list: 'أجندة',
+    },
+    weekText: 'أسبوع',
+    allDayText: 'اليوم كله',
+    moreLinkText: 'أخرى',
+    noEventsText: 'أي أحداث لعرض',
+  };
+
+  return arSa;
+
+}());
diff --git a/InvenTree/InvenTree/static/fullcalendar/locales/ar-tn.js b/InvenTree/InvenTree/static/fullcalendar/locales/ar-tn.js
new file mode 100644
index 0000000000..ea5953dbde
--- /dev/null
+++ b/InvenTree/InvenTree/static/fullcalendar/locales/ar-tn.js
@@ -0,0 +1,28 @@
+FullCalendar.globalLocales.push(function () {
+  'use strict';
+
+  var arTn = {
+    code: 'ar-tn',
+    week: {
+      dow: 1, // Monday is the first day of the week.
+      doy: 4, // The week that contains Jan 4th is the first week of the year.
+    },
+    direction: 'rtl',
+    buttonText: {
+      prev: 'السابق',
+      next: 'التالي',
+      today: 'اليوم',
+      month: 'شهر',
+      week: 'أسبوع',
+      day: 'يوم',
+      list: 'أجندة',
+    },
+    weekText: 'أسبوع',
+    allDayText: 'اليوم كله',
+    moreLinkText: 'أخرى',
+    noEventsText: 'أي أحداث لعرض',
+  };
+
+  return arTn;
+
+}());
diff --git a/InvenTree/InvenTree/static/fullcalendar/locales/ar.js b/InvenTree/InvenTree/static/fullcalendar/locales/ar.js
new file mode 100644
index 0000000000..1da9728db0
--- /dev/null
+++ b/InvenTree/InvenTree/static/fullcalendar/locales/ar.js
@@ -0,0 +1,28 @@
+FullCalendar.globalLocales.push(function () {
+  'use strict';
+
+  var ar = {
+    code: 'ar',
+    week: {
+      dow: 6, // Saturday is the first day of the week.
+      doy: 12, // The week that contains Jan 1st is the first week of the year.
+    },
+    direction: 'rtl',
+    buttonText: {
+      prev: 'السابق',
+      next: 'التالي',
+      today: 'اليوم',
+      month: 'شهر',
+      week: 'أسبوع',
+      day: 'يوم',
+      list: 'أجندة',
+    },
+    weekText: 'أسبوع',
+    allDayText: 'اليوم كله',
+    moreLinkText: 'أخرى',
+    noEventsText: 'أي أحداث لعرض',
+  };
+
+  return ar;
+
+}());
diff --git a/InvenTree/InvenTree/static/fullcalendar/locales/az.js b/InvenTree/InvenTree/static/fullcalendar/locales/az.js
new file mode 100644
index 0000000000..13ce6ebd82
--- /dev/null
+++ b/InvenTree/InvenTree/static/fullcalendar/locales/az.js
@@ -0,0 +1,29 @@
+FullCalendar.globalLocales.push(function () {
+  'use strict';
+
+  var az = {
+    code: 'az',
+    week: {
+      dow: 1, // Monday is the first day of the week.
+      doy: 4, // The week that contains Jan 4th is the first week of the year.
+    },
+    buttonText: {
+      prev: 'Əvvəl',
+      next: 'Sonra',
+      today: 'Bu Gün',
+      month: 'Ay',
+      week: 'Həftə',
+      day: 'Gün',
+      list: 'Gündəm',
+    },
+    weekText: 'Həftə',
+    allDayText: 'Bütün Gün',
+    moreLinkText: function(n) {
+      return '+ daha çox ' + n
+    },
+    noEventsText: 'Göstərmək üçün hadisə yoxdur',
+  };
+
+  return az;
+
+}());
diff --git a/InvenTree/InvenTree/static/fullcalendar/locales/bg.js b/InvenTree/InvenTree/static/fullcalendar/locales/bg.js
new file mode 100644
index 0000000000..6484e0aa21
--- /dev/null
+++ b/InvenTree/InvenTree/static/fullcalendar/locales/bg.js
@@ -0,0 +1,28 @@
+FullCalendar.globalLocales.push(function () {
+  'use strict';
+
+  var bg = {
+    code: 'bg',
+    week: {
+      dow: 1, // Monday is the first day of the week.
+      doy: 7, // The week that contains Jan 1st is the first week of the year.
+    },
+    buttonText: {
+      prev: 'назад',
+      next: 'напред',
+      today: 'днес',
+      month: 'Месец',
+      week: 'Седмица',
+      day: 'Ден',
+      list: 'График',
+    },
+    allDayText: 'Цял ден',
+    moreLinkText: function(n) {
+      return '+още ' + n
+    },
+    noEventsText: 'Няма събития за показване',
+  };
+
+  return bg;
+
+}());
diff --git a/InvenTree/InvenTree/static/fullcalendar/locales/bs.js b/InvenTree/InvenTree/static/fullcalendar/locales/bs.js
new file mode 100644
index 0000000000..e4d378a39c
--- /dev/null
+++ b/InvenTree/InvenTree/static/fullcalendar/locales/bs.js
@@ -0,0 +1,29 @@
+FullCalendar.globalLocales.push(function () {
+  'use strict';
+
+  var bs = {
+    code: 'bs',
+    week: {
+      dow: 1, // Monday is the first day of the week.
+      doy: 7, // The week that contains Jan 1st is the first week of the year.
+    },
+    buttonText: {
+      prev: 'Prošli',
+      next: 'Sljedeći',
+      today: 'Danas',
+      month: 'Mjesec',
+      week: 'Sedmica',
+      day: 'Dan',
+      list: 'Raspored',
+    },
+    weekText: 'Sed',
+    allDayText: 'Cijeli dan',
+    moreLinkText: function(n) {
+      return '+ još ' + n
+    },
+    noEventsText: 'Nema događaja za prikazivanje',
+  };
+
+  return bs;
+
+}());
diff --git a/InvenTree/InvenTree/static/fullcalendar/locales/ca.js b/InvenTree/InvenTree/static/fullcalendar/locales/ca.js
new file mode 100644
index 0000000000..4a3b872550
--- /dev/null
+++ b/InvenTree/InvenTree/static/fullcalendar/locales/ca.js
@@ -0,0 +1,27 @@
+FullCalendar.globalLocales.push(function () {
+  'use strict';
+
+  var ca = {
+    code: 'ca',
+    week: {
+      dow: 1, // Monday is the first day of the week.
+      doy: 4, // The week that contains Jan 4th is the first week of the year.
+    },
+    buttonText: {
+      prev: 'Anterior',
+      next: 'Següent',
+      today: 'Avui',
+      month: 'Mes',
+      week: 'Setmana',
+      day: 'Dia',
+      list: 'Agenda',
+    },
+    weekText: 'Set',
+    allDayText: 'Tot el dia',
+    moreLinkText: 'més',
+    noEventsText: 'No hi ha esdeveniments per mostrar',
+  };
+
+  return ca;
+
+}());
diff --git a/InvenTree/InvenTree/static/fullcalendar/locales/cs.js b/InvenTree/InvenTree/static/fullcalendar/locales/cs.js
new file mode 100644
index 0000000000..2d66fba359
--- /dev/null
+++ b/InvenTree/InvenTree/static/fullcalendar/locales/cs.js
@@ -0,0 +1,29 @@
+FullCalendar.globalLocales.push(function () {
+  'use strict';
+
+  var cs = {
+    code: 'cs',
+    week: {
+      dow: 1, // Monday is the first day of the week.
+      doy: 4, // The week that contains Jan 4th is the first week of the year.
+    },
+    buttonText: {
+      prev: 'Dříve',
+      next: 'Později',
+      today: 'Nyní',
+      month: 'Měsíc',
+      week: 'Týden',
+      day: 'Den',
+      list: 'Agenda',
+    },
+    weekText: 'Týd',
+    allDayText: 'Celý den',
+    moreLinkText: function(n) {
+      return '+další: ' + n
+    },
+    noEventsText: 'Žádné akce k zobrazení',
+  };
+
+  return cs;
+
+}());
diff --git a/InvenTree/InvenTree/static/fullcalendar/locales/cy.js b/InvenTree/InvenTree/static/fullcalendar/locales/cy.js
new file mode 100644
index 0000000000..3e5d26aeb5
--- /dev/null
+++ b/InvenTree/InvenTree/static/fullcalendar/locales/cy.js
@@ -0,0 +1,28 @@
+FullCalendar.globalLocales.push(function () {
+  'use strict';
+
+  var cy = {
+    code: 'cy',
+    week: {
+      dow: 1, // Monday is the first day of the week.
+      doy: 4, // The week that contains Jan 4th is the first week of the year.
+    },
+    buttonText: {
+      prev: 'Blaenorol',
+      next: 'Nesaf',
+      today: 'Heddiw',
+      year: 'Blwyddyn',
+      month: 'Mis',
+      week: 'Wythnos',
+      day: 'Dydd',
+      list: 'Rhestr',
+    },
+    weekText: 'Wythnos',
+    allDayText: 'Trwy\'r dydd',
+    moreLinkText: 'Mwy',
+    noEventsText: 'Dim digwyddiadau',
+  };
+
+  return cy;
+
+}());
diff --git a/InvenTree/InvenTree/static/fullcalendar/locales/da.js b/InvenTree/InvenTree/static/fullcalendar/locales/da.js
new file mode 100644
index 0000000000..c8856d956c
--- /dev/null
+++ b/InvenTree/InvenTree/static/fullcalendar/locales/da.js
@@ -0,0 +1,27 @@
+FullCalendar.globalLocales.push(function () {
+  'use strict';
+
+  var da = {
+    code: 'da',
+    week: {
+      dow: 1, // Monday is the first day of the week.
+      doy: 4, // The week that contains Jan 4th is the first week of the year.
+    },
+    buttonText: {
+      prev: 'Forrige',
+      next: 'Næste',
+      today: 'I dag',
+      month: 'Måned',
+      week: 'Uge',
+      day: 'Dag',
+      list: 'Agenda',
+    },
+    weekText: 'Uge',
+    allDayText: 'Hele dagen',
+    moreLinkText: 'flere',
+    noEventsText: 'Ingen arrangementer at vise',
+  };
+
+  return da;
+
+}());
diff --git a/InvenTree/InvenTree/static/fullcalendar/locales/de-at.js b/InvenTree/InvenTree/static/fullcalendar/locales/de-at.js
new file mode 100644
index 0000000000..d509d1ebb4
--- /dev/null
+++ b/InvenTree/InvenTree/static/fullcalendar/locales/de-at.js
@@ -0,0 +1,30 @@
+FullCalendar.globalLocales.push(function () {
+  'use strict';
+
+  var deAt = {
+    code: 'de-at',
+    week: {
+      dow: 1, // Monday is the first day of the week.
+      doy: 4, // The week that contains Jan 4th is the first week of the year.
+    },
+    buttonText: {
+      prev: 'Zurück',
+      next: 'Vor',
+      today: 'Heute',
+      year: 'Jahr',
+      month: 'Monat',
+      week: 'Woche',
+      day: 'Tag',
+      list: 'Terminübersicht',
+    },
+    weekText: 'KW',
+    allDayText: 'Ganztägig',
+    moreLinkText: function(n) {
+      return '+ weitere ' + n
+    },
+    noEventsText: 'Keine Ereignisse anzuzeigen',
+  };
+
+  return deAt;
+
+}());
diff --git a/InvenTree/InvenTree/static/fullcalendar/locales/de.js b/InvenTree/InvenTree/static/fullcalendar/locales/de.js
new file mode 100644
index 0000000000..a03d5451e6
--- /dev/null
+++ b/InvenTree/InvenTree/static/fullcalendar/locales/de.js
@@ -0,0 +1,30 @@
+FullCalendar.globalLocales.push(function () {
+  'use strict';
+
+  var de = {
+    code: 'de',
+    week: {
+      dow: 1, // Monday is the first day of the week.
+      doy: 4, // The week that contains Jan 4th is the first week of the year.
+    },
+    buttonText: {
+      prev: 'Zurück',
+      next: 'Vor',
+      today: 'Heute',
+      year: 'Jahr',
+      month: 'Monat',
+      week: 'Woche',
+      day: 'Tag',
+      list: 'Terminübersicht',
+    },
+    weekText: 'KW',
+    allDayText: 'Ganztägig',
+    moreLinkText: function(n) {
+      return '+ weitere ' + n
+    },
+    noEventsText: 'Keine Ereignisse anzuzeigen',
+  };
+
+  return de;
+
+}());
diff --git a/InvenTree/InvenTree/static/fullcalendar/locales/el.js b/InvenTree/InvenTree/static/fullcalendar/locales/el.js
new file mode 100644
index 0000000000..01e51044df
--- /dev/null
+++ b/InvenTree/InvenTree/static/fullcalendar/locales/el.js
@@ -0,0 +1,27 @@
+FullCalendar.globalLocales.push(function () {
+  'use strict';
+
+  var el = {
+    code: 'el',
+    week: {
+      dow: 1, // Monday is the first day of the week.
+      doy: 4, // The week that contains Jan 4st is the first week of the year.
+    },
+    buttonText: {
+      prev: 'Προηγούμενος',
+      next: 'Επόμενος',
+      today: 'Σήμερα',
+      month: 'Μήνας',
+      week: 'Εβδομάδα',
+      day: 'Ημέρα',
+      list: 'Ατζέντα',
+    },
+    weekText: 'Εβδ',
+    allDayText: 'Ολοήμερο',
+    moreLinkText: 'περισσότερα',
+    noEventsText: 'Δεν υπάρχουν γεγονότα προς εμφάνιση',
+  };
+
+  return el;
+
+}());
diff --git a/InvenTree/InvenTree/static/fullcalendar/locales/en-au.js b/InvenTree/InvenTree/static/fullcalendar/locales/en-au.js
new file mode 100644
index 0000000000..259ce9a2e4
--- /dev/null
+++ b/InvenTree/InvenTree/static/fullcalendar/locales/en-au.js
@@ -0,0 +1,14 @@
+FullCalendar.globalLocales.push(function () {
+  'use strict';
+
+  var enAu = {
+    code: 'en-au',
+    week: {
+      dow: 1, // Monday is the first day of the week.
+      doy: 4, // The week that contains Jan 4th is the first week of the year.
+    },
+  };
+
+  return enAu;
+
+}());
diff --git a/InvenTree/InvenTree/static/fullcalendar/locales/en-gb.js b/InvenTree/InvenTree/static/fullcalendar/locales/en-gb.js
new file mode 100644
index 0000000000..286eb10fa5
--- /dev/null
+++ b/InvenTree/InvenTree/static/fullcalendar/locales/en-gb.js
@@ -0,0 +1,14 @@
+FullCalendar.globalLocales.push(function () {
+  'use strict';
+
+  var enGb = {
+    code: 'en-gb',
+    week: {
+      dow: 1, // Monday is the first day of the week.
+      doy: 4, // The week that contains Jan 4th is the first week of the year.
+    },
+  };
+
+  return enGb;
+
+}());
diff --git a/InvenTree/InvenTree/static/fullcalendar/locales/en-nz.js b/InvenTree/InvenTree/static/fullcalendar/locales/en-nz.js
new file mode 100644
index 0000000000..c1006f8f31
--- /dev/null
+++ b/InvenTree/InvenTree/static/fullcalendar/locales/en-nz.js
@@ -0,0 +1,14 @@
+FullCalendar.globalLocales.push(function () {
+  'use strict';
+
+  var enNz = {
+    code: 'en-nz',
+    week: {
+      dow: 1, // Monday is the first day of the week.
+      doy: 4, // The week that contains Jan 4th is the first week of the year.
+    },
+  };
+
+  return enNz;
+
+}());
diff --git a/InvenTree/InvenTree/static/fullcalendar/locales/eo.js b/InvenTree/InvenTree/static/fullcalendar/locales/eo.js
new file mode 100644
index 0000000000..68f36d13fa
--- /dev/null
+++ b/InvenTree/InvenTree/static/fullcalendar/locales/eo.js
@@ -0,0 +1,27 @@
+FullCalendar.globalLocales.push(function () {
+  'use strict';
+
+  var eo = {
+    code: 'eo',
+    week: {
+      dow: 1, // Monday is the first day of the week.
+      doy: 4, // The week that contains Jan 4th is the first week of the year.
+    },
+    buttonText: {
+      prev: 'Antaŭa',
+      next: 'Sekva',
+      today: 'Hodiaŭ',
+      month: 'Monato',
+      week: 'Semajno',
+      day: 'Tago',
+      list: 'Tagordo',
+    },
+    weekText: 'Sm',
+    allDayText: 'Tuta tago',
+    moreLinkText: 'pli',
+    noEventsText: 'Neniuj eventoj por montri',
+  };
+
+  return eo;
+
+}());
diff --git a/InvenTree/InvenTree/static/fullcalendar/locales/es-us.js b/InvenTree/InvenTree/static/fullcalendar/locales/es-us.js
new file mode 100644
index 0000000000..2696726cbe
--- /dev/null
+++ b/InvenTree/InvenTree/static/fullcalendar/locales/es-us.js
@@ -0,0 +1,27 @@
+FullCalendar.globalLocales.push(function () {
+  'use strict';
+
+  var esUs = {
+    code: 'es',
+    week: {
+      dow: 0, // Sunday is the first day of the week.
+      doy: 6, // The week that contains Jan 1st is the first week of the year.
+    },
+    buttonText: {
+      prev: 'Ant',
+      next: 'Sig',
+      today: 'Hoy',
+      month: 'Mes',
+      week: 'Semana',
+      day: 'Día',
+      list: 'Agenda',
+    },
+    weekText: 'Sm',
+    allDayText: 'Todo el día',
+    moreLinkText: 'más',
+    noEventsText: 'No hay eventos para mostrar',
+  };
+
+  return esUs;
+
+}());
diff --git a/InvenTree/InvenTree/static/fullcalendar/locales/es.js b/InvenTree/InvenTree/static/fullcalendar/locales/es.js
new file mode 100644
index 0000000000..4de4e7667b
--- /dev/null
+++ b/InvenTree/InvenTree/static/fullcalendar/locales/es.js
@@ -0,0 +1,27 @@
+FullCalendar.globalLocales.push(function () {
+  'use strict';
+
+  var es = {
+    code: 'es',
+    week: {
+      dow: 1, // Monday is the first day of the week.
+      doy: 4, // The week that contains Jan 4th is the first week of the year.
+    },
+    buttonText: {
+      prev: 'Ant',
+      next: 'Sig',
+      today: 'Hoy',
+      month: 'Mes',
+      week: 'Semana',
+      day: 'Día',
+      list: 'Agenda',
+    },
+    weekText: 'Sm',
+    allDayText: 'Todo el día',
+    moreLinkText: 'más',
+    noEventsText: 'No hay eventos para mostrar',
+  };
+
+  return es;
+
+}());
diff --git a/InvenTree/InvenTree/static/fullcalendar/locales/et.js b/InvenTree/InvenTree/static/fullcalendar/locales/et.js
new file mode 100644
index 0000000000..cd115804aa
--- /dev/null
+++ b/InvenTree/InvenTree/static/fullcalendar/locales/et.js
@@ -0,0 +1,29 @@
+FullCalendar.globalLocales.push(function () {
+  'use strict';
+
+  var et = {
+    code: 'et',
+    week: {
+      dow: 1, // Monday is the first day of the week.
+      doy: 4, // The week that contains Jan 4th is the first week of the year.
+    },
+    buttonText: {
+      prev: 'Eelnev',
+      next: 'Järgnev',
+      today: 'Täna',
+      month: 'Kuu',
+      week: 'Nädal',
+      day: 'Päev',
+      list: 'Päevakord',
+    },
+    weekText: 'näd',
+    allDayText: 'Kogu päev',
+    moreLinkText: function(n) {
+      return '+ veel ' + n
+    },
+    noEventsText: 'Kuvamiseks puuduvad sündmused',
+  };
+
+  return et;
+
+}());
diff --git a/InvenTree/InvenTree/static/fullcalendar/locales/eu.js b/InvenTree/InvenTree/static/fullcalendar/locales/eu.js
new file mode 100644
index 0000000000..8f7d056ccc
--- /dev/null
+++ b/InvenTree/InvenTree/static/fullcalendar/locales/eu.js
@@ -0,0 +1,27 @@
+FullCalendar.globalLocales.push(function () {
+  'use strict';
+
+  var eu = {
+    code: 'eu',
+    week: {
+      dow: 1, // Monday is the first day of the week.
+      doy: 7, // The week that contains Jan 1st is the first week of the year.
+    },
+    buttonText: {
+      prev: 'Aur',
+      next: 'Hur',
+      today: 'Gaur',
+      month: 'Hilabetea',
+      week: 'Astea',
+      day: 'Eguna',
+      list: 'Agenda',
+    },
+    weekText: 'As',
+    allDayText: 'Egun osoa',
+    moreLinkText: 'gehiago',
+    noEventsText: 'Ez dago ekitaldirik erakusteko',
+  };
+
+  return eu;
+
+}());
diff --git a/InvenTree/InvenTree/static/fullcalendar/locales/fa.js b/InvenTree/InvenTree/static/fullcalendar/locales/fa.js
new file mode 100644
index 0000000000..52aa1781b2
--- /dev/null
+++ b/InvenTree/InvenTree/static/fullcalendar/locales/fa.js
@@ -0,0 +1,30 @@
+FullCalendar.globalLocales.push(function () {
+  'use strict';
+
+  var fa = {
+    code: 'fa',
+    week: {
+      dow: 6, // Saturday is the first day of the week.
+      doy: 12, // The week that contains Jan 1st is the first week of the year.
+    },
+    direction: 'rtl',
+    buttonText: {
+      prev: 'قبلی',
+      next: 'بعدی',
+      today: 'امروز',
+      month: 'ماه',
+      week: 'هفته',
+      day: 'روز',
+      list: 'برنامه',
+    },
+    weekText: 'هف',
+    allDayText: 'تمام روز',
+    moreLinkText: function(n) {
+      return 'بیش از ' + n
+    },
+    noEventsText: 'هیچ رویدادی به نمایش',
+  };
+
+  return fa;
+
+}());
diff --git a/InvenTree/InvenTree/static/fullcalendar/locales/fi.js b/InvenTree/InvenTree/static/fullcalendar/locales/fi.js
new file mode 100644
index 0000000000..d1cec613be
--- /dev/null
+++ b/InvenTree/InvenTree/static/fullcalendar/locales/fi.js
@@ -0,0 +1,27 @@
+FullCalendar.globalLocales.push(function () {
+  'use strict';
+
+  var fi = {
+    code: 'fi',
+    week: {
+      dow: 1, // Monday is the first day of the week.
+      doy: 4, // The week that contains Jan 4th is the first week of the year.
+    },
+    buttonText: {
+      prev: 'Edellinen',
+      next: 'Seuraava',
+      today: 'Tänään',
+      month: 'Kuukausi',
+      week: 'Viikko',
+      day: 'Päivä',
+      list: 'Tapahtumat',
+    },
+    weekText: 'Vk',
+    allDayText: 'Koko päivä',
+    moreLinkText: 'lisää',
+    noEventsText: 'Ei näytettäviä tapahtumia',
+  };
+
+  return fi;
+
+}());
diff --git a/InvenTree/InvenTree/static/fullcalendar/locales/fr-ca.js b/InvenTree/InvenTree/static/fullcalendar/locales/fr-ca.js
new file mode 100644
index 0000000000..54027881b0
--- /dev/null
+++ b/InvenTree/InvenTree/static/fullcalendar/locales/fr-ca.js
@@ -0,0 +1,24 @@
+FullCalendar.globalLocales.push(function () {
+  'use strict';
+
+  var frCa = {
+    code: 'fr',
+    buttonText: {
+      prev: 'Précédent',
+      next: 'Suivant',
+      today: "Aujourd'hui",
+      year: 'Année',
+      month: 'Mois',
+      week: 'Semaine',
+      day: 'Jour',
+      list: 'Mon planning',
+    },
+    weekText: 'Sem.',
+    allDayText: 'Toute la journée',
+    moreLinkText: 'en plus',
+    noEventsText: 'Aucun événement à afficher',
+  };
+
+  return frCa;
+
+}());
diff --git a/InvenTree/InvenTree/static/fullcalendar/locales/fr-ch.js b/InvenTree/InvenTree/static/fullcalendar/locales/fr-ch.js
new file mode 100644
index 0000000000..c22591b5f4
--- /dev/null
+++ b/InvenTree/InvenTree/static/fullcalendar/locales/fr-ch.js
@@ -0,0 +1,28 @@
+FullCalendar.globalLocales.push(function () {
+  'use strict';
+
+  var frCh = {
+    code: 'fr-ch',
+    week: {
+      dow: 1, // Monday is the first day of the week.
+      doy: 4, // The week that contains Jan 4th is the first week of the year.
+    },
+    buttonText: {
+      prev: 'Précédent',
+      next: 'Suivant',
+      today: 'Courant',
+      year: 'Année',
+      month: 'Mois',
+      week: 'Semaine',
+      day: 'Jour',
+      list: 'Mon planning',
+    },
+    weekText: 'Sm',
+    allDayText: 'Toute la journée',
+    moreLinkText: 'en plus',
+    noEventsText: 'Aucun événement à afficher',
+  };
+
+  return frCh;
+
+}());
diff --git a/InvenTree/InvenTree/static/fullcalendar/locales/fr.js b/InvenTree/InvenTree/static/fullcalendar/locales/fr.js
new file mode 100644
index 0000000000..5fac2c7a78
--- /dev/null
+++ b/InvenTree/InvenTree/static/fullcalendar/locales/fr.js
@@ -0,0 +1,28 @@
+FullCalendar.globalLocales.push(function () {
+  'use strict';
+
+  var fr = {
+    code: 'fr',
+    week: {
+      dow: 1, // Monday is the first day of the week.
+      doy: 4, // The week that contains Jan 4th is the first week of the year.
+    },
+    buttonText: {
+      prev: 'Précédent',
+      next: 'Suivant',
+      today: "Aujourd'hui",
+      year: 'Année',
+      month: 'Mois',
+      week: 'Semaine',
+      day: 'Jour',
+      list: 'Planning',
+    },
+    weekText: 'Sem.',
+    allDayText: 'Toute la journée',
+    moreLinkText: 'en plus',
+    noEventsText: 'Aucun événement à afficher',
+  };
+
+  return fr;
+
+}());
diff --git a/InvenTree/InvenTree/static/fullcalendar/locales/gl.js b/InvenTree/InvenTree/static/fullcalendar/locales/gl.js
new file mode 100644
index 0000000000..4614ae52b9
--- /dev/null
+++ b/InvenTree/InvenTree/static/fullcalendar/locales/gl.js
@@ -0,0 +1,27 @@
+FullCalendar.globalLocales.push(function () {
+  'use strict';
+
+  var gl = {
+    code: 'gl',
+    week: {
+      dow: 1, // Monday is the first day of the week.
+      doy: 4, // The week that contains Jan 4th is the first week of the year.
+    },
+    buttonText: {
+      prev: 'Ant',
+      next: 'Seg',
+      today: 'Hoxe',
+      month: 'Mes',
+      week: 'Semana',
+      day: 'Día',
+      list: 'Axenda',
+    },
+    weekText: 'Sm',
+    allDayText: 'Todo o día',
+    moreLinkText: 'máis',
+    noEventsText: 'Non hai eventos para amosar',
+  };
+
+  return gl;
+
+}());
diff --git a/InvenTree/InvenTree/static/fullcalendar/locales/he.js b/InvenTree/InvenTree/static/fullcalendar/locales/he.js
new file mode 100644
index 0000000000..b5597e7412
--- /dev/null
+++ b/InvenTree/InvenTree/static/fullcalendar/locales/he.js
@@ -0,0 +1,24 @@
+FullCalendar.globalLocales.push(function () {
+  'use strict';
+
+  var he = {
+    code: 'he',
+    direction: 'rtl',
+    buttonText: {
+      prev: 'הקודם',
+      next: 'הבא',
+      today: 'היום',
+      month: 'חודש',
+      week: 'שבוע',
+      day: 'יום',
+      list: 'סדר יום',
+    },
+    allDayText: 'כל היום',
+    moreLinkText: 'אחר',
+    noEventsText: 'אין אירועים להצגה',
+    weekText: 'שבוע',
+  };
+
+  return he;
+
+}());
diff --git a/InvenTree/InvenTree/static/fullcalendar/locales/hi.js b/InvenTree/InvenTree/static/fullcalendar/locales/hi.js
new file mode 100644
index 0000000000..ca0ef21f52
--- /dev/null
+++ b/InvenTree/InvenTree/static/fullcalendar/locales/hi.js
@@ -0,0 +1,29 @@
+FullCalendar.globalLocales.push(function () {
+  'use strict';
+
+  var hi = {
+    code: 'hi',
+    week: {
+      dow: 0, // Sunday is the first day of the week.
+      doy: 6, // The week that contains Jan 1st is the first week of the year.
+    },
+    buttonText: {
+      prev: 'पिछला',
+      next: 'अगला',
+      today: 'आज',
+      month: 'महीना',
+      week: 'सप्ताह',
+      day: 'दिन',
+      list: 'कार्यसूची',
+    },
+    weekText: 'हफ्ता',
+    allDayText: 'सभी दिन',
+    moreLinkText: function(n) {
+      return '+अधिक ' + n
+    },
+    noEventsText: 'कोई घटनाओं को प्रदर्शित करने के लिए',
+  };
+
+  return hi;
+
+}());
diff --git a/InvenTree/InvenTree/static/fullcalendar/locales/hr.js b/InvenTree/InvenTree/static/fullcalendar/locales/hr.js
new file mode 100644
index 0000000000..1f3c7142f6
--- /dev/null
+++ b/InvenTree/InvenTree/static/fullcalendar/locales/hr.js
@@ -0,0 +1,29 @@
+FullCalendar.globalLocales.push(function () {
+  'use strict';
+
+  var hr = {
+    code: 'hr',
+    week: {
+      dow: 1, // Monday is the first day of the week.
+      doy: 7, // The week that contains Jan 1st is the first week of the year.
+    },
+    buttonText: {
+      prev: 'Prijašnji',
+      next: 'Sljedeći',
+      today: 'Danas',
+      month: 'Mjesec',
+      week: 'Tjedan',
+      day: 'Dan',
+      list: 'Raspored',
+    },
+    weekText: 'Tje',
+    allDayText: 'Cijeli dan',
+    moreLinkText: function(n) {
+      return '+ još ' + n
+    },
+    noEventsText: 'Nema događaja za prikaz',
+  };
+
+  return hr;
+
+}());
diff --git a/InvenTree/InvenTree/static/fullcalendar/locales/hu.js b/InvenTree/InvenTree/static/fullcalendar/locales/hu.js
new file mode 100644
index 0000000000..8d1c9381ea
--- /dev/null
+++ b/InvenTree/InvenTree/static/fullcalendar/locales/hu.js
@@ -0,0 +1,27 @@
+FullCalendar.globalLocales.push(function () {
+  'use strict';
+
+  var hu = {
+    code: 'hu',
+    week: {
+      dow: 1, // Monday is the first day of the week.
+      doy: 4, // The week that contains Jan 4th is the first week of the year.
+    },
+    buttonText: {
+      prev: 'vissza',
+      next: 'előre',
+      today: 'ma',
+      month: 'Hónap',
+      week: 'Hét',
+      day: 'Nap',
+      list: 'Napló',
+    },
+    weekText: 'Hét',
+    allDayText: 'Egész nap',
+    moreLinkText: 'további',
+    noEventsText: 'Nincs megjeleníthető esemény',
+  };
+
+  return hu;
+
+}());
diff --git a/InvenTree/InvenTree/static/fullcalendar/locales/hy-am.js b/InvenTree/InvenTree/static/fullcalendar/locales/hy-am.js
new file mode 100644
index 0000000000..81c86c1dbd
--- /dev/null
+++ b/InvenTree/InvenTree/static/fullcalendar/locales/hy-am.js
@@ -0,0 +1,29 @@
+FullCalendar.globalLocales.push(function () {
+  'use strict';
+
+  var hyAm = {
+    code: 'hy-am',
+    week: {
+      dow: 1, // Monday is the first day of the week.
+      doy: 4, // The week that contains Jan 4th is the first week of the year.
+    },
+    buttonText: {
+      prev: 'Նախորդ',
+      next: 'Հաջորդ',
+      today: 'Այսօր',
+      month: 'Ամիս',
+      week: 'Շաբաթ',
+      day: 'Օր',
+      list: 'Օրվա ցուցակ',
+    },
+    weekText: 'Շաբ',
+    allDayText: 'Ամբողջ օր',
+    moreLinkText: function(n) {
+      return '+ ևս ' + n
+    },
+    noEventsText: 'Բացակայում է իրադարձությունը ցուցադրելու',
+  };
+
+  return hyAm;
+
+}());
diff --git a/InvenTree/InvenTree/static/fullcalendar/locales/id.js b/InvenTree/InvenTree/static/fullcalendar/locales/id.js
new file mode 100644
index 0000000000..7653f93234
--- /dev/null
+++ b/InvenTree/InvenTree/static/fullcalendar/locales/id.js
@@ -0,0 +1,27 @@
+FullCalendar.globalLocales.push(function () {
+  'use strict';
+
+  var id = {
+    code: 'id',
+    week: {
+      dow: 1, // Monday is the first day of the week.
+      doy: 7, // The week that contains Jan 1st is the first week of the year.
+    },
+    buttonText: {
+      prev: 'mundur',
+      next: 'maju',
+      today: 'hari ini',
+      month: 'Bulan',
+      week: 'Minggu',
+      day: 'Hari',
+      list: 'Agenda',
+    },
+    weekText: 'Mg',
+    allDayText: 'Sehari penuh',
+    moreLinkText: 'lebih',
+    noEventsText: 'Tidak ada acara untuk ditampilkan',
+  };
+
+  return id;
+
+}());
diff --git a/InvenTree/InvenTree/static/fullcalendar/locales/is.js b/InvenTree/InvenTree/static/fullcalendar/locales/is.js
new file mode 100644
index 0000000000..f9f6594938
--- /dev/null
+++ b/InvenTree/InvenTree/static/fullcalendar/locales/is.js
@@ -0,0 +1,27 @@
+FullCalendar.globalLocales.push(function () {
+  'use strict';
+
+  var is = {
+    code: 'is',
+    week: {
+      dow: 1, // Monday is the first day of the week.
+      doy: 4, // The week that contains Jan 4th is the first week of the year.
+    },
+    buttonText: {
+      prev: 'Fyrri',
+      next: 'Næsti',
+      today: 'Í dag',
+      month: 'Mánuður',
+      week: 'Vika',
+      day: 'Dagur',
+      list: 'Dagskrá',
+    },
+    weekText: 'Vika',
+    allDayText: 'Allan daginn',
+    moreLinkText: 'meira',
+    noEventsText: 'Engir viðburðir til að sýna',
+  };
+
+  return is;
+
+}());
diff --git a/InvenTree/InvenTree/static/fullcalendar/locales/it.js b/InvenTree/InvenTree/static/fullcalendar/locales/it.js
new file mode 100644
index 0000000000..4e08f694a7
--- /dev/null
+++ b/InvenTree/InvenTree/static/fullcalendar/locales/it.js
@@ -0,0 +1,29 @@
+FullCalendar.globalLocales.push(function () {
+  'use strict';
+
+  var it = {
+    code: 'it',
+    week: {
+      dow: 1, // Monday is the first day of the week.
+      doy: 4, // The week that contains Jan 4th is the first week of the year.
+    },
+    buttonText: {
+      prev: 'Prec',
+      next: 'Succ',
+      today: 'Oggi',
+      month: 'Mese',
+      week: 'Settimana',
+      day: 'Giorno',
+      list: 'Agenda',
+    },
+    weekText: 'Sm',
+    allDayText: 'Tutto il giorno',
+    moreLinkText: function(n) {
+      return '+altri ' + n
+    },
+    noEventsText: 'Non ci sono eventi da visualizzare',
+  };
+
+  return it;
+
+}());
diff --git a/InvenTree/InvenTree/static/fullcalendar/locales/ja.js b/InvenTree/InvenTree/static/fullcalendar/locales/ja.js
new file mode 100644
index 0000000000..7809cdea16
--- /dev/null
+++ b/InvenTree/InvenTree/static/fullcalendar/locales/ja.js
@@ -0,0 +1,25 @@
+FullCalendar.globalLocales.push(function () {
+  'use strict';
+
+  var ja = {
+    code: 'ja',
+    buttonText: {
+      prev: '前',
+      next: '次',
+      today: '今日',
+      month: '月',
+      week: '週',
+      day: '日',
+      list: '予定リスト',
+    },
+    weekText: '週',
+    allDayText: '終日',
+    moreLinkText: function(n) {
+      return '他 ' + n + ' 件'
+    },
+    noEventsText: '表示する予定はありません',
+  };
+
+  return ja;
+
+}());
diff --git a/InvenTree/InvenTree/static/fullcalendar/locales/ka.js b/InvenTree/InvenTree/static/fullcalendar/locales/ka.js
new file mode 100644
index 0000000000..030e95f724
--- /dev/null
+++ b/InvenTree/InvenTree/static/fullcalendar/locales/ka.js
@@ -0,0 +1,29 @@
+FullCalendar.globalLocales.push(function () {
+  'use strict';
+
+  var ka = {
+    code: 'ka',
+    week: {
+      dow: 1,
+      doy: 7,
+    },
+    buttonText: {
+      prev: 'წინა',
+      next: 'შემდეგი',
+      today: 'დღეს',
+      month: 'თვე',
+      week: 'კვირა',
+      day: 'დღე',
+      list: 'დღის წესრიგი',
+    },
+    weekText: 'კვ',
+    allDayText: 'მთელი დღე',
+    moreLinkText: function(n) {
+      return '+ კიდევ ' + n
+    },
+    noEventsText: 'ღონისძიებები არ არის',
+  };
+
+  return ka;
+
+}());
diff --git a/InvenTree/InvenTree/static/fullcalendar/locales/kk.js b/InvenTree/InvenTree/static/fullcalendar/locales/kk.js
new file mode 100644
index 0000000000..77db338a35
--- /dev/null
+++ b/InvenTree/InvenTree/static/fullcalendar/locales/kk.js
@@ -0,0 +1,29 @@
+FullCalendar.globalLocales.push(function () {
+  'use strict';
+
+  var kk = {
+    code: 'kk',
+    week: {
+      dow: 1, // Monday is the first day of the week.
+      doy: 7, // The week that contains Jan 1st is the first week of the year.
+    },
+    buttonText: {
+      prev: 'Алдыңғы',
+      next: 'Келесі',
+      today: 'Бүгін',
+      month: 'Ай',
+      week: 'Апта',
+      day: 'Күн',
+      list: 'Күн тәртібі',
+    },
+    weekText: 'Не',
+    allDayText: 'Күні бойы',
+    moreLinkText: function(n) {
+      return '+ тағы ' + n
+    },
+    noEventsText: 'Көрсету үшін оқиғалар жоқ',
+  };
+
+  return kk;
+
+}());
diff --git a/InvenTree/InvenTree/static/fullcalendar/locales/ko.js b/InvenTree/InvenTree/static/fullcalendar/locales/ko.js
new file mode 100644
index 0000000000..120c28597f
--- /dev/null
+++ b/InvenTree/InvenTree/static/fullcalendar/locales/ko.js
@@ -0,0 +1,23 @@
+FullCalendar.globalLocales.push(function () {
+  'use strict';
+
+  var ko = {
+    code: 'ko',
+    buttonText: {
+      prev: '이전달',
+      next: '다음달',
+      today: '오늘',
+      month: '월',
+      week: '주',
+      day: '일',
+      list: '일정목록',
+    },
+    weekText: '주',
+    allDayText: '종일',
+    moreLinkText: '개',
+    noEventsText: '일정이 없습니다',
+  };
+
+  return ko;
+
+}());
diff --git a/InvenTree/InvenTree/static/fullcalendar/locales/lb.js b/InvenTree/InvenTree/static/fullcalendar/locales/lb.js
new file mode 100644
index 0000000000..c65100e9d6
--- /dev/null
+++ b/InvenTree/InvenTree/static/fullcalendar/locales/lb.js
@@ -0,0 +1,27 @@
+FullCalendar.globalLocales.push(function () {
+  'use strict';
+
+  var lb = {
+    code: 'lb',
+    week: {
+      dow: 1, // Monday is the first day of the week.
+      doy: 4, // The week that contains Jan 4th is the first week of the year.
+    },
+    buttonText: {
+      prev: 'Zréck',
+      next: 'Weider',
+      today: 'Haut',
+      month: 'Mount',
+      week: 'Woch',
+      day: 'Dag',
+      list: 'Terminiwwersiicht',
+    },
+    weekText: 'W',
+    allDayText: 'Ganzen Dag',
+    moreLinkText: 'méi',
+    noEventsText: 'Nee Evenementer ze affichéieren',
+  };
+
+  return lb;
+
+}());
diff --git a/InvenTree/InvenTree/static/fullcalendar/locales/lt.js b/InvenTree/InvenTree/static/fullcalendar/locales/lt.js
new file mode 100644
index 0000000000..92f629c5d1
--- /dev/null
+++ b/InvenTree/InvenTree/static/fullcalendar/locales/lt.js
@@ -0,0 +1,27 @@
+FullCalendar.globalLocales.push(function () {
+  'use strict';
+
+  var lt = {
+    code: 'lt',
+    week: {
+      dow: 1, // Monday is the first day of the week.
+      doy: 4, // The week that contains Jan 4th is the first week of the year.
+    },
+    buttonText: {
+      prev: 'Atgal',
+      next: 'Pirmyn',
+      today: 'Šiandien',
+      month: 'Mėnuo',
+      week: 'Savaitė',
+      day: 'Diena',
+      list: 'Darbotvarkė',
+    },
+    weekText: 'SAV',
+    allDayText: 'Visą dieną',
+    moreLinkText: 'daugiau',
+    noEventsText: 'Nėra įvykių rodyti',
+  };
+
+  return lt;
+
+}());
diff --git a/InvenTree/InvenTree/static/fullcalendar/locales/lv.js b/InvenTree/InvenTree/static/fullcalendar/locales/lv.js
new file mode 100644
index 0000000000..e9677a1e00
--- /dev/null
+++ b/InvenTree/InvenTree/static/fullcalendar/locales/lv.js
@@ -0,0 +1,29 @@
+FullCalendar.globalLocales.push(function () {
+  'use strict';
+
+  var lv = {
+    code: 'lv',
+    week: {
+      dow: 1, // Monday is the first day of the week.
+      doy: 4, // The week that contains Jan 4th is the first week of the year.
+    },
+    buttonText: {
+      prev: 'Iepr.',
+      next: 'Nāk.',
+      today: 'Šodien',
+      month: 'Mēnesis',
+      week: 'Nedēļa',
+      day: 'Diena',
+      list: 'Dienas kārtība',
+    },
+    weekText: 'Ned.',
+    allDayText: 'Visu dienu',
+    moreLinkText: function(n) {
+      return '+vēl ' + n
+    },
+    noEventsText: 'Nav notikumu',
+  };
+
+  return lv;
+
+}());
diff --git a/InvenTree/InvenTree/static/fullcalendar/locales/mk.js b/InvenTree/InvenTree/static/fullcalendar/locales/mk.js
new file mode 100644
index 0000000000..9eb98f4956
--- /dev/null
+++ b/InvenTree/InvenTree/static/fullcalendar/locales/mk.js
@@ -0,0 +1,25 @@
+FullCalendar.globalLocales.push(function () {
+  'use strict';
+
+  var mk = {
+    code: 'mk',
+    buttonText: {
+      prev: 'претходно',
+      next: 'следно',
+      today: 'Денес',
+      month: 'Месец',
+      week: 'Недела',
+      day: 'Ден',
+      list: 'График',
+    },
+    weekText: 'Сед',
+    allDayText: 'Цел ден',
+    moreLinkText: function(n) {
+      return '+повеќе ' + n
+    },
+    noEventsText: 'Нема настани за прикажување',
+  };
+
+  return mk;
+
+}());
diff --git a/InvenTree/InvenTree/static/fullcalendar/locales/ms.js b/InvenTree/InvenTree/static/fullcalendar/locales/ms.js
new file mode 100644
index 0000000000..2539f931c8
--- /dev/null
+++ b/InvenTree/InvenTree/static/fullcalendar/locales/ms.js
@@ -0,0 +1,29 @@
+FullCalendar.globalLocales.push(function () {
+  'use strict';
+
+  var ms = {
+    code: 'ms',
+    week: {
+      dow: 1, // Monday is the first day of the week.
+      doy: 7, // The week that contains Jan 1st is the first week of the year.
+    },
+    buttonText: {
+      prev: 'Sebelum',
+      next: 'Selepas',
+      today: 'hari ini',
+      month: 'Bulan',
+      week: 'Minggu',
+      day: 'Hari',
+      list: 'Agenda',
+    },
+    weekText: 'Mg',
+    allDayText: 'Sepanjang hari',
+    moreLinkText: function(n) {
+      return 'masih ada ' + n + ' acara'
+    },
+    noEventsText: 'Tiada peristiwa untuk dipaparkan',
+  };
+
+  return ms;
+
+}());
diff --git a/InvenTree/InvenTree/static/fullcalendar/locales/nb.js b/InvenTree/InvenTree/static/fullcalendar/locales/nb.js
new file mode 100644
index 0000000000..2dab831bf2
--- /dev/null
+++ b/InvenTree/InvenTree/static/fullcalendar/locales/nb.js
@@ -0,0 +1,27 @@
+FullCalendar.globalLocales.push(function () {
+  'use strict';
+
+  var nb = {
+    code: 'nb',
+    week: {
+      dow: 1, // Monday is the first day of the week.
+      doy: 4, // The week that contains Jan 4th is the first week of the year.
+    },
+    buttonText: {
+      prev: 'Forrige',
+      next: 'Neste',
+      today: 'I dag',
+      month: 'Måned',
+      week: 'Uke',
+      day: 'Dag',
+      list: 'Agenda',
+    },
+    weekText: 'Uke',
+    allDayText: 'Hele dagen',
+    moreLinkText: 'til',
+    noEventsText: 'Ingen hendelser å vise',
+  };
+
+  return nb;
+
+}());
diff --git a/InvenTree/InvenTree/static/fullcalendar/locales/ne.js b/InvenTree/InvenTree/static/fullcalendar/locales/ne.js
new file mode 100644
index 0000000000..2215dd4a89
--- /dev/null
+++ b/InvenTree/InvenTree/static/fullcalendar/locales/ne.js
@@ -0,0 +1,27 @@
+FullCalendar.globalLocales.push(function () {
+  'use strict';
+
+  var ne = {
+    code: 'ne', // code for nepal
+    week: {
+      dow: 7, // Sunday is the first day of the week.
+      doy: 1, // The week that contains Jan 1st is the first week of the year.
+    },
+    buttonText: {
+      prev: 'अघिल्लो',
+      next: 'अर्को',
+      today: 'आज',
+      month: 'महिना',
+      week: 'हप्ता',
+      day: 'दिन',
+      list: 'सूची',
+    },
+    weekText: 'हप्ता',
+    allDayText: 'दिनभरि',
+    moreLinkText: 'थप लिंक',
+    noEventsText: 'देखाउनको लागि कुनै घटनाहरू छैनन्',
+  };
+
+  return ne;
+
+}());
diff --git a/InvenTree/InvenTree/static/fullcalendar/locales/nl.js b/InvenTree/InvenTree/static/fullcalendar/locales/nl.js
new file mode 100644
index 0000000000..d13d2d2e77
--- /dev/null
+++ b/InvenTree/InvenTree/static/fullcalendar/locales/nl.js
@@ -0,0 +1,27 @@
+FullCalendar.globalLocales.push(function () {
+  'use strict';
+
+  var nl = {
+    code: 'nl',
+    week: {
+      dow: 1, // Monday is the first day of the week.
+      doy: 4, // The week that contains Jan 4th is the first week of the year.
+    },
+    buttonText: {
+      prev: 'Vorige',
+      next: 'Volgende',
+      today: 'Vandaag',
+      year: 'Jaar',
+      month: 'Maand',
+      week: 'Week',
+      day: 'Dag',
+      list: 'Agenda',
+    },
+    allDayText: 'Hele dag',
+    moreLinkText: 'extra',
+    noEventsText: 'Geen evenementen om te laten zien',
+  };
+
+  return nl;
+
+}());
diff --git a/InvenTree/InvenTree/static/fullcalendar/locales/nn.js b/InvenTree/InvenTree/static/fullcalendar/locales/nn.js
new file mode 100644
index 0000000000..2f9a5fdeb7
--- /dev/null
+++ b/InvenTree/InvenTree/static/fullcalendar/locales/nn.js
@@ -0,0 +1,27 @@
+FullCalendar.globalLocales.push(function () {
+  'use strict';
+
+  var nn = {
+    code: 'nn',
+    week: {
+      dow: 1, // Monday is the first day of the week.
+      doy: 4, // The week that contains Jan 4th is the first week of the year.
+    },
+    buttonText: {
+      prev: 'Førre',
+      next: 'Neste',
+      today: 'I dag',
+      month: 'Månad',
+      week: 'Veke',
+      day: 'Dag',
+      list: 'Agenda',
+    },
+    weekText: 'Veke',
+    allDayText: 'Heile dagen',
+    moreLinkText: 'til',
+    noEventsText: 'Ingen hendelser å vise',
+  };
+
+  return nn;
+
+}());
diff --git a/InvenTree/InvenTree/static/fullcalendar/locales/pl.js b/InvenTree/InvenTree/static/fullcalendar/locales/pl.js
new file mode 100644
index 0000000000..996071df85
--- /dev/null
+++ b/InvenTree/InvenTree/static/fullcalendar/locales/pl.js
@@ -0,0 +1,27 @@
+FullCalendar.globalLocales.push(function () {
+  'use strict';
+
+  var pl = {
+    code: 'pl',
+    week: {
+      dow: 1, // Monday is the first day of the week.
+      doy: 4, // The week that contains Jan 4th is the first week of the year.
+    },
+    buttonText: {
+      prev: 'Poprzedni',
+      next: 'Następny',
+      today: 'Dziś',
+      month: 'Miesiąc',
+      week: 'Tydzień',
+      day: 'Dzień',
+      list: 'Plan dnia',
+    },
+    weekText: 'Tydz',
+    allDayText: 'Cały dzień',
+    moreLinkText: 'więcej',
+    noEventsText: 'Brak wydarzeń do wyświetlenia',
+  };
+
+  return pl;
+
+}());
diff --git a/InvenTree/InvenTree/static/fullcalendar/locales/pt-br.js b/InvenTree/InvenTree/static/fullcalendar/locales/pt-br.js
new file mode 100644
index 0000000000..e26167a60b
--- /dev/null
+++ b/InvenTree/InvenTree/static/fullcalendar/locales/pt-br.js
@@ -0,0 +1,25 @@
+FullCalendar.globalLocales.push(function () {
+  'use strict';
+
+  var ptBr = {
+    code: 'pt-br',
+    buttonText: {
+      prev: 'Anterior',
+      next: 'Próximo',
+      today: 'Hoje',
+      month: 'Mês',
+      week: 'Semana',
+      day: 'Dia',
+      list: 'Lista',
+    },
+    weekText: 'Sm',
+    allDayText: 'dia inteiro',
+    moreLinkText: function(n) {
+      return 'mais +' + n
+    },
+    noEventsText: 'Não há eventos para mostrar',
+  };
+
+  return ptBr;
+
+}());
diff --git a/InvenTree/InvenTree/static/fullcalendar/locales/pt.js b/InvenTree/InvenTree/static/fullcalendar/locales/pt.js
new file mode 100644
index 0000000000..34dc37706b
--- /dev/null
+++ b/InvenTree/InvenTree/static/fullcalendar/locales/pt.js
@@ -0,0 +1,27 @@
+FullCalendar.globalLocales.push(function () {
+  'use strict';
+
+  var pt = {
+    code: 'pt',
+    week: {
+      dow: 1, // Monday is the first day of the week.
+      doy: 4, // The week that contains Jan 4th is the first week of the year.
+    },
+    buttonText: {
+      prev: 'Anterior',
+      next: 'Seguinte',
+      today: 'Hoje',
+      month: 'Mês',
+      week: 'Semana',
+      day: 'Dia',
+      list: 'Agenda',
+    },
+    weekText: 'Sem',
+    allDayText: 'Todo o dia',
+    moreLinkText: 'mais',
+    noEventsText: 'Não há eventos para mostrar',
+  };
+
+  return pt;
+
+}());
diff --git a/InvenTree/InvenTree/static/fullcalendar/locales/ro.js b/InvenTree/InvenTree/static/fullcalendar/locales/ro.js
new file mode 100644
index 0000000000..d74f23cb5f
--- /dev/null
+++ b/InvenTree/InvenTree/static/fullcalendar/locales/ro.js
@@ -0,0 +1,29 @@
+FullCalendar.globalLocales.push(function () {
+  'use strict';
+
+  var ro = {
+    code: 'ro',
+    week: {
+      dow: 1, // Monday is the first day of the week.
+      doy: 7, // The week that contains Jan 1st is the first week of the year.
+    },
+    buttonText: {
+      prev: 'precedentă',
+      next: 'următoare',
+      today: 'Azi',
+      month: 'Lună',
+      week: 'Săptămână',
+      day: 'Zi',
+      list: 'Agendă',
+    },
+    weekText: 'Săpt',
+    allDayText: 'Toată ziua',
+    moreLinkText: function(n) {
+      return '+alte ' + n
+    },
+    noEventsText: 'Nu există evenimente de afișat',
+  };
+
+  return ro;
+
+}());
diff --git a/InvenTree/InvenTree/static/fullcalendar/locales/ru.js b/InvenTree/InvenTree/static/fullcalendar/locales/ru.js
new file mode 100644
index 0000000000..58fa31152e
--- /dev/null
+++ b/InvenTree/InvenTree/static/fullcalendar/locales/ru.js
@@ -0,0 +1,29 @@
+FullCalendar.globalLocales.push(function () {
+  'use strict';
+
+  var ru = {
+    code: 'ru',
+    week: {
+      dow: 1, // Monday is the first day of the week.
+      doy: 4, // The week that contains Jan 4th is the first week of the year.
+    },
+    buttonText: {
+      prev: 'Пред',
+      next: 'След',
+      today: 'Сегодня',
+      month: 'Месяц',
+      week: 'Неделя',
+      day: 'День',
+      list: 'Повестка дня',
+    },
+    weekText: 'Нед',
+    allDayText: 'Весь день',
+    moreLinkText: function(n) {
+      return '+ ещё ' + n
+    },
+    noEventsText: 'Нет событий для отображения',
+  };
+
+  return ru;
+
+}());
diff --git a/InvenTree/InvenTree/static/fullcalendar/locales/sk.js b/InvenTree/InvenTree/static/fullcalendar/locales/sk.js
new file mode 100644
index 0000000000..ffb5e8b9a2
--- /dev/null
+++ b/InvenTree/InvenTree/static/fullcalendar/locales/sk.js
@@ -0,0 +1,29 @@
+FullCalendar.globalLocales.push(function () {
+  'use strict';
+
+  var sk = {
+    code: 'sk',
+    week: {
+      dow: 1, // Monday is the first day of the week.
+      doy: 4, // The week that contains Jan 4th is the first week of the year.
+    },
+    buttonText: {
+      prev: 'Predchádzajúci',
+      next: 'Nasledujúci',
+      today: 'Dnes',
+      month: 'Mesiac',
+      week: 'Týždeň',
+      day: 'Deň',
+      list: 'Rozvrh',
+    },
+    weekText: 'Ty',
+    allDayText: 'Celý deň',
+    moreLinkText: function(n) {
+      return '+ďalšie: ' + n
+    },
+    noEventsText: 'Žiadne akcie na zobrazenie',
+  };
+
+  return sk;
+
+}());
diff --git a/InvenTree/InvenTree/static/fullcalendar/locales/sl.js b/InvenTree/InvenTree/static/fullcalendar/locales/sl.js
new file mode 100644
index 0000000000..4c62799418
--- /dev/null
+++ b/InvenTree/InvenTree/static/fullcalendar/locales/sl.js
@@ -0,0 +1,27 @@
+FullCalendar.globalLocales.push(function () {
+  'use strict';
+
+  var sl = {
+    code: 'sl',
+    week: {
+      dow: 1, // Monday is the first day of the week.
+      doy: 7, // The week that contains Jan 1st is the first week of the year.
+    },
+    buttonText: {
+      prev: 'Prejšnji',
+      next: 'Naslednji',
+      today: 'Trenutni',
+      month: 'Mesec',
+      week: 'Teden',
+      day: 'Dan',
+      list: 'Dnevni red',
+    },
+    weekText: 'Teden',
+    allDayText: 'Ves dan',
+    moreLinkText: 'več',
+    noEventsText: 'Ni dogodkov za prikaz',
+  };
+
+  return sl;
+
+}());
diff --git a/InvenTree/InvenTree/static/fullcalendar/locales/sq.js b/InvenTree/InvenTree/static/fullcalendar/locales/sq.js
new file mode 100644
index 0000000000..a7b630ea79
--- /dev/null
+++ b/InvenTree/InvenTree/static/fullcalendar/locales/sq.js
@@ -0,0 +1,29 @@
+FullCalendar.globalLocales.push(function () {
+  'use strict';
+
+  var sq = {
+    code: 'sq',
+    week: {
+      dow: 1, // Monday is the first day of the week.
+      doy: 4, // The week that contains Jan 4th is the first week of the year.
+    },
+    buttonText: {
+      prev: 'mbrapa',
+      next: 'Përpara',
+      today: 'sot',
+      month: 'Muaj',
+      week: 'Javë',
+      day: 'Ditë',
+      list: 'Listë',
+    },
+    weekText: 'Ja',
+    allDayText: 'Gjithë ditën',
+    moreLinkText: function(n) {
+      return '+më tepër ' + n
+    },
+    noEventsText: 'Nuk ka evente për të shfaqur',
+  };
+
+  return sq;
+
+}());
diff --git a/InvenTree/InvenTree/static/fullcalendar/locales/sr-cyrl.js b/InvenTree/InvenTree/static/fullcalendar/locales/sr-cyrl.js
new file mode 100644
index 0000000000..58fecd15b6
--- /dev/null
+++ b/InvenTree/InvenTree/static/fullcalendar/locales/sr-cyrl.js
@@ -0,0 +1,29 @@
+FullCalendar.globalLocales.push(function () {
+  'use strict';
+
+  var srCyrl = {
+    code: 'sr-cyrl',
+    week: {
+      dow: 1, // Monday is the first day of the week.
+      doy: 7, // The week that contains Jan 1st is the first week of the year.
+    },
+    buttonText: {
+      prev: 'Претходна',
+      next: 'следећи',
+      today: 'Данас',
+      month: 'Месец',
+      week: 'Недеља',
+      day: 'Дан',
+      list: 'Планер',
+    },
+    weekText: 'Сед',
+    allDayText: 'Цео дан',
+    moreLinkText: function(n) {
+      return '+ још ' + n
+    },
+    noEventsText: 'Нема догађаја за приказ',
+  };
+
+  return srCyrl;
+
+}());
diff --git a/InvenTree/InvenTree/static/fullcalendar/locales/sr.js b/InvenTree/InvenTree/static/fullcalendar/locales/sr.js
new file mode 100644
index 0000000000..a6ecda5f7f
--- /dev/null
+++ b/InvenTree/InvenTree/static/fullcalendar/locales/sr.js
@@ -0,0 +1,29 @@
+FullCalendar.globalLocales.push(function () {
+  'use strict';
+
+  var sr = {
+    code: 'sr',
+    week: {
+      dow: 1, // Monday is the first day of the week.
+      doy: 7, // The week that contains Jan 1st is the first week of the year.
+    },
+    buttonText: {
+      prev: 'Prethodna',
+      next: 'Sledeći',
+      today: 'Danas',
+      month: 'Mеsеc',
+      week: 'Nеdеlja',
+      day: 'Dan',
+      list: 'Planеr',
+    },
+    weekText: 'Sed',
+    allDayText: 'Cеo dan',
+    moreLinkText: function(n) {
+      return '+ još ' + n
+    },
+    noEventsText: 'Nеma događaja za prikaz',
+  };
+
+  return sr;
+
+}());
diff --git a/InvenTree/InvenTree/static/fullcalendar/locales/sv.js b/InvenTree/InvenTree/static/fullcalendar/locales/sv.js
new file mode 100644
index 0000000000..c040ecd508
--- /dev/null
+++ b/InvenTree/InvenTree/static/fullcalendar/locales/sv.js
@@ -0,0 +1,27 @@
+FullCalendar.globalLocales.push(function () {
+  'use strict';
+
+  var sv = {
+    code: 'sv',
+    week: {
+      dow: 1, // Monday is the first day of the week.
+      doy: 4, // The week that contains Jan 4th is the first week of the year.
+    },
+    buttonText: {
+      prev: 'Förra',
+      next: 'Nästa',
+      today: 'Idag',
+      month: 'Månad',
+      week: 'Vecka',
+      day: 'Dag',
+      list: 'Program',
+    },
+    weekText: 'v.',
+    allDayText: 'Heldag',
+    moreLinkText: 'till',
+    noEventsText: 'Inga händelser att visa',
+  };
+
+  return sv;
+
+}());
diff --git a/InvenTree/InvenTree/static/fullcalendar/locales/th.js b/InvenTree/InvenTree/static/fullcalendar/locales/th.js
new file mode 100644
index 0000000000..6e817430b6
--- /dev/null
+++ b/InvenTree/InvenTree/static/fullcalendar/locales/th.js
@@ -0,0 +1,30 @@
+FullCalendar.globalLocales.push(function () {
+  'use strict';
+
+  var th = {
+    code: 'th',
+    week: {
+      dow: 1, // Monday is the first day of the week.
+      doy: 4, // The week that contains Jan 4th is the first week of the year.
+    },
+    buttonText: {
+      prev: 'ก่อนหน้า',
+      next: 'ถัดไป',
+      prevYear: 'ปีก่อนหน้า',
+      nextYear: 'ปีถัดไป',
+      year: 'ปี',
+      today: 'วันนี้',
+      month: 'เดือน',
+      week: 'สัปดาห์',
+      day: 'วัน',
+      list: 'กำหนดการ',
+    },
+    weekText: 'สัปดาห์',
+    allDayText: 'ตลอดวัน',
+    moreLinkText: 'เพิ่มเติม',
+    noEventsText: 'ไม่มีกิจกรรมที่จะแสดง',
+  };
+
+  return th;
+
+}());
diff --git a/InvenTree/InvenTree/static/fullcalendar/locales/tr.js b/InvenTree/InvenTree/static/fullcalendar/locales/tr.js
new file mode 100644
index 0000000000..5575f1c8f0
--- /dev/null
+++ b/InvenTree/InvenTree/static/fullcalendar/locales/tr.js
@@ -0,0 +1,27 @@
+FullCalendar.globalLocales.push(function () {
+  'use strict';
+
+  var tr = {
+    code: 'tr',
+    week: {
+      dow: 1, // Monday is the first day of the week.
+      doy: 7, // The week that contains Jan 1st is the first week of the year.
+    },
+    buttonText: {
+      prev: 'geri',
+      next: 'ileri',
+      today: 'bugün',
+      month: 'Ay',
+      week: 'Hafta',
+      day: 'Gün',
+      list: 'Ajanda',
+    },
+    weekText: 'Hf',
+    allDayText: 'Tüm gün',
+    moreLinkText: 'daha fazla',
+    noEventsText: 'Gösterilecek etkinlik yok',
+  };
+
+  return tr;
+
+}());
diff --git a/InvenTree/InvenTree/static/fullcalendar/locales/ug.js b/InvenTree/InvenTree/static/fullcalendar/locales/ug.js
new file mode 100644
index 0000000000..266d436feb
--- /dev/null
+++ b/InvenTree/InvenTree/static/fullcalendar/locales/ug.js
@@ -0,0 +1,17 @@
+FullCalendar.globalLocales.push(function () {
+  'use strict';
+
+  var ug = {
+    code: 'ug',
+    buttonText: {
+      month: 'ئاي',
+      week: 'ھەپتە',
+      day: 'كۈن',
+      list: 'كۈنتەرتىپ',
+    },
+    allDayText: 'پۈتۈن كۈن',
+  };
+
+  return ug;
+
+}());
diff --git a/InvenTree/InvenTree/static/fullcalendar/locales/uk.js b/InvenTree/InvenTree/static/fullcalendar/locales/uk.js
new file mode 100644
index 0000000000..665c5f4d29
--- /dev/null
+++ b/InvenTree/InvenTree/static/fullcalendar/locales/uk.js
@@ -0,0 +1,29 @@
+FullCalendar.globalLocales.push(function () {
+  'use strict';
+
+  var uk = {
+    code: 'uk',
+    week: {
+      dow: 1, // Monday is the first day of the week.
+      doy: 7, // The week that contains Jan 1st is the first week of the year.
+    },
+    buttonText: {
+      prev: 'Попередній',
+      next: 'далі',
+      today: 'Сьогодні',
+      month: 'Місяць',
+      week: 'Тиждень',
+      day: 'День',
+      list: 'Порядок денний',
+    },
+    weekText: 'Тиж',
+    allDayText: 'Увесь день',
+    moreLinkText: function(n) {
+      return '+ще ' + n + '...'
+    },
+    noEventsText: 'Немає подій для відображення',
+  };
+
+  return uk;
+
+}());
diff --git a/InvenTree/InvenTree/static/fullcalendar/locales/uz.js b/InvenTree/InvenTree/static/fullcalendar/locales/uz.js
new file mode 100644
index 0000000000..84bb49923f
--- /dev/null
+++ b/InvenTree/InvenTree/static/fullcalendar/locales/uz.js
@@ -0,0 +1,21 @@
+FullCalendar.globalLocales.push(function () {
+  'use strict';
+
+  var uz = {
+    code: 'uz',
+    buttonText: {
+      month: 'Oy',
+      week: 'Xafta',
+      day: 'Kun',
+      list: 'Kun tartibi',
+    },
+    allDayText: "Kun bo'yi",
+    moreLinkText: function(n) {
+      return '+ yana ' + n
+    },
+    noEventsText: "Ko'rsatish uchun voqealar yo'q",
+  };
+
+  return uz;
+
+}());
diff --git a/InvenTree/InvenTree/static/fullcalendar/locales/vi.js b/InvenTree/InvenTree/static/fullcalendar/locales/vi.js
new file mode 100644
index 0000000000..63d58632b1
--- /dev/null
+++ b/InvenTree/InvenTree/static/fullcalendar/locales/vi.js
@@ -0,0 +1,29 @@
+FullCalendar.globalLocales.push(function () {
+  'use strict';
+
+  var vi = {
+    code: 'vi',
+    week: {
+      dow: 1, // Monday is the first day of the week.
+      doy: 4, // The week that contains Jan 4th is the first week of the year.
+    },
+    buttonText: {
+      prev: 'Trước',
+      next: 'Tiếp',
+      today: 'Hôm nay',
+      month: 'Tháng',
+      week: 'Tuần',
+      day: 'Ngày',
+      list: 'Lịch biểu',
+    },
+    weekText: 'Tu',
+    allDayText: 'Cả ngày',
+    moreLinkText: function(n) {
+      return '+ thêm ' + n
+    },
+    noEventsText: 'Không có sự kiện để hiển thị',
+  };
+
+  return vi;
+
+}());
diff --git a/InvenTree/InvenTree/static/fullcalendar/locales/zh-cn.js b/InvenTree/InvenTree/static/fullcalendar/locales/zh-cn.js
new file mode 100644
index 0000000000..c0b46e9c9f
--- /dev/null
+++ b/InvenTree/InvenTree/static/fullcalendar/locales/zh-cn.js
@@ -0,0 +1,30 @@
+FullCalendar.globalLocales.push(function () {
+  'use strict';
+
+  var zhCn = {
+    code: 'zh-cn',
+    week: {
+      // GB/T 7408-1994《数据元和交换格式·信息交换·日期和时间表示法》与ISO 8601:1988等效
+      dow: 1, // Monday is the first day of the week.
+      doy: 4, // The week that contains Jan 4th is the first week of the year.
+    },
+    buttonText: {
+      prev: '上月',
+      next: '下月',
+      today: '今天',
+      month: '月',
+      week: '周',
+      day: '日',
+      list: '日程',
+    },
+    weekText: '周',
+    allDayText: '全天',
+    moreLinkText: function(n) {
+      return '另外 ' + n + ' 个'
+    },
+    noEventsText: '没有事件显示',
+  };
+
+  return zhCn;
+
+}());
diff --git a/InvenTree/InvenTree/static/fullcalendar/locales/zh-tw.js b/InvenTree/InvenTree/static/fullcalendar/locales/zh-tw.js
new file mode 100644
index 0000000000..a71cde9a31
--- /dev/null
+++ b/InvenTree/InvenTree/static/fullcalendar/locales/zh-tw.js
@@ -0,0 +1,23 @@
+FullCalendar.globalLocales.push(function () {
+  'use strict';
+
+  var zhTw = {
+    code: 'zh-tw',
+    buttonText: {
+      prev: '上月',
+      next: '下月',
+      today: '今天',
+      month: '月',
+      week: '週',
+      day: '天',
+      list: '活動列表',
+    },
+    weekText: '周',
+    allDayText: '整天',
+    moreLinkText: '顯示更多',
+    noEventsText: '没有任何活動',
+  };
+
+  return zhTw;
+
+}());
diff --git a/InvenTree/InvenTree/static/fullcalendar/main.css b/InvenTree/InvenTree/static/fullcalendar/main.css
new file mode 100644
index 0000000000..2b276f5288
--- /dev/null
+++ b/InvenTree/InvenTree/static/fullcalendar/main.css
@@ -0,0 +1,1429 @@
+
+/* classes attached to <body> */
+
+.fc-not-allowed,
+.fc-not-allowed .fc-event { /* override events' custom cursors */
+  cursor: not-allowed;
+}
+
+.fc-unselectable {
+  -webkit-user-select: none;
+     -moz-user-select: none;
+      -ms-user-select: none;
+          user-select: none;
+  -webkit-touch-callout: none;
+  -webkit-tap-highlight-color: rgba(0, 0, 0, 0);
+}
+.fc {
+  /* layout of immediate children */
+  display: flex;
+  flex-direction: column;
+
+  font-size: 1em
+}
+.fc,
+  .fc *,
+  .fc *:before,
+  .fc *:after {
+    box-sizing: border-box;
+  }
+.fc table {
+    border-collapse: collapse;
+    border-spacing: 0;
+    font-size: 1em; /* normalize cross-browser */
+  }
+.fc th {
+    text-align: center;
+  }
+.fc th,
+  .fc td {
+    vertical-align: top;
+    padding: 0;
+  }
+.fc a[data-navlink] {
+    cursor: pointer;
+  }
+.fc a[data-navlink]:hover {
+    text-decoration: underline;
+  }
+.fc-direction-ltr {
+  direction: ltr;
+  text-align: left;
+}
+.fc-direction-rtl {
+  direction: rtl;
+  text-align: right;
+}
+.fc-theme-standard td,
+  .fc-theme-standard th {
+    border: 1px solid #ddd;
+    border: 1px solid var(--fc-border-color, #ddd);
+  }
+/* for FF, which doesn't expand a 100% div within a table cell. use absolute positioning */
+/* inner-wrappers are responsible for being absolute */
+/* TODO: best place for this? */
+.fc-liquid-hack td,
+  .fc-liquid-hack th {
+    position: relative;
+  }
+
+@font-face {
+  font-family: 'fcicons';
+  src: url("data:application/x-font-ttf;charset=utf-8;base64,AAEAAAALAIAAAwAwT1MvMg8SBfAAAAC8AAAAYGNtYXAXVtKNAAABHAAAAFRnYXNwAAAAEAAAAXAAAAAIZ2x5ZgYydxIAAAF4AAAFNGhlYWQUJ7cIAAAGrAAAADZoaGVhB20DzAAABuQAAAAkaG10eCIABhQAAAcIAAAALGxvY2ED4AU6AAAHNAAAABhtYXhwAA8AjAAAB0wAAAAgbmFtZXsr690AAAdsAAABhnBvc3QAAwAAAAAI9AAAACAAAwPAAZAABQAAApkCzAAAAI8CmQLMAAAB6wAzAQkAAAAAAAAAAAAAAAAAAAABEAAAAAAAAAAAAAAAAAAAAABAAADpBgPA/8AAQAPAAEAAAAABAAAAAAAAAAAAAAAgAAAAAAADAAAAAwAAABwAAQADAAAAHAADAAEAAAAcAAQAOAAAAAoACAACAAIAAQAg6Qb//f//AAAAAAAg6QD//f//AAH/4xcEAAMAAQAAAAAAAAAAAAAAAQAB//8ADwABAAAAAAAAAAAAAgAANzkBAAAAAAEAAAAAAAAAAAACAAA3OQEAAAAAAQAAAAAAAAAAAAIAADc5AQAAAAABAWIAjQKeAskAEwAAJSc3NjQnJiIHAQYUFwEWMjc2NCcCnuLiDQ0MJAz/AA0NAQAMJAwNDcni4gwjDQwM/wANIwz/AA0NDCMNAAAAAQFiAI0CngLJABMAACUBNjQnASYiBwYUHwEHBhQXFjI3AZ4BAA0N/wAMJAwNDeLiDQ0MJAyNAQAMIw0BAAwMDSMM4uINIwwNDQAAAAIA4gC3Ax4CngATACcAACUnNzY0JyYiDwEGFB8BFjI3NjQnISc3NjQnJiIPAQYUHwEWMjc2NCcB87e3DQ0MIw3VDQ3VDSMMDQ0BK7e3DQ0MJAzVDQ3VDCQMDQ3zuLcMJAwNDdUNIwzWDAwNIwy4twwkDA0N1Q0jDNYMDA0jDAAAAgDiALcDHgKeABMAJwAAJTc2NC8BJiIHBhQfAQcGFBcWMjchNzY0LwEmIgcGFB8BBwYUFxYyNwJJ1Q0N1Q0jDA0Nt7cNDQwjDf7V1Q0N1QwkDA0Nt7cNDQwkDLfWDCMN1Q0NDCQMt7gMIw0MDNYMIw3VDQ0MJAy3uAwjDQwMAAADAFUAAAOrA1UAMwBoAHcAABMiBgcOAQcOAQcOARURFBYXHgEXHgEXHgEzITI2Nz4BNz4BNz4BNRE0JicuAScuAScuASMFITIWFx4BFx4BFx4BFREUBgcOAQcOAQcOASMhIiYnLgEnLgEnLgE1ETQ2Nz4BNz4BNz4BMxMhMjY1NCYjISIGFRQWM9UNGAwLFQkJDgUFBQUFBQ4JCRULDBgNAlYNGAwLFQkJDgUFBQUFBQ4JCRULDBgN/aoCVgQIBAQHAwMFAQIBAQIBBQMDBwQECAT9qgQIBAQHAwMFAQIBAQIBBQMDBwQECASAAVYRGRkR/qoRGRkRA1UFBAUOCQkVDAsZDf2rDRkLDBUJCA4FBQUFBQUOCQgVDAsZDQJVDRkLDBUJCQ4FBAVVAgECBQMCBwQECAX9qwQJAwQHAwMFAQICAgIBBQMDBwQDCQQCVQUIBAQHAgMFAgEC/oAZEhEZGRESGQAAAAADAFUAAAOrA1UAMwBoAIkAABMiBgcOAQcOAQcOARURFBYXHgEXHgEXHgEzITI2Nz4BNz4BNz4BNRE0JicuAScuAScuASMFITIWFx4BFx4BFx4BFREUBgcOAQcOAQcOASMhIiYnLgEnLgEnLgE1ETQ2Nz4BNz4BNz4BMxMzFRQWMzI2PQEzMjY1NCYrATU0JiMiBh0BIyIGFRQWM9UNGAwLFQkJDgUFBQUFBQ4JCRULDBgNAlYNGAwLFQkJDgUFBQUFBQ4JCRULDBgN/aoCVgQIBAQHAwMFAQIBAQIBBQMDBwQECAT9qgQIBAQHAwMFAQIBAQIBBQMDBwQECASAgBkSEhmAERkZEYAZEhIZgBEZGREDVQUEBQ4JCRUMCxkN/asNGQsMFQkIDgUFBQUFBQ4JCBUMCxkNAlUNGQsMFQkJDgUEBVUCAQIFAwIHBAQIBf2rBAkDBAcDAwUBAgICAgEFAwMHBAMJBAJVBQgEBAcCAwUCAQL+gIASGRkSgBkSERmAEhkZEoAZERIZAAABAOIAjQMeAskAIAAAExcHBhQXFjI/ARcWMjc2NC8BNzY0JyYiDwEnJiIHBhQX4uLiDQ0MJAzi4gwkDA0N4uINDQwkDOLiDCQMDQ0CjeLiDSMMDQ3h4Q0NDCMN4uIMIw0MDOLiDAwNIwwAAAABAAAAAQAAa5n0y18PPPUACwQAAAAAANivOVsAAAAA2K85WwAAAAADqwNVAAAACAACAAAAAAAAAAEAAAPA/8AAAAQAAAAAAAOrAAEAAAAAAAAAAAAAAAAAAAALBAAAAAAAAAAAAAAAAgAAAAQAAWIEAAFiBAAA4gQAAOIEAABVBAAAVQQAAOIAAAAAAAoAFAAeAEQAagCqAOoBngJkApoAAQAAAAsAigADAAAAAAACAAAAAAAAAAAAAAAAAAAAAAAAAA4ArgABAAAAAAABAAcAAAABAAAAAAACAAcAYAABAAAAAAADAAcANgABAAAAAAAEAAcAdQABAAAAAAAFAAsAFQABAAAAAAAGAAcASwABAAAAAAAKABoAigADAAEECQABAA4ABwADAAEECQACAA4AZwADAAEECQADAA4APQADAAEECQAEAA4AfAADAAEECQAFABYAIAADAAEECQAGAA4AUgADAAEECQAKADQApGZjaWNvbnMAZgBjAGkAYwBvAG4Ac1ZlcnNpb24gMS4wAFYAZQByAHMAaQBvAG4AIAAxAC4AMGZjaWNvbnMAZgBjAGkAYwBvAG4Ac2ZjaWNvbnMAZgBjAGkAYwBvAG4Ac1JlZ3VsYXIAUgBlAGcAdQBsAGEAcmZjaWNvbnMAZgBjAGkAYwBvAG4Ac0ZvbnQgZ2VuZXJhdGVkIGJ5IEljb01vb24uAEYAbwBuAHQAIABnAGUAbgBlAHIAYQB0AGUAZAAgAGIAeQAgAEkAYwBvAE0AbwBvAG4ALgAAAAMAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=") format('truetype');
+  font-weight: normal;
+  font-style: normal;
+}
+
+.fc-icon {
+  /* added for fc */
+  display: inline-block;
+  width: 1em;
+  height: 1em;
+  text-align: center;
+  -webkit-user-select: none;
+     -moz-user-select: none;
+      -ms-user-select: none;
+          user-select: none;
+
+  /* use !important to prevent issues with browser extensions that change fonts */
+  font-family: 'fcicons' !important;
+  speak: none;
+  font-style: normal;
+  font-weight: normal;
+  font-variant: normal;
+  text-transform: none;
+  line-height: 1;
+
+  /* Better Font Rendering =========== */
+  -webkit-font-smoothing: antialiased;
+  -moz-osx-font-smoothing: grayscale;
+}
+
+.fc-icon-chevron-left:before {
+  content: "\e900";
+}
+
+.fc-icon-chevron-right:before {
+  content: "\e901";
+}
+
+.fc-icon-chevrons-left:before {
+  content: "\e902";
+}
+
+.fc-icon-chevrons-right:before {
+  content: "\e903";
+}
+
+.fc-icon-minus-square:before {
+  content: "\e904";
+}
+
+.fc-icon-plus-square:before {
+  content: "\e905";
+}
+
+.fc-icon-x:before {
+  content: "\e906";
+}
+/*
+Lots taken from Flatly (MIT): https://bootswatch.com/4/flatly/bootstrap.css
+
+These styles only apply when the standard-theme is activated.
+When it's NOT activated, the fc-button classes won't even be in the DOM.
+*/
+.fc {
+
+  /* reset */
+
+}
+.fc .fc-button {
+    border-radius: 0;
+    overflow: visible;
+    text-transform: none;
+    margin: 0;
+    font-family: inherit;
+    font-size: inherit;
+    line-height: inherit;
+  }
+.fc .fc-button:focus {
+    outline: 1px dotted;
+    outline: 5px auto -webkit-focus-ring-color;
+  }
+.fc .fc-button {
+    -webkit-appearance: button;
+  }
+.fc .fc-button:not(:disabled) {
+    cursor: pointer;
+  }
+.fc .fc-button::-moz-focus-inner {
+    padding: 0;
+    border-style: none;
+  }
+.fc {
+
+  /* theme */
+
+}
+.fc .fc-button {
+    display: inline-block;
+    font-weight: 400;
+    text-align: center;
+    vertical-align: middle;
+    -webkit-user-select: none;
+       -moz-user-select: none;
+        -ms-user-select: none;
+            user-select: none;
+    background-color: transparent;
+    border: 1px solid transparent;
+    padding: 0.4em 0.65em;
+    font-size: 1em;
+    line-height: 1.5;
+    border-radius: 0.25em;
+  }
+.fc .fc-button:hover {
+    text-decoration: none;
+  }
+.fc .fc-button:focus {
+    outline: 0;
+    box-shadow: 0 0 0 0.2rem rgba(44, 62, 80, 0.25);
+  }
+.fc .fc-button:disabled {
+    opacity: 0.65;
+  }
+.fc {
+
+  /* "primary" coloring */
+
+}
+.fc .fc-button-primary {
+    color: #fff;
+    color: var(--fc-button-text-color, #fff);
+    background-color: #2C3E50;
+    background-color: var(--fc-button-bg-color, #2C3E50);
+    border-color: #2C3E50;
+    border-color: var(--fc-button-border-color, #2C3E50);
+  }
+.fc .fc-button-primary:hover {
+    color: #fff;
+    color: var(--fc-button-text-color, #fff);
+    background-color: #1e2b37;
+    background-color: var(--fc-button-hover-bg-color, #1e2b37);
+    border-color: #1a252f;
+    border-color: var(--fc-button-hover-border-color, #1a252f);
+  }
+.fc .fc-button-primary:disabled { /* not DRY */
+    color: #fff;
+    color: var(--fc-button-text-color, #fff);
+    background-color: #2C3E50;
+    background-color: var(--fc-button-bg-color, #2C3E50);
+    border-color: #2C3E50;
+    border-color: var(--fc-button-border-color, #2C3E50); /* overrides :hover */
+  }
+.fc .fc-button-primary:focus {
+    box-shadow: 0 0 0 0.2rem rgba(76, 91, 106, 0.5);
+  }
+.fc .fc-button-primary:not(:disabled):active,
+  .fc .fc-button-primary:not(:disabled).fc-button-active {
+    color: #fff;
+    color: var(--fc-button-text-color, #fff);
+    background-color: #1a252f;
+    background-color: var(--fc-button-active-bg-color, #1a252f);
+    border-color: #151e27;
+    border-color: var(--fc-button-active-border-color, #151e27);
+  }
+.fc .fc-button-primary:not(:disabled):active:focus,
+  .fc .fc-button-primary:not(:disabled).fc-button-active:focus {
+    box-shadow: 0 0 0 0.2rem rgba(76, 91, 106, 0.5);
+  }
+.fc {
+
+  /* icons within buttons */
+
+}
+.fc .fc-button .fc-icon {
+    vertical-align: middle;
+    font-size: 1.5em; /* bump up the size (but don't make it bigger than line-height of button, which is 1.5em also) */
+  }
+.fc .fc-button-group {
+    position: relative;
+    display: inline-flex;
+    vertical-align: middle;
+  }
+.fc .fc-button-group > .fc-button {
+    position: relative;
+    flex: 1 1 auto;
+  }
+.fc .fc-button-group > .fc-button:hover {
+    z-index: 1;
+  }
+.fc .fc-button-group > .fc-button:focus,
+  .fc .fc-button-group > .fc-button:active,
+  .fc .fc-button-group > .fc-button.fc-button-active {
+    z-index: 1;
+  }
+.fc-direction-ltr .fc-button-group > .fc-button:not(:first-child) {
+    margin-left: -1px;
+    border-top-left-radius: 0;
+    border-bottom-left-radius: 0;
+  }
+.fc-direction-ltr .fc-button-group > .fc-button:not(:last-child) {
+    border-top-right-radius: 0;
+    border-bottom-right-radius: 0;
+  }
+.fc-direction-rtl .fc-button-group > .fc-button:not(:first-child) {
+    margin-right: -1px;
+    border-top-right-radius: 0;
+    border-bottom-right-radius: 0;
+  }
+.fc-direction-rtl .fc-button-group > .fc-button:not(:last-child) {
+    border-top-left-radius: 0;
+    border-bottom-left-radius: 0;
+  }
+.fc .fc-toolbar {
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+  }
+.fc .fc-toolbar.fc-header-toolbar {
+    margin-bottom: 1.5em;
+  }
+.fc .fc-toolbar.fc-footer-toolbar {
+    margin-top: 1.5em;
+  }
+.fc .fc-toolbar-title {
+    font-size: 1.75em;
+    margin: 0;
+  }
+.fc-direction-ltr .fc-toolbar > * > :not(:first-child) {
+    margin-left: .75em; /* space between */
+  }
+.fc-direction-rtl .fc-toolbar > * > :not(:first-child) {
+    margin-right: .75em; /* space between */
+  }
+.fc-direction-rtl .fc-toolbar-ltr { /* when the toolbar-chunk positioning system is explicitly left-to-right */
+    flex-direction: row-reverse;
+  }
+.fc .fc-scroller {
+    -webkit-overflow-scrolling: touch;
+    position: relative; /* for abs-positioned elements within */
+  }
+.fc .fc-scroller-liquid {
+    height: 100%;
+  }
+.fc .fc-scroller-liquid-absolute {
+    position: absolute;
+    top: 0;
+    right: 0;
+    left: 0;
+    bottom: 0;
+  }
+.fc .fc-scroller-harness {
+    position: relative;
+    overflow: hidden;
+    direction: ltr;
+      /* hack for chrome computing the scroller's right/left wrong for rtl. undone below... */
+      /* TODO: demonstrate in codepen */
+  }
+.fc .fc-scroller-harness-liquid {
+    height: 100%;
+  }
+.fc-direction-rtl .fc-scroller-harness > .fc-scroller { /* undo above hack */
+    direction: rtl;
+  }
+.fc-theme-standard .fc-scrollgrid {
+    border: 1px solid #ddd;
+    border: 1px solid var(--fc-border-color, #ddd); /* bootstrap does this. match */
+  }
+.fc .fc-scrollgrid,
+    .fc .fc-scrollgrid table { /* all tables (self included) */
+      width: 100%; /* because tables don't normally do this */
+      table-layout: fixed;
+    }
+.fc .fc-scrollgrid table { /* inner tables */
+      border-top-style: hidden;
+      border-left-style: hidden;
+      border-right-style: hidden;
+    }
+.fc .fc-scrollgrid {
+
+    border-collapse: separate;
+    border-right-width: 0;
+    border-bottom-width: 0;
+
+  }
+.fc .fc-scrollgrid-liquid {
+    height: 100%;
+  }
+.fc .fc-scrollgrid-section { /* a <tr> */
+    height: 1px /* better than 0, for firefox */
+
+  }
+.fc .fc-scrollgrid-section > td {
+      height: 1px; /* needs a height so inner div within grow. better than 0, for firefox */
+    }
+.fc .fc-scrollgrid-section table {
+      height: 1px;
+        /* for most browsers, if a height isn't set on the table, can't do liquid-height within cells */
+        /* serves as a min-height. harmless */
+    }
+.fc .fc-scrollgrid-section-liquid {
+    height: auto
+
+  }
+.fc .fc-scrollgrid-section-liquid > td {
+      height: 100%; /* better than `auto`, for firefox */
+    }
+.fc .fc-scrollgrid-section > * {
+    border-top-width: 0;
+    border-left-width: 0;
+  }
+.fc .fc-scrollgrid-section-header > *,
+  .fc .fc-scrollgrid-section-footer > * {
+    border-bottom-width: 0;
+  }
+.fc .fc-scrollgrid-section-body table,
+  .fc .fc-scrollgrid-section-footer table {
+    border-bottom-style: hidden; /* head keeps its bottom border tho */
+  }
+.fc {
+
+  /* stickiness */
+
+}
+.fc .fc-scrollgrid-section-sticky > * {
+    background: #fff;
+    background: var(--fc-page-bg-color, #fff);
+    position: -webkit-sticky;
+    position: sticky;
+    z-index: 2; /* TODO: var */
+    /* TODO: box-shadow when sticking */
+  }
+.fc .fc-scrollgrid-section-header.fc-scrollgrid-section-sticky > * {
+    top: 0; /* because border-sharing causes a gap at the top */
+      /* TODO: give safari -1. has bug */
+  }
+.fc .fc-scrollgrid-section-footer.fc-scrollgrid-section-sticky > * {
+    bottom: 0; /* known bug: bottom-stickiness doesn't work in safari */
+  }
+.fc .fc-scrollgrid-sticky-shim { /* for horizontal scrollbar */
+    height: 1px; /* needs height to create scrollbars */
+    margin-bottom: -1px;
+  }
+.fc-sticky { /* no .fc wrap because used as child of body */
+  position: -webkit-sticky;
+  position: sticky;
+}
+.fc .fc-view-harness {
+    flex-grow: 1; /* because this harness is WITHIN the .fc's flexbox */
+    position: relative;
+  }
+.fc {
+
+  /* when the harness controls the height, make the view liquid */
+
+}
+.fc .fc-view-harness-active > .fc-view {
+    position: absolute;
+    top: 0;
+    right: 0;
+    bottom: 0;
+    left: 0;
+  }
+.fc .fc-col-header-cell-cushion {
+    display: inline-block; /* x-browser for when sticky (when multi-tier header) */
+    padding: 2px 4px;
+  }
+.fc .fc-bg-event,
+  .fc .fc-non-business,
+  .fc .fc-highlight {
+    /* will always have a harness with position:relative/absolute, so absolutely expand */
+    position: absolute;
+    top: 0;
+    left: 0;
+    right: 0;
+    bottom: 0;
+  }
+.fc .fc-non-business {
+    background: rgba(215, 215, 215, 0.3);
+    background: var(--fc-non-business-color, rgba(215, 215, 215, 0.3));
+  }
+.fc .fc-bg-event {
+    background: rgb(143, 223, 130);
+    background: var(--fc-bg-event-color, rgb(143, 223, 130));
+    opacity: 0.3;
+    opacity: var(--fc-bg-event-opacity, 0.3)
+  }
+.fc .fc-bg-event .fc-event-title {
+      margin: .5em;
+      font-size: .85em;
+      font-size: var(--fc-small-font-size, .85em);
+      font-style: italic;
+    }
+.fc .fc-highlight {
+    background: rgba(188, 232, 241, 0.3);
+    background: var(--fc-highlight-color, rgba(188, 232, 241, 0.3));
+  }
+.fc .fc-cell-shaded,
+  .fc .fc-day-disabled {
+    background: rgba(208, 208, 208, 0.3);
+    background: var(--fc-neutral-bg-color, rgba(208, 208, 208, 0.3));
+  }
+/* link resets */
+/* ---------------------------------------------------------------------------------------------------- */
+a.fc-event,
+a.fc-event:hover {
+  text-decoration: none;
+}
+/* cursor */
+.fc-event[href],
+.fc-event.fc-event-draggable {
+  cursor: pointer;
+}
+/* event text content */
+/* ---------------------------------------------------------------------------------------------------- */
+.fc-event .fc-event-main {
+    position: relative;
+    z-index: 2;
+  }
+/* dragging */
+/* ---------------------------------------------------------------------------------------------------- */
+.fc-event-dragging:not(.fc-event-selected) { /* MOUSE */
+    opacity: 0.75;
+  }
+.fc-event-dragging.fc-event-selected { /* TOUCH */
+    box-shadow: 0 2px 7px rgba(0, 0, 0, 0.3);
+  }
+/* resizing */
+/* ---------------------------------------------------------------------------------------------------- */
+/* (subclasses should hone positioning for touch and non-touch) */
+.fc-event .fc-event-resizer {
+    display: none;
+    position: absolute;
+    z-index: 4;
+  }
+.fc-event:hover, /* MOUSE */
+.fc-event-selected { /* TOUCH */
+
+}
+.fc-event:hover .fc-event-resizer, .fc-event-selected .fc-event-resizer {
+    display: block;
+  }
+.fc-event-selected .fc-event-resizer {
+    border-radius: 4px;
+    border-radius: calc(var(--fc-event-resizer-dot-total-width, 8px) / 2);
+    border-width: 1px;
+    border-width: var(--fc-event-resizer-dot-border-width, 1px);
+    width: 8px;
+    width: var(--fc-event-resizer-dot-total-width, 8px);
+    height: 8px;
+    height: var(--fc-event-resizer-dot-total-width, 8px);
+    border-style: solid;
+    border-color: inherit;
+    background: #fff;
+    background: var(--fc-page-bg-color, #fff)
+
+    /* expand hit area */
+
+  }
+.fc-event-selected .fc-event-resizer:before {
+      content: '';
+      position: absolute;
+      top: -20px;
+      left: -20px;
+      right: -20px;
+      bottom: -20px;
+    }
+/* selecting (always TOUCH) */
+/* ---------------------------------------------------------------------------------------------------- */
+.fc-event-selected {
+  box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2)
+
+  /* expand hit area (subclasses should expand) */
+
+}
+.fc-event-selected:before {
+    content: "";
+    position: absolute;
+    z-index: 3;
+    top: 0;
+    left: 0;
+    right: 0;
+    bottom: 0;
+  }
+.fc-event-selected {
+
+  /* dimmer effect */
+
+}
+.fc-event-selected:after {
+    content: "";
+    background: rgba(0, 0, 0, 0.25);
+    background: var(--fc-event-selected-overlay-color, rgba(0, 0, 0, 0.25));
+    position: absolute;
+    z-index: 1;
+
+    /* assume there's a border on all sides. overcome it. */
+    /* sometimes there's NOT a border, in which case the dimmer will go over */
+    /* an adjacent border, which looks fine. */
+    top: -1px;
+    left: -1px;
+    right: -1px;
+    bottom: -1px;
+  }
+/*
+A HORIZONTAL event
+*/
+.fc-h-event { /* allowed to be top-level */
+  display: block;
+  border: 1px solid #3788d8;
+  border: 1px solid var(--fc-event-border-color, #3788d8);
+  background-color: #3788d8;
+  background-color: var(--fc-event-bg-color, #3788d8)
+
+}
+.fc-h-event .fc-event-main {
+    color: #fff;
+    color: var(--fc-event-text-color, #fff);
+  }
+.fc-h-event .fc-event-main-frame {
+    display: flex; /* for make fc-event-title-container expand */
+  }
+.fc-h-event .fc-event-time {
+    max-width: 100%; /* clip overflow on this element */
+    overflow: hidden;
+  }
+.fc-h-event .fc-event-title-container { /* serves as a container for the sticky cushion */
+    flex-grow: 1;
+    flex-shrink: 1;
+    min-width: 0; /* important for allowing to shrink all the way */
+  }
+.fc-h-event .fc-event-title {
+    display: inline-block; /* need this to be sticky cross-browser */
+    vertical-align: top; /* for not messing up line-height */
+    left: 0;  /* for sticky */
+    right: 0; /* for sticky */
+    max-width: 100%; /* clip overflow on this element */
+    overflow: hidden;
+  }
+.fc-h-event.fc-event-selected:before {
+    /* expand hit area */
+    top: -10px;
+    bottom: -10px;
+  }
+/* adjust border and border-radius (if there is any) for non-start/end */
+.fc-direction-ltr .fc-daygrid-block-event:not(.fc-event-start),
+.fc-direction-rtl .fc-daygrid-block-event:not(.fc-event-end) {
+  border-top-left-radius: 0;
+  border-bottom-left-radius: 0;
+  border-left-width: 0;
+}
+.fc-direction-ltr .fc-daygrid-block-event:not(.fc-event-end),
+.fc-direction-rtl .fc-daygrid-block-event:not(.fc-event-start) {
+  border-top-right-radius: 0;
+  border-bottom-right-radius: 0;
+  border-right-width: 0;
+}
+/* resizers */
+.fc-h-event:not(.fc-event-selected) .fc-event-resizer {
+  top: 0;
+  bottom: 0;
+  width: 8px;
+  width: var(--fc-event-resizer-thickness, 8px);
+}
+.fc-direction-ltr .fc-h-event:not(.fc-event-selected) .fc-event-resizer-start,
+.fc-direction-rtl .fc-h-event:not(.fc-event-selected) .fc-event-resizer-end {
+  cursor: w-resize;
+  left: -4px;
+  left: calc(var(--fc-event-resizer-thickness, 8px) / -2);
+}
+.fc-direction-ltr .fc-h-event:not(.fc-event-selected) .fc-event-resizer-end,
+.fc-direction-rtl .fc-h-event:not(.fc-event-selected) .fc-event-resizer-start {
+  cursor: e-resize;
+  right: -4px;
+  right: calc(var(--fc-event-resizer-thickness, 8px) / -2);
+}
+/* resizers for TOUCH */
+.fc-h-event.fc-event-selected .fc-event-resizer {
+  top: 50%;
+  margin-top: -4px;
+  margin-top: calc(var(--fc-event-resizer-dot-total-width, 8px) / -2);
+}
+.fc-direction-ltr .fc-h-event.fc-event-selected .fc-event-resizer-start,
+.fc-direction-rtl .fc-h-event.fc-event-selected .fc-event-resizer-end {
+  left: -4px;
+  left: calc(var(--fc-event-resizer-dot-total-width, 8px) / -2);
+}
+.fc-direction-ltr .fc-h-event.fc-event-selected .fc-event-resizer-end,
+.fc-direction-rtl .fc-h-event.fc-event-selected .fc-event-resizer-start {
+  right: -4px;
+  right: calc(var(--fc-event-resizer-dot-total-width, 8px) / -2);
+}
+
+
+:root {
+  --fc-daygrid-event-dot-width: 8px;
+}
+.fc .fc-popover {
+    position: fixed;
+    top: 0; /* for when not positioned yet */
+    box-shadow: 0 2px 6px rgba(0,0,0,.15);
+  }
+.fc .fc-popover-header {
+    display: flex;
+    flex-direction: row;
+    justify-content: space-between;
+    align-items: center;
+    padding: 3px 4px;
+  }
+.fc .fc-popover-title {
+    margin: 0 2px;
+  }
+.fc .fc-popover-close {
+    cursor: pointer;
+    opacity: 0.65;
+    font-size: 1.1em;
+  }
+.fc-theme-standard .fc-popover {
+    border: 1px solid #ddd;
+    border: 1px solid var(--fc-border-color, #ddd);
+    background: #fff;
+    background: var(--fc-page-bg-color, #fff);
+  }
+.fc-theme-standard .fc-popover-header {
+    background: rgba(208, 208, 208, 0.3);
+    background: var(--fc-neutral-bg-color, rgba(208, 208, 208, 0.3));
+  }
+/* help things clear margins of inner content */
+.fc-daygrid-day-frame,
+.fc-daygrid-day-events,
+.fc-daygrid-event-harness { /* for event top/bottom margins */
+}
+.fc-daygrid-day-frame:before, .fc-daygrid-day-events:before, .fc-daygrid-event-harness:before {
+  content: "";
+  clear: both;
+  display: table; }
+.fc-daygrid-day-frame:after, .fc-daygrid-day-events:after, .fc-daygrid-event-harness:after {
+  content: "";
+  clear: both;
+  display: table; }
+.fc .fc-daygrid-body { /* a <div> that wraps the table */
+    position: relative;
+    z-index: 1; /* container inner z-index's because <tr>s can't do it */
+  }
+.fc .fc-daygrid-day.fc-day-today {
+      background-color: rgba(255, 220, 40, 0.15);
+      background-color: var(--fc-today-bg-color, rgba(255, 220, 40, 0.15));
+    }
+.fc .fc-daygrid-day-frame {
+    position: relative;
+    min-height: 100%; /* seems to work better than `height` because sets height after rows/cells naturally do it */
+  }
+.fc {
+
+  /* cell top */
+
+}
+.fc .fc-daygrid-day-top {
+    display: flex;
+    flex-direction: row-reverse;
+  }
+.fc .fc-day-other .fc-daygrid-day-top {
+    opacity: 0.3;
+  }
+.fc {
+
+  /* day number (within cell top) */
+
+}
+.fc .fc-daygrid-day-number {
+    position: relative;
+    z-index: 4;
+    padding: 4px;
+  }
+.fc {
+
+  /* event container */
+
+}
+.fc .fc-daygrid-day-events {
+    margin-top: 1px; /* needs to be margin, not padding, so that available cell height can be computed */
+  }
+.fc {
+
+  /* positioning for balanced vs natural */
+
+}
+.fc .fc-daygrid-body-balanced .fc-daygrid-day-events {
+      position: absolute;
+      left: 0;
+      right: 0;
+    }
+.fc .fc-daygrid-body-unbalanced .fc-daygrid-day-events {
+      position: relative; /* for containing abs positioned event harnesses */
+      min-height: 2em; /* in addition to being a min-height during natural height, equalizes the heights a little bit */
+    }
+.fc .fc-daygrid-body-natural { /* can coexist with -unbalanced */
+  }
+.fc .fc-daygrid-body-natural .fc-daygrid-day-events {
+      margin-bottom: 1em;
+    }
+.fc {
+
+  /* event harness */
+
+}
+.fc .fc-daygrid-event-harness {
+    position: relative;
+  }
+.fc .fc-daygrid-event-harness-abs {
+    position: absolute;
+    top: 0; /* fallback coords for when cannot yet be computed */
+    left: 0; /* */
+    right: 0; /* */
+  }
+.fc .fc-daygrid-bg-harness {
+    position: absolute;
+    top: 0;
+    bottom: 0;
+  }
+.fc {
+
+  /* bg content */
+
+}
+.fc .fc-daygrid-day-bg .fc-non-business { z-index: 1 }
+.fc .fc-daygrid-day-bg .fc-bg-event { z-index: 2 }
+.fc .fc-daygrid-day-bg .fc-highlight { z-index: 3 }
+.fc {
+
+  /* events */
+
+}
+.fc .fc-daygrid-event {
+    z-index: 6;
+    margin-top: 1px;
+  }
+.fc .fc-daygrid-event.fc-event-mirror {
+    z-index: 7;
+  }
+.fc {
+
+  /* cell bottom (within day-events) */
+
+}
+.fc .fc-daygrid-day-bottom {
+    font-size: .85em;
+    margin: 2px 3px 0;
+  }
+.fc .fc-daygrid-more-link {
+    position: relative;
+    z-index: 4;
+    cursor: pointer;
+  }
+.fc {
+
+  /* week number (within frame) */
+
+}
+.fc .fc-daygrid-week-number {
+    position: absolute;
+    z-index: 5;
+    top: 0;
+    padding: 2px;
+    min-width: 1.5em;
+    text-align: center;
+    background-color: rgba(208, 208, 208, 0.3);
+    background-color: var(--fc-neutral-bg-color, rgba(208, 208, 208, 0.3));
+    color: #808080;
+    color: var(--fc-neutral-text-color, #808080);
+  }
+.fc {
+
+  /* popover */
+
+}
+.fc .fc-more-popover {
+    z-index: 8;
+  }
+.fc .fc-more-popover .fc-popover-body {
+    min-width: 220px;
+    padding: 10px;
+  }
+.fc-direction-ltr .fc-daygrid-event.fc-event-start,
+.fc-direction-rtl .fc-daygrid-event.fc-event-end {
+  margin-left: 2px;
+}
+.fc-direction-ltr .fc-daygrid-event.fc-event-end,
+.fc-direction-rtl .fc-daygrid-event.fc-event-start {
+  margin-right: 2px;
+}
+.fc-direction-ltr .fc-daygrid-week-number {
+    left: 0;
+    border-radius: 0 0 3px 0;
+  }
+.fc-direction-rtl .fc-daygrid-week-number {
+    right: 0;
+    border-radius: 0 0 0 3px;
+  }
+.fc-liquid-hack .fc-daygrid-day-frame {
+    position: static; /* will cause inner absolute stuff to expand to <td> */
+  }
+.fc-daygrid-event { /* make root-level, because will be dragged-and-dropped outside of a component root */
+  position: relative; /* for z-indexes assigned later */
+  white-space: nowrap;
+  border-radius: 3px; /* dot event needs this to when selected */
+  font-size: .85em;
+  font-size: var(--fc-small-font-size, .85em);
+}
+/* --- the rectangle ("block") style of event --- */
+.fc-daygrid-block-event .fc-event-time {
+    font-weight: bold;
+  }
+.fc-daygrid-block-event .fc-event-time,
+  .fc-daygrid-block-event .fc-event-title {
+    padding: 1px;
+  }
+/* --- the dot style of event --- */
+.fc-daygrid-dot-event {
+  display: flex;
+  align-items: center;
+  padding: 2px 0
+
+}
+.fc-daygrid-dot-event .fc-event-title {
+    flex-grow: 1;
+    flex-shrink: 1;
+    min-width: 0; /* important for allowing to shrink all the way */
+    overflow: hidden;
+    font-weight: bold;
+  }
+.fc-daygrid-dot-event:hover,
+  .fc-daygrid-dot-event.fc-event-mirror {
+    background: rgba(0, 0, 0, 0.1);
+  }
+.fc-daygrid-dot-event.fc-event-selected:before {
+    /* expand hit area */
+    top: -10px;
+    bottom: -10px;
+  }
+.fc-daygrid-event-dot { /* the actual dot */
+  margin: 0 4px;
+  box-sizing: content-box;
+  width: 0;
+  height: 0;
+  border: 4px solid #3788d8;
+  border: calc(var(--fc-daygrid-event-dot-width, 8px) / 2) solid var(--fc-event-border-color, #3788d8);
+  border-radius: 4px;
+  border-radius: calc(var(--fc-daygrid-event-dot-width, 8px) / 2);
+}
+/* --- spacing between time and title --- */
+.fc-direction-ltr .fc-daygrid-event .fc-event-time {
+    margin-right: 3px;
+  }
+.fc-direction-rtl .fc-daygrid-event .fc-event-time {
+    margin-left: 3px;
+  }
+
+
+/*
+A VERTICAL event
+*/
+
+.fc-v-event { /* allowed to be top-level */
+  display: block;
+  border: 1px solid #3788d8;
+  border: 1px solid var(--fc-event-border-color, #3788d8);
+  background-color: #3788d8;
+  background-color: var(--fc-event-bg-color, #3788d8)
+
+}
+
+.fc-v-event .fc-event-main {
+    color: #fff;
+    color: var(--fc-event-text-color, #fff);
+    height: 100%;
+  }
+
+.fc-v-event .fc-event-main-frame {
+    height: 100%;
+    display: flex;
+    flex-direction: column;
+  }
+
+.fc-v-event .fc-event-time {
+    flex-grow: 0;
+    flex-shrink: 0;
+    max-height: 100%;
+    overflow: hidden;
+  }
+
+.fc-v-event .fc-event-title-container { /* a container for the sticky cushion */
+    flex-grow: 1;
+    flex-shrink: 1;
+    min-height: 0; /* important for allowing to shrink all the way */
+  }
+
+.fc-v-event .fc-event-title { /* will have fc-sticky on it */
+    top: 0;
+    bottom: 0;
+    max-height: 100%; /* clip overflow */
+    overflow: hidden;
+  }
+
+.fc-v-event:not(.fc-event-start) {
+    border-top-width: 0;
+    border-top-left-radius: 0;
+    border-top-right-radius: 0;
+  }
+
+.fc-v-event:not(.fc-event-end) {
+    border-bottom-width: 0;
+    border-bottom-left-radius: 0;
+    border-bottom-right-radius: 0;
+  }
+
+.fc-v-event.fc-event-selected:before {
+    /* expand hit area */
+    left: -10px;
+    right: -10px;
+  }
+
+.fc-v-event {
+
+  /* resizer (mouse AND touch) */
+
+}
+
+.fc-v-event .fc-event-resizer-start {
+    cursor: n-resize;
+  }
+
+.fc-v-event .fc-event-resizer-end {
+    cursor: s-resize;
+  }
+
+.fc-v-event {
+
+  /* resizer for MOUSE */
+
+}
+
+.fc-v-event:not(.fc-event-selected) .fc-event-resizer {
+      height: 8px;
+      height: var(--fc-event-resizer-thickness, 8px);
+      left: 0;
+      right: 0;
+    }
+
+.fc-v-event:not(.fc-event-selected) .fc-event-resizer-start {
+      top: -4px;
+      top: calc(var(--fc-event-resizer-thickness, 8px) / -2);
+    }
+
+.fc-v-event:not(.fc-event-selected) .fc-event-resizer-end {
+      bottom: -4px;
+      bottom: calc(var(--fc-event-resizer-thickness, 8px) / -2);
+    }
+
+.fc-v-event {
+
+  /* resizer for TOUCH (when event is "selected") */
+
+}
+
+.fc-v-event.fc-event-selected .fc-event-resizer {
+      left: 50%;
+      margin-left: -4px;
+      margin-left: calc(var(--fc-event-resizer-dot-total-width, 8px) / -2);
+    }
+
+.fc-v-event.fc-event-selected .fc-event-resizer-start {
+      top: -4px;
+      top: calc(var(--fc-event-resizer-dot-total-width, 8px) / -2);
+    }
+
+.fc-v-event.fc-event-selected .fc-event-resizer-end {
+      bottom: -4px;
+      bottom: calc(var(--fc-event-resizer-dot-total-width, 8px) / -2);
+    }
+.fc .fc-timegrid .fc-daygrid-body { /* the all-day daygrid within the timegrid view */
+    z-index: 2; /* put above the timegrid-body so that more-popover is above everything. TODO: better solution */
+  }
+.fc .fc-timegrid-divider {
+    padding: 0 0 2px; /* browsers get confused when you set height. use padding instead */
+  }
+.fc .fc-timegrid-body {
+    position: relative;
+    z-index: 1; /* scope the z-indexes of slots and cols */
+    min-height: 100%; /* fill height always, even when slat table doesn't grow */
+  }
+.fc .fc-timegrid-axis-chunk { /* for advanced ScrollGrid */
+    position: relative /* offset parent for now-indicator-container */
+
+  }
+.fc .fc-timegrid-axis-chunk > table {
+      position: relative;
+      z-index: 1; /* above the now-indicator-container */
+    }
+.fc .fc-timegrid-slots {
+    position: relative;
+    z-index: 1;
+  }
+.fc .fc-timegrid-slot { /* a <td> */
+    height: 1.5em;
+    border-bottom: 0 /* each cell owns its top border */
+  }
+.fc .fc-timegrid-slot:empty:before {
+      content: '\00a0'; /* make sure there's at least an empty space to create height for height syncing */
+    }
+.fc .fc-timegrid-slot-minor {
+    border-top-style: dotted;
+  }
+.fc .fc-timegrid-slot-label-cushion {
+    display: inline-block;
+    white-space: nowrap;
+  }
+.fc .fc-timegrid-slot-label {
+    vertical-align: middle; /* vertical align the slots */
+  }
+.fc {
+
+
+  /* slots AND axis cells (top-left corner of view including the "all-day" text) */
+
+}
+.fc .fc-timegrid-axis-cushion,
+  .fc .fc-timegrid-slot-label-cushion {
+    padding: 0 4px;
+  }
+.fc {
+
+
+  /* axis cells (top-left corner of view including the "all-day" text) */
+  /* vertical align is more complicated, uses flexbox */
+
+}
+.fc .fc-timegrid-axis-frame-liquid {
+    height: 100%; /* will need liquid-hack in FF */
+  }
+.fc .fc-timegrid-axis-frame {
+    overflow: hidden;
+    display: flex;
+    align-items: center; /* vertical align */
+    justify-content: flex-end; /* horizontal align. matches text-align below */
+  }
+.fc .fc-timegrid-axis-cushion {
+    max-width: 60px; /* limits the width of the "all-day" text */
+    flex-shrink: 0; /* allows text to expand how it normally would, regardless of constrained width */
+  }
+.fc-direction-ltr .fc-timegrid-slot-label-frame {
+    text-align: right;
+  }
+.fc-direction-rtl .fc-timegrid-slot-label-frame {
+    text-align: left;
+  }
+.fc-liquid-hack .fc-timegrid-axis-frame-liquid {
+  height: auto;
+  position: absolute;
+  top: 0;
+  right: 0;
+  bottom: 0;
+  left: 0;
+  }
+.fc .fc-timegrid-col.fc-day-today {
+      background-color: rgba(255, 220, 40, 0.15);
+      background-color: var(--fc-today-bg-color, rgba(255, 220, 40, 0.15));
+    }
+.fc .fc-timegrid-col-frame {
+    min-height: 100%; /* liquid-hack is below */
+    position: relative;
+  }
+.fc-liquid-hack .fc-timegrid-col-frame {
+  height: auto;
+  position: absolute;
+  top: 0;
+  right: 0;
+  bottom: 0;
+  left: 0;
+  }
+.fc-media-screen .fc-timegrid-cols {
+    position: absolute; /* no z-index. children will decide and go above slots */
+    top: 0;
+    left: 0;
+    right: 0;
+    bottom: 0
+  }
+.fc-media-screen .fc-timegrid-cols > table {
+      height: 100%;
+    }
+.fc-media-screen .fc-timegrid-col-bg,
+  .fc-media-screen .fc-timegrid-col-events,
+  .fc-media-screen .fc-timegrid-now-indicator-container {
+    position: absolute;
+    top: 0;
+    left: 0;
+    right: 0;
+  }
+.fc-media-screen .fc-timegrid-event-harness {
+    position: absolute; /* top/left/right/bottom will all be set by JS */
+  }
+.fc {
+
+  /* bg */
+
+}
+.fc .fc-timegrid-col-bg {
+    z-index: 2; /* TODO: kill */
+  }
+.fc .fc-timegrid-col-bg .fc-non-business { z-index: 1 }
+.fc .fc-timegrid-col-bg .fc-bg-event { z-index: 2 }
+.fc .fc-timegrid-col-bg .fc-highlight { z-index: 3 }
+.fc .fc-timegrid-bg-harness {
+    position: absolute; /* top/bottom will be set by JS */
+    left: 0;
+    right: 0;
+  }
+.fc {
+
+  /* fg events */
+  /* (the mirror segs are put into a separate container with same classname, */
+  /* and they must be after the normal seg container to appear at a higher z-index) */
+
+}
+.fc .fc-timegrid-col-events {
+    z-index: 3;
+    /* child event segs have z-indexes that are scoped within this div */
+  }
+.fc {
+
+  /* now indicator */
+
+}
+.fc .fc-timegrid-now-indicator-container {
+    bottom: 0;
+    overflow: hidden; /* don't let overflow of lines/arrows cause unnecessary scrolling */
+    /* z-index is set on the individual elements */
+  }
+.fc-direction-ltr .fc-timegrid-col-events {
+    margin: 0 2.5% 0 2px;
+  }
+.fc-direction-rtl .fc-timegrid-col-events {
+    margin: 0 2px 0 2.5%;
+  }
+.fc-timegrid-event-harness-inset .fc-timegrid-event,
+.fc-timegrid-event.fc-event-mirror {
+  box-shadow: 0px 0px 0px 1px #fff;
+  box-shadow: 0px 0px 0px 1px var(--fc-page-bg-color, #fff);
+}
+.fc-timegrid-event { /* events need to be root */
+
+  font-size: .85em;
+
+  font-size: var(--fc-small-font-size, .85em);
+  border-radius: 3px
+
+}
+.fc-timegrid-event .fc-event-main {
+    padding: 1px 1px 0;
+  }
+.fc-timegrid-event .fc-event-time {
+    white-space: nowrap;
+    font-size: .85em;
+    font-size: var(--fc-small-font-size, .85em);
+    margin-bottom: 1px;
+  }
+.fc-timegrid-event-condensed .fc-event-main-frame {
+    flex-direction: row;
+    overflow: hidden;
+  }
+.fc-timegrid-event-condensed .fc-event-time:after {
+    content: '\00a0-\00a0'; /* dash surrounded by non-breaking spaces */
+  }
+.fc-timegrid-event-condensed .fc-event-title {
+    font-size: .85em;
+    font-size: var(--fc-small-font-size, .85em)
+  }
+.fc-media-screen .fc-timegrid-event {
+    position: absolute; /* absolute WITHIN the harness */
+    top: 0;
+    bottom: 1px; /* stay away from bottom slot line */
+    left: 0;
+    right: 0;
+  }
+.fc {
+
+  /* line */
+
+}
+.fc .fc-timegrid-now-indicator-line {
+    position: absolute;
+    z-index: 4;
+    left: 0;
+    right: 0;
+    border-style: solid;
+    border-color: red;
+    border-color: var(--fc-now-indicator-color, red);
+    border-width: 1px 0 0;
+  }
+.fc {
+
+  /* arrow */
+
+}
+.fc .fc-timegrid-now-indicator-arrow {
+    position: absolute;
+    z-index: 4;
+    margin-top: -5px; /* vertically center on top coordinate */
+    border-style: solid;
+    border-color: red;
+    border-color: var(--fc-now-indicator-color, red);
+  }
+.fc-direction-ltr .fc-timegrid-now-indicator-arrow {
+    left: 0;
+
+    /* triangle pointing right. TODO: mixin */
+    border-width: 5px 0 5px 6px;
+    border-top-color: transparent;
+    border-bottom-color: transparent;
+  }
+.fc-direction-rtl .fc-timegrid-now-indicator-arrow {
+    right: 0;
+
+    /* triangle pointing left. TODO: mixin */
+    border-width: 5px 6px 5px 0;
+    border-top-color: transparent;
+    border-bottom-color: transparent;
+  }
+
+
+:root {
+  --fc-list-event-dot-width: 10px;
+  --fc-list-event-hover-bg-color: #f5f5f5;
+}
+.fc-theme-standard .fc-list {
+    border: 1px solid #ddd;
+    border: 1px solid var(--fc-border-color, #ddd);
+  }
+.fc {
+
+  /* message when no events */
+
+}
+.fc .fc-list-empty {
+    background-color: rgba(208, 208, 208, 0.3);
+    background-color: var(--fc-neutral-bg-color, rgba(208, 208, 208, 0.3));
+    height: 100%;
+    display: flex;
+    justify-content: center;
+    align-items: center; /* vertically aligns fc-list-empty-inner */
+  }
+.fc .fc-list-empty-cushion {
+    margin: 5em 0;
+  }
+.fc {
+
+  /* table within the scroller */
+  /* ---------------------------------------------------------------------------------------------------- */
+
+}
+.fc .fc-list-table {
+    width: 100%;
+    border-style: hidden; /* kill outer border on theme */
+  }
+.fc .fc-list-table tr > * {
+    border-left: 0;
+    border-right: 0;
+  }
+.fc .fc-list-sticky .fc-list-day > * { /* the cells */
+      position: -webkit-sticky;
+      position: sticky;
+      top: 0;
+      background: #fff;
+      background: var(--fc-page-bg-color, #fff); /* for when headers are styled to be transparent and sticky */
+    }
+.fc .fc-list-table th {
+    padding: 0; /* uses an inner-wrapper instead... */
+  }
+.fc .fc-list-table td,
+  .fc .fc-list-day-cushion {
+    padding: 8px 14px;
+  }
+.fc {
+
+
+  /* date heading rows */
+  /* ---------------------------------------------------------------------------------------------------- */
+
+}
+.fc .fc-list-day-cushion:after {
+  content: "";
+  clear: both;
+  display: table; /* clear floating */
+    }
+.fc-theme-standard .fc-list-day-cushion {
+    background-color: rgba(208, 208, 208, 0.3);
+    background-color: var(--fc-neutral-bg-color, rgba(208, 208, 208, 0.3));
+  }
+.fc-direction-ltr .fc-list-day-text,
+.fc-direction-rtl .fc-list-day-side-text {
+  float: left;
+}
+.fc-direction-ltr .fc-list-day-side-text,
+.fc-direction-rtl .fc-list-day-text {
+  float: right;
+}
+/* make the dot closer to the event title */
+.fc-direction-ltr .fc-list-table .fc-list-event-graphic { padding-right: 0 }
+.fc-direction-rtl .fc-list-table .fc-list-event-graphic { padding-left: 0 }
+.fc .fc-list-event.fc-event-forced-url {
+    cursor: pointer; /* whole row will seem clickable */
+  }
+.fc .fc-list-event:hover td {
+    background-color: #f5f5f5;
+    background-color: var(--fc-list-event-hover-bg-color, #f5f5f5);
+  }
+.fc {
+
+  /* shrink certain cols */
+
+}
+.fc .fc-list-event-graphic,
+  .fc .fc-list-event-time {
+    white-space: nowrap;
+    width: 1px;
+  }
+.fc .fc-list-event-dot {
+    display: inline-block;
+    box-sizing: content-box;
+    width: 0;
+    height: 0;
+    border: 5px solid #3788d8;
+    border: calc(var(--fc-list-event-dot-width, 10px) / 2) solid var(--fc-event-border-color, #3788d8);
+    border-radius: 5px;
+    border-radius: calc(var(--fc-list-event-dot-width, 10px) / 2);
+  }
+.fc {
+
+  /* reset <a> styling */
+
+}
+.fc .fc-list-event-title a {
+    color: inherit;
+    text-decoration: none;
+  }
+.fc {
+
+  /* underline link when hovering over any part of row */
+
+}
+.fc .fc-list-event.fc-event-forced-url:hover a {
+    text-decoration: underline;
+  }
+
+
+
+  .fc-theme-bootstrap a:not([href]) {
+    color: inherit; /* natural color for navlinks */
+  }
+
diff --git a/InvenTree/InvenTree/static/fullcalendar/main.js b/InvenTree/InvenTree/static/fullcalendar/main.js
new file mode 100644
index 0000000000..f173a6e89d
--- /dev/null
+++ b/InvenTree/InvenTree/static/fullcalendar/main.js
@@ -0,0 +1,14322 @@
+/*!
+FullCalendar v5.5.0
+Docs & License: https://fullcalendar.io/
+(c) 2020 Adam Shaw
+*/
+var FullCalendar = (function (exports) {
+    'use strict';
+
+    /*! *****************************************************************************
+    Copyright (c) Microsoft Corporation.
+
+    Permission to use, copy, modify, and/or distribute this software for any
+    purpose with or without fee is hereby granted.
+
+    THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
+    REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
+    AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
+    INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
+    LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
+    OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
+    PERFORMANCE OF THIS SOFTWARE.
+    ***************************************************************************** */
+    /* global Reflect, Promise */
+
+    var extendStatics = function(d, b) {
+        extendStatics = Object.setPrototypeOf ||
+            ({ __proto__: [] } instanceof Array && function (d, b) { d.__proto__ = b; }) ||
+            function (d, b) { for (var p in b) if (Object.prototype.hasOwnProperty.call(b, p)) d[p] = b[p]; };
+        return extendStatics(d, b);
+    };
+
+    function __extends(d, b) {
+        extendStatics(d, b);
+        function __() { this.constructor = d; }
+        d.prototype = b === null ? Object.create(b) : (__.prototype = b.prototype, new __());
+    }
+
+    var __assign = function() {
+        __assign = Object.assign || function __assign(t) {
+            for (var s, i = 1, n = arguments.length; i < n; i++) {
+                s = arguments[i];
+                for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p)) t[p] = s[p];
+            }
+            return t;
+        };
+        return __assign.apply(this, arguments);
+    };
+
+    function __spreadArrays() {
+        for (var s = 0, i = 0, il = arguments.length; i < il; i++) s += arguments[i].length;
+        for (var r = Array(s), k = 0, i = 0; i < il; i++)
+            for (var a = arguments[i], j = 0, jl = a.length; j < jl; j++, k++)
+                r[k] = a[j];
+        return r;
+    }
+
+    var n,u,i,t,o,r,f={},e=[],c=/acit|ex(?:s|g|n|p|$)|rph|grid|ows|mnc|ntw|ine[ch]|zoo|^ord|itera/i;function s(n,l){for(var u in l)n[u]=l[u];return n}function a(n){var l=n.parentNode;l&&l.removeChild(n);}function v(n,l,u){var i,t,o,r=arguments,f={};for(o in l)"key"==o?i=l[o]:"ref"==o?t=l[o]:f[o]=l[o];if(arguments.length>3)for(u=[u],o=3;o<arguments.length;o++)u.push(r[o]);if(null!=u&&(f.children=u),"function"==typeof n&&null!=n.defaultProps)for(o in n.defaultProps)void 0===f[o]&&(f[o]=n.defaultProps[o]);return h(n,f,i,t,null)}function h(l,u,i,t,o){var r={type:l,props:u,key:i,ref:t,__k:null,__:null,__b:0,__e:null,__d:void 0,__c:null,__h:null,constructor:void 0,__v:null==o?++n.__v:o};return null!=n.vnode&&n.vnode(r),r}function y(){return {current:null}}function p(n){return n.children}function d(n,l){this.props=n,this.context=l;}function _(n,l){if(null==l)return n.__?_(n.__,n.__.__k.indexOf(n)+1):null;for(var u;l<n.__k.length;l++)if(null!=(u=n.__k[l])&&null!=u.__e)return u.__e;return "function"==typeof n.type?_(n):null}function w(n){var l,u;if(null!=(n=n.__)&&null!=n.__c){for(n.__e=n.__c.base=null,l=0;l<n.__k.length;l++)if(null!=(u=n.__k[l])&&null!=u.__e){n.__e=n.__c.base=u.__e;break}return w(n)}}function k(l){(!l.__d&&(l.__d=!0)&&u.push(l)&&!g.__r++||t!==n.debounceRendering)&&((t=n.debounceRendering)||i)(g);}function g(){for(var n;g.__r=u.length;)n=u.sort(function(n,l){return n.__v.__b-l.__v.__b}),u=[],n.some(function(n){var l,u,i,t,o,r,f;n.__d&&(r=(o=(l=n).__v).__e,(f=l.__P)&&(u=[],(i=s({},o)).__v=o.__v+1,t=$(f,o,i,l.__n,void 0!==f.ownerSVGElement,null!=o.__h?[r]:null,u,null==r?_(o):r,o.__h),j(u,o),t!=r&&w(o)));});}function m(n,l,u,i,t,o,r,c,s,v){var y,d,w,k,g,m,b,A=i&&i.__k||e,P=A.length;for(s==f&&(s=null!=r?r[0]:P?_(i,0):null),u.__k=[],y=0;y<l.length;y++)if(null!=(k=u.__k[y]=null==(k=l[y])||"boolean"==typeof k?null:"string"==typeof k||"number"==typeof k?h(null,k,null,null,k):Array.isArray(k)?h(p,{children:k},null,null,null):null!=k.__e||null!=k.__c?h(k.type,k.props,k.key,null,k.__v):k)){if(k.__=u,k.__b=u.__b+1,null===(w=A[y])||w&&k.key==w.key&&k.type===w.type)A[y]=void 0;else for(d=0;d<P;d++){if((w=A[d])&&k.key==w.key&&k.type===w.type){A[d]=void 0;break}w=null;}g=$(n,k,w=w||f,t,o,r,c,s,v),(d=k.ref)&&w.ref!=d&&(b||(b=[]),w.ref&&b.push(w.ref,null,k),b.push(d,k.__c||g,k)),null!=g?(null==m&&(m=g),s=x(n,k,w,A,r,g,s),v||"option"!=u.type?"function"==typeof u.type&&(u.__d=s):n.value=""):s&&w.__e==s&&s.parentNode!=n&&(s=_(w));}if(u.__e=m,null!=r&&"function"!=typeof u.type)for(y=r.length;y--;)null!=r[y]&&a(r[y]);for(y=P;y--;)null!=A[y]&&L(A[y],A[y]);if(b)for(y=0;y<b.length;y++)I(b[y],b[++y],b[++y]);}function x(n,l,u,i,t,o,r){var f,e,c;if(void 0!==l.__d)f=l.__d,l.__d=void 0;else if(t==u||o!=r||null==o.parentNode)n:if(null==r||r.parentNode!==n)n.appendChild(o),f=null;else {for(e=r,c=0;(e=e.nextSibling)&&c<i.length;c+=2)if(e==o)break n;n.insertBefore(o,r),f=r;}return void 0!==f?f:o.nextSibling}function A(n,l,u,i,t){var o;for(o in u)"children"===o||"key"===o||o in l||C(n,o,null,u[o],i);for(o in l)t&&"function"!=typeof l[o]||"children"===o||"key"===o||"value"===o||"checked"===o||u[o]===l[o]||C(n,o,l[o],u[o],i);}function P(n,l,u){"-"===l[0]?n.setProperty(l,u):n[l]=null==u?"":"number"!=typeof u||c.test(l)?u:u+"px";}function C(n,l,u,i,t){var o,r,f;if(t&&"className"==l&&(l="class"),"style"===l)if("string"==typeof u)n.style.cssText=u;else {if("string"==typeof i&&(n.style.cssText=i=""),i)for(l in i)u&&l in u||P(n.style,l,"");if(u)for(l in u)i&&u[l]===i[l]||P(n.style,l,u[l]);}else "o"===l[0]&&"n"===l[1]?(o=l!==(l=l.replace(/Capture$/,"")),(r=l.toLowerCase())in n&&(l=r),l=l.slice(2),n.l||(n.l={}),n.l[l+o]=u,f=o?N:z,u?i||n.addEventListener(l,f,o):n.removeEventListener(l,f,o)):"list"!==l&&"tagName"!==l&&"form"!==l&&"type"!==l&&"size"!==l&&"download"!==l&&"href"!==l&&!t&&l in n?n[l]=null==u?"":u:"function"!=typeof u&&"dangerouslySetInnerHTML"!==l&&(l!==(l=l.replace(/xlink:?/,""))?null==u||!1===u?n.removeAttributeNS("http://www.w3.org/1999/xlink",l.toLowerCase()):n.setAttributeNS("http://www.w3.org/1999/xlink",l.toLowerCase(),u):null==u||!1===u&&!/^ar/.test(l)?n.removeAttribute(l):n.setAttribute(l,u));}function z(l){this.l[l.type+!1](n.event?n.event(l):l);}function N(l){this.l[l.type+!0](n.event?n.event(l):l);}function T(n,l,u){var i,t;for(i=0;i<n.__k.length;i++)(t=n.__k[i])&&(t.__=n,t.__e&&("function"==typeof t.type&&t.__k.length>1&&T(t,l,u),l=x(u,t,t,n.__k,null,t.__e,l),"function"==typeof n.type&&(n.__d=l)));}function $(l,u,i,t,o,r,f,e,c){var a,v,h,y,_,w,k,g,b,x,A,P=u.type;if(void 0!==u.constructor)return null;null!=i.__h&&(c=i.__h,e=u.__e=i.__e,u.__h=null,r=[e]),(a=n.__b)&&a(u);try{n:if("function"==typeof P){if(g=u.props,b=(a=P.contextType)&&t[a.__c],x=a?b?b.props.value:a.__:t,i.__c?k=(v=u.__c=i.__c).__=v.__E:("prototype"in P&&P.prototype.render?u.__c=v=new P(g,x):(u.__c=v=new d(g,x),v.constructor=P,v.render=M),b&&b.sub(v),v.props=g,v.state||(v.state={}),v.context=x,v.__n=t,h=v.__d=!0,v.__h=[]),null==v.__s&&(v.__s=v.state),null!=P.getDerivedStateFromProps&&(v.__s==v.state&&(v.__s=s({},v.__s)),s(v.__s,P.getDerivedStateFromProps(g,v.__s))),y=v.props,_=v.state,h)null==P.getDerivedStateFromProps&&null!=v.componentWillMount&&v.componentWillMount(),null!=v.componentDidMount&&v.__h.push(v.componentDidMount);else {if(null==P.getDerivedStateFromProps&&g!==y&&null!=v.componentWillReceiveProps&&v.componentWillReceiveProps(g,x),!v.__e&&null!=v.shouldComponentUpdate&&!1===v.shouldComponentUpdate(g,v.__s,x)||u.__v===i.__v){v.props=g,v.state=v.__s,u.__v!==i.__v&&(v.__d=!1),v.__v=u,u.__e=i.__e,u.__k=i.__k,v.__h.length&&f.push(v),T(u,e,l);break n}null!=v.componentWillUpdate&&v.componentWillUpdate(g,v.__s,x),null!=v.componentDidUpdate&&v.__h.push(function(){v.componentDidUpdate(y,_,w);});}v.context=x,v.props=g,v.state=v.__s,(a=n.__r)&&a(u),v.__d=!1,v.__v=u,v.__P=l,a=v.render(v.props,v.state,v.context),v.state=v.__s,null!=v.getChildContext&&(t=s(s({},t),v.getChildContext())),h||null==v.getSnapshotBeforeUpdate||(w=v.getSnapshotBeforeUpdate(y,_)),A=null!=a&&a.type==p&&null==a.key?a.props.children:a,m(l,Array.isArray(A)?A:[A],u,i,t,o,r,f,e,c),v.base=u.__e,u.__h=null,v.__h.length&&f.push(v),k&&(v.__E=v.__=null),v.__e=!1;}else null==r&&u.__v===i.__v?(u.__k=i.__k,u.__e=i.__e):u.__e=H(i.__e,u,i,t,o,r,f,c);(a=n.diffed)&&a(u);}catch(l){u.__v=null,(c||null!=r)&&(u.__e=e,u.__h=!!c,r[r.indexOf(e)]=null),n.__e(l,u,i);}return u.__e}function j(l,u){n.__c&&n.__c(u,l),l.some(function(u){try{l=u.__h,u.__h=[],l.some(function(n){n.call(u);});}catch(l){n.__e(l,u.__v);}});}function H(n,l,u,i,t,o,r,c){var s,a,v,h,y,p=u.props,d=l.props;if(t="svg"===l.type||t,null!=o)for(s=0;s<o.length;s++)if(null!=(a=o[s])&&((null===l.type?3===a.nodeType:a.localName===l.type)||n==a)){n=a,o[s]=null;break}if(null==n){if(null===l.type)return document.createTextNode(d);n=t?document.createElementNS("http://www.w3.org/2000/svg",l.type):document.createElement(l.type,d.is&&{is:d.is}),o=null,c=!1;}if(null===l.type)p===d||c&&n.data===d||(n.data=d);else {if(null!=o&&(o=e.slice.call(n.childNodes)),v=(p=u.props||f).dangerouslySetInnerHTML,h=d.dangerouslySetInnerHTML,!c){if(null!=o)for(p={},y=0;y<n.attributes.length;y++)p[n.attributes[y].name]=n.attributes[y].value;(h||v)&&(h&&(v&&h.__html==v.__html||h.__html===n.innerHTML)||(n.innerHTML=h&&h.__html||""));}A(n,d,p,t,c),h?l.__k=[]:(s=l.props.children,m(n,Array.isArray(s)?s:[s],l,u,i,"foreignObject"!==l.type&&t,o,r,f,c)),c||("value"in d&&void 0!==(s=d.value)&&(s!==n.value||"progress"===l.type&&!s)&&C(n,"value",s,p.value,!1),"checked"in d&&void 0!==(s=d.checked)&&s!==n.checked&&C(n,"checked",s,p.checked,!1));}return n}function I(l,u,i){try{"function"==typeof l?l(u):l.current=u;}catch(l){n.__e(l,i);}}function L(l,u,i){var t,o,r;if(n.unmount&&n.unmount(l),(t=l.ref)&&(t.current&&t.current!==l.__e||I(t,null,u)),i||"function"==typeof l.type||(i=null!=(o=l.__e)),l.__e=l.__d=void 0,null!=(t=l.__c)){if(t.componentWillUnmount)try{t.componentWillUnmount();}catch(l){n.__e(l,u);}t.base=t.__P=null;}if(t=l.__k)for(r=0;r<t.length;r++)t[r]&&L(t[r],u,i);null!=o&&a(o);}function M(n,l,u){return this.constructor(n,u)}function O(l,u,i){var t,r,c;n.__&&n.__(l,u),r=(t=i===o)?null:i&&i.__k||u.__k,l=v(p,null,[l]),c=[],$(u,(t?u:i||u).__k=l,r||f,f,void 0!==u.ownerSVGElement,i&&!t?[i]:r?null:u.childNodes.length?e.slice.call(u.childNodes):null,c,i||f,t),j(c,l);}function B(n,l){var u={__c:l="__cC"+r++,__:n,Consumer:function(n,l){return n.children(l)},Provider:function(n,u,i){return this.getChildContext||(u=[],(i={})[l]=this,this.getChildContext=function(){return i},this.shouldComponentUpdate=function(n){this.props.value!==n.value&&u.some(k);},this.sub=function(n){u.push(n);var l=n.componentWillUnmount;n.componentWillUnmount=function(){u.splice(u.indexOf(n),1),l&&l.call(n);};}),n.children}};return u.Provider.__=u.Consumer.contextType=u}n={__e:function(n,l){for(var u,i,t,o=l.__h;l=l.__;)if((u=l.__c)&&!u.__)try{if((i=u.constructor)&&null!=i.getDerivedStateFromError&&(u.setState(i.getDerivedStateFromError(n)),t=u.__d),null!=u.componentDidCatch&&(u.componentDidCatch(n),t=u.__d),t)return l.__h=o,u.__E=u}catch(l){n=l;}throw n},__v:0},d.prototype.setState=function(n,l){var u;u=null!=this.__s&&this.__s!==this.state?this.__s:this.__s=s({},this.state),"function"==typeof n&&(n=n(s({},u),this.props)),n&&s(u,n),null!=n&&this.__v&&(l&&this.__h.push(l),k(this));},d.prototype.forceUpdate=function(n){this.__v&&(this.__e=!0,n&&this.__h.push(n),k(this));},d.prototype.render=p,u=[],i="function"==typeof Promise?Promise.prototype.then.bind(Promise.resolve()):setTimeout,g.__r=0,o=f,r=0;
+
+    var globalObj = typeof globalThis !== 'undefined' ? globalThis : window; // // TODO: streamline when killing IE11 support
+    if (globalObj.FullCalendarVDom) {
+        console.warn('FullCalendar VDOM already loaded');
+    }
+    else {
+        globalObj.FullCalendarVDom = {
+            Component: d,
+            createElement: v,
+            render: O,
+            createRef: y,
+            Fragment: p,
+            createContext: createContext,
+            flushToDom: flushToDom,
+            unmountComponentAtNode: unmountComponentAtNode,
+        };
+    }
+    // HACKS...
+    // TODO: lock version
+    // TODO: link gh issues
+    function flushToDom() {
+        var oldDebounceRendering = n.debounceRendering; // orig
+        var callbackQ = [];
+        function execCallbackSync(callback) {
+            callbackQ.push(callback);
+        }
+        n.debounceRendering = execCallbackSync;
+        O(v(FakeComponent, {}), document.createElement('div'));
+        while (callbackQ.length) {
+            callbackQ.shift()();
+        }
+        n.debounceRendering = oldDebounceRendering;
+    }
+    var FakeComponent = /** @class */ (function (_super) {
+        __extends(FakeComponent, _super);
+        function FakeComponent() {
+            return _super !== null && _super.apply(this, arguments) || this;
+        }
+        FakeComponent.prototype.render = function () { return v('div', {}); };
+        FakeComponent.prototype.componentDidMount = function () { this.setState({}); };
+        return FakeComponent;
+    }(d));
+    function createContext(defaultValue) {
+        var ContextType = B(defaultValue);
+        var origProvider = ContextType.Provider;
+        ContextType.Provider = function () {
+            var _this = this;
+            var isNew = !this.getChildContext;
+            var children = origProvider.apply(this, arguments); // eslint-disable-line prefer-rest-params
+            if (isNew) {
+                var subs_1 = [];
+                this.shouldComponentUpdate = function (_props) {
+                    if (_this.props.value !== _props.value) {
+                        subs_1.forEach(function (c) {
+                            c.context = _props.value;
+                            c.forceUpdate();
+                        });
+                    }
+                };
+                this.sub = function (c) {
+                    subs_1.push(c);
+                    var old = c.componentWillUnmount;
+                    c.componentWillUnmount = function () {
+                        subs_1.splice(subs_1.indexOf(c), 1);
+                        old && old.call(c);
+                    };
+                };
+            }
+            return children;
+        };
+        return ContextType;
+    }
+    function unmountComponentAtNode(node) {
+        O(null, node);
+    }
+
+    // no public types yet. when there are, export from:
+    // import {} from './api-type-deps'
+    var EventSourceApi = /** @class */ (function () {
+        function EventSourceApi(context, internalEventSource) {
+            this.context = context;
+            this.internalEventSource = internalEventSource;
+        }
+        EventSourceApi.prototype.remove = function () {
+            this.context.dispatch({
+                type: 'REMOVE_EVENT_SOURCE',
+                sourceId: this.internalEventSource.sourceId,
+            });
+        };
+        EventSourceApi.prototype.refetch = function () {
+            this.context.dispatch({
+                type: 'FETCH_EVENT_SOURCES',
+                sourceIds: [this.internalEventSource.sourceId],
+            });
+        };
+        Object.defineProperty(EventSourceApi.prototype, "id", {
+            get: function () {
+                return this.internalEventSource.publicId;
+            },
+            enumerable: false,
+            configurable: true
+        });
+        Object.defineProperty(EventSourceApi.prototype, "url", {
+            get: function () {
+                return this.internalEventSource.meta.url;
+            },
+            enumerable: false,
+            configurable: true
+        });
+        Object.defineProperty(EventSourceApi.prototype, "format", {
+            get: function () {
+                return this.internalEventSource.meta.format; // TODO: bad. not guaranteed
+            },
+            enumerable: false,
+            configurable: true
+        });
+        return EventSourceApi;
+    }());
+
+    function removeElement(el) {
+        if (el.parentNode) {
+            el.parentNode.removeChild(el);
+        }
+    }
+    // Querying
+    // ----------------------------------------------------------------------------------------------------------------
+    function elementClosest(el, selector) {
+        if (el.closest) {
+            return el.closest(selector);
+            // really bad fallback for IE
+            // from https://developer.mozilla.org/en-US/docs/Web/API/Element/closest
+        }
+        if (!document.documentElement.contains(el)) {
+            return null;
+        }
+        do {
+            if (elementMatches(el, selector)) {
+                return el;
+            }
+            el = (el.parentElement || el.parentNode);
+        } while (el !== null && el.nodeType === 1);
+        return null;
+    }
+    function elementMatches(el, selector) {
+        var method = el.matches || el.matchesSelector || el.msMatchesSelector;
+        return method.call(el, selector);
+    }
+    // accepts multiple subject els
+    // returns a real array. good for methods like forEach
+    // TODO: accept the document
+    function findElements(container, selector) {
+        var containers = container instanceof HTMLElement ? [container] : container;
+        var allMatches = [];
+        for (var i = 0; i < containers.length; i += 1) {
+            var matches = containers[i].querySelectorAll(selector);
+            for (var j = 0; j < matches.length; j += 1) {
+                allMatches.push(matches[j]);
+            }
+        }
+        return allMatches;
+    }
+    // accepts multiple subject els
+    // only queries direct child elements // TODO: rename to findDirectChildren!
+    function findDirectChildren(parent, selector) {
+        var parents = parent instanceof HTMLElement ? [parent] : parent;
+        var allMatches = [];
+        for (var i = 0; i < parents.length; i += 1) {
+            var childNodes = parents[i].children; // only ever elements
+            for (var j = 0; j < childNodes.length; j += 1) {
+                var childNode = childNodes[j];
+                if (!selector || elementMatches(childNode, selector)) {
+                    allMatches.push(childNode);
+                }
+            }
+        }
+        return allMatches;
+    }
+    // Style
+    // ----------------------------------------------------------------------------------------------------------------
+    var PIXEL_PROP_RE = /(top|left|right|bottom|width|height)$/i;
+    function applyStyle(el, props) {
+        for (var propName in props) {
+            applyStyleProp(el, propName, props[propName]);
+        }
+    }
+    function applyStyleProp(el, name, val) {
+        if (val == null) {
+            el.style[name] = '';
+        }
+        else if (typeof val === 'number' && PIXEL_PROP_RE.test(name)) {
+            el.style[name] = val + "px";
+        }
+        else {
+            el.style[name] = val;
+        }
+    }
+
+    // Stops a mouse/touch event from doing it's native browser action
+    function preventDefault(ev) {
+        ev.preventDefault();
+    }
+    // Event Delegation
+    // ----------------------------------------------------------------------------------------------------------------
+    function buildDelegationHandler(selector, handler) {
+        return function (ev) {
+            var matchedChild = elementClosest(ev.target, selector);
+            if (matchedChild) {
+                handler.call(matchedChild, ev, matchedChild);
+            }
+        };
+    }
+    function listenBySelector(container, eventType, selector, handler) {
+        var attachedHandler = buildDelegationHandler(selector, handler);
+        container.addEventListener(eventType, attachedHandler);
+        return function () {
+            container.removeEventListener(eventType, attachedHandler);
+        };
+    }
+    function listenToHoverBySelector(container, selector, onMouseEnter, onMouseLeave) {
+        var currentMatchedChild;
+        return listenBySelector(container, 'mouseover', selector, function (mouseOverEv, matchedChild) {
+            if (matchedChild !== currentMatchedChild) {
+                currentMatchedChild = matchedChild;
+                onMouseEnter(mouseOverEv, matchedChild);
+                var realOnMouseLeave_1 = function (mouseLeaveEv) {
+                    currentMatchedChild = null;
+                    onMouseLeave(mouseLeaveEv, matchedChild);
+                    matchedChild.removeEventListener('mouseleave', realOnMouseLeave_1);
+                };
+                // listen to the next mouseleave, and then unattach
+                matchedChild.addEventListener('mouseleave', realOnMouseLeave_1);
+            }
+        });
+    }
+    // Animation
+    // ----------------------------------------------------------------------------------------------------------------
+    var transitionEventNames = [
+        'webkitTransitionEnd',
+        'otransitionend',
+        'oTransitionEnd',
+        'msTransitionEnd',
+        'transitionend',
+    ];
+    // triggered only when the next single subsequent transition finishes
+    function whenTransitionDone(el, callback) {
+        var realCallback = function (ev) {
+            callback(ev);
+            transitionEventNames.forEach(function (eventName) {
+                el.removeEventListener(eventName, realCallback);
+            });
+        };
+        transitionEventNames.forEach(function (eventName) {
+            el.addEventListener(eventName, realCallback); // cross-browser way to determine when the transition finishes
+        });
+    }
+
+    var guidNumber = 0;
+    function guid() {
+        guidNumber += 1;
+        return String(guidNumber);
+    }
+    /* FullCalendar-specific DOM Utilities
+    ----------------------------------------------------------------------------------------------------------------------*/
+    // Make the mouse cursor express that an event is not allowed in the current area
+    function disableCursor() {
+        document.body.classList.add('fc-not-allowed');
+    }
+    // Returns the mouse cursor to its original look
+    function enableCursor() {
+        document.body.classList.remove('fc-not-allowed');
+    }
+    /* Selection
+    ----------------------------------------------------------------------------------------------------------------------*/
+    function preventSelection(el) {
+        el.classList.add('fc-unselectable');
+        el.addEventListener('selectstart', preventDefault);
+    }
+    function allowSelection(el) {
+        el.classList.remove('fc-unselectable');
+        el.removeEventListener('selectstart', preventDefault);
+    }
+    /* Context Menu
+    ----------------------------------------------------------------------------------------------------------------------*/
+    function preventContextMenu(el) {
+        el.addEventListener('contextmenu', preventDefault);
+    }
+    function allowContextMenu(el) {
+        el.removeEventListener('contextmenu', preventDefault);
+    }
+    function parseFieldSpecs(input) {
+        var specs = [];
+        var tokens = [];
+        var i;
+        var token;
+        if (typeof input === 'string') {
+            tokens = input.split(/\s*,\s*/);
+        }
+        else if (typeof input === 'function') {
+            tokens = [input];
+        }
+        else if (Array.isArray(input)) {
+            tokens = input;
+        }
+        for (i = 0; i < tokens.length; i += 1) {
+            token = tokens[i];
+            if (typeof token === 'string') {
+                specs.push(token.charAt(0) === '-' ?
+                    { field: token.substring(1), order: -1 } :
+                    { field: token, order: 1 });
+            }
+            else if (typeof token === 'function') {
+                specs.push({ func: token });
+            }
+        }
+        return specs;
+    }
+    function compareByFieldSpecs(obj0, obj1, fieldSpecs) {
+        var i;
+        var cmp;
+        for (i = 0; i < fieldSpecs.length; i += 1) {
+            cmp = compareByFieldSpec(obj0, obj1, fieldSpecs[i]);
+            if (cmp) {
+                return cmp;
+            }
+        }
+        return 0;
+    }
+    function compareByFieldSpec(obj0, obj1, fieldSpec) {
+        if (fieldSpec.func) {
+            return fieldSpec.func(obj0, obj1);
+        }
+        return flexibleCompare(obj0[fieldSpec.field], obj1[fieldSpec.field])
+            * (fieldSpec.order || 1);
+    }
+    function flexibleCompare(a, b) {
+        if (!a && !b) {
+            return 0;
+        }
+        if (b == null) {
+            return -1;
+        }
+        if (a == null) {
+            return 1;
+        }
+        if (typeof a === 'string' || typeof b === 'string') {
+            return String(a).localeCompare(String(b));
+        }
+        return a - b;
+    }
+    /* String Utilities
+    ----------------------------------------------------------------------------------------------------------------------*/
+    function padStart(val, len) {
+        var s = String(val);
+        return '000'.substr(0, len - s.length) + s;
+    }
+    /* Number Utilities
+    ----------------------------------------------------------------------------------------------------------------------*/
+    function compareNumbers(a, b) {
+        return a - b;
+    }
+    function isInt(n) {
+        return n % 1 === 0;
+    }
+    /* FC-specific DOM dimension stuff
+    ----------------------------------------------------------------------------------------------------------------------*/
+    function computeSmallestCellWidth(cellEl) {
+        var allWidthEl = cellEl.querySelector('.fc-scrollgrid-shrink-frame');
+        var contentWidthEl = cellEl.querySelector('.fc-scrollgrid-shrink-cushion');
+        if (!allWidthEl) {
+            throw new Error('needs fc-scrollgrid-shrink-frame className'); // TODO: use const
+        }
+        if (!contentWidthEl) {
+            throw new Error('needs fc-scrollgrid-shrink-cushion className');
+        }
+        return cellEl.getBoundingClientRect().width - allWidthEl.getBoundingClientRect().width + // the cell padding+border
+            contentWidthEl.getBoundingClientRect().width;
+    }
+
+    var DAY_IDS = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'];
+    // Adding
+    function addWeeks(m, n) {
+        var a = dateToUtcArray(m);
+        a[2] += n * 7;
+        return arrayToUtcDate(a);
+    }
+    function addDays(m, n) {
+        var a = dateToUtcArray(m);
+        a[2] += n;
+        return arrayToUtcDate(a);
+    }
+    function addMs(m, n) {
+        var a = dateToUtcArray(m);
+        a[6] += n;
+        return arrayToUtcDate(a);
+    }
+    // Diffing (all return floats)
+    // TODO: why not use ranges?
+    function diffWeeks(m0, m1) {
+        return diffDays(m0, m1) / 7;
+    }
+    function diffDays(m0, m1) {
+        return (m1.valueOf() - m0.valueOf()) / (1000 * 60 * 60 * 24);
+    }
+    function diffHours(m0, m1) {
+        return (m1.valueOf() - m0.valueOf()) / (1000 * 60 * 60);
+    }
+    function diffMinutes(m0, m1) {
+        return (m1.valueOf() - m0.valueOf()) / (1000 * 60);
+    }
+    function diffSeconds(m0, m1) {
+        return (m1.valueOf() - m0.valueOf()) / 1000;
+    }
+    function diffDayAndTime(m0, m1) {
+        var m0day = startOfDay(m0);
+        var m1day = startOfDay(m1);
+        return {
+            years: 0,
+            months: 0,
+            days: Math.round(diffDays(m0day, m1day)),
+            milliseconds: (m1.valueOf() - m1day.valueOf()) - (m0.valueOf() - m0day.valueOf()),
+        };
+    }
+    // Diffing Whole Units
+    function diffWholeWeeks(m0, m1) {
+        var d = diffWholeDays(m0, m1);
+        if (d !== null && d % 7 === 0) {
+            return d / 7;
+        }
+        return null;
+    }
+    function diffWholeDays(m0, m1) {
+        if (timeAsMs(m0) === timeAsMs(m1)) {
+            return Math.round(diffDays(m0, m1));
+        }
+        return null;
+    }
+    // Start-Of
+    function startOfDay(m) {
+        return arrayToUtcDate([
+            m.getUTCFullYear(),
+            m.getUTCMonth(),
+            m.getUTCDate(),
+        ]);
+    }
+    function startOfHour(m) {
+        return arrayToUtcDate([
+            m.getUTCFullYear(),
+            m.getUTCMonth(),
+            m.getUTCDate(),
+            m.getUTCHours(),
+        ]);
+    }
+    function startOfMinute(m) {
+        return arrayToUtcDate([
+            m.getUTCFullYear(),
+            m.getUTCMonth(),
+            m.getUTCDate(),
+            m.getUTCHours(),
+            m.getUTCMinutes(),
+        ]);
+    }
+    function startOfSecond(m) {
+        return arrayToUtcDate([
+            m.getUTCFullYear(),
+            m.getUTCMonth(),
+            m.getUTCDate(),
+            m.getUTCHours(),
+            m.getUTCMinutes(),
+            m.getUTCSeconds(),
+        ]);
+    }
+    // Week Computation
+    function weekOfYear(marker, dow, doy) {
+        var y = marker.getUTCFullYear();
+        var w = weekOfGivenYear(marker, y, dow, doy);
+        if (w < 1) {
+            return weekOfGivenYear(marker, y - 1, dow, doy);
+        }
+        var nextW = weekOfGivenYear(marker, y + 1, dow, doy);
+        if (nextW >= 1) {
+            return Math.min(w, nextW);
+        }
+        return w;
+    }
+    function weekOfGivenYear(marker, year, dow, doy) {
+        var firstWeekStart = arrayToUtcDate([year, 0, 1 + firstWeekOffset(year, dow, doy)]);
+        var dayStart = startOfDay(marker);
+        var days = Math.round(diffDays(firstWeekStart, dayStart));
+        return Math.floor(days / 7) + 1; // zero-indexed
+    }
+    // start-of-first-week - start-of-year
+    function firstWeekOffset(year, dow, doy) {
+        // first-week day -- which january is always in the first week (4 for iso, 1 for other)
+        var fwd = 7 + dow - doy;
+        // first-week day local weekday -- which local weekday is fwd
+        var fwdlw = (7 + arrayToUtcDate([year, 0, fwd]).getUTCDay() - dow) % 7;
+        return -fwdlw + fwd - 1;
+    }
+    // Array Conversion
+    function dateToLocalArray(date) {
+        return [
+            date.getFullYear(),
+            date.getMonth(),
+            date.getDate(),
+            date.getHours(),
+            date.getMinutes(),
+            date.getSeconds(),
+            date.getMilliseconds(),
+        ];
+    }
+    function arrayToLocalDate(a) {
+        return new Date(a[0], a[1] || 0, a[2] == null ? 1 : a[2], // day of month
+        a[3] || 0, a[4] || 0, a[5] || 0);
+    }
+    function dateToUtcArray(date) {
+        return [
+            date.getUTCFullYear(),
+            date.getUTCMonth(),
+            date.getUTCDate(),
+            date.getUTCHours(),
+            date.getUTCMinutes(),
+            date.getUTCSeconds(),
+            date.getUTCMilliseconds(),
+        ];
+    }
+    function arrayToUtcDate(a) {
+        // according to web standards (and Safari), a month index is required.
+        // massage if only given a year.
+        if (a.length === 1) {
+            a = a.concat([0]);
+        }
+        return new Date(Date.UTC.apply(Date, a));
+    }
+    // Other Utils
+    function isValidDate(m) {
+        return !isNaN(m.valueOf());
+    }
+    function timeAsMs(m) {
+        return m.getUTCHours() * 1000 * 60 * 60 +
+            m.getUTCMinutes() * 1000 * 60 +
+            m.getUTCSeconds() * 1000 +
+            m.getUTCMilliseconds();
+    }
+
+    function createEventInstance(defId, range, forcedStartTzo, forcedEndTzo) {
+        return {
+            instanceId: guid(),
+            defId: defId,
+            range: range,
+            forcedStartTzo: forcedStartTzo == null ? null : forcedStartTzo,
+            forcedEndTzo: forcedEndTzo == null ? null : forcedEndTzo,
+        };
+    }
+
+    var hasOwnProperty = Object.prototype.hasOwnProperty;
+    // Merges an array of objects into a single object.
+    // The second argument allows for an array of property names who's object values will be merged together.
+    function mergeProps(propObjs, complexPropsMap) {
+        var dest = {};
+        if (complexPropsMap) {
+            for (var name_1 in complexPropsMap) {
+                var complexObjs = [];
+                // collect the trailing object values, stopping when a non-object is discovered
+                for (var i = propObjs.length - 1; i >= 0; i -= 1) {
+                    var val = propObjs[i][name_1];
+                    if (typeof val === 'object' && val) { // non-null object
+                        complexObjs.unshift(val);
+                    }
+                    else if (val !== undefined) {
+                        dest[name_1] = val; // if there were no objects, this value will be used
+                        break;
+                    }
+                }
+                // if the trailing values were objects, use the merged value
+                if (complexObjs.length) {
+                    dest[name_1] = mergeProps(complexObjs);
+                }
+            }
+        }
+        // copy values into the destination, going from last to first
+        for (var i = propObjs.length - 1; i >= 0; i -= 1) {
+            var props = propObjs[i];
+            for (var name_2 in props) {
+                if (!(name_2 in dest)) { // if already assigned by previous props or complex props, don't reassign
+                    dest[name_2] = props[name_2];
+                }
+            }
+        }
+        return dest;
+    }
+    function filterHash(hash, func) {
+        var filtered = {};
+        for (var key in hash) {
+            if (func(hash[key], key)) {
+                filtered[key] = hash[key];
+            }
+        }
+        return filtered;
+    }
+    function mapHash(hash, func) {
+        var newHash = {};
+        for (var key in hash) {
+            newHash[key] = func(hash[key], key);
+        }
+        return newHash;
+    }
+    function arrayToHash(a) {
+        var hash = {};
+        for (var _i = 0, a_1 = a; _i < a_1.length; _i++) {
+            var item = a_1[_i];
+            hash[item] = true;
+        }
+        return hash;
+    }
+    function buildHashFromArray(a, func) {
+        var hash = {};
+        for (var i = 0; i < a.length; i += 1) {
+            var tuple = func(a[i], i);
+            hash[tuple[0]] = tuple[1];
+        }
+        return hash;
+    }
+    function hashValuesToArray(obj) {
+        var a = [];
+        for (var key in obj) {
+            a.push(obj[key]);
+        }
+        return a;
+    }
+    function isPropsEqual(obj0, obj1) {
+        if (obj0 === obj1) {
+            return true;
+        }
+        for (var key in obj0) {
+            if (hasOwnProperty.call(obj0, key)) {
+                if (!(key in obj1)) {
+                    return false;
+                }
+            }
+        }
+        for (var key in obj1) {
+            if (hasOwnProperty.call(obj1, key)) {
+                if (obj0[key] !== obj1[key]) {
+                    return false;
+                }
+            }
+        }
+        return true;
+    }
+    function getUnequalProps(obj0, obj1) {
+        var keys = [];
+        for (var key in obj0) {
+            if (hasOwnProperty.call(obj0, key)) {
+                if (!(key in obj1)) {
+                    keys.push(key);
+                }
+            }
+        }
+        for (var key in obj1) {
+            if (hasOwnProperty.call(obj1, key)) {
+                if (obj0[key] !== obj1[key]) {
+                    keys.push(key);
+                }
+            }
+        }
+        return keys;
+    }
+    function compareObjs(oldProps, newProps, equalityFuncs) {
+        if (equalityFuncs === void 0) { equalityFuncs = {}; }
+        if (oldProps === newProps) {
+            return true;
+        }
+        for (var key in newProps) {
+            if (key in oldProps && isObjValsEqual(oldProps[key], newProps[key], equalityFuncs[key])) ;
+            else {
+                return false;
+            }
+        }
+        // check for props that were omitted in the new
+        for (var key in oldProps) {
+            if (!(key in newProps)) {
+                return false;
+            }
+        }
+        return true;
+    }
+    /*
+    assumed "true" equality for handler names like "onReceiveSomething"
+    */
+    function isObjValsEqual(val0, val1, comparator) {
+        if (val0 === val1 || comparator === true) {
+            return true;
+        }
+        if (comparator) {
+            return comparator(val0, val1);
+        }
+        return false;
+    }
+    function collectFromHash(hash, startIndex, endIndex, step) {
+        if (startIndex === void 0) { startIndex = 0; }
+        if (step === void 0) { step = 1; }
+        var res = [];
+        if (endIndex == null) {
+            endIndex = Object.keys(hash).length;
+        }
+        for (var i = startIndex; i < endIndex; i += step) {
+            var val = hash[i];
+            if (val !== undefined) { // will disregard undefined for sparse arrays
+                res.push(val);
+            }
+        }
+        return res;
+    }
+
+    function parseRecurring(refined, defaultAllDay, dateEnv, recurringTypes) {
+        for (var i = 0; i < recurringTypes.length; i += 1) {
+            var parsed = recurringTypes[i].parse(refined, dateEnv);
+            if (parsed) {
+                var allDay = refined.allDay;
+                if (allDay == null) {
+                    allDay = defaultAllDay;
+                    if (allDay == null) {
+                        allDay = parsed.allDayGuess;
+                        if (allDay == null) {
+                            allDay = false;
+                        }
+                    }
+                }
+                return {
+                    allDay: allDay,
+                    duration: parsed.duration,
+                    typeData: parsed.typeData,
+                    typeId: i,
+                };
+            }
+        }
+        return null;
+    }
+    function expandRecurring(eventStore, framingRange, context) {
+        var dateEnv = context.dateEnv, pluginHooks = context.pluginHooks, options = context.options;
+        var defs = eventStore.defs, instances = eventStore.instances;
+        // remove existing recurring instances
+        // TODO: bad. always expand events as a second step
+        instances = filterHash(instances, function (instance) { return !defs[instance.defId].recurringDef; });
+        for (var defId in defs) {
+            var def = defs[defId];
+            if (def.recurringDef) {
+                var duration = def.recurringDef.duration;
+                if (!duration) {
+                    duration = def.allDay ?
+                        options.defaultAllDayEventDuration :
+                        options.defaultTimedEventDuration;
+                }
+                var starts = expandRecurringRanges(def, duration, framingRange, dateEnv, pluginHooks.recurringTypes);
+                for (var _i = 0, starts_1 = starts; _i < starts_1.length; _i++) {
+                    var start = starts_1[_i];
+                    var instance = createEventInstance(defId, {
+                        start: start,
+                        end: dateEnv.add(start, duration),
+                    });
+                    instances[instance.instanceId] = instance;
+                }
+            }
+        }
+        return { defs: defs, instances: instances };
+    }
+    /*
+    Event MUST have a recurringDef
+    */
+    function expandRecurringRanges(eventDef, duration, framingRange, dateEnv, recurringTypes) {
+        var typeDef = recurringTypes[eventDef.recurringDef.typeId];
+        var markers = typeDef.expand(eventDef.recurringDef.typeData, {
+            start: dateEnv.subtract(framingRange.start, duration),
+            end: framingRange.end,
+        }, dateEnv);
+        // the recurrence plugins don't guarantee that all-day events are start-of-day, so we have to
+        if (eventDef.allDay) {
+            markers = markers.map(startOfDay);
+        }
+        return markers;
+    }
+
+    var INTERNAL_UNITS = ['years', 'months', 'days', 'milliseconds'];
+    var PARSE_RE = /^(-?)(?:(\d+)\.)?(\d+):(\d\d)(?::(\d\d)(?:\.(\d\d\d))?)?/;
+    // Parsing and Creation
+    function createDuration(input, unit) {
+        var _a;
+        if (typeof input === 'string') {
+            return parseString(input);
+        }
+        if (typeof input === 'object' && input) { // non-null object
+            return parseObject(input);
+        }
+        if (typeof input === 'number') {
+            return parseObject((_a = {}, _a[unit || 'milliseconds'] = input, _a));
+        }
+        return null;
+    }
+    function parseString(s) {
+        var m = PARSE_RE.exec(s);
+        if (m) {
+            var sign = m[1] ? -1 : 1;
+            return {
+                years: 0,
+                months: 0,
+                days: sign * (m[2] ? parseInt(m[2], 10) : 0),
+                milliseconds: sign * ((m[3] ? parseInt(m[3], 10) : 0) * 60 * 60 * 1000 + // hours
+                    (m[4] ? parseInt(m[4], 10) : 0) * 60 * 1000 + // minutes
+                    (m[5] ? parseInt(m[5], 10) : 0) * 1000 + // seconds
+                    (m[6] ? parseInt(m[6], 10) : 0) // ms
+                ),
+            };
+        }
+        return null;
+    }
+    function parseObject(obj) {
+        var duration = {
+            years: obj.years || obj.year || 0,
+            months: obj.months || obj.month || 0,
+            days: obj.days || obj.day || 0,
+            milliseconds: (obj.hours || obj.hour || 0) * 60 * 60 * 1000 + // hours
+                (obj.minutes || obj.minute || 0) * 60 * 1000 + // minutes
+                (obj.seconds || obj.second || 0) * 1000 + // seconds
+                (obj.milliseconds || obj.millisecond || obj.ms || 0),
+        };
+        var weeks = obj.weeks || obj.week;
+        if (weeks) {
+            duration.days += weeks * 7;
+            duration.specifiedWeeks = true;
+        }
+        return duration;
+    }
+    // Equality
+    function durationsEqual(d0, d1) {
+        return d0.years === d1.years &&
+            d0.months === d1.months &&
+            d0.days === d1.days &&
+            d0.milliseconds === d1.milliseconds;
+    }
+    function asCleanDays(dur) {
+        if (!dur.years && !dur.months && !dur.milliseconds) {
+            return dur.days;
+        }
+        return 0;
+    }
+    // Simple Math
+    function addDurations(d0, d1) {
+        return {
+            years: d0.years + d1.years,
+            months: d0.months + d1.months,
+            days: d0.days + d1.days,
+            milliseconds: d0.milliseconds + d1.milliseconds,
+        };
+    }
+    function subtractDurations(d1, d0) {
+        return {
+            years: d1.years - d0.years,
+            months: d1.months - d0.months,
+            days: d1.days - d0.days,
+            milliseconds: d1.milliseconds - d0.milliseconds,
+        };
+    }
+    function multiplyDuration(d, n) {
+        return {
+            years: d.years * n,
+            months: d.months * n,
+            days: d.days * n,
+            milliseconds: d.milliseconds * n,
+        };
+    }
+    // Conversions
+    // "Rough" because they are based on average-case Gregorian months/years
+    function asRoughYears(dur) {
+        return asRoughDays(dur) / 365;
+    }
+    function asRoughMonths(dur) {
+        return asRoughDays(dur) / 30;
+    }
+    function asRoughDays(dur) {
+        return asRoughMs(dur) / 864e5;
+    }
+    function asRoughMinutes(dur) {
+        return asRoughMs(dur) / (1000 * 60);
+    }
+    function asRoughSeconds(dur) {
+        return asRoughMs(dur) / 1000;
+    }
+    function asRoughMs(dur) {
+        return dur.years * (365 * 864e5) +
+            dur.months * (30 * 864e5) +
+            dur.days * 864e5 +
+            dur.milliseconds;
+    }
+    // Advanced Math
+    function wholeDivideDurations(numerator, denominator) {
+        var res = null;
+        for (var i = 0; i < INTERNAL_UNITS.length; i += 1) {
+            var unit = INTERNAL_UNITS[i];
+            if (denominator[unit]) {
+                var localRes = numerator[unit] / denominator[unit];
+                if (!isInt(localRes) || (res !== null && res !== localRes)) {
+                    return null;
+                }
+                res = localRes;
+            }
+            else if (numerator[unit]) {
+                // needs to divide by something but can't!
+                return null;
+            }
+        }
+        return res;
+    }
+    function greatestDurationDenominator(dur) {
+        var ms = dur.milliseconds;
+        if (ms) {
+            if (ms % 1000 !== 0) {
+                return { unit: 'millisecond', value: ms };
+            }
+            if (ms % (1000 * 60) !== 0) {
+                return { unit: 'second', value: ms / 1000 };
+            }
+            if (ms % (1000 * 60 * 60) !== 0) {
+                return { unit: 'minute', value: ms / (1000 * 60) };
+            }
+            if (ms) {
+                return { unit: 'hour', value: ms / (1000 * 60 * 60) };
+            }
+        }
+        if (dur.days) {
+            if (dur.specifiedWeeks && dur.days % 7 === 0) {
+                return { unit: 'week', value: dur.days / 7 };
+            }
+            return { unit: 'day', value: dur.days };
+        }
+        if (dur.months) {
+            return { unit: 'month', value: dur.months };
+        }
+        if (dur.years) {
+            return { unit: 'year', value: dur.years };
+        }
+        return { unit: 'millisecond', value: 0 };
+    }
+
+    // timeZoneOffset is in minutes
+    function buildIsoString(marker, timeZoneOffset, stripZeroTime) {
+        if (stripZeroTime === void 0) { stripZeroTime = false; }
+        var s = marker.toISOString();
+        s = s.replace('.000', '');
+        if (stripZeroTime) {
+            s = s.replace('T00:00:00Z', '');
+        }
+        if (s.length > 10) { // time part wasn't stripped, can add timezone info
+            if (timeZoneOffset == null) {
+                s = s.replace('Z', '');
+            }
+            else if (timeZoneOffset !== 0) {
+                s = s.replace('Z', formatTimeZoneOffset(timeZoneOffset, true));
+            }
+            // otherwise, its UTC-0 and we want to keep the Z
+        }
+        return s;
+    }
+    // formats the date, but with no time part
+    // TODO: somehow merge with buildIsoString and stripZeroTime
+    // TODO: rename. omit "string"
+    function formatDayString(marker) {
+        return marker.toISOString().replace(/T.*$/, '');
+    }
+    // TODO: use Date::toISOString and use everything after the T?
+    function formatIsoTimeString(marker) {
+        return padStart(marker.getUTCHours(), 2) + ':' +
+            padStart(marker.getUTCMinutes(), 2) + ':' +
+            padStart(marker.getUTCSeconds(), 2);
+    }
+    function formatTimeZoneOffset(minutes, doIso) {
+        if (doIso === void 0) { doIso = false; }
+        var sign = minutes < 0 ? '-' : '+';
+        var abs = Math.abs(minutes);
+        var hours = Math.floor(abs / 60);
+        var mins = Math.round(abs % 60);
+        if (doIso) {
+            return sign + padStart(hours, 2) + ":" + padStart(mins, 2);
+        }
+        return "GMT" + sign + hours + (mins ? ":" + padStart(mins, 2) : '');
+    }
+
+    // TODO: new util arrayify?
+    function removeExact(array, exactVal) {
+        var removeCnt = 0;
+        var i = 0;
+        while (i < array.length) {
+            if (array[i] === exactVal) {
+                array.splice(i, 1);
+                removeCnt += 1;
+            }
+            else {
+                i += 1;
+            }
+        }
+        return removeCnt;
+    }
+    function isArraysEqual(a0, a1, equalityFunc) {
+        if (a0 === a1) {
+            return true;
+        }
+        var len = a0.length;
+        var i;
+        if (len !== a1.length) { // not array? or not same length?
+            return false;
+        }
+        for (i = 0; i < len; i += 1) {
+            if (!(equalityFunc ? equalityFunc(a0[i], a1[i]) : a0[i] === a1[i])) {
+                return false;
+            }
+        }
+        return true;
+    }
+
+    function memoize(workerFunc, resEquality, teardownFunc) {
+        var currentArgs;
+        var currentRes;
+        return function () {
+            var newArgs = [];
+            for (var _i = 0; _i < arguments.length; _i++) {
+                newArgs[_i] = arguments[_i];
+            }
+            if (!currentArgs) {
+                currentRes = workerFunc.apply(this, newArgs);
+            }
+            else if (!isArraysEqual(currentArgs, newArgs)) {
+                if (teardownFunc) {
+                    teardownFunc(currentRes);
+                }
+                var res = workerFunc.apply(this, newArgs);
+                if (!resEquality || !resEquality(res, currentRes)) {
+                    currentRes = res;
+                }
+            }
+            currentArgs = newArgs;
+            return currentRes;
+        };
+    }
+    function memoizeObjArg(workerFunc, resEquality, teardownFunc) {
+        var _this = this;
+        var currentArg;
+        var currentRes;
+        return function (newArg) {
+            if (!currentArg) {
+                currentRes = workerFunc.call(_this, newArg);
+            }
+            else if (!isPropsEqual(currentArg, newArg)) {
+                if (teardownFunc) {
+                    teardownFunc(currentRes);
+                }
+                var res = workerFunc.call(_this, newArg);
+                if (!resEquality || !resEquality(res, currentRes)) {
+                    currentRes = res;
+                }
+            }
+            currentArg = newArg;
+            return currentRes;
+        };
+    }
+    function memoizeArraylike(// used at all?
+    workerFunc, resEquality, teardownFunc) {
+        var _this = this;
+        var currentArgSets = [];
+        var currentResults = [];
+        return function (newArgSets) {
+            var currentLen = currentArgSets.length;
+            var newLen = newArgSets.length;
+            var i = 0;
+            for (; i < currentLen; i += 1) {
+                if (!newArgSets[i]) { // one of the old sets no longer exists
+                    if (teardownFunc) {
+                        teardownFunc(currentResults[i]);
+                    }
+                }
+                else if (!isArraysEqual(currentArgSets[i], newArgSets[i])) {
+                    if (teardownFunc) {
+                        teardownFunc(currentResults[i]);
+                    }
+                    var res = workerFunc.apply(_this, newArgSets[i]);
+                    if (!resEquality || !resEquality(res, currentResults[i])) {
+                        currentResults[i] = res;
+                    }
+                }
+            }
+            for (; i < newLen; i += 1) {
+                currentResults[i] = workerFunc.apply(_this, newArgSets[i]);
+            }
+            currentArgSets = newArgSets;
+            currentResults.splice(newLen); // remove excess
+            return currentResults;
+        };
+    }
+    function memoizeHashlike(// used?
+    workerFunc, resEquality, teardownFunc) {
+        var _this = this;
+        var currentArgHash = {};
+        var currentResHash = {};
+        return function (newArgHash) {
+            var newResHash = {};
+            for (var key in newArgHash) {
+                if (!currentResHash[key]) {
+                    newResHash[key] = workerFunc.apply(_this, newArgHash[key]);
+                }
+                else if (!isArraysEqual(currentArgHash[key], newArgHash[key])) {
+                    if (teardownFunc) {
+                        teardownFunc(currentResHash[key]);
+                    }
+                    var res = workerFunc.apply(_this, newArgHash[key]);
+                    newResHash[key] = (resEquality && resEquality(res, currentResHash[key]))
+                        ? currentResHash[key]
+                        : res;
+                }
+                else {
+                    newResHash[key] = currentResHash[key];
+                }
+            }
+            currentArgHash = newArgHash;
+            currentResHash = newResHash;
+            return newResHash;
+        };
+    }
+
+    var EXTENDED_SETTINGS_AND_SEVERITIES = {
+        week: 3,
+        separator: 0,
+        omitZeroMinute: 0,
+        meridiem: 0,
+        omitCommas: 0,
+    };
+    var STANDARD_DATE_PROP_SEVERITIES = {
+        timeZoneName: 7,
+        era: 6,
+        year: 5,
+        month: 4,
+        day: 2,
+        weekday: 2,
+        hour: 1,
+        minute: 1,
+        second: 1,
+    };
+    var MERIDIEM_RE = /\s*([ap])\.?m\.?/i; // eats up leading spaces too
+    var COMMA_RE = /,/g; // we need re for globalness
+    var MULTI_SPACE_RE = /\s+/g;
+    var LTR_RE = /\u200e/g; // control character
+    var UTC_RE = /UTC|GMT/;
+    var NativeFormatter = /** @class */ (function () {
+        function NativeFormatter(formatSettings) {
+            var standardDateProps = {};
+            var extendedSettings = {};
+            var severity = 0;
+            for (var name_1 in formatSettings) {
+                if (name_1 in EXTENDED_SETTINGS_AND_SEVERITIES) {
+                    extendedSettings[name_1] = formatSettings[name_1];
+                    severity = Math.max(EXTENDED_SETTINGS_AND_SEVERITIES[name_1], severity);
+                }
+                else {
+                    standardDateProps[name_1] = formatSettings[name_1];
+                    if (name_1 in STANDARD_DATE_PROP_SEVERITIES) { // TODO: what about hour12? no severity
+                        severity = Math.max(STANDARD_DATE_PROP_SEVERITIES[name_1], severity);
+                    }
+                }
+            }
+            this.standardDateProps = standardDateProps;
+            this.extendedSettings = extendedSettings;
+            this.severity = severity;
+            this.buildFormattingFunc = memoize(buildFormattingFunc);
+        }
+        NativeFormatter.prototype.format = function (date, context) {
+            return this.buildFormattingFunc(this.standardDateProps, this.extendedSettings, context)(date);
+        };
+        NativeFormatter.prototype.formatRange = function (start, end, context, betterDefaultSeparator) {
+            var _a = this, standardDateProps = _a.standardDateProps, extendedSettings = _a.extendedSettings;
+            var diffSeverity = computeMarkerDiffSeverity(start.marker, end.marker, context.calendarSystem);
+            if (!diffSeverity) {
+                return this.format(start, context);
+            }
+            var biggestUnitForPartial = diffSeverity;
+            if (biggestUnitForPartial > 1 && // the two dates are different in a way that's larger scale than time
+                (standardDateProps.year === 'numeric' || standardDateProps.year === '2-digit') &&
+                (standardDateProps.month === 'numeric' || standardDateProps.month === '2-digit') &&
+                (standardDateProps.day === 'numeric' || standardDateProps.day === '2-digit')) {
+                biggestUnitForPartial = 1; // make it look like the dates are only different in terms of time
+            }
+            var full0 = this.format(start, context);
+            var full1 = this.format(end, context);
+            if (full0 === full1) {
+                return full0;
+            }
+            var partialDateProps = computePartialFormattingOptions(standardDateProps, biggestUnitForPartial);
+            var partialFormattingFunc = buildFormattingFunc(partialDateProps, extendedSettings, context);
+            var partial0 = partialFormattingFunc(start);
+            var partial1 = partialFormattingFunc(end);
+            var insertion = findCommonInsertion(full0, partial0, full1, partial1);
+            var separator = extendedSettings.separator || betterDefaultSeparator || context.defaultSeparator || '';
+            if (insertion) {
+                return insertion.before + partial0 + separator + partial1 + insertion.after;
+            }
+            return full0 + separator + full1;
+        };
+        NativeFormatter.prototype.getLargestUnit = function () {
+            switch (this.severity) {
+                case 7:
+                case 6:
+                case 5:
+                    return 'year';
+                case 4:
+                    return 'month';
+                case 3:
+                    return 'week';
+                case 2:
+                    return 'day';
+                default:
+                    return 'time'; // really?
+            }
+        };
+        return NativeFormatter;
+    }());
+    function buildFormattingFunc(standardDateProps, extendedSettings, context) {
+        var standardDatePropCnt = Object.keys(standardDateProps).length;
+        if (standardDatePropCnt === 1 && standardDateProps.timeZoneName === 'short') {
+            return function (date) { return (formatTimeZoneOffset(date.timeZoneOffset)); };
+        }
+        if (standardDatePropCnt === 0 && extendedSettings.week) {
+            return function (date) { return (formatWeekNumber(context.computeWeekNumber(date.marker), context.weekText, context.locale, extendedSettings.week)); };
+        }
+        return buildNativeFormattingFunc(standardDateProps, extendedSettings, context);
+    }
+    function buildNativeFormattingFunc(standardDateProps, extendedSettings, context) {
+        standardDateProps = __assign({}, standardDateProps); // copy
+        extendedSettings = __assign({}, extendedSettings); // copy
+        sanitizeSettings(standardDateProps, extendedSettings);
+        standardDateProps.timeZone = 'UTC'; // we leverage the only guaranteed timeZone for our UTC markers
+        var normalFormat = new Intl.DateTimeFormat(context.locale.codes, standardDateProps);
+        var zeroFormat; // needed?
+        if (extendedSettings.omitZeroMinute) {
+            var zeroProps = __assign({}, standardDateProps);
+            delete zeroProps.minute; // seconds and ms were already considered in sanitizeSettings
+            zeroFormat = new Intl.DateTimeFormat(context.locale.codes, zeroProps);
+        }
+        return function (date) {
+            var marker = date.marker;
+            var format;
+            if (zeroFormat && !marker.getUTCMinutes()) {
+                format = zeroFormat;
+            }
+            else {
+                format = normalFormat;
+            }
+            var s = format.format(marker);
+            return postProcess(s, date, standardDateProps, extendedSettings, context);
+        };
+    }
+    function sanitizeSettings(standardDateProps, extendedSettings) {
+        // deal with a browser inconsistency where formatting the timezone
+        // requires that the hour/minute be present.
+        if (standardDateProps.timeZoneName) {
+            if (!standardDateProps.hour) {
+                standardDateProps.hour = '2-digit';
+            }
+            if (!standardDateProps.minute) {
+                standardDateProps.minute = '2-digit';
+            }
+        }
+        // only support short timezone names
+        if (standardDateProps.timeZoneName === 'long') {
+            standardDateProps.timeZoneName = 'short';
+        }
+        // if requesting to display seconds, MUST display minutes
+        if (extendedSettings.omitZeroMinute && (standardDateProps.second || standardDateProps.millisecond)) {
+            delete extendedSettings.omitZeroMinute;
+        }
+    }
+    function postProcess(s, date, standardDateProps, extendedSettings, context) {
+        s = s.replace(LTR_RE, ''); // remove left-to-right control chars. do first. good for other regexes
+        if (standardDateProps.timeZoneName === 'short') {
+            s = injectTzoStr(s, (context.timeZone === 'UTC' || date.timeZoneOffset == null) ?
+                'UTC' : // important to normalize for IE, which does "GMT"
+                formatTimeZoneOffset(date.timeZoneOffset));
+        }
+        if (extendedSettings.omitCommas) {
+            s = s.replace(COMMA_RE, '').trim();
+        }
+        if (extendedSettings.omitZeroMinute) {
+            s = s.replace(':00', ''); // zeroFormat doesn't always achieve this
+        }
+        // ^ do anything that might create adjacent spaces before this point,
+        // because MERIDIEM_RE likes to eat up loading spaces
+        if (extendedSettings.meridiem === false) {
+            s = s.replace(MERIDIEM_RE, '').trim();
+        }
+        else if (extendedSettings.meridiem === 'narrow') { // a/p
+            s = s.replace(MERIDIEM_RE, function (m0, m1) { return m1.toLocaleLowerCase(); });
+        }
+        else if (extendedSettings.meridiem === 'short') { // am/pm
+            s = s.replace(MERIDIEM_RE, function (m0, m1) { return m1.toLocaleLowerCase() + "m"; });
+        }
+        else if (extendedSettings.meridiem === 'lowercase') { // other meridiem transformers already converted to lowercase
+            s = s.replace(MERIDIEM_RE, function (m0) { return m0.toLocaleLowerCase(); });
+        }
+        s = s.replace(MULTI_SPACE_RE, ' ');
+        s = s.trim();
+        return s;
+    }
+    function injectTzoStr(s, tzoStr) {
+        var replaced = false;
+        s = s.replace(UTC_RE, function () {
+            replaced = true;
+            return tzoStr;
+        });
+        // IE11 doesn't include UTC/GMT in the original string, so append to end
+        if (!replaced) {
+            s += " " + tzoStr;
+        }
+        return s;
+    }
+    function formatWeekNumber(num, weekText, locale, display) {
+        var parts = [];
+        if (display === 'narrow') {
+            parts.push(weekText);
+        }
+        else if (display === 'short') {
+            parts.push(weekText, ' ');
+        }
+        // otherwise, considered 'numeric'
+        parts.push(locale.simpleNumberFormat.format(num));
+        if (locale.options.direction === 'rtl') { // TODO: use control characters instead?
+            parts.reverse();
+        }
+        return parts.join('');
+    }
+    // Range Formatting Utils
+    // 0 = exactly the same
+    // 1 = different by time
+    // and bigger
+    function computeMarkerDiffSeverity(d0, d1, ca) {
+        if (ca.getMarkerYear(d0) !== ca.getMarkerYear(d1)) {
+            return 5;
+        }
+        if (ca.getMarkerMonth(d0) !== ca.getMarkerMonth(d1)) {
+            return 4;
+        }
+        if (ca.getMarkerDay(d0) !== ca.getMarkerDay(d1)) {
+            return 2;
+        }
+        if (timeAsMs(d0) !== timeAsMs(d1)) {
+            return 1;
+        }
+        return 0;
+    }
+    function computePartialFormattingOptions(options, biggestUnit) {
+        var partialOptions = {};
+        for (var name_2 in options) {
+            if (!(name_2 in STANDARD_DATE_PROP_SEVERITIES) || // not a date part prop (like timeZone)
+                STANDARD_DATE_PROP_SEVERITIES[name_2] <= biggestUnit) {
+                partialOptions[name_2] = options[name_2];
+            }
+        }
+        return partialOptions;
+    }
+    function findCommonInsertion(full0, partial0, full1, partial1) {
+        var i0 = 0;
+        while (i0 < full0.length) {
+            var found0 = full0.indexOf(partial0, i0);
+            if (found0 === -1) {
+                break;
+            }
+            var before0 = full0.substr(0, found0);
+            i0 = found0 + partial0.length;
+            var after0 = full0.substr(i0);
+            var i1 = 0;
+            while (i1 < full1.length) {
+                var found1 = full1.indexOf(partial1, i1);
+                if (found1 === -1) {
+                    break;
+                }
+                var before1 = full1.substr(0, found1);
+                i1 = found1 + partial1.length;
+                var after1 = full1.substr(i1);
+                if (before0 === before1 && after0 === after1) {
+                    return {
+                        before: before0,
+                        after: after0,
+                    };
+                }
+            }
+        }
+        return null;
+    }
+
+    function expandZonedMarker(dateInfo, calendarSystem) {
+        var a = calendarSystem.markerToArray(dateInfo.marker);
+        return {
+            marker: dateInfo.marker,
+            timeZoneOffset: dateInfo.timeZoneOffset,
+            array: a,
+            year: a[0],
+            month: a[1],
+            day: a[2],
+            hour: a[3],
+            minute: a[4],
+            second: a[5],
+            millisecond: a[6],
+        };
+    }
+
+    function createVerboseFormattingArg(start, end, context, betterDefaultSeparator) {
+        var startInfo = expandZonedMarker(start, context.calendarSystem);
+        var endInfo = end ? expandZonedMarker(end, context.calendarSystem) : null;
+        return {
+            date: startInfo,
+            start: startInfo,
+            end: endInfo,
+            timeZone: context.timeZone,
+            localeCodes: context.locale.codes,
+            defaultSeparator: betterDefaultSeparator || context.defaultSeparator,
+        };
+    }
+
+    /*
+    TODO: fix the terminology of "formatter" vs "formatting func"
+    */
+    /*
+    At the time of instantiation, this object does not know which cmd-formatting system it will use.
+    It receives this at the time of formatting, as a setting.
+    */
+    var CmdFormatter = /** @class */ (function () {
+        function CmdFormatter(cmdStr) {
+            this.cmdStr = cmdStr;
+        }
+        CmdFormatter.prototype.format = function (date, context, betterDefaultSeparator) {
+            return context.cmdFormatter(this.cmdStr, createVerboseFormattingArg(date, null, context, betterDefaultSeparator));
+        };
+        CmdFormatter.prototype.formatRange = function (start, end, context, betterDefaultSeparator) {
+            return context.cmdFormatter(this.cmdStr, createVerboseFormattingArg(start, end, context, betterDefaultSeparator));
+        };
+        return CmdFormatter;
+    }());
+
+    var FuncFormatter = /** @class */ (function () {
+        function FuncFormatter(func) {
+            this.func = func;
+        }
+        FuncFormatter.prototype.format = function (date, context, betterDefaultSeparator) {
+            return this.func(createVerboseFormattingArg(date, null, context, betterDefaultSeparator));
+        };
+        FuncFormatter.prototype.formatRange = function (start, end, context, betterDefaultSeparator) {
+            return this.func(createVerboseFormattingArg(start, end, context, betterDefaultSeparator));
+        };
+        return FuncFormatter;
+    }());
+
+    function createFormatter(input) {
+        if (typeof input === 'object' && input) { // non-null object
+            return new NativeFormatter(input);
+        }
+        if (typeof input === 'string') {
+            return new CmdFormatter(input);
+        }
+        if (typeof input === 'function') {
+            return new FuncFormatter(input);
+        }
+        return null;
+    }
+
+    // base options
+    // ------------
+    var BASE_OPTION_REFINERS = {
+        navLinkDayClick: identity,
+        navLinkWeekClick: identity,
+        duration: createDuration,
+        bootstrapFontAwesome: identity,
+        buttonIcons: identity,
+        customButtons: identity,
+        defaultAllDayEventDuration: createDuration,
+        defaultTimedEventDuration: createDuration,
+        nextDayThreshold: createDuration,
+        scrollTime: createDuration,
+        slotMinTime: createDuration,
+        slotMaxTime: createDuration,
+        dayPopoverFormat: createFormatter,
+        slotDuration: createDuration,
+        snapDuration: createDuration,
+        headerToolbar: identity,
+        footerToolbar: identity,
+        defaultRangeSeparator: String,
+        titleRangeSeparator: String,
+        forceEventDuration: Boolean,
+        dayHeaders: Boolean,
+        dayHeaderFormat: createFormatter,
+        dayHeaderClassNames: identity,
+        dayHeaderContent: identity,
+        dayHeaderDidMount: identity,
+        dayHeaderWillUnmount: identity,
+        dayCellClassNames: identity,
+        dayCellContent: identity,
+        dayCellDidMount: identity,
+        dayCellWillUnmount: identity,
+        initialView: String,
+        aspectRatio: Number,
+        weekends: Boolean,
+        weekNumberCalculation: identity,
+        weekNumbers: Boolean,
+        weekNumberClassNames: identity,
+        weekNumberContent: identity,
+        weekNumberDidMount: identity,
+        weekNumberWillUnmount: identity,
+        editable: Boolean,
+        viewClassNames: identity,
+        viewDidMount: identity,
+        viewWillUnmount: identity,
+        nowIndicator: Boolean,
+        nowIndicatorClassNames: identity,
+        nowIndicatorContent: identity,
+        nowIndicatorDidMount: identity,
+        nowIndicatorWillUnmount: identity,
+        showNonCurrentDates: Boolean,
+        lazyFetching: Boolean,
+        startParam: String,
+        endParam: String,
+        timeZoneParam: String,
+        timeZone: String,
+        locales: identity,
+        locale: identity,
+        themeSystem: String,
+        dragRevertDuration: Number,
+        dragScroll: Boolean,
+        allDayMaintainDuration: Boolean,
+        unselectAuto: Boolean,
+        dropAccept: identity,
+        eventOrder: parseFieldSpecs,
+        handleWindowResize: Boolean,
+        windowResizeDelay: Number,
+        longPressDelay: Number,
+        eventDragMinDistance: Number,
+        expandRows: Boolean,
+        height: identity,
+        contentHeight: identity,
+        direction: String,
+        weekNumberFormat: createFormatter,
+        eventResizableFromStart: Boolean,
+        displayEventTime: Boolean,
+        displayEventEnd: Boolean,
+        weekText: String,
+        progressiveEventRendering: Boolean,
+        businessHours: identity,
+        initialDate: identity,
+        now: identity,
+        eventDataTransform: identity,
+        stickyHeaderDates: identity,
+        stickyFooterScrollbar: identity,
+        viewHeight: identity,
+        defaultAllDay: Boolean,
+        eventSourceFailure: identity,
+        eventSourceSuccess: identity,
+        eventDisplay: String,
+        eventStartEditable: Boolean,
+        eventDurationEditable: Boolean,
+        eventOverlap: identity,
+        eventConstraint: identity,
+        eventAllow: identity,
+        eventBackgroundColor: String,
+        eventBorderColor: String,
+        eventTextColor: String,
+        eventColor: String,
+        eventClassNames: identity,
+        eventContent: identity,
+        eventDidMount: identity,
+        eventWillUnmount: identity,
+        selectConstraint: identity,
+        selectOverlap: identity,
+        selectAllow: identity,
+        droppable: Boolean,
+        unselectCancel: String,
+        slotLabelFormat: identity,
+        slotLaneClassNames: identity,
+        slotLaneContent: identity,
+        slotLaneDidMount: identity,
+        slotLaneWillUnmount: identity,
+        slotLabelClassNames: identity,
+        slotLabelContent: identity,
+        slotLabelDidMount: identity,
+        slotLabelWillUnmount: identity,
+        dayMaxEvents: identity,
+        dayMaxEventRows: identity,
+        dayMinWidth: Number,
+        slotLabelInterval: createDuration,
+        allDayText: String,
+        allDayClassNames: identity,
+        allDayContent: identity,
+        allDayDidMount: identity,
+        allDayWillUnmount: identity,
+        slotMinWidth: Number,
+        navLinks: Boolean,
+        eventTimeFormat: createFormatter,
+        rerenderDelay: Number,
+        moreLinkText: identity,
+        selectMinDistance: Number,
+        selectable: Boolean,
+        selectLongPressDelay: Number,
+        eventLongPressDelay: Number,
+        selectMirror: Boolean,
+        eventMinHeight: Number,
+        slotEventOverlap: Boolean,
+        plugins: identity,
+        firstDay: Number,
+        dayCount: Number,
+        dateAlignment: String,
+        dateIncrement: createDuration,
+        hiddenDays: identity,
+        monthMode: Boolean,
+        fixedWeekCount: Boolean,
+        validRange: identity,
+        visibleRange: identity,
+        titleFormat: identity,
+        // only used by list-view, but languages define the value, so we need it in base options
+        noEventsText: String,
+    };
+    // do NOT give a type here. need `typeof BASE_OPTION_DEFAULTS` to give real results.
+    // raw values.
+    var BASE_OPTION_DEFAULTS = {
+        eventDisplay: 'auto',
+        defaultRangeSeparator: ' - ',
+        titleRangeSeparator: ' \u2013 ',
+        defaultTimedEventDuration: '01:00:00',
+        defaultAllDayEventDuration: { day: 1 },
+        forceEventDuration: false,
+        nextDayThreshold: '00:00:00',
+        dayHeaders: true,
+        initialView: '',
+        aspectRatio: 1.35,
+        headerToolbar: {
+            start: 'title',
+            center: '',
+            end: 'today prev,next',
+        },
+        weekends: true,
+        weekNumbers: false,
+        weekNumberCalculation: 'local',
+        editable: false,
+        nowIndicator: false,
+        scrollTime: '06:00:00',
+        slotMinTime: '00:00:00',
+        slotMaxTime: '24:00:00',
+        showNonCurrentDates: true,
+        lazyFetching: true,
+        startParam: 'start',
+        endParam: 'end',
+        timeZoneParam: 'timeZone',
+        timeZone: 'local',
+        locales: [],
+        locale: '',
+        themeSystem: 'standard',
+        dragRevertDuration: 500,
+        dragScroll: true,
+        allDayMaintainDuration: false,
+        unselectAuto: true,
+        dropAccept: '*',
+        eventOrder: 'start,-duration,allDay,title',
+        dayPopoverFormat: { month: 'long', day: 'numeric', year: 'numeric' },
+        handleWindowResize: true,
+        windowResizeDelay: 100,
+        longPressDelay: 1000,
+        eventDragMinDistance: 5,
+        expandRows: false,
+        navLinks: false,
+        selectable: false,
+    };
+    // calendar listeners
+    // ------------------
+    var CALENDAR_LISTENER_REFINERS = {
+        datesSet: identity,
+        eventsSet: identity,
+        eventAdd: identity,
+        eventChange: identity,
+        eventRemove: identity,
+        windowResize: identity,
+        eventClick: identity,
+        eventMouseEnter: identity,
+        eventMouseLeave: identity,
+        select: identity,
+        unselect: identity,
+        loading: identity,
+        // internal
+        _unmount: identity,
+        _beforeprint: identity,
+        _afterprint: identity,
+        _noEventDrop: identity,
+        _noEventResize: identity,
+        _resize: identity,
+        _scrollRequest: identity,
+    };
+    // calendar-specific options
+    // -------------------------
+    var CALENDAR_OPTION_REFINERS = {
+        buttonText: identity,
+        views: identity,
+        plugins: identity,
+        initialEvents: identity,
+        events: identity,
+        eventSources: identity,
+    };
+    var COMPLEX_OPTION_COMPARATORS = {
+        headerToolbar: isBoolComplexEqual,
+        footerToolbar: isBoolComplexEqual,
+        buttonText: isBoolComplexEqual,
+        buttonIcons: isBoolComplexEqual,
+    };
+    function isBoolComplexEqual(a, b) {
+        if (typeof a === 'object' && typeof b === 'object' && a && b) { // both non-null objects
+            return isPropsEqual(a, b);
+        }
+        return a === b;
+    }
+    // view-specific options
+    // ---------------------
+    var VIEW_OPTION_REFINERS = {
+        type: String,
+        component: identity,
+        buttonText: String,
+        buttonTextKey: String,
+        dateProfileGeneratorClass: identity,
+        usesMinMaxTime: Boolean,
+        classNames: identity,
+        content: identity,
+        didMount: identity,
+        willUnmount: identity,
+    };
+    // util funcs
+    // ----------------------------------------------------------------------------------------------------
+    function mergeRawOptions(optionSets) {
+        return mergeProps(optionSets, COMPLEX_OPTION_COMPARATORS);
+    }
+    function refineProps(input, refiners) {
+        var refined = {};
+        var extra = {};
+        for (var propName in refiners) {
+            if (propName in input) {
+                refined[propName] = refiners[propName](input[propName]);
+            }
+        }
+        for (var propName in input) {
+            if (!(propName in refiners)) {
+                extra[propName] = input[propName];
+            }
+        }
+        return { refined: refined, extra: extra };
+    }
+    function identity(raw) {
+        return raw;
+    }
+
+    function parseEvents(rawEvents, eventSource, context, allowOpenRange) {
+        var eventStore = createEmptyEventStore();
+        var eventRefiners = buildEventRefiners(context);
+        for (var _i = 0, rawEvents_1 = rawEvents; _i < rawEvents_1.length; _i++) {
+            var rawEvent = rawEvents_1[_i];
+            var tuple = parseEvent(rawEvent, eventSource, context, allowOpenRange, eventRefiners);
+            if (tuple) {
+                eventTupleToStore(tuple, eventStore);
+            }
+        }
+        return eventStore;
+    }
+    function eventTupleToStore(tuple, eventStore) {
+        if (eventStore === void 0) { eventStore = createEmptyEventStore(); }
+        eventStore.defs[tuple.def.defId] = tuple.def;
+        if (tuple.instance) {
+            eventStore.instances[tuple.instance.instanceId] = tuple.instance;
+        }
+        return eventStore;
+    }
+    // retrieves events that have the same groupId as the instance specified by `instanceId`
+    // or they are the same as the instance.
+    // why might instanceId not be in the store? an event from another calendar?
+    function getRelevantEvents(eventStore, instanceId) {
+        var instance = eventStore.instances[instanceId];
+        if (instance) {
+            var def_1 = eventStore.defs[instance.defId];
+            // get events/instances with same group
+            var newStore = filterEventStoreDefs(eventStore, function (lookDef) { return isEventDefsGrouped(def_1, lookDef); });
+            // add the original
+            // TODO: wish we could use eventTupleToStore or something like it
+            newStore.defs[def_1.defId] = def_1;
+            newStore.instances[instance.instanceId] = instance;
+            return newStore;
+        }
+        return createEmptyEventStore();
+    }
+    function isEventDefsGrouped(def0, def1) {
+        return Boolean(def0.groupId && def0.groupId === def1.groupId);
+    }
+    function createEmptyEventStore() {
+        return { defs: {}, instances: {} };
+    }
+    function mergeEventStores(store0, store1) {
+        return {
+            defs: __assign(__assign({}, store0.defs), store1.defs),
+            instances: __assign(__assign({}, store0.instances), store1.instances),
+        };
+    }
+    function filterEventStoreDefs(eventStore, filterFunc) {
+        var defs = filterHash(eventStore.defs, filterFunc);
+        var instances = filterHash(eventStore.instances, function (instance) { return (defs[instance.defId] // still exists?
+        ); });
+        return { defs: defs, instances: instances };
+    }
+    function excludeSubEventStore(master, sub) {
+        var defs = master.defs, instances = master.instances;
+        var filteredDefs = {};
+        var filteredInstances = {};
+        for (var defId in defs) {
+            if (!sub.defs[defId]) { // not explicitly excluded
+                filteredDefs[defId] = defs[defId];
+            }
+        }
+        for (var instanceId in instances) {
+            if (!sub.instances[instanceId] && // not explicitly excluded
+                filteredDefs[instances[instanceId].defId] // def wasn't filtered away
+            ) {
+                filteredInstances[instanceId] = instances[instanceId];
+            }
+        }
+        return {
+            defs: filteredDefs,
+            instances: filteredInstances,
+        };
+    }
+
+    function normalizeConstraint(input, context) {
+        if (Array.isArray(input)) {
+            return parseEvents(input, null, context, true); // allowOpenRange=true
+        }
+        if (typeof input === 'object' && input) { // non-null object
+            return parseEvents([input], null, context, true); // allowOpenRange=true
+        }
+        if (input != null) {
+            return String(input);
+        }
+        return null;
+    }
+
+    function parseClassNames(raw) {
+        if (Array.isArray(raw)) {
+            return raw;
+        }
+        if (typeof raw === 'string') {
+            return raw.split(/\s+/);
+        }
+        return [];
+    }
+
+    // TODO: better called "EventSettings" or "EventConfig"
+    // TODO: move this file into structs
+    // TODO: separate constraint/overlap/allow, because selection uses only that, not other props
+    var EVENT_UI_REFINERS = {
+        display: String,
+        editable: Boolean,
+        startEditable: Boolean,
+        durationEditable: Boolean,
+        constraint: identity,
+        overlap: identity,
+        allow: identity,
+        className: parseClassNames,
+        classNames: parseClassNames,
+        color: String,
+        backgroundColor: String,
+        borderColor: String,
+        textColor: String,
+    };
+    var EMPTY_EVENT_UI = {
+        display: null,
+        startEditable: null,
+        durationEditable: null,
+        constraints: [],
+        overlap: null,
+        allows: [],
+        backgroundColor: '',
+        borderColor: '',
+        textColor: '',
+        classNames: [],
+    };
+    function createEventUi(refined, context) {
+        var constraint = normalizeConstraint(refined.constraint, context);
+        return {
+            display: refined.display || null,
+            startEditable: refined.startEditable != null ? refined.startEditable : refined.editable,
+            durationEditable: refined.durationEditable != null ? refined.durationEditable : refined.editable,
+            constraints: constraint != null ? [constraint] : [],
+            overlap: refined.overlap != null ? refined.overlap : null,
+            allows: refined.allow != null ? [refined.allow] : [],
+            backgroundColor: refined.backgroundColor || refined.color || '',
+            borderColor: refined.borderColor || refined.color || '',
+            textColor: refined.textColor || '',
+            classNames: (refined.className || []).concat(refined.classNames || []),
+        };
+    }
+    // TODO: prevent against problems with <2 args!
+    function combineEventUis(uis) {
+        return uis.reduce(combineTwoEventUis, EMPTY_EVENT_UI);
+    }
+    function combineTwoEventUis(item0, item1) {
+        return {
+            display: item1.display != null ? item1.display : item0.display,
+            startEditable: item1.startEditable != null ? item1.startEditable : item0.startEditable,
+            durationEditable: item1.durationEditable != null ? item1.durationEditable : item0.durationEditable,
+            constraints: item0.constraints.concat(item1.constraints),
+            overlap: typeof item1.overlap === 'boolean' ? item1.overlap : item0.overlap,
+            allows: item0.allows.concat(item1.allows),
+            backgroundColor: item1.backgroundColor || item0.backgroundColor,
+            borderColor: item1.borderColor || item0.borderColor,
+            textColor: item1.textColor || item0.textColor,
+            classNames: item0.classNames.concat(item1.classNames),
+        };
+    }
+
+    var EVENT_NON_DATE_REFINERS = {
+        id: String,
+        groupId: String,
+        title: String,
+        url: String,
+    };
+    var EVENT_DATE_REFINERS = {
+        start: identity,
+        end: identity,
+        date: identity,
+        allDay: Boolean,
+    };
+    var EVENT_REFINERS = __assign(__assign(__assign({}, EVENT_NON_DATE_REFINERS), EVENT_DATE_REFINERS), { extendedProps: identity });
+    function parseEvent(raw, eventSource, context, allowOpenRange, refiners) {
+        if (refiners === void 0) { refiners = buildEventRefiners(context); }
+        var _a = refineEventDef(raw, context, refiners), refined = _a.refined, extra = _a.extra;
+        var defaultAllDay = computeIsDefaultAllDay(eventSource, context);
+        var recurringRes = parseRecurring(refined, defaultAllDay, context.dateEnv, context.pluginHooks.recurringTypes);
+        if (recurringRes) {
+            var def = parseEventDef(refined, extra, eventSource ? eventSource.sourceId : '', recurringRes.allDay, Boolean(recurringRes.duration), context);
+            def.recurringDef = {
+                typeId: recurringRes.typeId,
+                typeData: recurringRes.typeData,
+                duration: recurringRes.duration,
+            };
+            return { def: def, instance: null };
+        }
+        var singleRes = parseSingle(refined, defaultAllDay, context, allowOpenRange);
+        if (singleRes) {
+            var def = parseEventDef(refined, extra, eventSource ? eventSource.sourceId : '', singleRes.allDay, singleRes.hasEnd, context);
+            var instance = createEventInstance(def.defId, singleRes.range, singleRes.forcedStartTzo, singleRes.forcedEndTzo);
+            return { def: def, instance: instance };
+        }
+        return null;
+    }
+    function refineEventDef(raw, context, refiners) {
+        if (refiners === void 0) { refiners = buildEventRefiners(context); }
+        return refineProps(raw, refiners);
+    }
+    function buildEventRefiners(context) {
+        return __assign(__assign(__assign({}, EVENT_UI_REFINERS), EVENT_REFINERS), context.pluginHooks.eventRefiners);
+    }
+    /*
+    Will NOT populate extendedProps with the leftover properties.
+    Will NOT populate date-related props.
+    */
+    function parseEventDef(refined, extra, sourceId, allDay, hasEnd, context) {
+        var def = {
+            title: refined.title || '',
+            groupId: refined.groupId || '',
+            publicId: refined.id || '',
+            url: refined.url || '',
+            recurringDef: null,
+            defId: guid(),
+            sourceId: sourceId,
+            allDay: allDay,
+            hasEnd: hasEnd,
+            ui: createEventUi(refined, context),
+            extendedProps: __assign(__assign({}, (refined.extendedProps || {})), extra),
+        };
+        for (var _i = 0, _a = context.pluginHooks.eventDefMemberAdders; _i < _a.length; _i++) {
+            var memberAdder = _a[_i];
+            __assign(def, memberAdder(refined));
+        }
+        // help out EventApi from having user modify props
+        Object.freeze(def.ui.classNames);
+        Object.freeze(def.extendedProps);
+        return def;
+    }
+    function parseSingle(refined, defaultAllDay, context, allowOpenRange) {
+        var allDay = refined.allDay;
+        var startMeta;
+        var startMarker = null;
+        var hasEnd = false;
+        var endMeta;
+        var endMarker = null;
+        var startInput = refined.start != null ? refined.start : refined.date;
+        startMeta = context.dateEnv.createMarkerMeta(startInput);
+        if (startMeta) {
+            startMarker = startMeta.marker;
+        }
+        else if (!allowOpenRange) {
+            return null;
+        }
+        if (refined.end != null) {
+            endMeta = context.dateEnv.createMarkerMeta(refined.end);
+        }
+        if (allDay == null) {
+            if (defaultAllDay != null) {
+                allDay = defaultAllDay;
+            }
+            else {
+                // fall back to the date props LAST
+                allDay = (!startMeta || startMeta.isTimeUnspecified) &&
+                    (!endMeta || endMeta.isTimeUnspecified);
+            }
+        }
+        if (allDay && startMarker) {
+            startMarker = startOfDay(startMarker);
+        }
+        if (endMeta) {
+            endMarker = endMeta.marker;
+            if (allDay) {
+                endMarker = startOfDay(endMarker);
+            }
+            if (startMarker && endMarker <= startMarker) {
+                endMarker = null;
+            }
+        }
+        if (endMarker) {
+            hasEnd = true;
+        }
+        else if (!allowOpenRange) {
+            hasEnd = context.options.forceEventDuration || false;
+            endMarker = context.dateEnv.add(startMarker, allDay ?
+                context.options.defaultAllDayEventDuration :
+                context.options.defaultTimedEventDuration);
+        }
+        return {
+            allDay: allDay,
+            hasEnd: hasEnd,
+            range: { start: startMarker, end: endMarker },
+            forcedStartTzo: startMeta ? startMeta.forcedTzo : null,
+            forcedEndTzo: endMeta ? endMeta.forcedTzo : null,
+        };
+    }
+    function computeIsDefaultAllDay(eventSource, context) {
+        var res = null;
+        if (eventSource) {
+            res = eventSource.defaultAllDay;
+        }
+        if (res == null) {
+            res = context.options.defaultAllDay;
+        }
+        return res;
+    }
+
+    /* Date stuff that doesn't belong in datelib core
+    ----------------------------------------------------------------------------------------------------------------------*/
+    // given a timed range, computes an all-day range that has the same exact duration,
+    // but whose start time is aligned with the start of the day.
+    function computeAlignedDayRange(timedRange) {
+        var dayCnt = Math.floor(diffDays(timedRange.start, timedRange.end)) || 1;
+        var start = startOfDay(timedRange.start);
+        var end = addDays(start, dayCnt);
+        return { start: start, end: end };
+    }
+    // given a timed range, computes an all-day range based on how for the end date bleeds into the next day
+    // TODO: give nextDayThreshold a default arg
+    function computeVisibleDayRange(timedRange, nextDayThreshold) {
+        if (nextDayThreshold === void 0) { nextDayThreshold = createDuration(0); }
+        var startDay = null;
+        var endDay = null;
+        if (timedRange.end) {
+            endDay = startOfDay(timedRange.end);
+            var endTimeMS = timedRange.end.valueOf() - endDay.valueOf(); // # of milliseconds into `endDay`
+            // If the end time is actually inclusively part of the next day and is equal to or
+            // beyond the next day threshold, adjust the end to be the exclusive end of `endDay`.
+            // Otherwise, leaving it as inclusive will cause it to exclude `endDay`.
+            if (endTimeMS && endTimeMS >= asRoughMs(nextDayThreshold)) {
+                endDay = addDays(endDay, 1);
+            }
+        }
+        if (timedRange.start) {
+            startDay = startOfDay(timedRange.start); // the beginning of the day the range starts
+            // If end is within `startDay` but not past nextDayThreshold, assign the default duration of one day.
+            if (endDay && endDay <= startDay) {
+                endDay = addDays(startDay, 1);
+            }
+        }
+        return { start: startDay, end: endDay };
+    }
+    // spans from one day into another?
+    function isMultiDayRange(range) {
+        var visibleRange = computeVisibleDayRange(range);
+        return diffDays(visibleRange.start, visibleRange.end) > 1;
+    }
+    function diffDates(date0, date1, dateEnv, largeUnit) {
+        if (largeUnit === 'year') {
+            return createDuration(dateEnv.diffWholeYears(date0, date1), 'year');
+        }
+        if (largeUnit === 'month') {
+            return createDuration(dateEnv.diffWholeMonths(date0, date1), 'month');
+        }
+        return diffDayAndTime(date0, date1); // returns a duration
+    }
+
+    function parseRange(input, dateEnv) {
+        var start = null;
+        var end = null;
+        if (input.start) {
+            start = dateEnv.createMarker(input.start);
+        }
+        if (input.end) {
+            end = dateEnv.createMarker(input.end);
+        }
+        if (!start && !end) {
+            return null;
+        }
+        if (start && end && end < start) {
+            return null;
+        }
+        return { start: start, end: end };
+    }
+    // SIDE-EFFECT: will mutate ranges.
+    // Will return a new array result.
+    function invertRanges(ranges, constraintRange) {
+        var invertedRanges = [];
+        var start = constraintRange.start; // the end of the previous range. the start of the new range
+        var i;
+        var dateRange;
+        // ranges need to be in order. required for our date-walking algorithm
+        ranges.sort(compareRanges);
+        for (i = 0; i < ranges.length; i += 1) {
+            dateRange = ranges[i];
+            // add the span of time before the event (if there is any)
+            if (dateRange.start > start) { // compare millisecond time (skip any ambig logic)
+                invertedRanges.push({ start: start, end: dateRange.start });
+            }
+            if (dateRange.end > start) {
+                start = dateRange.end;
+            }
+        }
+        // add the span of time after the last event (if there is any)
+        if (start < constraintRange.end) { // compare millisecond time (skip any ambig logic)
+            invertedRanges.push({ start: start, end: constraintRange.end });
+        }
+        return invertedRanges;
+    }
+    function compareRanges(range0, range1) {
+        return range0.start.valueOf() - range1.start.valueOf(); // earlier ranges go first
+    }
+    function intersectRanges(range0, range1) {
+        var start = range0.start, end = range0.end;
+        var newRange = null;
+        if (range1.start !== null) {
+            if (start === null) {
+                start = range1.start;
+            }
+            else {
+                start = new Date(Math.max(start.valueOf(), range1.start.valueOf()));
+            }
+        }
+        if (range1.end != null) {
+            if (end === null) {
+                end = range1.end;
+            }
+            else {
+                end = new Date(Math.min(end.valueOf(), range1.end.valueOf()));
+            }
+        }
+        if (start === null || end === null || start < end) {
+            newRange = { start: start, end: end };
+        }
+        return newRange;
+    }
+    function rangesEqual(range0, range1) {
+        return (range0.start === null ? null : range0.start.valueOf()) === (range1.start === null ? null : range1.start.valueOf()) &&
+            (range0.end === null ? null : range0.end.valueOf()) === (range1.end === null ? null : range1.end.valueOf());
+    }
+    function rangesIntersect(range0, range1) {
+        return (range0.end === null || range1.start === null || range0.end > range1.start) &&
+            (range0.start === null || range1.end === null || range0.start < range1.end);
+    }
+    function rangeContainsRange(outerRange, innerRange) {
+        return (outerRange.start === null || (innerRange.start !== null && innerRange.start >= outerRange.start)) &&
+            (outerRange.end === null || (innerRange.end !== null && innerRange.end <= outerRange.end));
+    }
+    function rangeContainsMarker(range, date) {
+        return (range.start === null || date >= range.start) &&
+            (range.end === null || date < range.end);
+    }
+    // If the given date is not within the given range, move it inside.
+    // (If it's past the end, make it one millisecond before the end).
+    function constrainMarkerToRange(date, range) {
+        if (range.start != null && date < range.start) {
+            return range.start;
+        }
+        if (range.end != null && date >= range.end) {
+            return new Date(range.end.valueOf() - 1);
+        }
+        return date;
+    }
+
+    /*
+    Specifying nextDayThreshold signals that all-day ranges should be sliced.
+    */
+    function sliceEventStore(eventStore, eventUiBases, framingRange, nextDayThreshold) {
+        var inverseBgByGroupId = {};
+        var inverseBgByDefId = {};
+        var defByGroupId = {};
+        var bgRanges = [];
+        var fgRanges = [];
+        var eventUis = compileEventUis(eventStore.defs, eventUiBases);
+        for (var defId in eventStore.defs) {
+            var def = eventStore.defs[defId];
+            var ui = eventUis[def.defId];
+            if (ui.display === 'inverse-background') {
+                if (def.groupId) {
+                    inverseBgByGroupId[def.groupId] = [];
+                    if (!defByGroupId[def.groupId]) {
+                        defByGroupId[def.groupId] = def;
+                    }
+                }
+                else {
+                    inverseBgByDefId[defId] = [];
+                }
+            }
+        }
+        for (var instanceId in eventStore.instances) {
+            var instance = eventStore.instances[instanceId];
+            var def = eventStore.defs[instance.defId];
+            var ui = eventUis[def.defId];
+            var origRange = instance.range;
+            var normalRange = (!def.allDay && nextDayThreshold) ?
+                computeVisibleDayRange(origRange, nextDayThreshold) :
+                origRange;
+            var slicedRange = intersectRanges(normalRange, framingRange);
+            if (slicedRange) {
+                if (ui.display === 'inverse-background') {
+                    if (def.groupId) {
+                        inverseBgByGroupId[def.groupId].push(slicedRange);
+                    }
+                    else {
+                        inverseBgByDefId[instance.defId].push(slicedRange);
+                    }
+                }
+                else if (ui.display !== 'none') {
+                    (ui.display === 'background' ? bgRanges : fgRanges).push({
+                        def: def,
+                        ui: ui,
+                        instance: instance,
+                        range: slicedRange,
+                        isStart: normalRange.start && normalRange.start.valueOf() === slicedRange.start.valueOf(),
+                        isEnd: normalRange.end && normalRange.end.valueOf() === slicedRange.end.valueOf(),
+                    });
+                }
+            }
+        }
+        for (var groupId in inverseBgByGroupId) { // BY GROUP
+            var ranges = inverseBgByGroupId[groupId];
+            var invertedRanges = invertRanges(ranges, framingRange);
+            for (var _i = 0, invertedRanges_1 = invertedRanges; _i < invertedRanges_1.length; _i++) {
+                var invertedRange = invertedRanges_1[_i];
+                var def = defByGroupId[groupId];
+                var ui = eventUis[def.defId];
+                bgRanges.push({
+                    def: def,
+                    ui: ui,
+                    instance: null,
+                    range: invertedRange,
+                    isStart: false,
+                    isEnd: false,
+                });
+            }
+        }
+        for (var defId in inverseBgByDefId) {
+            var ranges = inverseBgByDefId[defId];
+            var invertedRanges = invertRanges(ranges, framingRange);
+            for (var _a = 0, invertedRanges_2 = invertedRanges; _a < invertedRanges_2.length; _a++) {
+                var invertedRange = invertedRanges_2[_a];
+                bgRanges.push({
+                    def: eventStore.defs[defId],
+                    ui: eventUis[defId],
+                    instance: null,
+                    range: invertedRange,
+                    isStart: false,
+                    isEnd: false,
+                });
+            }
+        }
+        return { bg: bgRanges, fg: fgRanges };
+    }
+    function hasBgRendering(def) {
+        return def.ui.display === 'background' || def.ui.display === 'inverse-background';
+    }
+    function setElSeg(el, seg) {
+        el.fcSeg = seg;
+    }
+    function getElSeg(el) {
+        return el.fcSeg ||
+            el.parentNode.fcSeg || // for the harness
+            null;
+    }
+    // event ui computation
+    function compileEventUis(eventDefs, eventUiBases) {
+        return mapHash(eventDefs, function (eventDef) { return compileEventUi(eventDef, eventUiBases); });
+    }
+    function compileEventUi(eventDef, eventUiBases) {
+        var uis = [];
+        if (eventUiBases['']) {
+            uis.push(eventUiBases['']);
+        }
+        if (eventUiBases[eventDef.defId]) {
+            uis.push(eventUiBases[eventDef.defId]);
+        }
+        uis.push(eventDef.ui);
+        return combineEventUis(uis);
+    }
+    function sortEventSegs(segs, eventOrderSpecs) {
+        var objs = segs.map(buildSegCompareObj);
+        objs.sort(function (obj0, obj1) { return compareByFieldSpecs(obj0, obj1, eventOrderSpecs); });
+        return objs.map(function (c) { return c._seg; });
+    }
+    // returns a object with all primitive props that can be compared
+    function buildSegCompareObj(seg) {
+        var eventRange = seg.eventRange;
+        var eventDef = eventRange.def;
+        var range = eventRange.instance ? eventRange.instance.range : eventRange.range;
+        var start = range.start ? range.start.valueOf() : 0; // TODO: better support for open-range events
+        var end = range.end ? range.end.valueOf() : 0; // "
+        return __assign(__assign(__assign({}, eventDef.extendedProps), eventDef), { id: eventDef.publicId, start: start,
+            end: end, duration: end - start, allDay: Number(eventDef.allDay), _seg: seg });
+    }
+    function computeSegDraggable(seg, context) {
+        var pluginHooks = context.pluginHooks;
+        var transformers = pluginHooks.isDraggableTransformers;
+        var _a = seg.eventRange, def = _a.def, ui = _a.ui;
+        var val = ui.startEditable;
+        for (var _i = 0, transformers_1 = transformers; _i < transformers_1.length; _i++) {
+            var transformer = transformers_1[_i];
+            val = transformer(val, def, ui, context);
+        }
+        return val;
+    }
+    function computeSegStartResizable(seg, context) {
+        return seg.isStart && seg.eventRange.ui.durationEditable && context.options.eventResizableFromStart;
+    }
+    function computeSegEndResizable(seg, context) {
+        return seg.isEnd && seg.eventRange.ui.durationEditable;
+    }
+    function buildSegTimeText(seg, timeFormat, context, defaultDisplayEventTime, // defaults to true
+    defaultDisplayEventEnd, // defaults to true
+    startOverride, endOverride) {
+        var dateEnv = context.dateEnv, options = context.options;
+        var displayEventTime = options.displayEventTime, displayEventEnd = options.displayEventEnd;
+        var eventDef = seg.eventRange.def;
+        var eventInstance = seg.eventRange.instance;
+        if (displayEventTime == null) {
+            displayEventTime = defaultDisplayEventTime !== false;
+        }
+        if (displayEventEnd == null) {
+            displayEventEnd = defaultDisplayEventEnd !== false;
+        }
+        if (displayEventTime && !eventDef.allDay && (seg.isStart || seg.isEnd)) {
+            var segStart = startOverride || (seg.isStart ? eventInstance.range.start : (seg.start || seg.eventRange.range.start));
+            var segEnd = endOverride || (seg.isEnd ? eventInstance.range.end : (seg.end || seg.eventRange.range.end));
+            if (displayEventEnd && eventDef.hasEnd) {
+                return dateEnv.formatRange(segStart, segEnd, timeFormat, {
+                    forcedStartTzo: startOverride ? null : eventInstance.forcedStartTzo,
+                    forcedEndTzo: endOverride ? null : eventInstance.forcedEndTzo,
+                });
+            }
+            return dateEnv.format(segStart, timeFormat, {
+                forcedTzo: startOverride ? null : eventInstance.forcedStartTzo,
+            });
+        }
+        return '';
+    }
+    function getSegMeta(seg, todayRange, nowDate) {
+        var segRange = seg.eventRange.range;
+        return {
+            isPast: segRange.end < (nowDate || todayRange.start),
+            isFuture: segRange.start >= (nowDate || todayRange.end),
+            isToday: todayRange && rangeContainsMarker(todayRange, segRange.start),
+        };
+    }
+    function getEventClassNames(props) {
+        var classNames = ['fc-event'];
+        if (props.isMirror) {
+            classNames.push('fc-event-mirror');
+        }
+        if (props.isDraggable) {
+            classNames.push('fc-event-draggable');
+        }
+        if (props.isStartResizable || props.isEndResizable) {
+            classNames.push('fc-event-resizable');
+        }
+        if (props.isDragging) {
+            classNames.push('fc-event-dragging');
+        }
+        if (props.isResizing) {
+            classNames.push('fc-event-resizing');
+        }
+        if (props.isSelected) {
+            classNames.push('fc-event-selected');
+        }
+        if (props.isStart) {
+            classNames.push('fc-event-start');
+        }
+        if (props.isEnd) {
+            classNames.push('fc-event-end');
+        }
+        if (props.isPast) {
+            classNames.push('fc-event-past');
+        }
+        if (props.isToday) {
+            classNames.push('fc-event-today');
+        }
+        if (props.isFuture) {
+            classNames.push('fc-event-future');
+        }
+        return classNames;
+    }
+    function buildEventRangeKey(eventRange) {
+        return eventRange.instance
+            ? eventRange.instance.instanceId
+            : eventRange.def.defId + ":" + eventRange.range.start.toISOString();
+        // inverse-background events don't have specific instances. TODO: better solution
+    }
+
+    var STANDARD_PROPS = {
+        start: identity,
+        end: identity,
+        allDay: Boolean,
+    };
+    function parseDateSpan(raw, dateEnv, defaultDuration) {
+        var span = parseOpenDateSpan(raw, dateEnv);
+        var range = span.range;
+        if (!range.start) {
+            return null;
+        }
+        if (!range.end) {
+            if (defaultDuration == null) {
+                return null;
+            }
+            range.end = dateEnv.add(range.start, defaultDuration);
+        }
+        return span;
+    }
+    /*
+    TODO: somehow combine with parseRange?
+    Will return null if the start/end props were present but parsed invalidly.
+    */
+    function parseOpenDateSpan(raw, dateEnv) {
+        var _a = refineProps(raw, STANDARD_PROPS), standardProps = _a.refined, extra = _a.extra;
+        var startMeta = standardProps.start ? dateEnv.createMarkerMeta(standardProps.start) : null;
+        var endMeta = standardProps.end ? dateEnv.createMarkerMeta(standardProps.end) : null;
+        var allDay = standardProps.allDay;
+        if (allDay == null) {
+            allDay = (startMeta && startMeta.isTimeUnspecified) &&
+                (!endMeta || endMeta.isTimeUnspecified);
+        }
+        return __assign({ range: {
+                start: startMeta ? startMeta.marker : null,
+                end: endMeta ? endMeta.marker : null,
+            }, allDay: allDay }, extra);
+    }
+    function isDateSpansEqual(span0, span1) {
+        return rangesEqual(span0.range, span1.range) &&
+            span0.allDay === span1.allDay &&
+            isSpanPropsEqual(span0, span1);
+    }
+    // the NON-DATE-RELATED props
+    function isSpanPropsEqual(span0, span1) {
+        for (var propName in span1) {
+            if (propName !== 'range' && propName !== 'allDay') {
+                if (span0[propName] !== span1[propName]) {
+                    return false;
+                }
+            }
+        }
+        // are there any props that span0 has that span1 DOESN'T have?
+        // both have range/allDay, so no need to special-case.
+        for (var propName in span0) {
+            if (!(propName in span1)) {
+                return false;
+            }
+        }
+        return true;
+    }
+    function buildDateSpanApi(span, dateEnv) {
+        return __assign(__assign({}, buildRangeApi(span.range, dateEnv, span.allDay)), { allDay: span.allDay });
+    }
+    function buildRangeApiWithTimeZone(range, dateEnv, omitTime) {
+        return __assign(__assign({}, buildRangeApi(range, dateEnv, omitTime)), { timeZone: dateEnv.timeZone });
+    }
+    function buildRangeApi(range, dateEnv, omitTime) {
+        return {
+            start: dateEnv.toDate(range.start),
+            end: dateEnv.toDate(range.end),
+            startStr: dateEnv.formatIso(range.start, { omitTime: omitTime }),
+            endStr: dateEnv.formatIso(range.end, { omitTime: omitTime }),
+        };
+    }
+    function fabricateEventRange(dateSpan, eventUiBases, context) {
+        var res = refineEventDef({ editable: false }, context);
+        var def = parseEventDef(res.refined, res.extra, '', // sourceId
+        dateSpan.allDay, true, // hasEnd
+        context);
+        return {
+            def: def,
+            ui: compileEventUi(def, eventUiBases),
+            instance: createEventInstance(def.defId, dateSpan.range),
+            range: dateSpan.range,
+            isStart: true,
+            isEnd: true,
+        };
+    }
+
+    function triggerDateSelect(selection, pev, context) {
+        context.emitter.trigger('select', __assign(__assign({}, buildDateSpanApiWithContext(selection, context)), { jsEvent: pev ? pev.origEvent : null, view: context.viewApi || context.calendarApi.view }));
+    }
+    function triggerDateUnselect(pev, context) {
+        context.emitter.trigger('unselect', {
+            jsEvent: pev ? pev.origEvent : null,
+            view: context.viewApi || context.calendarApi.view,
+        });
+    }
+    function buildDateSpanApiWithContext(dateSpan, context) {
+        var props = {};
+        for (var _i = 0, _a = context.pluginHooks.dateSpanTransforms; _i < _a.length; _i++) {
+            var transform = _a[_i];
+            __assign(props, transform(dateSpan, context));
+        }
+        __assign(props, buildDateSpanApi(dateSpan, context.dateEnv));
+        return props;
+    }
+    // Given an event's allDay status and start date, return what its fallback end date should be.
+    // TODO: rename to computeDefaultEventEnd
+    function getDefaultEventEnd(allDay, marker, context) {
+        var dateEnv = context.dateEnv, options = context.options;
+        var end = marker;
+        if (allDay) {
+            end = startOfDay(end);
+            end = dateEnv.add(end, options.defaultAllDayEventDuration);
+        }
+        else {
+            end = dateEnv.add(end, options.defaultTimedEventDuration);
+        }
+        return end;
+    }
+
+    // applies the mutation to ALL defs/instances within the event store
+    function applyMutationToEventStore(eventStore, eventConfigBase, mutation, context) {
+        var eventConfigs = compileEventUis(eventStore.defs, eventConfigBase);
+        var dest = createEmptyEventStore();
+        for (var defId in eventStore.defs) {
+            var def = eventStore.defs[defId];
+            dest.defs[defId] = applyMutationToEventDef(def, eventConfigs[defId], mutation, context);
+        }
+        for (var instanceId in eventStore.instances) {
+            var instance = eventStore.instances[instanceId];
+            var def = dest.defs[instance.defId]; // important to grab the newly modified def
+            dest.instances[instanceId] = applyMutationToEventInstance(instance, def, eventConfigs[instance.defId], mutation, context);
+        }
+        return dest;
+    }
+    function applyMutationToEventDef(eventDef, eventConfig, mutation, context) {
+        var standardProps = mutation.standardProps || {};
+        // if hasEnd has not been specified, guess a good value based on deltas.
+        // if duration will change, there's no way the default duration will persist,
+        // and thus, we need to mark the event as having a real end
+        if (standardProps.hasEnd == null &&
+            eventConfig.durationEditable &&
+            (mutation.startDelta || mutation.endDelta)) {
+            standardProps.hasEnd = true; // TODO: is this mutation okay?
+        }
+        var copy = __assign(__assign(__assign({}, eventDef), standardProps), { ui: __assign(__assign({}, eventDef.ui), standardProps.ui) });
+        if (mutation.extendedProps) {
+            copy.extendedProps = __assign(__assign({}, copy.extendedProps), mutation.extendedProps);
+        }
+        for (var _i = 0, _a = context.pluginHooks.eventDefMutationAppliers; _i < _a.length; _i++) {
+            var applier = _a[_i];
+            applier(copy, mutation, context);
+        }
+        if (!copy.hasEnd && context.options.forceEventDuration) {
+            copy.hasEnd = true;
+        }
+        return copy;
+    }
+    function applyMutationToEventInstance(eventInstance, eventDef, // must first be modified by applyMutationToEventDef
+    eventConfig, mutation, context) {
+        var dateEnv = context.dateEnv;
+        var forceAllDay = mutation.standardProps && mutation.standardProps.allDay === true;
+        var clearEnd = mutation.standardProps && mutation.standardProps.hasEnd === false;
+        var copy = __assign({}, eventInstance);
+        if (forceAllDay) {
+            copy.range = computeAlignedDayRange(copy.range);
+        }
+        if (mutation.datesDelta && eventConfig.startEditable) {
+            copy.range = {
+                start: dateEnv.add(copy.range.start, mutation.datesDelta),
+                end: dateEnv.add(copy.range.end, mutation.datesDelta),
+            };
+        }
+        if (mutation.startDelta && eventConfig.durationEditable) {
+            copy.range = {
+                start: dateEnv.add(copy.range.start, mutation.startDelta),
+                end: copy.range.end,
+            };
+        }
+        if (mutation.endDelta && eventConfig.durationEditable) {
+            copy.range = {
+                start: copy.range.start,
+                end: dateEnv.add(copy.range.end, mutation.endDelta),
+            };
+        }
+        if (clearEnd) {
+            copy.range = {
+                start: copy.range.start,
+                end: getDefaultEventEnd(eventDef.allDay, copy.range.start, context),
+            };
+        }
+        // in case event was all-day but the supplied deltas were not
+        // better util for this?
+        if (eventDef.allDay) {
+            copy.range = {
+                start: startOfDay(copy.range.start),
+                end: startOfDay(copy.range.end),
+            };
+        }
+        // handle invalid durations
+        if (copy.range.end < copy.range.start) {
+            copy.range.end = getDefaultEventEnd(eventDef.allDay, copy.range.start, context);
+        }
+        return copy;
+    }
+
+    // no public types yet. when there are, export from:
+    // import {} from './api-type-deps'
+    var ViewApi = /** @class */ (function () {
+        function ViewApi(type, getCurrentData, dateEnv) {
+            this.type = type;
+            this.getCurrentData = getCurrentData;
+            this.dateEnv = dateEnv;
+        }
+        Object.defineProperty(ViewApi.prototype, "calendar", {
+            get: function () {
+                return this.getCurrentData().calendarApi;
+            },
+            enumerable: false,
+            configurable: true
+        });
+        Object.defineProperty(ViewApi.prototype, "title", {
+            get: function () {
+                return this.getCurrentData().viewTitle;
+            },
+            enumerable: false,
+            configurable: true
+        });
+        Object.defineProperty(ViewApi.prototype, "activeStart", {
+            get: function () {
+                return this.dateEnv.toDate(this.getCurrentData().dateProfile.activeRange.start);
+            },
+            enumerable: false,
+            configurable: true
+        });
+        Object.defineProperty(ViewApi.prototype, "activeEnd", {
+            get: function () {
+                return this.dateEnv.toDate(this.getCurrentData().dateProfile.activeRange.end);
+            },
+            enumerable: false,
+            configurable: true
+        });
+        Object.defineProperty(ViewApi.prototype, "currentStart", {
+            get: function () {
+                return this.dateEnv.toDate(this.getCurrentData().dateProfile.currentRange.start);
+            },
+            enumerable: false,
+            configurable: true
+        });
+        Object.defineProperty(ViewApi.prototype, "currentEnd", {
+            get: function () {
+                return this.dateEnv.toDate(this.getCurrentData().dateProfile.currentRange.end);
+            },
+            enumerable: false,
+            configurable: true
+        });
+        ViewApi.prototype.getOption = function (name) {
+            return this.getCurrentData().options[name]; // are the view-specific options
+        };
+        return ViewApi;
+    }());
+
+    var EVENT_SOURCE_REFINERS = {
+        id: String,
+        defaultAllDay: Boolean,
+        url: String,
+        format: String,
+        events: identity,
+        eventDataTransform: identity,
+        // for any network-related sources
+        success: identity,
+        failure: identity,
+    };
+    function parseEventSource(raw, context, refiners) {
+        if (refiners === void 0) { refiners = buildEventSourceRefiners(context); }
+        var rawObj;
+        if (typeof raw === 'string') {
+            rawObj = { url: raw };
+        }
+        else if (typeof raw === 'function' || Array.isArray(raw)) {
+            rawObj = { events: raw };
+        }
+        else if (typeof raw === 'object' && raw) { // not null
+            rawObj = raw;
+        }
+        if (rawObj) {
+            var _a = refineProps(rawObj, refiners), refined = _a.refined, extra = _a.extra;
+            var metaRes = buildEventSourceMeta(refined, context);
+            if (metaRes) {
+                return {
+                    _raw: raw,
+                    isFetching: false,
+                    latestFetchId: '',
+                    fetchRange: null,
+                    defaultAllDay: refined.defaultAllDay,
+                    eventDataTransform: refined.eventDataTransform,
+                    success: refined.success,
+                    failure: refined.failure,
+                    publicId: refined.id || '',
+                    sourceId: guid(),
+                    sourceDefId: metaRes.sourceDefId,
+                    meta: metaRes.meta,
+                    ui: createEventUi(refined, context),
+                    extendedProps: extra,
+                };
+            }
+        }
+        return null;
+    }
+    function buildEventSourceRefiners(context) {
+        return __assign(__assign(__assign({}, EVENT_UI_REFINERS), EVENT_SOURCE_REFINERS), context.pluginHooks.eventSourceRefiners);
+    }
+    function buildEventSourceMeta(raw, context) {
+        var defs = context.pluginHooks.eventSourceDefs;
+        for (var i = defs.length - 1; i >= 0; i -= 1) { // later-added plugins take precedence
+            var def = defs[i];
+            var meta = def.parseMeta(raw);
+            if (meta) {
+                return { sourceDefId: i, meta: meta };
+            }
+        }
+        return null;
+    }
+
+    function reduceCurrentDate(currentDate, action) {
+        switch (action.type) {
+            case 'CHANGE_DATE':
+                return action.dateMarker;
+            default:
+                return currentDate;
+        }
+    }
+    function getInitialDate(options, dateEnv) {
+        var initialDateInput = options.initialDate;
+        // compute the initial ambig-timezone date
+        if (initialDateInput != null) {
+            return dateEnv.createMarker(initialDateInput);
+        }
+        return getNow(options.now, dateEnv); // getNow already returns unzoned
+    }
+    function getNow(nowInput, dateEnv) {
+        if (typeof nowInput === 'function') {
+            nowInput = nowInput();
+        }
+        if (nowInput == null) {
+            return dateEnv.createNowMarker();
+        }
+        return dateEnv.createMarker(nowInput);
+    }
+
+    var CalendarApi = /** @class */ (function () {
+        function CalendarApi() {
+        }
+        CalendarApi.prototype.getCurrentData = function () {
+            return this.currentDataManager.getCurrentData();
+        };
+        CalendarApi.prototype.dispatch = function (action) {
+            return this.currentDataManager.dispatch(action);
+        };
+        Object.defineProperty(CalendarApi.prototype, "view", {
+            get: function () { return this.getCurrentData().viewApi; } // for public API
+            ,
+            enumerable: false,
+            configurable: true
+        });
+        CalendarApi.prototype.batchRendering = function (callback) {
+            callback();
+        };
+        CalendarApi.prototype.updateSize = function () {
+            this.trigger('_resize', true);
+        };
+        // Options
+        // -----------------------------------------------------------------------------------------------------------------
+        CalendarApi.prototype.setOption = function (name, val) {
+            this.dispatch({
+                type: 'SET_OPTION',
+                optionName: name,
+                rawOptionValue: val,
+            });
+        };
+        CalendarApi.prototype.getOption = function (name) {
+            return this.currentDataManager.currentCalendarOptionsInput[name];
+        };
+        CalendarApi.prototype.getAvailableLocaleCodes = function () {
+            return Object.keys(this.getCurrentData().availableRawLocales);
+        };
+        // Trigger
+        // -----------------------------------------------------------------------------------------------------------------
+        CalendarApi.prototype.on = function (handlerName, handler) {
+            var currentDataManager = this.currentDataManager;
+            if (currentDataManager.currentCalendarOptionsRefiners[handlerName]) {
+                currentDataManager.emitter.on(handlerName, handler);
+            }
+            else {
+                console.warn("Unknown listener name '" + handlerName + "'");
+            }
+        };
+        CalendarApi.prototype.off = function (handlerName, handler) {
+            this.currentDataManager.emitter.off(handlerName, handler);
+        };
+        // not meant for public use
+        CalendarApi.prototype.trigger = function (handlerName) {
+            var _a;
+            var args = [];
+            for (var _i = 1; _i < arguments.length; _i++) {
+                args[_i - 1] = arguments[_i];
+            }
+            (_a = this.currentDataManager.emitter).trigger.apply(_a, __spreadArrays([handlerName], args));
+        };
+        // View
+        // -----------------------------------------------------------------------------------------------------------------
+        CalendarApi.prototype.changeView = function (viewType, dateOrRange) {
+            var _this = this;
+            this.batchRendering(function () {
+                _this.unselect();
+                if (dateOrRange) {
+                    if (dateOrRange.start && dateOrRange.end) { // a range
+                        _this.dispatch({
+                            type: 'CHANGE_VIEW_TYPE',
+                            viewType: viewType,
+                        });
+                        _this.dispatch({
+                            type: 'SET_OPTION',
+                            optionName: 'visibleRange',
+                            rawOptionValue: dateOrRange,
+                        });
+                    }
+                    else {
+                        var dateEnv = _this.getCurrentData().dateEnv;
+                        _this.dispatch({
+                            type: 'CHANGE_VIEW_TYPE',
+                            viewType: viewType,
+                            dateMarker: dateEnv.createMarker(dateOrRange),
+                        });
+                    }
+                }
+                else {
+                    _this.dispatch({
+                        type: 'CHANGE_VIEW_TYPE',
+                        viewType: viewType,
+                    });
+                }
+            });
+        };
+        // Forces navigation to a view for the given date.
+        // `viewType` can be a specific view name or a generic one like "week" or "day".
+        // needs to change
+        CalendarApi.prototype.zoomTo = function (dateMarker, viewType) {
+            var state = this.getCurrentData();
+            var spec;
+            viewType = viewType || 'day'; // day is default zoom
+            spec = state.viewSpecs[viewType] || this.getUnitViewSpec(viewType);
+            this.unselect();
+            if (spec) {
+                this.dispatch({
+                    type: 'CHANGE_VIEW_TYPE',
+                    viewType: spec.type,
+                    dateMarker: dateMarker,
+                });
+            }
+            else {
+                this.dispatch({
+                    type: 'CHANGE_DATE',
+                    dateMarker: dateMarker,
+                });
+            }
+        };
+        // Given a duration singular unit, like "week" or "day", finds a matching view spec.
+        // Preference is given to views that have corresponding buttons.
+        CalendarApi.prototype.getUnitViewSpec = function (unit) {
+            var _a = this.getCurrentData(), viewSpecs = _a.viewSpecs, toolbarConfig = _a.toolbarConfig;
+            var viewTypes = [].concat(toolbarConfig.viewsWithButtons);
+            var i;
+            var spec;
+            for (var viewType in viewSpecs) {
+                viewTypes.push(viewType);
+            }
+            for (i = 0; i < viewTypes.length; i += 1) {
+                spec = viewSpecs[viewTypes[i]];
+                if (spec) {
+                    if (spec.singleUnit === unit) {
+                        return spec;
+                    }
+                }
+            }
+            return null;
+        };
+        // Current Date
+        // -----------------------------------------------------------------------------------------------------------------
+        CalendarApi.prototype.prev = function () {
+            this.unselect();
+            this.dispatch({ type: 'PREV' });
+        };
+        CalendarApi.prototype.next = function () {
+            this.unselect();
+            this.dispatch({ type: 'NEXT' });
+        };
+        CalendarApi.prototype.prevYear = function () {
+            var state = this.getCurrentData();
+            this.unselect();
+            this.dispatch({
+                type: 'CHANGE_DATE',
+                dateMarker: state.dateEnv.addYears(state.currentDate, -1),
+            });
+        };
+        CalendarApi.prototype.nextYear = function () {
+            var state = this.getCurrentData();
+            this.unselect();
+            this.dispatch({
+                type: 'CHANGE_DATE',
+                dateMarker: state.dateEnv.addYears(state.currentDate, 1),
+            });
+        };
+        CalendarApi.prototype.today = function () {
+            var state = this.getCurrentData();
+            this.unselect();
+            this.dispatch({
+                type: 'CHANGE_DATE',
+                dateMarker: getNow(state.calendarOptions.now, state.dateEnv),
+            });
+        };
+        CalendarApi.prototype.gotoDate = function (zonedDateInput) {
+            var state = this.getCurrentData();
+            this.unselect();
+            this.dispatch({
+                type: 'CHANGE_DATE',
+                dateMarker: state.dateEnv.createMarker(zonedDateInput),
+            });
+        };
+        CalendarApi.prototype.incrementDate = function (deltaInput) {
+            var state = this.getCurrentData();
+            var delta = createDuration(deltaInput);
+            if (delta) { // else, warn about invalid input?
+                this.unselect();
+                this.dispatch({
+                    type: 'CHANGE_DATE',
+                    dateMarker: state.dateEnv.add(state.currentDate, delta),
+                });
+            }
+        };
+        // for external API
+        CalendarApi.prototype.getDate = function () {
+            var state = this.getCurrentData();
+            return state.dateEnv.toDate(state.currentDate);
+        };
+        // Date Formatting Utils
+        // -----------------------------------------------------------------------------------------------------------------
+        CalendarApi.prototype.formatDate = function (d, formatter) {
+            var dateEnv = this.getCurrentData().dateEnv;
+            return dateEnv.format(dateEnv.createMarker(d), createFormatter(formatter));
+        };
+        // `settings` is for formatter AND isEndExclusive
+        CalendarApi.prototype.formatRange = function (d0, d1, settings) {
+            var dateEnv = this.getCurrentData().dateEnv;
+            return dateEnv.formatRange(dateEnv.createMarker(d0), dateEnv.createMarker(d1), createFormatter(settings), settings);
+        };
+        CalendarApi.prototype.formatIso = function (d, omitTime) {
+            var dateEnv = this.getCurrentData().dateEnv;
+            return dateEnv.formatIso(dateEnv.createMarker(d), { omitTime: omitTime });
+        };
+        // Date Selection / Event Selection / DayClick
+        // -----------------------------------------------------------------------------------------------------------------
+        // this public method receives start/end dates in any format, with any timezone
+        // NOTE: args were changed from v3
+        CalendarApi.prototype.select = function (dateOrObj, endDate) {
+            var selectionInput;
+            if (endDate == null) {
+                if (dateOrObj.start != null) {
+                    selectionInput = dateOrObj;
+                }
+                else {
+                    selectionInput = {
+                        start: dateOrObj,
+                        end: null,
+                    };
+                }
+            }
+            else {
+                selectionInput = {
+                    start: dateOrObj,
+                    end: endDate,
+                };
+            }
+            var state = this.getCurrentData();
+            var selection = parseDateSpan(selectionInput, state.dateEnv, createDuration({ days: 1 }));
+            if (selection) { // throw parse error otherwise?
+                this.dispatch({ type: 'SELECT_DATES', selection: selection });
+                triggerDateSelect(selection, null, state);
+            }
+        };
+        // public method
+        CalendarApi.prototype.unselect = function (pev) {
+            var state = this.getCurrentData();
+            if (state.dateSelection) {
+                this.dispatch({ type: 'UNSELECT_DATES' });
+                triggerDateUnselect(pev, state);
+            }
+        };
+        // Public Events API
+        // -----------------------------------------------------------------------------------------------------------------
+        CalendarApi.prototype.addEvent = function (eventInput, sourceInput) {
+            if (eventInput instanceof EventApi) {
+                var def = eventInput._def;
+                var instance = eventInput._instance;
+                var currentData = this.getCurrentData();
+                // not already present? don't want to add an old snapshot
+                if (!currentData.eventStore.defs[def.defId]) {
+                    this.dispatch({
+                        type: 'ADD_EVENTS',
+                        eventStore: eventTupleToStore({ def: def, instance: instance }),
+                    });
+                    this.triggerEventAdd(eventInput);
+                }
+                return eventInput;
+            }
+            var state = this.getCurrentData();
+            var eventSource;
+            if (sourceInput instanceof EventSourceApi) {
+                eventSource = sourceInput.internalEventSource;
+            }
+            else if (typeof sourceInput === 'boolean') {
+                if (sourceInput) { // true. part of the first event source
+                    eventSource = hashValuesToArray(state.eventSources)[0];
+                }
+            }
+            else if (sourceInput != null) { // an ID. accepts a number too
+                var sourceApi = this.getEventSourceById(sourceInput); // TODO: use an internal function
+                if (!sourceApi) {
+                    console.warn("Could not find an event source with ID \"" + sourceInput + "\""); // TODO: test
+                    return null;
+                }
+                eventSource = sourceApi.internalEventSource;
+            }
+            var tuple = parseEvent(eventInput, eventSource, state, false);
+            if (tuple) {
+                var newEventApi = new EventApi(state, tuple.def, tuple.def.recurringDef ? null : tuple.instance);
+                this.dispatch({
+                    type: 'ADD_EVENTS',
+                    eventStore: eventTupleToStore(tuple),
+                });
+                this.triggerEventAdd(newEventApi);
+                return newEventApi;
+            }
+            return null;
+        };
+        CalendarApi.prototype.triggerEventAdd = function (eventApi) {
+            var _this = this;
+            var emitter = this.getCurrentData().emitter;
+            emitter.trigger('eventAdd', {
+                event: eventApi,
+                relatedEvents: [],
+                revert: function () {
+                    _this.dispatch({
+                        type: 'REMOVE_EVENTS',
+                        eventStore: eventApiToStore(eventApi),
+                    });
+                },
+            });
+        };
+        // TODO: optimize
+        CalendarApi.prototype.getEventById = function (id) {
+            var state = this.getCurrentData();
+            var _a = state.eventStore, defs = _a.defs, instances = _a.instances;
+            id = String(id);
+            for (var defId in defs) {
+                var def = defs[defId];
+                if (def.publicId === id) {
+                    if (def.recurringDef) {
+                        return new EventApi(state, def, null);
+                    }
+                    for (var instanceId in instances) {
+                        var instance = instances[instanceId];
+                        if (instance.defId === def.defId) {
+                            return new EventApi(state, def, instance);
+                        }
+                    }
+                }
+            }
+            return null;
+        };
+        CalendarApi.prototype.getEvents = function () {
+            var currentData = this.getCurrentData();
+            return buildEventApis(currentData.eventStore, currentData);
+        };
+        CalendarApi.prototype.removeAllEvents = function () {
+            this.dispatch({ type: 'REMOVE_ALL_EVENTS' });
+        };
+        // Public Event Sources API
+        // -----------------------------------------------------------------------------------------------------------------
+        CalendarApi.prototype.getEventSources = function () {
+            var state = this.getCurrentData();
+            var sourceHash = state.eventSources;
+            var sourceApis = [];
+            for (var internalId in sourceHash) {
+                sourceApis.push(new EventSourceApi(state, sourceHash[internalId]));
+            }
+            return sourceApis;
+        };
+        CalendarApi.prototype.getEventSourceById = function (id) {
+            var state = this.getCurrentData();
+            var sourceHash = state.eventSources;
+            id = String(id);
+            for (var sourceId in sourceHash) {
+                if (sourceHash[sourceId].publicId === id) {
+                    return new EventSourceApi(state, sourceHash[sourceId]);
+                }
+            }
+            return null;
+        };
+        CalendarApi.prototype.addEventSource = function (sourceInput) {
+            var state = this.getCurrentData();
+            if (sourceInput instanceof EventSourceApi) {
+                // not already present? don't want to add an old snapshot
+                if (!state.eventSources[sourceInput.internalEventSource.sourceId]) {
+                    this.dispatch({
+                        type: 'ADD_EVENT_SOURCES',
+                        sources: [sourceInput.internalEventSource],
+                    });
+                }
+                return sourceInput;
+            }
+            var eventSource = parseEventSource(sourceInput, state);
+            if (eventSource) { // TODO: error otherwise?
+                this.dispatch({ type: 'ADD_EVENT_SOURCES', sources: [eventSource] });
+                return new EventSourceApi(state, eventSource);
+            }
+            return null;
+        };
+        CalendarApi.prototype.removeAllEventSources = function () {
+            this.dispatch({ type: 'REMOVE_ALL_EVENT_SOURCES' });
+        };
+        CalendarApi.prototype.refetchEvents = function () {
+            this.dispatch({ type: 'FETCH_EVENT_SOURCES' });
+        };
+        // Scroll
+        // -----------------------------------------------------------------------------------------------------------------
+        CalendarApi.prototype.scrollToTime = function (timeInput) {
+            var time = createDuration(timeInput);
+            if (time) {
+                this.trigger('_scrollRequest', { time: time });
+            }
+        };
+        return CalendarApi;
+    }());
+
+    var EventApi = /** @class */ (function () {
+        // instance will be null if expressing a recurring event that has no current instances,
+        // OR if trying to validate an incoming external event that has no dates assigned
+        function EventApi(context, def, instance) {
+            this._context = context;
+            this._def = def;
+            this._instance = instance || null;
+        }
+        /*
+        TODO: make event struct more responsible for this
+        */
+        EventApi.prototype.setProp = function (name, val) {
+            var _a, _b;
+            if (name in EVENT_DATE_REFINERS) {
+                console.warn('Could not set date-related prop \'name\'. Use one of the date-related methods instead.');
+            }
+            else if (name in EVENT_NON_DATE_REFINERS) {
+                val = EVENT_NON_DATE_REFINERS[name](val);
+                this.mutate({
+                    standardProps: (_a = {}, _a[name] = val, _a),
+                });
+            }
+            else if (name in EVENT_UI_REFINERS) {
+                var ui = EVENT_UI_REFINERS[name](val);
+                if (name === 'color') {
+                    ui = { backgroundColor: val, borderColor: val };
+                }
+                else if (name === 'editable') {
+                    ui = { startEditable: val, durationEditable: val };
+                }
+                else {
+                    ui = (_b = {}, _b[name] = val, _b);
+                }
+                this.mutate({
+                    standardProps: { ui: ui },
+                });
+            }
+            else {
+                console.warn("Could not set prop '" + name + "'. Use setExtendedProp instead.");
+            }
+        };
+        EventApi.prototype.setExtendedProp = function (name, val) {
+            var _a;
+            this.mutate({
+                extendedProps: (_a = {}, _a[name] = val, _a),
+            });
+        };
+        EventApi.prototype.setStart = function (startInput, options) {
+            if (options === void 0) { options = {}; }
+            var dateEnv = this._context.dateEnv;
+            var start = dateEnv.createMarker(startInput);
+            if (start && this._instance) { // TODO: warning if parsed bad
+                var instanceRange = this._instance.range;
+                var startDelta = diffDates(instanceRange.start, start, dateEnv, options.granularity); // what if parsed bad!?
+                if (options.maintainDuration) {
+                    this.mutate({ datesDelta: startDelta });
+                }
+                else {
+                    this.mutate({ startDelta: startDelta });
+                }
+            }
+        };
+        EventApi.prototype.setEnd = function (endInput, options) {
+            if (options === void 0) { options = {}; }
+            var dateEnv = this._context.dateEnv;
+            var end;
+            if (endInput != null) {
+                end = dateEnv.createMarker(endInput);
+                if (!end) {
+                    return; // TODO: warning if parsed bad
+                }
+            }
+            if (this._instance) {
+                if (end) {
+                    var endDelta = diffDates(this._instance.range.end, end, dateEnv, options.granularity);
+                    this.mutate({ endDelta: endDelta });
+                }
+                else {
+                    this.mutate({ standardProps: { hasEnd: false } });
+                }
+            }
+        };
+        EventApi.prototype.setDates = function (startInput, endInput, options) {
+            if (options === void 0) { options = {}; }
+            var dateEnv = this._context.dateEnv;
+            var standardProps = { allDay: options.allDay };
+            var start = dateEnv.createMarker(startInput);
+            var end;
+            if (!start) {
+                return; // TODO: warning if parsed bad
+            }
+            if (endInput != null) {
+                end = dateEnv.createMarker(endInput);
+                if (!end) { // TODO: warning if parsed bad
+                    return;
+                }
+            }
+            if (this._instance) {
+                var instanceRange = this._instance.range;
+                // when computing the diff for an event being converted to all-day,
+                // compute diff off of the all-day values the way event-mutation does.
+                if (options.allDay === true) {
+                    instanceRange = computeAlignedDayRange(instanceRange);
+                }
+                var startDelta = diffDates(instanceRange.start, start, dateEnv, options.granularity);
+                if (end) {
+                    var endDelta = diffDates(instanceRange.end, end, dateEnv, options.granularity);
+                    if (durationsEqual(startDelta, endDelta)) {
+                        this.mutate({ datesDelta: startDelta, standardProps: standardProps });
+                    }
+                    else {
+                        this.mutate({ startDelta: startDelta, endDelta: endDelta, standardProps: standardProps });
+                    }
+                }
+                else { // means "clear the end"
+                    standardProps.hasEnd = false;
+                    this.mutate({ datesDelta: startDelta, standardProps: standardProps });
+                }
+            }
+        };
+        EventApi.prototype.moveStart = function (deltaInput) {
+            var delta = createDuration(deltaInput);
+            if (delta) { // TODO: warning if parsed bad
+                this.mutate({ startDelta: delta });
+            }
+        };
+        EventApi.prototype.moveEnd = function (deltaInput) {
+            var delta = createDuration(deltaInput);
+            if (delta) { // TODO: warning if parsed bad
+                this.mutate({ endDelta: delta });
+            }
+        };
+        EventApi.prototype.moveDates = function (deltaInput) {
+            var delta = createDuration(deltaInput);
+            if (delta) { // TODO: warning if parsed bad
+                this.mutate({ datesDelta: delta });
+            }
+        };
+        EventApi.prototype.setAllDay = function (allDay, options) {
+            if (options === void 0) { options = {}; }
+            var standardProps = { allDay: allDay };
+            var maintainDuration = options.maintainDuration;
+            if (maintainDuration == null) {
+                maintainDuration = this._context.options.allDayMaintainDuration;
+            }
+            if (this._def.allDay !== allDay) {
+                standardProps.hasEnd = maintainDuration;
+            }
+            this.mutate({ standardProps: standardProps });
+        };
+        EventApi.prototype.formatRange = function (formatInput) {
+            var dateEnv = this._context.dateEnv;
+            var instance = this._instance;
+            var formatter = createFormatter(formatInput);
+            if (this._def.hasEnd) {
+                return dateEnv.formatRange(instance.range.start, instance.range.end, formatter, {
+                    forcedStartTzo: instance.forcedStartTzo,
+                    forcedEndTzo: instance.forcedEndTzo,
+                });
+            }
+            return dateEnv.format(instance.range.start, formatter, {
+                forcedTzo: instance.forcedStartTzo,
+            });
+        };
+        EventApi.prototype.mutate = function (mutation) {
+            var instance = this._instance;
+            if (instance) {
+                var def = this._def;
+                var context_1 = this._context;
+                var eventStore_1 = context_1.getCurrentData().eventStore;
+                var relevantEvents = getRelevantEvents(eventStore_1, instance.instanceId);
+                var eventConfigBase = {
+                    '': {
+                        display: '',
+                        startEditable: true,
+                        durationEditable: true,
+                        constraints: [],
+                        overlap: null,
+                        allows: [],
+                        backgroundColor: '',
+                        borderColor: '',
+                        textColor: '',
+                        classNames: [],
+                    },
+                };
+                relevantEvents = applyMutationToEventStore(relevantEvents, eventConfigBase, mutation, context_1);
+                var oldEvent = new EventApi(context_1, def, instance); // snapshot
+                this._def = relevantEvents.defs[def.defId];
+                this._instance = relevantEvents.instances[instance.instanceId];
+                context_1.dispatch({
+                    type: 'MERGE_EVENTS',
+                    eventStore: relevantEvents,
+                });
+                context_1.emitter.trigger('eventChange', {
+                    oldEvent: oldEvent,
+                    event: this,
+                    relatedEvents: buildEventApis(relevantEvents, context_1, instance),
+                    revert: function () {
+                        context_1.dispatch({
+                            type: 'RESET_EVENTS',
+                            eventStore: eventStore_1,
+                        });
+                    },
+                });
+            }
+        };
+        EventApi.prototype.remove = function () {
+            var context = this._context;
+            var asStore = eventApiToStore(this);
+            context.dispatch({
+                type: 'REMOVE_EVENTS',
+                eventStore: asStore,
+            });
+            context.emitter.trigger('eventRemove', {
+                event: this,
+                relatedEvents: [],
+                revert: function () {
+                    context.dispatch({
+                        type: 'MERGE_EVENTS',
+                        eventStore: asStore,
+                    });
+                },
+            });
+        };
+        Object.defineProperty(EventApi.prototype, "source", {
+            get: function () {
+                var sourceId = this._def.sourceId;
+                if (sourceId) {
+                    return new EventSourceApi(this._context, this._context.getCurrentData().eventSources[sourceId]);
+                }
+                return null;
+            },
+            enumerable: false,
+            configurable: true
+        });
+        Object.defineProperty(EventApi.prototype, "start", {
+            get: function () {
+                return this._instance ?
+                    this._context.dateEnv.toDate(this._instance.range.start) :
+                    null;
+            },
+            enumerable: false,
+            configurable: true
+        });
+        Object.defineProperty(EventApi.prototype, "end", {
+            get: function () {
+                return (this._instance && this._def.hasEnd) ?
+                    this._context.dateEnv.toDate(this._instance.range.end) :
+                    null;
+            },
+            enumerable: false,
+            configurable: true
+        });
+        Object.defineProperty(EventApi.prototype, "startStr", {
+            get: function () {
+                var instance = this._instance;
+                if (instance) {
+                    return this._context.dateEnv.formatIso(instance.range.start, {
+                        omitTime: this._def.allDay,
+                        forcedTzo: instance.forcedStartTzo,
+                    });
+                }
+                return '';
+            },
+            enumerable: false,
+            configurable: true
+        });
+        Object.defineProperty(EventApi.prototype, "endStr", {
+            get: function () {
+                var instance = this._instance;
+                if (instance && this._def.hasEnd) {
+                    return this._context.dateEnv.formatIso(instance.range.end, {
+                        omitTime: this._def.allDay,
+                        forcedTzo: instance.forcedEndTzo,
+                    });
+                }
+                return '';
+            },
+            enumerable: false,
+            configurable: true
+        });
+        Object.defineProperty(EventApi.prototype, "id", {
+            // computable props that all access the def
+            // TODO: find a TypeScript-compatible way to do this at scale
+            get: function () { return this._def.publicId; },
+            enumerable: false,
+            configurable: true
+        });
+        Object.defineProperty(EventApi.prototype, "groupId", {
+            get: function () { return this._def.groupId; },
+            enumerable: false,
+            configurable: true
+        });
+        Object.defineProperty(EventApi.prototype, "allDay", {
+            get: function () { return this._def.allDay; },
+            enumerable: false,
+            configurable: true
+        });
+        Object.defineProperty(EventApi.prototype, "title", {
+            get: function () { return this._def.title; },
+            enumerable: false,
+            configurable: true
+        });
+        Object.defineProperty(EventApi.prototype, "url", {
+            get: function () { return this._def.url; },
+            enumerable: false,
+            configurable: true
+        });
+        Object.defineProperty(EventApi.prototype, "display", {
+            get: function () { return this._def.ui.display || 'auto'; } // bad. just normalize the type earlier
+            ,
+            enumerable: false,
+            configurable: true
+        });
+        Object.defineProperty(EventApi.prototype, "startEditable", {
+            get: function () { return this._def.ui.startEditable; },
+            enumerable: false,
+            configurable: true
+        });
+        Object.defineProperty(EventApi.prototype, "durationEditable", {
+            get: function () { return this._def.ui.durationEditable; },
+            enumerable: false,
+            configurable: true
+        });
+        Object.defineProperty(EventApi.prototype, "constraint", {
+            get: function () { return this._def.ui.constraints[0] || null; },
+            enumerable: false,
+            configurable: true
+        });
+        Object.defineProperty(EventApi.prototype, "overlap", {
+            get: function () { return this._def.ui.overlap; },
+            enumerable: false,
+            configurable: true
+        });
+        Object.defineProperty(EventApi.prototype, "allow", {
+            get: function () { return this._def.ui.allows[0] || null; },
+            enumerable: false,
+            configurable: true
+        });
+        Object.defineProperty(EventApi.prototype, "backgroundColor", {
+            get: function () { return this._def.ui.backgroundColor; },
+            enumerable: false,
+            configurable: true
+        });
+        Object.defineProperty(EventApi.prototype, "borderColor", {
+            get: function () { return this._def.ui.borderColor; },
+            enumerable: false,
+            configurable: true
+        });
+        Object.defineProperty(EventApi.prototype, "textColor", {
+            get: function () { return this._def.ui.textColor; },
+            enumerable: false,
+            configurable: true
+        });
+        Object.defineProperty(EventApi.prototype, "classNames", {
+            // NOTE: user can't modify these because Object.freeze was called in event-def parsing
+            get: function () { return this._def.ui.classNames; },
+            enumerable: false,
+            configurable: true
+        });
+        Object.defineProperty(EventApi.prototype, "extendedProps", {
+            get: function () { return this._def.extendedProps; },
+            enumerable: false,
+            configurable: true
+        });
+        EventApi.prototype.toPlainObject = function (settings) {
+            if (settings === void 0) { settings = {}; }
+            var def = this._def;
+            var ui = def.ui;
+            var _a = this, startStr = _a.startStr, endStr = _a.endStr;
+            var res = {};
+            if (def.title) {
+                res.title = def.title;
+            }
+            if (startStr) {
+                res.start = startStr;
+            }
+            if (endStr) {
+                res.end = endStr;
+            }
+            if (def.publicId) {
+                res.id = def.publicId;
+            }
+            if (def.groupId) {
+                res.groupId = def.groupId;
+            }
+            if (def.url) {
+                res.url = def.url;
+            }
+            if (ui.display && ui.display !== 'auto') {
+                res.display = ui.display;
+            }
+            // TODO: what about recurring-event properties???
+            // TODO: include startEditable/durationEditable/constraint/overlap/allow
+            if (settings.collapseColor && ui.backgroundColor && ui.backgroundColor === ui.borderColor) {
+                res.color = ui.backgroundColor;
+            }
+            else {
+                if (ui.backgroundColor) {
+                    res.backgroundColor = ui.backgroundColor;
+                }
+                if (ui.borderColor) {
+                    res.borderColor = ui.borderColor;
+                }
+            }
+            if (ui.textColor) {
+                res.textColor = ui.textColor;
+            }
+            if (ui.classNames.length) {
+                res.classNames = ui.classNames;
+            }
+            if (Object.keys(def.extendedProps).length) {
+                if (settings.collapseExtendedProps) {
+                    __assign(res, def.extendedProps);
+                }
+                else {
+                    res.extendedProps = def.extendedProps;
+                }
+            }
+            return res;
+        };
+        EventApi.prototype.toJSON = function () {
+            return this.toPlainObject();
+        };
+        return EventApi;
+    }());
+    function eventApiToStore(eventApi) {
+        var _a, _b;
+        var def = eventApi._def;
+        var instance = eventApi._instance;
+        return {
+            defs: (_a = {}, _a[def.defId] = def, _a),
+            instances: instance
+                ? (_b = {}, _b[instance.instanceId] = instance, _b) : {},
+        };
+    }
+    function buildEventApis(eventStore, context, excludeInstance) {
+        var defs = eventStore.defs, instances = eventStore.instances;
+        var eventApis = [];
+        var excludeInstanceId = excludeInstance ? excludeInstance.instanceId : '';
+        for (var id in instances) {
+            var instance = instances[id];
+            var def = defs[instance.defId];
+            if (instance.instanceId !== excludeInstanceId) {
+                eventApis.push(new EventApi(context, def, instance));
+            }
+        }
+        return eventApis;
+    }
+
+    var calendarSystemClassMap = {};
+    function registerCalendarSystem(name, theClass) {
+        calendarSystemClassMap[name] = theClass;
+    }
+    function createCalendarSystem(name) {
+        return new calendarSystemClassMap[name]();
+    }
+    var GregorianCalendarSystem = /** @class */ (function () {
+        function GregorianCalendarSystem() {
+        }
+        GregorianCalendarSystem.prototype.getMarkerYear = function (d) {
+            return d.getUTCFullYear();
+        };
+        GregorianCalendarSystem.prototype.getMarkerMonth = function (d) {
+            return d.getUTCMonth();
+        };
+        GregorianCalendarSystem.prototype.getMarkerDay = function (d) {
+            return d.getUTCDate();
+        };
+        GregorianCalendarSystem.prototype.arrayToMarker = function (arr) {
+            return arrayToUtcDate(arr);
+        };
+        GregorianCalendarSystem.prototype.markerToArray = function (marker) {
+            return dateToUtcArray(marker);
+        };
+        return GregorianCalendarSystem;
+    }());
+    registerCalendarSystem('gregory', GregorianCalendarSystem);
+
+    var ISO_RE = /^\s*(\d{4})(-?(\d{2})(-?(\d{2})([T ](\d{2}):?(\d{2})(:?(\d{2})(\.(\d+))?)?(Z|(([-+])(\d{2})(:?(\d{2}))?))?)?)?)?$/;
+    function parse(str) {
+        var m = ISO_RE.exec(str);
+        if (m) {
+            var marker = new Date(Date.UTC(Number(m[1]), m[3] ? Number(m[3]) - 1 : 0, Number(m[5] || 1), Number(m[7] || 0), Number(m[8] || 0), Number(m[10] || 0), m[12] ? Number("0." + m[12]) * 1000 : 0));
+            if (isValidDate(marker)) {
+                var timeZoneOffset = null;
+                if (m[13]) {
+                    timeZoneOffset = (m[15] === '-' ? -1 : 1) * (Number(m[16] || 0) * 60 +
+                        Number(m[18] || 0));
+                }
+                return {
+                    marker: marker,
+                    isTimeUnspecified: !m[6],
+                    timeZoneOffset: timeZoneOffset,
+                };
+            }
+        }
+        return null;
+    }
+
+    var DateEnv = /** @class */ (function () {
+        function DateEnv(settings) {
+            var timeZone = this.timeZone = settings.timeZone;
+            var isNamedTimeZone = timeZone !== 'local' && timeZone !== 'UTC';
+            if (settings.namedTimeZoneImpl && isNamedTimeZone) {
+                this.namedTimeZoneImpl = new settings.namedTimeZoneImpl(timeZone);
+            }
+            this.canComputeOffset = Boolean(!isNamedTimeZone || this.namedTimeZoneImpl);
+            this.calendarSystem = createCalendarSystem(settings.calendarSystem);
+            this.locale = settings.locale;
+            this.weekDow = settings.locale.week.dow;
+            this.weekDoy = settings.locale.week.doy;
+            if (settings.weekNumberCalculation === 'ISO') {
+                this.weekDow = 1;
+                this.weekDoy = 4;
+            }
+            if (typeof settings.firstDay === 'number') {
+                this.weekDow = settings.firstDay;
+            }
+            if (typeof settings.weekNumberCalculation === 'function') {
+                this.weekNumberFunc = settings.weekNumberCalculation;
+            }
+            this.weekText = settings.weekText != null ? settings.weekText : settings.locale.options.weekText;
+            this.cmdFormatter = settings.cmdFormatter;
+            this.defaultSeparator = settings.defaultSeparator;
+        }
+        // Creating / Parsing
+        DateEnv.prototype.createMarker = function (input) {
+            var meta = this.createMarkerMeta(input);
+            if (meta === null) {
+                return null;
+            }
+            return meta.marker;
+        };
+        DateEnv.prototype.createNowMarker = function () {
+            if (this.canComputeOffset) {
+                return this.timestampToMarker(new Date().valueOf());
+            }
+            // if we can't compute the current date val for a timezone,
+            // better to give the current local date vals than UTC
+            return arrayToUtcDate(dateToLocalArray(new Date()));
+        };
+        DateEnv.prototype.createMarkerMeta = function (input) {
+            if (typeof input === 'string') {
+                return this.parse(input);
+            }
+            var marker = null;
+            if (typeof input === 'number') {
+                marker = this.timestampToMarker(input);
+            }
+            else if (input instanceof Date) {
+                input = input.valueOf();
+                if (!isNaN(input)) {
+                    marker = this.timestampToMarker(input);
+                }
+            }
+            else if (Array.isArray(input)) {
+                marker = arrayToUtcDate(input);
+            }
+            if (marker === null || !isValidDate(marker)) {
+                return null;
+            }
+            return { marker: marker, isTimeUnspecified: false, forcedTzo: null };
+        };
+        DateEnv.prototype.parse = function (s) {
+            var parts = parse(s);
+            if (parts === null) {
+                return null;
+            }
+            var marker = parts.marker;
+            var forcedTzo = null;
+            if (parts.timeZoneOffset !== null) {
+                if (this.canComputeOffset) {
+                    marker = this.timestampToMarker(marker.valueOf() - parts.timeZoneOffset * 60 * 1000);
+                }
+                else {
+                    forcedTzo = parts.timeZoneOffset;
+                }
+            }
+            return { marker: marker, isTimeUnspecified: parts.isTimeUnspecified, forcedTzo: forcedTzo };
+        };
+        // Accessors
+        DateEnv.prototype.getYear = function (marker) {
+            return this.calendarSystem.getMarkerYear(marker);
+        };
+        DateEnv.prototype.getMonth = function (marker) {
+            return this.calendarSystem.getMarkerMonth(marker);
+        };
+        // Adding / Subtracting
+        DateEnv.prototype.add = function (marker, dur) {
+            var a = this.calendarSystem.markerToArray(marker);
+            a[0] += dur.years;
+            a[1] += dur.months;
+            a[2] += dur.days;
+            a[6] += dur.milliseconds;
+            return this.calendarSystem.arrayToMarker(a);
+        };
+        DateEnv.prototype.subtract = function (marker, dur) {
+            var a = this.calendarSystem.markerToArray(marker);
+            a[0] -= dur.years;
+            a[1] -= dur.months;
+            a[2] -= dur.days;
+            a[6] -= dur.milliseconds;
+            return this.calendarSystem.arrayToMarker(a);
+        };
+        DateEnv.prototype.addYears = function (marker, n) {
+            var a = this.calendarSystem.markerToArray(marker);
+            a[0] += n;
+            return this.calendarSystem.arrayToMarker(a);
+        };
+        DateEnv.prototype.addMonths = function (marker, n) {
+            var a = this.calendarSystem.markerToArray(marker);
+            a[1] += n;
+            return this.calendarSystem.arrayToMarker(a);
+        };
+        // Diffing Whole Units
+        DateEnv.prototype.diffWholeYears = function (m0, m1) {
+            var calendarSystem = this.calendarSystem;
+            if (timeAsMs(m0) === timeAsMs(m1) &&
+                calendarSystem.getMarkerDay(m0) === calendarSystem.getMarkerDay(m1) &&
+                calendarSystem.getMarkerMonth(m0) === calendarSystem.getMarkerMonth(m1)) {
+                return calendarSystem.getMarkerYear(m1) - calendarSystem.getMarkerYear(m0);
+            }
+            return null;
+        };
+        DateEnv.prototype.diffWholeMonths = function (m0, m1) {
+            var calendarSystem = this.calendarSystem;
+            if (timeAsMs(m0) === timeAsMs(m1) &&
+                calendarSystem.getMarkerDay(m0) === calendarSystem.getMarkerDay(m1)) {
+                return (calendarSystem.getMarkerMonth(m1) - calendarSystem.getMarkerMonth(m0)) +
+                    (calendarSystem.getMarkerYear(m1) - calendarSystem.getMarkerYear(m0)) * 12;
+            }
+            return null;
+        };
+        // Range / Duration
+        DateEnv.prototype.greatestWholeUnit = function (m0, m1) {
+            var n = this.diffWholeYears(m0, m1);
+            if (n !== null) {
+                return { unit: 'year', value: n };
+            }
+            n = this.diffWholeMonths(m0, m1);
+            if (n !== null) {
+                return { unit: 'month', value: n };
+            }
+            n = diffWholeWeeks(m0, m1);
+            if (n !== null) {
+                return { unit: 'week', value: n };
+            }
+            n = diffWholeDays(m0, m1);
+            if (n !== null) {
+                return { unit: 'day', value: n };
+            }
+            n = diffHours(m0, m1);
+            if (isInt(n)) {
+                return { unit: 'hour', value: n };
+            }
+            n = diffMinutes(m0, m1);
+            if (isInt(n)) {
+                return { unit: 'minute', value: n };
+            }
+            n = diffSeconds(m0, m1);
+            if (isInt(n)) {
+                return { unit: 'second', value: n };
+            }
+            return { unit: 'millisecond', value: m1.valueOf() - m0.valueOf() };
+        };
+        DateEnv.prototype.countDurationsBetween = function (m0, m1, d) {
+            // TODO: can use greatestWholeUnit
+            var diff;
+            if (d.years) {
+                diff = this.diffWholeYears(m0, m1);
+                if (diff !== null) {
+                    return diff / asRoughYears(d);
+                }
+            }
+            if (d.months) {
+                diff = this.diffWholeMonths(m0, m1);
+                if (diff !== null) {
+                    return diff / asRoughMonths(d);
+                }
+            }
+            if (d.days) {
+                diff = diffWholeDays(m0, m1);
+                if (diff !== null) {
+                    return diff / asRoughDays(d);
+                }
+            }
+            return (m1.valueOf() - m0.valueOf()) / asRoughMs(d);
+        };
+        // Start-Of
+        // these DON'T return zoned-dates. only UTC start-of dates
+        DateEnv.prototype.startOf = function (m, unit) {
+            if (unit === 'year') {
+                return this.startOfYear(m);
+            }
+            if (unit === 'month') {
+                return this.startOfMonth(m);
+            }
+            if (unit === 'week') {
+                return this.startOfWeek(m);
+            }
+            if (unit === 'day') {
+                return startOfDay(m);
+            }
+            if (unit === 'hour') {
+                return startOfHour(m);
+            }
+            if (unit === 'minute') {
+                return startOfMinute(m);
+            }
+            if (unit === 'second') {
+                return startOfSecond(m);
+            }
+            return null;
+        };
+        DateEnv.prototype.startOfYear = function (m) {
+            return this.calendarSystem.arrayToMarker([
+                this.calendarSystem.getMarkerYear(m),
+            ]);
+        };
+        DateEnv.prototype.startOfMonth = function (m) {
+            return this.calendarSystem.arrayToMarker([
+                this.calendarSystem.getMarkerYear(m),
+                this.calendarSystem.getMarkerMonth(m),
+            ]);
+        };
+        DateEnv.prototype.startOfWeek = function (m) {
+            return this.calendarSystem.arrayToMarker([
+                this.calendarSystem.getMarkerYear(m),
+                this.calendarSystem.getMarkerMonth(m),
+                m.getUTCDate() - ((m.getUTCDay() - this.weekDow + 7) % 7),
+            ]);
+        };
+        // Week Number
+        DateEnv.prototype.computeWeekNumber = function (marker) {
+            if (this.weekNumberFunc) {
+                return this.weekNumberFunc(this.toDate(marker));
+            }
+            return weekOfYear(marker, this.weekDow, this.weekDoy);
+        };
+        // TODO: choke on timeZoneName: long
+        DateEnv.prototype.format = function (marker, formatter, dateOptions) {
+            if (dateOptions === void 0) { dateOptions = {}; }
+            return formatter.format({
+                marker: marker,
+                timeZoneOffset: dateOptions.forcedTzo != null ?
+                    dateOptions.forcedTzo :
+                    this.offsetForMarker(marker),
+            }, this);
+        };
+        DateEnv.prototype.formatRange = function (start, end, formatter, dateOptions) {
+            if (dateOptions === void 0) { dateOptions = {}; }
+            if (dateOptions.isEndExclusive) {
+                end = addMs(end, -1);
+            }
+            return formatter.formatRange({
+                marker: start,
+                timeZoneOffset: dateOptions.forcedStartTzo != null ?
+                    dateOptions.forcedStartTzo :
+                    this.offsetForMarker(start),
+            }, {
+                marker: end,
+                timeZoneOffset: dateOptions.forcedEndTzo != null ?
+                    dateOptions.forcedEndTzo :
+                    this.offsetForMarker(end),
+            }, this, dateOptions.defaultSeparator);
+        };
+        /*
+        DUMB: the omitTime arg is dumb. if we omit the time, we want to omit the timezone offset. and if we do that,
+        might as well use buildIsoString or some other util directly
+        */
+        DateEnv.prototype.formatIso = function (marker, extraOptions) {
+            if (extraOptions === void 0) { extraOptions = {}; }
+            var timeZoneOffset = null;
+            if (!extraOptions.omitTimeZoneOffset) {
+                if (extraOptions.forcedTzo != null) {
+                    timeZoneOffset = extraOptions.forcedTzo;
+                }
+                else {
+                    timeZoneOffset = this.offsetForMarker(marker);
+                }
+            }
+            return buildIsoString(marker, timeZoneOffset, extraOptions.omitTime);
+        };
+        // TimeZone
+        DateEnv.prototype.timestampToMarker = function (ms) {
+            if (this.timeZone === 'local') {
+                return arrayToUtcDate(dateToLocalArray(new Date(ms)));
+            }
+            if (this.timeZone === 'UTC' || !this.namedTimeZoneImpl) {
+                return new Date(ms);
+            }
+            return arrayToUtcDate(this.namedTimeZoneImpl.timestampToArray(ms));
+        };
+        DateEnv.prototype.offsetForMarker = function (m) {
+            if (this.timeZone === 'local') {
+                return -arrayToLocalDate(dateToUtcArray(m)).getTimezoneOffset(); // convert "inverse" offset to "normal" offset
+            }
+            if (this.timeZone === 'UTC') {
+                return 0;
+            }
+            if (this.namedTimeZoneImpl) {
+                return this.namedTimeZoneImpl.offsetForArray(dateToUtcArray(m));
+            }
+            return null;
+        };
+        // Conversion
+        DateEnv.prototype.toDate = function (m, forcedTzo) {
+            if (this.timeZone === 'local') {
+                return arrayToLocalDate(dateToUtcArray(m));
+            }
+            if (this.timeZone === 'UTC') {
+                return new Date(m.valueOf()); // make sure it's a copy
+            }
+            if (!this.namedTimeZoneImpl) {
+                return new Date(m.valueOf() - (forcedTzo || 0));
+            }
+            return new Date(m.valueOf() -
+                this.namedTimeZoneImpl.offsetForArray(dateToUtcArray(m)) * 1000 * 60);
+        };
+        return DateEnv;
+    }());
+
+    var globalLocales = [];
+
+    var RAW_EN_LOCALE = {
+        code: 'en',
+        week: {
+            dow: 0,
+            doy: 4,
+        },
+        direction: 'ltr',
+        buttonText: {
+            prev: 'prev',
+            next: 'next',
+            prevYear: 'prev year',
+            nextYear: 'next year',
+            year: 'year',
+            today: 'today',
+            month: 'month',
+            week: 'week',
+            day: 'day',
+            list: 'list',
+        },
+        weekText: 'W',
+        allDayText: 'all-day',
+        moreLinkText: 'more',
+        noEventsText: 'No events to display',
+    };
+    function organizeRawLocales(explicitRawLocales) {
+        var defaultCode = explicitRawLocales.length > 0 ? explicitRawLocales[0].code : 'en';
+        var allRawLocales = globalLocales.concat(explicitRawLocales);
+        var rawLocaleMap = {
+            en: RAW_EN_LOCALE,
+        };
+        for (var _i = 0, allRawLocales_1 = allRawLocales; _i < allRawLocales_1.length; _i++) {
+            var rawLocale = allRawLocales_1[_i];
+            rawLocaleMap[rawLocale.code] = rawLocale;
+        }
+        return {
+            map: rawLocaleMap,
+            defaultCode: defaultCode,
+        };
+    }
+    function buildLocale(inputSingular, available) {
+        if (typeof inputSingular === 'object' && !Array.isArray(inputSingular)) {
+            return parseLocale(inputSingular.code, [inputSingular.code], inputSingular);
+        }
+        return queryLocale(inputSingular, available);
+    }
+    function queryLocale(codeArg, available) {
+        var codes = [].concat(codeArg || []); // will convert to array
+        var raw = queryRawLocale(codes, available) || RAW_EN_LOCALE;
+        return parseLocale(codeArg, codes, raw);
+    }
+    function queryRawLocale(codes, available) {
+        for (var i = 0; i < codes.length; i += 1) {
+            var parts = codes[i].toLocaleLowerCase().split('-');
+            for (var j = parts.length; j > 0; j -= 1) {
+                var simpleId = parts.slice(0, j).join('-');
+                if (available[simpleId]) {
+                    return available[simpleId];
+                }
+            }
+        }
+        return null;
+    }
+    function parseLocale(codeArg, codes, raw) {
+        var merged = mergeProps([RAW_EN_LOCALE, raw], ['buttonText']);
+        delete merged.code; // don't want this part of the options
+        var week = merged.week;
+        delete merged.week;
+        return {
+            codeArg: codeArg,
+            codes: codes,
+            week: week,
+            simpleNumberFormat: new Intl.NumberFormat(codeArg),
+            options: merged,
+        };
+    }
+
+    function formatDate(dateInput, options) {
+        if (options === void 0) { options = {}; }
+        var dateEnv = buildDateEnv(options);
+        var formatter = createFormatter(options);
+        var dateMeta = dateEnv.createMarkerMeta(dateInput);
+        if (!dateMeta) { // TODO: warning?
+            return '';
+        }
+        return dateEnv.format(dateMeta.marker, formatter, {
+            forcedTzo: dateMeta.forcedTzo,
+        });
+    }
+    function formatRange(startInput, endInput, options) {
+        var dateEnv = buildDateEnv(typeof options === 'object' && options ? options : {}); // pass in if non-null object
+        var formatter = createFormatter(options);
+        var startMeta = dateEnv.createMarkerMeta(startInput);
+        var endMeta = dateEnv.createMarkerMeta(endInput);
+        if (!startMeta || !endMeta) { // TODO: warning?
+            return '';
+        }
+        return dateEnv.formatRange(startMeta.marker, endMeta.marker, formatter, {
+            forcedStartTzo: startMeta.forcedTzo,
+            forcedEndTzo: endMeta.forcedTzo,
+            isEndExclusive: options.isEndExclusive,
+            defaultSeparator: BASE_OPTION_DEFAULTS.defaultRangeSeparator,
+        });
+    }
+    // TODO: more DRY and optimized
+    function buildDateEnv(settings) {
+        var locale = buildLocale(settings.locale || 'en', organizeRawLocales([]).map); // TODO: don't hardcode 'en' everywhere
+        return new DateEnv(__assign(__assign({ timeZone: BASE_OPTION_DEFAULTS.timeZone, calendarSystem: 'gregory' }, settings), { locale: locale }));
+    }
+
+    var DEF_DEFAULTS = {
+        startTime: '09:00',
+        endTime: '17:00',
+        daysOfWeek: [1, 2, 3, 4, 5],
+        display: 'inverse-background',
+        classNames: 'fc-non-business',
+        groupId: '_businessHours',
+    };
+    /*
+    TODO: pass around as EventDefHash!!!
+    */
+    function parseBusinessHours(input, context) {
+        return parseEvents(refineInputs(input), null, context);
+    }
+    function refineInputs(input) {
+        var rawDefs;
+        if (input === true) {
+            rawDefs = [{}]; // will get DEF_DEFAULTS verbatim
+        }
+        else if (Array.isArray(input)) {
+            // if specifying an array, every sub-definition NEEDS a day-of-week
+            rawDefs = input.filter(function (rawDef) { return rawDef.daysOfWeek; });
+        }
+        else if (typeof input === 'object' && input) { // non-null object
+            rawDefs = [input];
+        }
+        else { // is probably false
+            rawDefs = [];
+        }
+        rawDefs = rawDefs.map(function (rawDef) { return (__assign(__assign({}, DEF_DEFAULTS), rawDef)); });
+        return rawDefs;
+    }
+
+    function pointInsideRect(point, rect) {
+        return point.left >= rect.left &&
+            point.left < rect.right &&
+            point.top >= rect.top &&
+            point.top < rect.bottom;
+    }
+    // Returns a new rectangle that is the intersection of the two rectangles. If they don't intersect, returns false
+    function intersectRects(rect1, rect2) {
+        var res = {
+            left: Math.max(rect1.left, rect2.left),
+            right: Math.min(rect1.right, rect2.right),
+            top: Math.max(rect1.top, rect2.top),
+            bottom: Math.min(rect1.bottom, rect2.bottom),
+        };
+        if (res.left < res.right && res.top < res.bottom) {
+            return res;
+        }
+        return false;
+    }
+    function translateRect(rect, deltaX, deltaY) {
+        return {
+            left: rect.left + deltaX,
+            right: rect.right + deltaX,
+            top: rect.top + deltaY,
+            bottom: rect.bottom + deltaY,
+        };
+    }
+    // Returns a new point that will have been moved to reside within the given rectangle
+    function constrainPoint(point, rect) {
+        return {
+            left: Math.min(Math.max(point.left, rect.left), rect.right),
+            top: Math.min(Math.max(point.top, rect.top), rect.bottom),
+        };
+    }
+    // Returns a point that is the center of the given rectangle
+    function getRectCenter(rect) {
+        return {
+            left: (rect.left + rect.right) / 2,
+            top: (rect.top + rect.bottom) / 2,
+        };
+    }
+    // Subtracts point2's coordinates from point1's coordinates, returning a delta
+    function diffPoints(point1, point2) {
+        return {
+            left: point1.left - point2.left,
+            top: point1.top - point2.top,
+        };
+    }
+
+    var canVGrowWithinCell;
+    function getCanVGrowWithinCell() {
+        if (canVGrowWithinCell == null) {
+            canVGrowWithinCell = computeCanVGrowWithinCell();
+        }
+        return canVGrowWithinCell;
+    }
+    function computeCanVGrowWithinCell() {
+        // for SSR, because this function is call immediately at top-level
+        // TODO: just make this logic execute top-level, immediately, instead of doing lazily
+        if (typeof document === 'undefined') {
+            return true;
+        }
+        var el = document.createElement('div');
+        el.style.position = 'absolute';
+        el.style.top = '0px';
+        el.style.left = '0px';
+        el.innerHTML = '<table><tr><td><div></div></td></tr></table>';
+        el.querySelector('table').style.height = '100px';
+        el.querySelector('div').style.height = '100%';
+        document.body.appendChild(el);
+        var div = el.querySelector('div');
+        var possible = div.offsetHeight > 0;
+        document.body.removeChild(el);
+        return possible;
+    }
+
+    var EMPTY_EVENT_STORE = createEmptyEventStore(); // for purecomponents. TODO: keep elsewhere
+    var Splitter = /** @class */ (function () {
+        function Splitter() {
+            this.getKeysForEventDefs = memoize(this._getKeysForEventDefs);
+            this.splitDateSelection = memoize(this._splitDateSpan);
+            this.splitEventStore = memoize(this._splitEventStore);
+            this.splitIndividualUi = memoize(this._splitIndividualUi);
+            this.splitEventDrag = memoize(this._splitInteraction);
+            this.splitEventResize = memoize(this._splitInteraction);
+            this.eventUiBuilders = {}; // TODO: typescript protection
+        }
+        Splitter.prototype.splitProps = function (props) {
+            var _this = this;
+            var keyInfos = this.getKeyInfo(props);
+            var defKeys = this.getKeysForEventDefs(props.eventStore);
+            var dateSelections = this.splitDateSelection(props.dateSelection);
+            var individualUi = this.splitIndividualUi(props.eventUiBases, defKeys); // the individual *bases*
+            var eventStores = this.splitEventStore(props.eventStore, defKeys);
+            var eventDrags = this.splitEventDrag(props.eventDrag);
+            var eventResizes = this.splitEventResize(props.eventResize);
+            var splitProps = {};
+            this.eventUiBuilders = mapHash(keyInfos, function (info, key) { return _this.eventUiBuilders[key] || memoize(buildEventUiForKey); });
+            for (var key in keyInfos) {
+                var keyInfo = keyInfos[key];
+                var eventStore = eventStores[key] || EMPTY_EVENT_STORE;
+                var buildEventUi = this.eventUiBuilders[key];
+                splitProps[key] = {
+                    businessHours: keyInfo.businessHours || props.businessHours,
+                    dateSelection: dateSelections[key] || null,
+                    eventStore: eventStore,
+                    eventUiBases: buildEventUi(props.eventUiBases[''], keyInfo.ui, individualUi[key]),
+                    eventSelection: eventStore.instances[props.eventSelection] ? props.eventSelection : '',
+                    eventDrag: eventDrags[key] || null,
+                    eventResize: eventResizes[key] || null,
+                };
+            }
+            return splitProps;
+        };
+        Splitter.prototype._splitDateSpan = function (dateSpan) {
+            var dateSpans = {};
+            if (dateSpan) {
+                var keys = this.getKeysForDateSpan(dateSpan);
+                for (var _i = 0, keys_1 = keys; _i < keys_1.length; _i++) {
+                    var key = keys_1[_i];
+                    dateSpans[key] = dateSpan;
+                }
+            }
+            return dateSpans;
+        };
+        Splitter.prototype._getKeysForEventDefs = function (eventStore) {
+            var _this = this;
+            return mapHash(eventStore.defs, function (eventDef) { return _this.getKeysForEventDef(eventDef); });
+        };
+        Splitter.prototype._splitEventStore = function (eventStore, defKeys) {
+            var defs = eventStore.defs, instances = eventStore.instances;
+            var splitStores = {};
+            for (var defId in defs) {
+                for (var _i = 0, _a = defKeys[defId]; _i < _a.length; _i++) {
+                    var key = _a[_i];
+                    if (!splitStores[key]) {
+                        splitStores[key] = createEmptyEventStore();
+                    }
+                    splitStores[key].defs[defId] = defs[defId];
+                }
+            }
+            for (var instanceId in instances) {
+                var instance = instances[instanceId];
+                for (var _b = 0, _c = defKeys[instance.defId]; _b < _c.length; _b++) {
+                    var key = _c[_b];
+                    if (splitStores[key]) { // must have already been created
+                        splitStores[key].instances[instanceId] = instance;
+                    }
+                }
+            }
+            return splitStores;
+        };
+        Splitter.prototype._splitIndividualUi = function (eventUiBases, defKeys) {
+            var splitHashes = {};
+            for (var defId in eventUiBases) {
+                if (defId) { // not the '' key
+                    for (var _i = 0, _a = defKeys[defId]; _i < _a.length; _i++) {
+                        var key = _a[_i];
+                        if (!splitHashes[key]) {
+                            splitHashes[key] = {};
+                        }
+                        splitHashes[key][defId] = eventUiBases[defId];
+                    }
+                }
+            }
+            return splitHashes;
+        };
+        Splitter.prototype._splitInteraction = function (interaction) {
+            var splitStates = {};
+            if (interaction) {
+                var affectedStores_1 = this._splitEventStore(interaction.affectedEvents, this._getKeysForEventDefs(interaction.affectedEvents));
+                // can't rely on defKeys because event data is mutated
+                var mutatedKeysByDefId = this._getKeysForEventDefs(interaction.mutatedEvents);
+                var mutatedStores_1 = this._splitEventStore(interaction.mutatedEvents, mutatedKeysByDefId);
+                var populate = function (key) {
+                    if (!splitStates[key]) {
+                        splitStates[key] = {
+                            affectedEvents: affectedStores_1[key] || EMPTY_EVENT_STORE,
+                            mutatedEvents: mutatedStores_1[key] || EMPTY_EVENT_STORE,
+                            isEvent: interaction.isEvent,
+                        };
+                    }
+                };
+                for (var key in affectedStores_1) {
+                    populate(key);
+                }
+                for (var key in mutatedStores_1) {
+                    populate(key);
+                }
+            }
+            return splitStates;
+        };
+        return Splitter;
+    }());
+    function buildEventUiForKey(allUi, eventUiForKey, individualUi) {
+        var baseParts = [];
+        if (allUi) {
+            baseParts.push(allUi);
+        }
+        if (eventUiForKey) {
+            baseParts.push(eventUiForKey);
+        }
+        var stuff = {
+            '': combineEventUis(baseParts),
+        };
+        if (individualUi) {
+            __assign(stuff, individualUi);
+        }
+        return stuff;
+    }
+
+    function getDateMeta(date, todayRange, nowDate, dateProfile) {
+        return {
+            dow: date.getUTCDay(),
+            isDisabled: Boolean(dateProfile && !rangeContainsMarker(dateProfile.activeRange, date)),
+            isOther: Boolean(dateProfile && !rangeContainsMarker(dateProfile.currentRange, date)),
+            isToday: Boolean(todayRange && rangeContainsMarker(todayRange, date)),
+            isPast: Boolean(nowDate ? (date < nowDate) : todayRange ? (date < todayRange.start) : false),
+            isFuture: Boolean(nowDate ? (date > nowDate) : todayRange ? (date >= todayRange.end) : false),
+        };
+    }
+    function getDayClassNames(meta, theme) {
+        var classNames = [
+            'fc-day',
+            "fc-day-" + DAY_IDS[meta.dow],
+        ];
+        if (meta.isDisabled) {
+            classNames.push('fc-day-disabled');
+        }
+        else {
+            if (meta.isToday) {
+                classNames.push('fc-day-today');
+                classNames.push(theme.getClass('today'));
+            }
+            if (meta.isPast) {
+                classNames.push('fc-day-past');
+            }
+            if (meta.isFuture) {
+                classNames.push('fc-day-future');
+            }
+            if (meta.isOther) {
+                classNames.push('fc-day-other');
+            }
+        }
+        return classNames;
+    }
+    function getSlotClassNames(meta, theme) {
+        var classNames = [
+            'fc-slot',
+            "fc-slot-" + DAY_IDS[meta.dow],
+        ];
+        if (meta.isDisabled) {
+            classNames.push('fc-slot-disabled');
+        }
+        else {
+            if (meta.isToday) {
+                classNames.push('fc-slot-today');
+                classNames.push(theme.getClass('today'));
+            }
+            if (meta.isPast) {
+                classNames.push('fc-slot-past');
+            }
+            if (meta.isFuture) {
+                classNames.push('fc-slot-future');
+            }
+        }
+        return classNames;
+    }
+
+    function buildNavLinkData(date, type) {
+        if (type === void 0) { type = 'day'; }
+        return JSON.stringify({
+            date: formatDayString(date),
+            type: type,
+        });
+    }
+
+    var _isRtlScrollbarOnLeft = null;
+    function getIsRtlScrollbarOnLeft() {
+        if (_isRtlScrollbarOnLeft === null) {
+            _isRtlScrollbarOnLeft = computeIsRtlScrollbarOnLeft();
+        }
+        return _isRtlScrollbarOnLeft;
+    }
+    function computeIsRtlScrollbarOnLeft() {
+        var outerEl = document.createElement('div');
+        applyStyle(outerEl, {
+            position: 'absolute',
+            top: -1000,
+            left: 0,
+            border: 0,
+            padding: 0,
+            overflow: 'scroll',
+            direction: 'rtl',
+        });
+        outerEl.innerHTML = '<div></div>';
+        document.body.appendChild(outerEl);
+        var innerEl = outerEl.firstChild;
+        var res = innerEl.getBoundingClientRect().left > outerEl.getBoundingClientRect().left;
+        removeElement(outerEl);
+        return res;
+    }
+
+    var _scrollbarWidths;
+    function getScrollbarWidths() {
+        if (!_scrollbarWidths) {
+            _scrollbarWidths = computeScrollbarWidths();
+        }
+        return _scrollbarWidths;
+    }
+    function computeScrollbarWidths() {
+        var el = document.createElement('div');
+        el.style.overflow = 'scroll';
+        el.style.position = 'absolute';
+        el.style.top = '-9999px';
+        el.style.left = '-9999px';
+        document.body.appendChild(el);
+        var res = computeScrollbarWidthsForEl(el);
+        document.body.removeChild(el);
+        return res;
+    }
+    // WARNING: will include border
+    function computeScrollbarWidthsForEl(el) {
+        return {
+            x: el.offsetHeight - el.clientHeight,
+            y: el.offsetWidth - el.clientWidth,
+        };
+    }
+
+    function computeEdges(el, getPadding) {
+        if (getPadding === void 0) { getPadding = false; }
+        var computedStyle = window.getComputedStyle(el);
+        var borderLeft = parseInt(computedStyle.borderLeftWidth, 10) || 0;
+        var borderRight = parseInt(computedStyle.borderRightWidth, 10) || 0;
+        var borderTop = parseInt(computedStyle.borderTopWidth, 10) || 0;
+        var borderBottom = parseInt(computedStyle.borderBottomWidth, 10) || 0;
+        var badScrollbarWidths = computeScrollbarWidthsForEl(el); // includes border!
+        var scrollbarLeftRight = badScrollbarWidths.y - borderLeft - borderRight;
+        var scrollbarBottom = badScrollbarWidths.x - borderTop - borderBottom;
+        var res = {
+            borderLeft: borderLeft,
+            borderRight: borderRight,
+            borderTop: borderTop,
+            borderBottom: borderBottom,
+            scrollbarBottom: scrollbarBottom,
+            scrollbarLeft: 0,
+            scrollbarRight: 0,
+        };
+        if (getIsRtlScrollbarOnLeft() && computedStyle.direction === 'rtl') { // is the scrollbar on the left side?
+            res.scrollbarLeft = scrollbarLeftRight;
+        }
+        else {
+            res.scrollbarRight = scrollbarLeftRight;
+        }
+        if (getPadding) {
+            res.paddingLeft = parseInt(computedStyle.paddingLeft, 10) || 0;
+            res.paddingRight = parseInt(computedStyle.paddingRight, 10) || 0;
+            res.paddingTop = parseInt(computedStyle.paddingTop, 10) || 0;
+            res.paddingBottom = parseInt(computedStyle.paddingBottom, 10) || 0;
+        }
+        return res;
+    }
+    function computeInnerRect(el, goWithinPadding, doFromWindowViewport) {
+        if (goWithinPadding === void 0) { goWithinPadding = false; }
+        var outerRect = doFromWindowViewport ? el.getBoundingClientRect() : computeRect(el);
+        var edges = computeEdges(el, goWithinPadding);
+        var res = {
+            left: outerRect.left + edges.borderLeft + edges.scrollbarLeft,
+            right: outerRect.right - edges.borderRight - edges.scrollbarRight,
+            top: outerRect.top + edges.borderTop,
+            bottom: outerRect.bottom - edges.borderBottom - edges.scrollbarBottom,
+        };
+        if (goWithinPadding) {
+            res.left += edges.paddingLeft;
+            res.right -= edges.paddingRight;
+            res.top += edges.paddingTop;
+            res.bottom -= edges.paddingBottom;
+        }
+        return res;
+    }
+    function computeRect(el) {
+        var rect = el.getBoundingClientRect();
+        return {
+            left: rect.left + window.pageXOffset,
+            top: rect.top + window.pageYOffset,
+            right: rect.right + window.pageXOffset,
+            bottom: rect.bottom + window.pageYOffset,
+        };
+    }
+    function computeHeightAndMargins(el) {
+        return el.getBoundingClientRect().height + computeVMargins(el);
+    }
+    function computeVMargins(el) {
+        var computed = window.getComputedStyle(el);
+        return parseInt(computed.marginTop, 10) +
+            parseInt(computed.marginBottom, 10);
+    }
+    // does not return window
+    function getClippingParents(el) {
+        var parents = [];
+        while (el instanceof HTMLElement) { // will stop when gets to document or null
+            var computedStyle = window.getComputedStyle(el);
+            if (computedStyle.position === 'fixed') {
+                break;
+            }
+            if ((/(auto|scroll)/).test(computedStyle.overflow + computedStyle.overflowY + computedStyle.overflowX)) {
+                parents.push(el);
+            }
+            el = el.parentNode;
+        }
+        return parents;
+    }
+
+    // given a function that resolves a result asynchronously.
+    // the function can either call passed-in success and failure callbacks,
+    // or it can return a promise.
+    // if you need to pass additional params to func, bind them first.
+    function unpromisify(func, success, failure) {
+        // guard against success/failure callbacks being called more than once
+        // and guard against a promise AND callback being used together.
+        var isResolved = false;
+        var wrappedSuccess = function () {
+            if (!isResolved) {
+                isResolved = true;
+                success.apply(this, arguments); // eslint-disable-line prefer-rest-params
+            }
+        };
+        var wrappedFailure = function () {
+            if (!isResolved) {
+                isResolved = true;
+                if (failure) {
+                    failure.apply(this, arguments); // eslint-disable-line prefer-rest-params
+                }
+            }
+        };
+        var res = func(wrappedSuccess, wrappedFailure);
+        if (res && typeof res.then === 'function') {
+            res.then(wrappedSuccess, wrappedFailure);
+        }
+    }
+
+    var Emitter = /** @class */ (function () {
+        function Emitter() {
+            this.handlers = {};
+            this.thisContext = null;
+        }
+        Emitter.prototype.setThisContext = function (thisContext) {
+            this.thisContext = thisContext;
+        };
+        Emitter.prototype.setOptions = function (options) {
+            this.options = options;
+        };
+        Emitter.prototype.on = function (type, handler) {
+            addToHash(this.handlers, type, handler);
+        };
+        Emitter.prototype.off = function (type, handler) {
+            removeFromHash(this.handlers, type, handler);
+        };
+        Emitter.prototype.trigger = function (type) {
+            var args = [];
+            for (var _i = 1; _i < arguments.length; _i++) {
+                args[_i - 1] = arguments[_i];
+            }
+            var attachedHandlers = this.handlers[type] || [];
+            var optionHandler = this.options && this.options[type];
+            var handlers = [].concat(optionHandler || [], attachedHandlers);
+            for (var _a = 0, handlers_1 = handlers; _a < handlers_1.length; _a++) {
+                var handler = handlers_1[_a];
+                handler.apply(this.thisContext, args);
+            }
+        };
+        Emitter.prototype.hasHandlers = function (type) {
+            return (this.handlers[type] && this.handlers[type].length) ||
+                (this.options && this.options[type]);
+        };
+        return Emitter;
+    }());
+    function addToHash(hash, type, handler) {
+        (hash[type] || (hash[type] = []))
+            .push(handler);
+    }
+    function removeFromHash(hash, type, handler) {
+        if (handler) {
+            if (hash[type]) {
+                hash[type] = hash[type].filter(function (func) { return func !== handler; });
+            }
+        }
+        else {
+            delete hash[type]; // remove all handler funcs for this type
+        }
+    }
+
+    /*
+    Records offset information for a set of elements, relative to an origin element.
+    Can record the left/right OR the top/bottom OR both.
+    Provides methods for querying the cache by position.
+    */
+    var PositionCache = /** @class */ (function () {
+        function PositionCache(originEl, els, isHorizontal, isVertical) {
+            this.els = els;
+            var originClientRect = this.originClientRect = originEl.getBoundingClientRect(); // relative to viewport top-left
+            if (isHorizontal) {
+                this.buildElHorizontals(originClientRect.left);
+            }
+            if (isVertical) {
+                this.buildElVerticals(originClientRect.top);
+            }
+        }
+        // Populates the left/right internal coordinate arrays
+        PositionCache.prototype.buildElHorizontals = function (originClientLeft) {
+            var lefts = [];
+            var rights = [];
+            for (var _i = 0, _a = this.els; _i < _a.length; _i++) {
+                var el = _a[_i];
+                var rect = el.getBoundingClientRect();
+                lefts.push(rect.left - originClientLeft);
+                rights.push(rect.right - originClientLeft);
+            }
+            this.lefts = lefts;
+            this.rights = rights;
+        };
+        // Populates the top/bottom internal coordinate arrays
+        PositionCache.prototype.buildElVerticals = function (originClientTop) {
+            var tops = [];
+            var bottoms = [];
+            for (var _i = 0, _a = this.els; _i < _a.length; _i++) {
+                var el = _a[_i];
+                var rect = el.getBoundingClientRect();
+                tops.push(rect.top - originClientTop);
+                bottoms.push(rect.bottom - originClientTop);
+            }
+            this.tops = tops;
+            this.bottoms = bottoms;
+        };
+        // Given a left offset (from document left), returns the index of the el that it horizontally intersects.
+        // If no intersection is made, returns undefined.
+        PositionCache.prototype.leftToIndex = function (leftPosition) {
+            var _a = this, lefts = _a.lefts, rights = _a.rights;
+            var len = lefts.length;
+            var i;
+            for (i = 0; i < len; i += 1) {
+                if (leftPosition >= lefts[i] && leftPosition < rights[i]) {
+                    return i;
+                }
+            }
+            return undefined; // TODO: better
+        };
+        // Given a top offset (from document top), returns the index of the el that it vertically intersects.
+        // If no intersection is made, returns undefined.
+        PositionCache.prototype.topToIndex = function (topPosition) {
+            var _a = this, tops = _a.tops, bottoms = _a.bottoms;
+            var len = tops.length;
+            var i;
+            for (i = 0; i < len; i += 1) {
+                if (topPosition >= tops[i] && topPosition < bottoms[i]) {
+                    return i;
+                }
+            }
+            return undefined; // TODO: better
+        };
+        // Gets the width of the element at the given index
+        PositionCache.prototype.getWidth = function (leftIndex) {
+            return this.rights[leftIndex] - this.lefts[leftIndex];
+        };
+        // Gets the height of the element at the given index
+        PositionCache.prototype.getHeight = function (topIndex) {
+            return this.bottoms[topIndex] - this.tops[topIndex];
+        };
+        return PositionCache;
+    }());
+
+    /* eslint max-classes-per-file: "off" */
+    /*
+    An object for getting/setting scroll-related information for an element.
+    Internally, this is done very differently for window versus DOM element,
+    so this object serves as a common interface.
+    */
+    var ScrollController = /** @class */ (function () {
+        function ScrollController() {
+        }
+        ScrollController.prototype.getMaxScrollTop = function () {
+            return this.getScrollHeight() - this.getClientHeight();
+        };
+        ScrollController.prototype.getMaxScrollLeft = function () {
+            return this.getScrollWidth() - this.getClientWidth();
+        };
+        ScrollController.prototype.canScrollVertically = function () {
+            return this.getMaxScrollTop() > 0;
+        };
+        ScrollController.prototype.canScrollHorizontally = function () {
+            return this.getMaxScrollLeft() > 0;
+        };
+        ScrollController.prototype.canScrollUp = function () {
+            return this.getScrollTop() > 0;
+        };
+        ScrollController.prototype.canScrollDown = function () {
+            return this.getScrollTop() < this.getMaxScrollTop();
+        };
+        ScrollController.prototype.canScrollLeft = function () {
+            return this.getScrollLeft() > 0;
+        };
+        ScrollController.prototype.canScrollRight = function () {
+            return this.getScrollLeft() < this.getMaxScrollLeft();
+        };
+        return ScrollController;
+    }());
+    var ElementScrollController = /** @class */ (function (_super) {
+        __extends(ElementScrollController, _super);
+        function ElementScrollController(el) {
+            var _this = _super.call(this) || this;
+            _this.el = el;
+            return _this;
+        }
+        ElementScrollController.prototype.getScrollTop = function () {
+            return this.el.scrollTop;
+        };
+        ElementScrollController.prototype.getScrollLeft = function () {
+            return this.el.scrollLeft;
+        };
+        ElementScrollController.prototype.setScrollTop = function (top) {
+            this.el.scrollTop = top;
+        };
+        ElementScrollController.prototype.setScrollLeft = function (left) {
+            this.el.scrollLeft = left;
+        };
+        ElementScrollController.prototype.getScrollWidth = function () {
+            return this.el.scrollWidth;
+        };
+        ElementScrollController.prototype.getScrollHeight = function () {
+            return this.el.scrollHeight;
+        };
+        ElementScrollController.prototype.getClientHeight = function () {
+            return this.el.clientHeight;
+        };
+        ElementScrollController.prototype.getClientWidth = function () {
+            return this.el.clientWidth;
+        };
+        return ElementScrollController;
+    }(ScrollController));
+    var WindowScrollController = /** @class */ (function (_super) {
+        __extends(WindowScrollController, _super);
+        function WindowScrollController() {
+            return _super !== null && _super.apply(this, arguments) || this;
+        }
+        WindowScrollController.prototype.getScrollTop = function () {
+            return window.pageYOffset;
+        };
+        WindowScrollController.prototype.getScrollLeft = function () {
+            return window.pageXOffset;
+        };
+        WindowScrollController.prototype.setScrollTop = function (n) {
+            window.scroll(window.pageXOffset, n);
+        };
+        WindowScrollController.prototype.setScrollLeft = function (n) {
+            window.scroll(n, window.pageYOffset);
+        };
+        WindowScrollController.prototype.getScrollWidth = function () {
+            return document.documentElement.scrollWidth;
+        };
+        WindowScrollController.prototype.getScrollHeight = function () {
+            return document.documentElement.scrollHeight;
+        };
+        WindowScrollController.prototype.getClientHeight = function () {
+            return document.documentElement.clientHeight;
+        };
+        WindowScrollController.prototype.getClientWidth = function () {
+            return document.documentElement.clientWidth;
+        };
+        return WindowScrollController;
+    }(ScrollController));
+
+    var Theme = /** @class */ (function () {
+        function Theme(calendarOptions) {
+            if (this.iconOverrideOption) {
+                this.setIconOverride(calendarOptions[this.iconOverrideOption]);
+            }
+        }
+        Theme.prototype.setIconOverride = function (iconOverrideHash) {
+            var iconClassesCopy;
+            var buttonName;
+            if (typeof iconOverrideHash === 'object' && iconOverrideHash) { // non-null object
+                iconClassesCopy = __assign({}, this.iconClasses);
+                for (buttonName in iconOverrideHash) {
+                    iconClassesCopy[buttonName] = this.applyIconOverridePrefix(iconOverrideHash[buttonName]);
+                }
+                this.iconClasses = iconClassesCopy;
+            }
+            else if (iconOverrideHash === false) {
+                this.iconClasses = {};
+            }
+        };
+        Theme.prototype.applyIconOverridePrefix = function (className) {
+            var prefix = this.iconOverridePrefix;
+            if (prefix && className.indexOf(prefix) !== 0) { // if not already present
+                className = prefix + className;
+            }
+            return className;
+        };
+        Theme.prototype.getClass = function (key) {
+            return this.classes[key] || '';
+        };
+        Theme.prototype.getIconClass = function (buttonName, isRtl) {
+            var className;
+            if (isRtl && this.rtlIconClasses) {
+                className = this.rtlIconClasses[buttonName] || this.iconClasses[buttonName];
+            }
+            else {
+                className = this.iconClasses[buttonName];
+            }
+            if (className) {
+                return this.baseIconClass + " " + className;
+            }
+            return '';
+        };
+        Theme.prototype.getCustomButtonIconClass = function (customButtonProps) {
+            var className;
+            if (this.iconOverrideCustomButtonOption) {
+                className = customButtonProps[this.iconOverrideCustomButtonOption];
+                if (className) {
+                    return this.baseIconClass + " " + this.applyIconOverridePrefix(className);
+                }
+            }
+            return '';
+        };
+        return Theme;
+    }());
+    Theme.prototype.classes = {};
+    Theme.prototype.iconClasses = {};
+    Theme.prototype.baseIconClass = '';
+    Theme.prototype.iconOverridePrefix = '';
+
+    /// <reference types="@fullcalendar/core-preact" />
+    if (typeof FullCalendarVDom === 'undefined') {
+        throw new Error('Please import the top-level fullcalendar lib before attempting to import a plugin.');
+    }
+    var Component = FullCalendarVDom.Component;
+    var createElement = FullCalendarVDom.createElement;
+    var render = FullCalendarVDom.render;
+    var createRef = FullCalendarVDom.createRef;
+    var Fragment = FullCalendarVDom.Fragment;
+    var createContext$1 = FullCalendarVDom.createContext;
+    var flushToDom$1 = FullCalendarVDom.flushToDom;
+    var unmountComponentAtNode$1 = FullCalendarVDom.unmountComponentAtNode;
+
+    var ScrollResponder = /** @class */ (function () {
+        function ScrollResponder(execFunc, emitter, scrollTime) {
+            var _this = this;
+            this.execFunc = execFunc;
+            this.emitter = emitter;
+            this.scrollTime = scrollTime;
+            this.handleScrollRequest = function (request) {
+                _this.queuedRequest = __assign({}, _this.queuedRequest || {}, request);
+                _this.drain();
+            };
+            emitter.on('_scrollRequest', this.handleScrollRequest);
+            this.fireInitialScroll();
+        }
+        ScrollResponder.prototype.detach = function () {
+            this.emitter.off('_scrollRequest', this.handleScrollRequest);
+        };
+        ScrollResponder.prototype.update = function (isDatesNew) {
+            if (isDatesNew) {
+                this.fireInitialScroll(); // will drain
+            }
+            else {
+                this.drain();
+            }
+        };
+        ScrollResponder.prototype.fireInitialScroll = function () {
+            this.handleScrollRequest({
+                time: this.scrollTime,
+            });
+        };
+        ScrollResponder.prototype.drain = function () {
+            if (this.queuedRequest && this.execFunc(this.queuedRequest)) {
+                this.queuedRequest = null;
+            }
+        };
+        return ScrollResponder;
+    }());
+
+    var ViewContextType = createContext$1({}); // for Components
+    function buildViewContext(viewSpec, viewApi, viewOptions, dateProfileGenerator, dateEnv, theme, pluginHooks, dispatch, getCurrentData, emitter, calendarApi, registerInteractiveComponent, unregisterInteractiveComponent) {
+        return {
+            dateEnv: dateEnv,
+            options: viewOptions,
+            pluginHooks: pluginHooks,
+            emitter: emitter,
+            dispatch: dispatch,
+            getCurrentData: getCurrentData,
+            calendarApi: calendarApi,
+            viewSpec: viewSpec,
+            viewApi: viewApi,
+            dateProfileGenerator: dateProfileGenerator,
+            theme: theme,
+            isRtl: viewOptions.direction === 'rtl',
+            addResizeHandler: function (handler) {
+                emitter.on('_resize', handler);
+            },
+            removeResizeHandler: function (handler) {
+                emitter.off('_resize', handler);
+            },
+            createScrollResponder: function (execFunc) {
+                return new ScrollResponder(execFunc, emitter, createDuration(viewOptions.scrollTime));
+            },
+            registerInteractiveComponent: registerInteractiveComponent,
+            unregisterInteractiveComponent: unregisterInteractiveComponent,
+        };
+    }
+
+    /* eslint max-classes-per-file: off */
+    var PureComponent = /** @class */ (function (_super) {
+        __extends(PureComponent, _super);
+        function PureComponent() {
+            return _super !== null && _super.apply(this, arguments) || this;
+        }
+        PureComponent.prototype.shouldComponentUpdate = function (nextProps, nextState) {
+            if (this.debug) {
+                // eslint-disable-next-line no-console
+                console.log(getUnequalProps(nextProps, this.props), getUnequalProps(nextState, this.state));
+            }
+            return !compareObjs(this.props, nextProps, this.propEquality) ||
+                !compareObjs(this.state, nextState, this.stateEquality);
+        };
+        PureComponent.addPropsEquality = addPropsEquality;
+        PureComponent.addStateEquality = addStateEquality;
+        PureComponent.contextType = ViewContextType;
+        return PureComponent;
+    }(Component));
+    PureComponent.prototype.propEquality = {};
+    PureComponent.prototype.stateEquality = {};
+    var BaseComponent = /** @class */ (function (_super) {
+        __extends(BaseComponent, _super);
+        function BaseComponent() {
+            return _super !== null && _super.apply(this, arguments) || this;
+        }
+        BaseComponent.contextType = ViewContextType;
+        return BaseComponent;
+    }(PureComponent));
+    function addPropsEquality(propEquality) {
+        var hash = Object.create(this.prototype.propEquality);
+        __assign(hash, propEquality);
+        this.prototype.propEquality = hash;
+    }
+    function addStateEquality(stateEquality) {
+        var hash = Object.create(this.prototype.stateEquality);
+        __assign(hash, stateEquality);
+        this.prototype.stateEquality = hash;
+    }
+    // use other one
+    function setRef(ref, current) {
+        if (typeof ref === 'function') {
+            ref(current);
+        }
+        else if (ref) {
+            // see https://github.com/facebook/react/issues/13029
+            ref.current = current;
+        }
+    }
+
+    function reduceEventStore(eventStore, action, eventSources, dateProfile, context) {
+        switch (action.type) {
+            case 'RECEIVE_EVENTS': // raw
+                return receiveRawEvents(eventStore, eventSources[action.sourceId], action.fetchId, action.fetchRange, action.rawEvents, context);
+            case 'ADD_EVENTS': // already parsed, but not expanded
+                return addEvent(eventStore, action.eventStore, // new ones
+                dateProfile ? dateProfile.activeRange : null, context);
+            case 'RESET_EVENTS':
+                return action.eventStore;
+            case 'MERGE_EVENTS': // already parsed and expanded
+                return mergeEventStores(eventStore, action.eventStore);
+            case 'PREV': // TODO: how do we track all actions that affect dateProfile :(
+            case 'NEXT':
+            case 'CHANGE_DATE':
+            case 'CHANGE_VIEW_TYPE':
+                if (dateProfile) {
+                    return expandRecurring(eventStore, dateProfile.activeRange, context);
+                }
+                return eventStore;
+            case 'REMOVE_EVENTS':
+                return excludeSubEventStore(eventStore, action.eventStore);
+            case 'REMOVE_EVENT_SOURCE':
+                return excludeEventsBySourceId(eventStore, action.sourceId);
+            case 'REMOVE_ALL_EVENT_SOURCES':
+                return filterEventStoreDefs(eventStore, function (eventDef) { return (!eventDef.sourceId // only keep events with no source id
+                ); });
+            case 'REMOVE_ALL_EVENTS':
+                return createEmptyEventStore();
+            default:
+                return eventStore;
+        }
+    }
+    function receiveRawEvents(eventStore, eventSource, fetchId, fetchRange, rawEvents, context) {
+        if (eventSource && // not already removed
+            fetchId === eventSource.latestFetchId // TODO: wish this logic was always in event-sources
+        ) {
+            var subset = parseEvents(transformRawEvents(rawEvents, eventSource, context), eventSource, context);
+            if (fetchRange) {
+                subset = expandRecurring(subset, fetchRange, context);
+            }
+            return mergeEventStores(excludeEventsBySourceId(eventStore, eventSource.sourceId), subset);
+        }
+        return eventStore;
+    }
+    function transformRawEvents(rawEvents, eventSource, context) {
+        var calEachTransform = context.options.eventDataTransform;
+        var sourceEachTransform = eventSource ? eventSource.eventDataTransform : null;
+        if (sourceEachTransform) {
+            rawEvents = transformEachRawEvent(rawEvents, sourceEachTransform);
+        }
+        if (calEachTransform) {
+            rawEvents = transformEachRawEvent(rawEvents, calEachTransform);
+        }
+        return rawEvents;
+    }
+    function transformEachRawEvent(rawEvents, func) {
+        var refinedEvents;
+        if (!func) {
+            refinedEvents = rawEvents;
+        }
+        else {
+            refinedEvents = [];
+            for (var _i = 0, rawEvents_1 = rawEvents; _i < rawEvents_1.length; _i++) {
+                var rawEvent = rawEvents_1[_i];
+                var refinedEvent = func(rawEvent);
+                if (refinedEvent) {
+                    refinedEvents.push(refinedEvent);
+                }
+                else if (refinedEvent == null) {
+                    refinedEvents.push(rawEvent);
+                } // if a different falsy value, do nothing
+            }
+        }
+        return refinedEvents;
+    }
+    function addEvent(eventStore, subset, expandRange, context) {
+        if (expandRange) {
+            subset = expandRecurring(subset, expandRange, context);
+        }
+        return mergeEventStores(eventStore, subset);
+    }
+    function rezoneEventStoreDates(eventStore, oldDateEnv, newDateEnv) {
+        var defs = eventStore.defs;
+        var instances = mapHash(eventStore.instances, function (instance) {
+            var def = defs[instance.defId];
+            if (def.allDay || def.recurringDef) {
+                return instance; // isn't dependent on timezone
+            }
+            return __assign(__assign({}, instance), { range: {
+                    start: newDateEnv.createMarker(oldDateEnv.toDate(instance.range.start, instance.forcedStartTzo)),
+                    end: newDateEnv.createMarker(oldDateEnv.toDate(instance.range.end, instance.forcedEndTzo)),
+                }, forcedStartTzo: newDateEnv.canComputeOffset ? null : instance.forcedStartTzo, forcedEndTzo: newDateEnv.canComputeOffset ? null : instance.forcedEndTzo });
+        });
+        return { defs: defs, instances: instances };
+    }
+    function excludeEventsBySourceId(eventStore, sourceId) {
+        return filterEventStoreDefs(eventStore, function (eventDef) { return eventDef.sourceId !== sourceId; });
+    }
+    // QUESTION: why not just return instances? do a general object-property-exclusion util
+    function excludeInstances(eventStore, removals) {
+        return {
+            defs: eventStore.defs,
+            instances: filterHash(eventStore.instances, function (instance) { return !removals[instance.instanceId]; }),
+        };
+    }
+
+    // high-level segmenting-aware tester functions
+    // ------------------------------------------------------------------------------------------------------------------------
+    function isInteractionValid(interaction, context) {
+        return isNewPropsValid({ eventDrag: interaction }, context); // HACK: the eventDrag props is used for ALL interactions
+    }
+    function isDateSelectionValid(dateSelection, context) {
+        return isNewPropsValid({ dateSelection: dateSelection }, context);
+    }
+    function isNewPropsValid(newProps, context) {
+        var calendarState = context.getCurrentData();
+        var props = __assign({ businessHours: calendarState.businessHours, dateSelection: '', eventStore: calendarState.eventStore, eventUiBases: calendarState.eventUiBases, eventSelection: '', eventDrag: null, eventResize: null }, newProps);
+        return (context.pluginHooks.isPropsValid || isPropsValid)(props, context);
+    }
+    function isPropsValid(state, context, dateSpanMeta, filterConfig) {
+        if (dateSpanMeta === void 0) { dateSpanMeta = {}; }
+        if (state.eventDrag && !isInteractionPropsValid(state, context, dateSpanMeta, filterConfig)) {
+            return false;
+        }
+        if (state.dateSelection && !isDateSelectionPropsValid(state, context, dateSpanMeta, filterConfig)) {
+            return false;
+        }
+        return true;
+    }
+    // Moving Event Validation
+    // ------------------------------------------------------------------------------------------------------------------------
+    function isInteractionPropsValid(state, context, dateSpanMeta, filterConfig) {
+        var currentState = context.getCurrentData();
+        var interaction = state.eventDrag; // HACK: the eventDrag props is used for ALL interactions
+        var subjectEventStore = interaction.mutatedEvents;
+        var subjectDefs = subjectEventStore.defs;
+        var subjectInstances = subjectEventStore.instances;
+        var subjectConfigs = compileEventUis(subjectDefs, interaction.isEvent ?
+            state.eventUiBases :
+            { '': currentState.selectionConfig });
+        if (filterConfig) {
+            subjectConfigs = mapHash(subjectConfigs, filterConfig);
+        }
+        // exclude the subject events. TODO: exclude defs too?
+        var otherEventStore = excludeInstances(state.eventStore, interaction.affectedEvents.instances);
+        var otherDefs = otherEventStore.defs;
+        var otherInstances = otherEventStore.instances;
+        var otherConfigs = compileEventUis(otherDefs, state.eventUiBases);
+        for (var subjectInstanceId in subjectInstances) {
+            var subjectInstance = subjectInstances[subjectInstanceId];
+            var subjectRange = subjectInstance.range;
+            var subjectConfig = subjectConfigs[subjectInstance.defId];
+            var subjectDef = subjectDefs[subjectInstance.defId];
+            // constraint
+            if (!allConstraintsPass(subjectConfig.constraints, subjectRange, otherEventStore, state.businessHours, context)) {
+                return false;
+            }
+            // overlap
+            var eventOverlap = context.options.eventOverlap;
+            var eventOverlapFunc = typeof eventOverlap === 'function' ? eventOverlap : null;
+            for (var otherInstanceId in otherInstances) {
+                var otherInstance = otherInstances[otherInstanceId];
+                // intersect! evaluate
+                if (rangesIntersect(subjectRange, otherInstance.range)) {
+                    var otherOverlap = otherConfigs[otherInstance.defId].overlap;
+                    // consider the other event's overlap. only do this if the subject event is a "real" event
+                    if (otherOverlap === false && interaction.isEvent) {
+                        return false;
+                    }
+                    if (subjectConfig.overlap === false) {
+                        return false;
+                    }
+                    if (eventOverlapFunc && !eventOverlapFunc(new EventApi(context, otherDefs[otherInstance.defId], otherInstance), // still event
+                    new EventApi(context, subjectDef, subjectInstance))) {
+                        return false;
+                    }
+                }
+            }
+            // allow (a function)
+            var calendarEventStore = currentState.eventStore; // need global-to-calendar, not local to component (splittable)state
+            for (var _i = 0, _a = subjectConfig.allows; _i < _a.length; _i++) {
+                var subjectAllow = _a[_i];
+                var subjectDateSpan = __assign(__assign({}, dateSpanMeta), { range: subjectInstance.range, allDay: subjectDef.allDay });
+                var origDef = calendarEventStore.defs[subjectDef.defId];
+                var origInstance = calendarEventStore.instances[subjectInstanceId];
+                var eventApi = void 0;
+                if (origDef) { // was previously in the calendar
+                    eventApi = new EventApi(context, origDef, origInstance);
+                }
+                else { // was an external event
+                    eventApi = new EventApi(context, subjectDef); // no instance, because had no dates
+                }
+                if (!subjectAllow(buildDateSpanApiWithContext(subjectDateSpan, context), eventApi)) {
+                    return false;
+                }
+            }
+        }
+        return true;
+    }
+    // Date Selection Validation
+    // ------------------------------------------------------------------------------------------------------------------------
+    function isDateSelectionPropsValid(state, context, dateSpanMeta, filterConfig) {
+        var relevantEventStore = state.eventStore;
+        var relevantDefs = relevantEventStore.defs;
+        var relevantInstances = relevantEventStore.instances;
+        var selection = state.dateSelection;
+        var selectionRange = selection.range;
+        var selectionConfig = context.getCurrentData().selectionConfig;
+        if (filterConfig) {
+            selectionConfig = filterConfig(selectionConfig);
+        }
+        // constraint
+        if (!allConstraintsPass(selectionConfig.constraints, selectionRange, relevantEventStore, state.businessHours, context)) {
+            return false;
+        }
+        // overlap
+        var selectOverlap = context.options.selectOverlap;
+        var selectOverlapFunc = typeof selectOverlap === 'function' ? selectOverlap : null;
+        for (var relevantInstanceId in relevantInstances) {
+            var relevantInstance = relevantInstances[relevantInstanceId];
+            // intersect! evaluate
+            if (rangesIntersect(selectionRange, relevantInstance.range)) {
+                if (selectionConfig.overlap === false) {
+                    return false;
+                }
+                if (selectOverlapFunc && !selectOverlapFunc(new EventApi(context, relevantDefs[relevantInstance.defId], relevantInstance), null)) {
+                    return false;
+                }
+            }
+        }
+        // allow (a function)
+        for (var _i = 0, _a = selectionConfig.allows; _i < _a.length; _i++) {
+            var selectionAllow = _a[_i];
+            var fullDateSpan = __assign(__assign({}, dateSpanMeta), selection);
+            if (!selectionAllow(buildDateSpanApiWithContext(fullDateSpan, context), null)) {
+                return false;
+            }
+        }
+        return true;
+    }
+    // Constraint Utils
+    // ------------------------------------------------------------------------------------------------------------------------
+    function allConstraintsPass(constraints, subjectRange, otherEventStore, businessHoursUnexpanded, context) {
+        for (var _i = 0, constraints_1 = constraints; _i < constraints_1.length; _i++) {
+            var constraint = constraints_1[_i];
+            if (!anyRangesContainRange(constraintToRanges(constraint, subjectRange, otherEventStore, businessHoursUnexpanded, context), subjectRange)) {
+                return false;
+            }
+        }
+        return true;
+    }
+    function constraintToRanges(constraint, subjectRange, // for expanding a recurring constraint, or expanding business hours
+    otherEventStore, // for if constraint is an even group ID
+    businessHoursUnexpanded, // for if constraint is 'businessHours'
+    context) {
+        if (constraint === 'businessHours') {
+            return eventStoreToRanges(expandRecurring(businessHoursUnexpanded, subjectRange, context));
+        }
+        if (typeof constraint === 'string') { // an group ID
+            return eventStoreToRanges(filterEventStoreDefs(otherEventStore, function (eventDef) { return eventDef.groupId === constraint; }));
+        }
+        if (typeof constraint === 'object' && constraint) { // non-null object
+            return eventStoreToRanges(expandRecurring(constraint, subjectRange, context));
+        }
+        return []; // if it's false
+    }
+    // TODO: move to event-store file?
+    function eventStoreToRanges(eventStore) {
+        var instances = eventStore.instances;
+        var ranges = [];
+        for (var instanceId in instances) {
+            ranges.push(instances[instanceId].range);
+        }
+        return ranges;
+    }
+    // TODO: move to geom file?
+    function anyRangesContainRange(outerRanges, innerRange) {
+        for (var _i = 0, outerRanges_1 = outerRanges; _i < outerRanges_1.length; _i++) {
+            var outerRange = outerRanges_1[_i];
+            if (rangeContainsRange(outerRange, innerRange)) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    /*
+    an INTERACTABLE date component
+
+    PURPOSES:
+    - hook up to fg, fill, and mirror renderers
+    - interface for dragging and hits
+    */
+    var DateComponent = /** @class */ (function (_super) {
+        __extends(DateComponent, _super);
+        function DateComponent() {
+            var _this = _super !== null && _super.apply(this, arguments) || this;
+            _this.uid = guid();
+            return _this;
+        }
+        // Hit System
+        // -----------------------------------------------------------------------------------------------------------------
+        DateComponent.prototype.prepareHits = function () {
+        };
+        DateComponent.prototype.queryHit = function (positionLeft, positionTop, elWidth, elHeight) {
+            return null; // this should be abstract
+        };
+        // Validation
+        // -----------------------------------------------------------------------------------------------------------------
+        DateComponent.prototype.isInteractionValid = function (interaction) {
+            var dateProfile = this.props.dateProfile; // HACK
+            var instances = interaction.mutatedEvents.instances;
+            if (dateProfile) { // HACK for MorePopover
+                for (var instanceId in instances) {
+                    if (!rangeContainsRange(dateProfile.validRange, instances[instanceId].range)) {
+                        return false;
+                    }
+                }
+            }
+            return isInteractionValid(interaction, this.context);
+        };
+        DateComponent.prototype.isDateSelectionValid = function (selection) {
+            var dateProfile = this.props.dateProfile; // HACK
+            if (dateProfile && // HACK for MorePopover
+                !rangeContainsRange(dateProfile.validRange, selection.range)) {
+                return false;
+            }
+            return isDateSelectionValid(selection, this.context);
+        };
+        // Pointer Interaction Utils
+        // -----------------------------------------------------------------------------------------------------------------
+        DateComponent.prototype.isValidSegDownEl = function (el) {
+            return !this.props.eventDrag && // HACK
+                !this.props.eventResize && // HACK
+                !elementClosest(el, '.fc-event-mirror');
+        };
+        DateComponent.prototype.isValidDateDownEl = function (el) {
+            return !elementClosest(el, '.fc-event:not(.fc-bg-event)') &&
+                !elementClosest(el, '.fc-daygrid-more-link') && // a "more.." link
+                !elementClosest(el, 'a[data-navlink]') && // a clickable nav link
+                !elementClosest(el, '.fc-popover'); // hack
+        };
+        return DateComponent;
+    }(BaseComponent));
+
+    // TODO: easier way to add new hooks? need to update a million things
+    function createPlugin(input) {
+        return {
+            id: guid(),
+            deps: input.deps || [],
+            reducers: input.reducers || [],
+            isLoadingFuncs: input.isLoadingFuncs || [],
+            contextInit: [].concat(input.contextInit || []),
+            eventRefiners: input.eventRefiners || {},
+            eventDefMemberAdders: input.eventDefMemberAdders || [],
+            eventSourceRefiners: input.eventSourceRefiners || {},
+            isDraggableTransformers: input.isDraggableTransformers || [],
+            eventDragMutationMassagers: input.eventDragMutationMassagers || [],
+            eventDefMutationAppliers: input.eventDefMutationAppliers || [],
+            dateSelectionTransformers: input.dateSelectionTransformers || [],
+            datePointTransforms: input.datePointTransforms || [],
+            dateSpanTransforms: input.dateSpanTransforms || [],
+            views: input.views || {},
+            viewPropsTransformers: input.viewPropsTransformers || [],
+            isPropsValid: input.isPropsValid || null,
+            externalDefTransforms: input.externalDefTransforms || [],
+            eventResizeJoinTransforms: input.eventResizeJoinTransforms || [],
+            viewContainerAppends: input.viewContainerAppends || [],
+            eventDropTransformers: input.eventDropTransformers || [],
+            componentInteractions: input.componentInteractions || [],
+            calendarInteractions: input.calendarInteractions || [],
+            themeClasses: input.themeClasses || {},
+            eventSourceDefs: input.eventSourceDefs || [],
+            cmdFormatter: input.cmdFormatter,
+            recurringTypes: input.recurringTypes || [],
+            namedTimeZonedImpl: input.namedTimeZonedImpl,
+            initialView: input.initialView || '',
+            elementDraggingImpl: input.elementDraggingImpl,
+            optionChangeHandlers: input.optionChangeHandlers || {},
+            scrollGridImpl: input.scrollGridImpl || null,
+            contentTypeHandlers: input.contentTypeHandlers || {},
+            listenerRefiners: input.listenerRefiners || {},
+            optionRefiners: input.optionRefiners || {},
+            propSetHandlers: input.propSetHandlers || {},
+        };
+    }
+    function buildPluginHooks(pluginDefs, globalDefs) {
+        var isAdded = {};
+        var hooks = {
+            reducers: [],
+            isLoadingFuncs: [],
+            contextInit: [],
+            eventRefiners: {},
+            eventDefMemberAdders: [],
+            eventSourceRefiners: {},
+            isDraggableTransformers: [],
+            eventDragMutationMassagers: [],
+            eventDefMutationAppliers: [],
+            dateSelectionTransformers: [],
+            datePointTransforms: [],
+            dateSpanTransforms: [],
+            views: {},
+            viewPropsTransformers: [],
+            isPropsValid: null,
+            externalDefTransforms: [],
+            eventResizeJoinTransforms: [],
+            viewContainerAppends: [],
+            eventDropTransformers: [],
+            componentInteractions: [],
+            calendarInteractions: [],
+            themeClasses: {},
+            eventSourceDefs: [],
+            cmdFormatter: null,
+            recurringTypes: [],
+            namedTimeZonedImpl: null,
+            initialView: '',
+            elementDraggingImpl: null,
+            optionChangeHandlers: {},
+            scrollGridImpl: null,
+            contentTypeHandlers: {},
+            listenerRefiners: {},
+            optionRefiners: {},
+            propSetHandlers: {},
+        };
+        function addDefs(defs) {
+            for (var _i = 0, defs_1 = defs; _i < defs_1.length; _i++) {
+                var def = defs_1[_i];
+                if (!isAdded[def.id]) {
+                    isAdded[def.id] = true;
+                    addDefs(def.deps);
+                    hooks = combineHooks(hooks, def);
+                }
+            }
+        }
+        if (pluginDefs) {
+            addDefs(pluginDefs);
+        }
+        addDefs(globalDefs);
+        return hooks;
+    }
+    function buildBuildPluginHooks() {
+        var currentOverrideDefs = [];
+        var currentGlobalDefs = [];
+        var currentHooks;
+        return function (overrideDefs, globalDefs) {
+            if (!currentHooks || !isArraysEqual(overrideDefs, currentOverrideDefs) || !isArraysEqual(globalDefs, currentGlobalDefs)) {
+                currentHooks = buildPluginHooks(overrideDefs, globalDefs);
+            }
+            currentOverrideDefs = overrideDefs;
+            currentGlobalDefs = globalDefs;
+            return currentHooks;
+        };
+    }
+    function combineHooks(hooks0, hooks1) {
+        return {
+            reducers: hooks0.reducers.concat(hooks1.reducers),
+            isLoadingFuncs: hooks0.isLoadingFuncs.concat(hooks1.isLoadingFuncs),
+            contextInit: hooks0.contextInit.concat(hooks1.contextInit),
+            eventRefiners: __assign(__assign({}, hooks0.eventRefiners), hooks1.eventRefiners),
+            eventDefMemberAdders: hooks0.eventDefMemberAdders.concat(hooks1.eventDefMemberAdders),
+            eventSourceRefiners: __assign(__assign({}, hooks0.eventSourceRefiners), hooks1.eventSourceRefiners),
+            isDraggableTransformers: hooks0.isDraggableTransformers.concat(hooks1.isDraggableTransformers),
+            eventDragMutationMassagers: hooks0.eventDragMutationMassagers.concat(hooks1.eventDragMutationMassagers),
+            eventDefMutationAppliers: hooks0.eventDefMutationAppliers.concat(hooks1.eventDefMutationAppliers),
+            dateSelectionTransformers: hooks0.dateSelectionTransformers.concat(hooks1.dateSelectionTransformers),
+            datePointTransforms: hooks0.datePointTransforms.concat(hooks1.datePointTransforms),
+            dateSpanTransforms: hooks0.dateSpanTransforms.concat(hooks1.dateSpanTransforms),
+            views: __assign(__assign({}, hooks0.views), hooks1.views),
+            viewPropsTransformers: hooks0.viewPropsTransformers.concat(hooks1.viewPropsTransformers),
+            isPropsValid: hooks1.isPropsValid || hooks0.isPropsValid,
+            externalDefTransforms: hooks0.externalDefTransforms.concat(hooks1.externalDefTransforms),
+            eventResizeJoinTransforms: hooks0.eventResizeJoinTransforms.concat(hooks1.eventResizeJoinTransforms),
+            viewContainerAppends: hooks0.viewContainerAppends.concat(hooks1.viewContainerAppends),
+            eventDropTransformers: hooks0.eventDropTransformers.concat(hooks1.eventDropTransformers),
+            calendarInteractions: hooks0.calendarInteractions.concat(hooks1.calendarInteractions),
+            componentInteractions: hooks0.componentInteractions.concat(hooks1.componentInteractions),
+            themeClasses: __assign(__assign({}, hooks0.themeClasses), hooks1.themeClasses),
+            eventSourceDefs: hooks0.eventSourceDefs.concat(hooks1.eventSourceDefs),
+            cmdFormatter: hooks1.cmdFormatter || hooks0.cmdFormatter,
+            recurringTypes: hooks0.recurringTypes.concat(hooks1.recurringTypes),
+            namedTimeZonedImpl: hooks1.namedTimeZonedImpl || hooks0.namedTimeZonedImpl,
+            initialView: hooks0.initialView || hooks1.initialView,
+            elementDraggingImpl: hooks0.elementDraggingImpl || hooks1.elementDraggingImpl,
+            optionChangeHandlers: __assign(__assign({}, hooks0.optionChangeHandlers), hooks1.optionChangeHandlers),
+            scrollGridImpl: hooks1.scrollGridImpl || hooks0.scrollGridImpl,
+            contentTypeHandlers: __assign(__assign({}, hooks0.contentTypeHandlers), hooks1.contentTypeHandlers),
+            listenerRefiners: __assign(__assign({}, hooks0.listenerRefiners), hooks1.listenerRefiners),
+            optionRefiners: __assign(__assign({}, hooks0.optionRefiners), hooks1.optionRefiners),
+            propSetHandlers: __assign(__assign({}, hooks0.propSetHandlers), hooks1.propSetHandlers),
+        };
+    }
+
+    var StandardTheme = /** @class */ (function (_super) {
+        __extends(StandardTheme, _super);
+        function StandardTheme() {
+            return _super !== null && _super.apply(this, arguments) || this;
+        }
+        return StandardTheme;
+    }(Theme));
+    StandardTheme.prototype.classes = {
+        root: 'fc-theme-standard',
+        tableCellShaded: 'fc-cell-shaded',
+        buttonGroup: 'fc-button-group',
+        button: 'fc-button fc-button-primary',
+        buttonActive: 'fc-button-active',
+    };
+    StandardTheme.prototype.baseIconClass = 'fc-icon';
+    StandardTheme.prototype.iconClasses = {
+        close: 'fc-icon-x',
+        prev: 'fc-icon-chevron-left',
+        next: 'fc-icon-chevron-right',
+        prevYear: 'fc-icon-chevrons-left',
+        nextYear: 'fc-icon-chevrons-right',
+    };
+    StandardTheme.prototype.rtlIconClasses = {
+        prev: 'fc-icon-chevron-right',
+        next: 'fc-icon-chevron-left',
+        prevYear: 'fc-icon-chevrons-right',
+        nextYear: 'fc-icon-chevrons-left',
+    };
+    StandardTheme.prototype.iconOverrideOption = 'buttonIcons'; // TODO: make TS-friendly
+    StandardTheme.prototype.iconOverrideCustomButtonOption = 'icon';
+    StandardTheme.prototype.iconOverridePrefix = 'fc-icon-';
+
+    function compileViewDefs(defaultConfigs, overrideConfigs) {
+        var hash = {};
+        var viewType;
+        for (viewType in defaultConfigs) {
+            ensureViewDef(viewType, hash, defaultConfigs, overrideConfigs);
+        }
+        for (viewType in overrideConfigs) {
+            ensureViewDef(viewType, hash, defaultConfigs, overrideConfigs);
+        }
+        return hash;
+    }
+    function ensureViewDef(viewType, hash, defaultConfigs, overrideConfigs) {
+        if (hash[viewType]) {
+            return hash[viewType];
+        }
+        var viewDef = buildViewDef(viewType, hash, defaultConfigs, overrideConfigs);
+        if (viewDef) {
+            hash[viewType] = viewDef;
+        }
+        return viewDef;
+    }
+    function buildViewDef(viewType, hash, defaultConfigs, overrideConfigs) {
+        var defaultConfig = defaultConfigs[viewType];
+        var overrideConfig = overrideConfigs[viewType];
+        var queryProp = function (name) { return ((defaultConfig && defaultConfig[name] !== null) ? defaultConfig[name] :
+            ((overrideConfig && overrideConfig[name] !== null) ? overrideConfig[name] : null)); };
+        var theComponent = queryProp('component');
+        var superType = queryProp('superType');
+        var superDef = null;
+        if (superType) {
+            if (superType === viewType) {
+                throw new Error('Can\'t have a custom view type that references itself');
+            }
+            superDef = ensureViewDef(superType, hash, defaultConfigs, overrideConfigs);
+        }
+        if (!theComponent && superDef) {
+            theComponent = superDef.component;
+        }
+        if (!theComponent) {
+            return null; // don't throw a warning, might be settings for a single-unit view
+        }
+        return {
+            type: viewType,
+            component: theComponent,
+            defaults: __assign(__assign({}, (superDef ? superDef.defaults : {})), (defaultConfig ? defaultConfig.rawOptions : {})),
+            overrides: __assign(__assign({}, (superDef ? superDef.overrides : {})), (overrideConfig ? overrideConfig.rawOptions : {})),
+        };
+    }
+
+    /* eslint max-classes-per-file: off */
+    // NOTE: in JSX, you should always use this class with <HookProps> arg. otherwise, will default to any???
+    var RenderHook = /** @class */ (function (_super) {
+        __extends(RenderHook, _super);
+        function RenderHook() {
+            var _this = _super !== null && _super.apply(this, arguments) || this;
+            _this.rootElRef = createRef();
+            _this.handleRootEl = function (el) {
+                setRef(_this.rootElRef, el);
+                if (_this.props.elRef) {
+                    setRef(_this.props.elRef, el);
+                }
+            };
+            return _this;
+        }
+        RenderHook.prototype.render = function () {
+            var _this = this;
+            var props = this.props;
+            var hookProps = props.hookProps;
+            return (createElement(MountHook, { hookProps: hookProps, didMount: props.didMount, willUnmount: props.willUnmount, elRef: this.handleRootEl }, function (rootElRef) { return (createElement(ContentHook, { hookProps: hookProps, content: props.content, defaultContent: props.defaultContent, backupElRef: _this.rootElRef }, function (innerElRef, innerContent) { return props.children(rootElRef, normalizeClassNames(props.classNames, hookProps), innerElRef, innerContent); })); }));
+        };
+        return RenderHook;
+    }(BaseComponent));
+    // TODO: rename to be about function, not default. use in above type
+    // for forcing rerender of components that use the ContentHook
+    var CustomContentRenderContext = createContext$1(0);
+    function ContentHook(props) {
+        return (createElement(CustomContentRenderContext.Consumer, null, function (renderId) { return (createElement(ContentHookInner, __assign({ renderId: renderId }, props))); }));
+    }
+    var ContentHookInner = /** @class */ (function (_super) {
+        __extends(ContentHookInner, _super);
+        function ContentHookInner() {
+            var _this = _super !== null && _super.apply(this, arguments) || this;
+            _this.innerElRef = createRef();
+            return _this;
+        }
+        ContentHookInner.prototype.render = function () {
+            return this.props.children(this.innerElRef, this.renderInnerContent());
+        };
+        ContentHookInner.prototype.componentDidMount = function () {
+            this.updateCustomContent();
+        };
+        ContentHookInner.prototype.componentDidUpdate = function () {
+            this.updateCustomContent();
+        };
+        ContentHookInner.prototype.componentWillUnmount = function () {
+            if (this.customContentInfo && this.customContentInfo.destroy) {
+                this.customContentInfo.destroy();
+            }
+        };
+        ContentHookInner.prototype.renderInnerContent = function () {
+            var contentTypeHandlers = this.context.pluginHooks.contentTypeHandlers;
+            var _a = this, props = _a.props, customContentInfo = _a.customContentInfo;
+            var rawVal = props.content;
+            var innerContent = normalizeContent(rawVal, props.hookProps);
+            var innerContentVDom = null;
+            if (innerContent === undefined) { // use the default
+                innerContent = normalizeContent(props.defaultContent, props.hookProps);
+            }
+            if (innerContent !== undefined) { // we allow custom content handlers to return nothing
+                if (customContentInfo) {
+                    customContentInfo.contentVal = innerContent[customContentInfo.contentKey];
+                }
+                else if (typeof innerContent === 'object') {
+                    // look for a prop that would indicate a custom content handler is needed
+                    for (var contentKey in contentTypeHandlers) {
+                        if (innerContent[contentKey] !== undefined) {
+                            var stuff = contentTypeHandlers[contentKey]();
+                            customContentInfo = this.customContentInfo = __assign({ contentKey: contentKey, contentVal: innerContent[contentKey] }, stuff);
+                            break;
+                        }
+                    }
+                }
+                if (customContentInfo) {
+                    innerContentVDom = []; // signal that something was specified
+                }
+                else {
+                    innerContentVDom = innerContent; // assume a [p]react vdom node. use it
+                }
+            }
+            return innerContentVDom;
+        };
+        ContentHookInner.prototype.updateCustomContent = function () {
+            if (this.customContentInfo) {
+                this.customContentInfo.render(this.innerElRef.current || this.props.backupElRef.current, // the element to render into
+                this.customContentInfo.contentVal);
+            }
+        };
+        return ContentHookInner;
+    }(BaseComponent));
+    var MountHook = /** @class */ (function (_super) {
+        __extends(MountHook, _super);
+        function MountHook() {
+            var _this = _super !== null && _super.apply(this, arguments) || this;
+            _this.handleRootEl = function (rootEl) {
+                _this.rootEl = rootEl;
+                if (_this.props.elRef) {
+                    setRef(_this.props.elRef, rootEl);
+                }
+            };
+            return _this;
+        }
+        MountHook.prototype.render = function () {
+            return this.props.children(this.handleRootEl);
+        };
+        MountHook.prototype.componentDidMount = function () {
+            var callback = this.props.didMount;
+            if (callback) {
+                callback(__assign(__assign({}, this.props.hookProps), { el: this.rootEl }));
+            }
+        };
+        MountHook.prototype.componentWillUnmount = function () {
+            var callback = this.props.willUnmount;
+            if (callback) {
+                callback(__assign(__assign({}, this.props.hookProps), { el: this.rootEl }));
+            }
+        };
+        return MountHook;
+    }(BaseComponent));
+    function buildClassNameNormalizer() {
+        var currentGenerator;
+        var currentHookProps;
+        var currentClassNames = [];
+        return function (generator, hookProps) {
+            if (!currentHookProps || !isPropsEqual(currentHookProps, hookProps) || generator !== currentGenerator) {
+                currentGenerator = generator;
+                currentHookProps = hookProps;
+                currentClassNames = normalizeClassNames(generator, hookProps);
+            }
+            return currentClassNames;
+        };
+    }
+    function normalizeClassNames(classNames, hookProps) {
+        if (typeof classNames === 'function') {
+            classNames = classNames(hookProps);
+        }
+        return parseClassNames(classNames);
+    }
+    function normalizeContent(input, hookProps) {
+        if (typeof input === 'function') {
+            return input(hookProps, createElement); // give the function the vdom-creation func
+        }
+        return input;
+    }
+
+    var ViewRoot = /** @class */ (function (_super) {
+        __extends(ViewRoot, _super);
+        function ViewRoot() {
+            var _this = _super !== null && _super.apply(this, arguments) || this;
+            _this.normalizeClassNames = buildClassNameNormalizer();
+            return _this;
+        }
+        ViewRoot.prototype.render = function () {
+            var _a = this, props = _a.props, context = _a.context;
+            var options = context.options;
+            var hookProps = { view: context.viewApi };
+            var customClassNames = this.normalizeClassNames(options.viewClassNames, hookProps);
+            return (createElement(MountHook, { hookProps: hookProps, didMount: options.viewDidMount, willUnmount: options.viewWillUnmount, elRef: props.elRef }, function (rootElRef) { return props.children(rootElRef, ["fc-" + props.viewSpec.type + "-view", 'fc-view'].concat(customClassNames)); }));
+        };
+        return ViewRoot;
+    }(BaseComponent));
+
+    function parseViewConfigs(inputs) {
+        return mapHash(inputs, parseViewConfig);
+    }
+    function parseViewConfig(input) {
+        var rawOptions = typeof input === 'function' ?
+            { component: input } :
+            input;
+        var component = rawOptions.component;
+        if (rawOptions.content) {
+            component = createViewHookComponent(rawOptions);
+            // TODO: remove content/classNames/didMount/etc from options?
+        }
+        return {
+            superType: rawOptions.type,
+            component: component,
+            rawOptions: rawOptions,
+        };
+    }
+    function createViewHookComponent(options) {
+        return function (viewProps) { return (createElement(ViewContextType.Consumer, null, function (context) { return (createElement(ViewRoot, { viewSpec: context.viewSpec }, function (viewElRef, viewClassNames) {
+            var hookProps = __assign(__assign({}, viewProps), { nextDayThreshold: context.options.nextDayThreshold });
+            return (createElement(RenderHook, { hookProps: hookProps, classNames: options.classNames, content: options.content, didMount: options.didMount, willUnmount: options.willUnmount, elRef: viewElRef }, function (rootElRef, customClassNames, innerElRef, innerContent) { return (createElement("div", { className: viewClassNames.concat(customClassNames).join(' '), ref: rootElRef }, innerContent)); }));
+        })); })); };
+    }
+
+    function buildViewSpecs(defaultInputs, optionOverrides, dynamicOptionOverrides, localeDefaults) {
+        var defaultConfigs = parseViewConfigs(defaultInputs);
+        var overrideConfigs = parseViewConfigs(optionOverrides.views);
+        var viewDefs = compileViewDefs(defaultConfigs, overrideConfigs);
+        return mapHash(viewDefs, function (viewDef) { return buildViewSpec(viewDef, overrideConfigs, optionOverrides, dynamicOptionOverrides, localeDefaults); });
+    }
+    function buildViewSpec(viewDef, overrideConfigs, optionOverrides, dynamicOptionOverrides, localeDefaults) {
+        var durationInput = viewDef.overrides.duration ||
+            viewDef.defaults.duration ||
+            dynamicOptionOverrides.duration ||
+            optionOverrides.duration;
+        var duration = null;
+        var durationUnit = '';
+        var singleUnit = '';
+        var singleUnitOverrides = {};
+        if (durationInput) {
+            duration = createDurationCached(durationInput);
+            if (duration) { // valid?
+                var denom = greatestDurationDenominator(duration);
+                durationUnit = denom.unit;
+                if (denom.value === 1) {
+                    singleUnit = durationUnit;
+                    singleUnitOverrides = overrideConfigs[durationUnit] ? overrideConfigs[durationUnit].rawOptions : {};
+                }
+            }
+        }
+        var queryButtonText = function (optionsSubset) {
+            var buttonTextMap = optionsSubset.buttonText || {};
+            var buttonTextKey = viewDef.defaults.buttonTextKey;
+            if (buttonTextKey != null && buttonTextMap[buttonTextKey] != null) {
+                return buttonTextMap[buttonTextKey];
+            }
+            if (buttonTextMap[viewDef.type] != null) {
+                return buttonTextMap[viewDef.type];
+            }
+            if (buttonTextMap[singleUnit] != null) {
+                return buttonTextMap[singleUnit];
+            }
+            return null;
+        };
+        return {
+            type: viewDef.type,
+            component: viewDef.component,
+            duration: duration,
+            durationUnit: durationUnit,
+            singleUnit: singleUnit,
+            optionDefaults: viewDef.defaults,
+            optionOverrides: __assign(__assign({}, singleUnitOverrides), viewDef.overrides),
+            buttonTextOverride: queryButtonText(dynamicOptionOverrides) ||
+                queryButtonText(optionOverrides) || // constructor-specified buttonText lookup hash takes precedence
+                viewDef.overrides.buttonText,
+            buttonTextDefault: queryButtonText(localeDefaults) ||
+                viewDef.defaults.buttonText ||
+                queryButtonText(BASE_OPTION_DEFAULTS) ||
+                viewDef.type,
+        };
+    }
+    // hack to get memoization working
+    var durationInputMap = {};
+    function createDurationCached(durationInput) {
+        var json = JSON.stringify(durationInput);
+        var res = durationInputMap[json];
+        if (res === undefined) {
+            res = createDuration(durationInput);
+            durationInputMap[json] = res;
+        }
+        return res;
+    }
+
+    var DateProfileGenerator = /** @class */ (function () {
+        function DateProfileGenerator(props) {
+            this.props = props;
+            this.nowDate = getNow(props.nowInput, props.dateEnv);
+            this.initHiddenDays();
+        }
+        /* Date Range Computation
+        ------------------------------------------------------------------------------------------------------------------*/
+        // Builds a structure with info about what the dates/ranges will be for the "prev" view.
+        DateProfileGenerator.prototype.buildPrev = function (currentDateProfile, currentDate, forceToValid) {
+            var dateEnv = this.props.dateEnv;
+            var prevDate = dateEnv.subtract(dateEnv.startOf(currentDate, currentDateProfile.currentRangeUnit), // important for start-of-month
+            currentDateProfile.dateIncrement);
+            return this.build(prevDate, -1, forceToValid);
+        };
+        // Builds a structure with info about what the dates/ranges will be for the "next" view.
+        DateProfileGenerator.prototype.buildNext = function (currentDateProfile, currentDate, forceToValid) {
+            var dateEnv = this.props.dateEnv;
+            var nextDate = dateEnv.add(dateEnv.startOf(currentDate, currentDateProfile.currentRangeUnit), // important for start-of-month
+            currentDateProfile.dateIncrement);
+            return this.build(nextDate, 1, forceToValid);
+        };
+        // Builds a structure holding dates/ranges for rendering around the given date.
+        // Optional direction param indicates whether the date is being incremented/decremented
+        // from its previous value. decremented = -1, incremented = 1 (default).
+        DateProfileGenerator.prototype.build = function (currentDate, direction, forceToValid) {
+            if (forceToValid === void 0) { forceToValid = true; }
+            var props = this.props;
+            var validRange;
+            var currentInfo;
+            var isRangeAllDay;
+            var renderRange;
+            var activeRange;
+            var isValid;
+            validRange = this.buildValidRange();
+            validRange = this.trimHiddenDays(validRange);
+            if (forceToValid) {
+                currentDate = constrainMarkerToRange(currentDate, validRange);
+            }
+            currentInfo = this.buildCurrentRangeInfo(currentDate, direction);
+            isRangeAllDay = /^(year|month|week|day)$/.test(currentInfo.unit);
+            renderRange = this.buildRenderRange(this.trimHiddenDays(currentInfo.range), currentInfo.unit, isRangeAllDay);
+            renderRange = this.trimHiddenDays(renderRange);
+            activeRange = renderRange;
+            if (!props.showNonCurrentDates) {
+                activeRange = intersectRanges(activeRange, currentInfo.range);
+            }
+            activeRange = this.adjustActiveRange(activeRange);
+            activeRange = intersectRanges(activeRange, validRange); // might return null
+            // it's invalid if the originally requested date is not contained,
+            // or if the range is completely outside of the valid range.
+            isValid = rangesIntersect(currentInfo.range, validRange);
+            return {
+                // constraint for where prev/next operations can go and where events can be dragged/resized to.
+                // an object with optional start and end properties.
+                validRange: validRange,
+                // range the view is formally responsible for.
+                // for example, a month view might have 1st-31st, excluding padded dates
+                currentRange: currentInfo.range,
+                // name of largest unit being displayed, like "month" or "week"
+                currentRangeUnit: currentInfo.unit,
+                isRangeAllDay: isRangeAllDay,
+                // dates that display events and accept drag-n-drop
+                // will be `null` if no dates accept events
+                activeRange: activeRange,
+                // date range with a rendered skeleton
+                // includes not-active days that need some sort of DOM
+                renderRange: renderRange,
+                // Duration object that denotes the first visible time of any given day
+                slotMinTime: props.slotMinTime,
+                // Duration object that denotes the exclusive visible end time of any given day
+                slotMaxTime: props.slotMaxTime,
+                isValid: isValid,
+                // how far the current date will move for a prev/next operation
+                dateIncrement: this.buildDateIncrement(currentInfo.duration),
+            };
+        };
+        // Builds an object with optional start/end properties.
+        // Indicates the minimum/maximum dates to display.
+        // not responsible for trimming hidden days.
+        DateProfileGenerator.prototype.buildValidRange = function () {
+            var input = this.props.validRangeInput;
+            var simpleInput = typeof input === 'function'
+                ? input.call(this.props.calendarApi, this.nowDate)
+                : input;
+            return this.refineRange(simpleInput) ||
+                { start: null, end: null }; // completely open-ended
+        };
+        // Builds a structure with info about the "current" range, the range that is
+        // highlighted as being the current month for example.
+        // See build() for a description of `direction`.
+        // Guaranteed to have `range` and `unit` properties. `duration` is optional.
+        DateProfileGenerator.prototype.buildCurrentRangeInfo = function (date, direction) {
+            var props = this.props;
+            var duration = null;
+            var unit = null;
+            var range = null;
+            var dayCount;
+            if (props.duration) {
+                duration = props.duration;
+                unit = props.durationUnit;
+                range = this.buildRangeFromDuration(date, direction, duration, unit);
+            }
+            else if ((dayCount = this.props.dayCount)) {
+                unit = 'day';
+                range = this.buildRangeFromDayCount(date, direction, dayCount);
+            }
+            else if ((range = this.buildCustomVisibleRange(date))) {
+                unit = props.dateEnv.greatestWholeUnit(range.start, range.end).unit;
+            }
+            else {
+                duration = this.getFallbackDuration();
+                unit = greatestDurationDenominator(duration).unit;
+                range = this.buildRangeFromDuration(date, direction, duration, unit);
+            }
+            return { duration: duration, unit: unit, range: range };
+        };
+        DateProfileGenerator.prototype.getFallbackDuration = function () {
+            return createDuration({ day: 1 });
+        };
+        // Returns a new activeRange to have time values (un-ambiguate)
+        // slotMinTime or slotMaxTime causes the range to expand.
+        DateProfileGenerator.prototype.adjustActiveRange = function (range) {
+            var _a = this.props, dateEnv = _a.dateEnv, usesMinMaxTime = _a.usesMinMaxTime, slotMinTime = _a.slotMinTime, slotMaxTime = _a.slotMaxTime;
+            var start = range.start, end = range.end;
+            if (usesMinMaxTime) {
+                // expand active range if slotMinTime is negative (why not when positive?)
+                if (asRoughDays(slotMinTime) < 0) {
+                    start = startOfDay(start); // necessary?
+                    start = dateEnv.add(start, slotMinTime);
+                }
+                // expand active range if slotMaxTime is beyond one day (why not when negative?)
+                if (asRoughDays(slotMaxTime) > 1) {
+                    end = startOfDay(end); // necessary?
+                    end = addDays(end, -1);
+                    end = dateEnv.add(end, slotMaxTime);
+                }
+            }
+            return { start: start, end: end };
+        };
+        // Builds the "current" range when it is specified as an explicit duration.
+        // `unit` is the already-computed greatestDurationDenominator unit of duration.
+        DateProfileGenerator.prototype.buildRangeFromDuration = function (date, direction, duration, unit) {
+            var _a = this.props, dateEnv = _a.dateEnv, dateAlignment = _a.dateAlignment;
+            var start;
+            var end;
+            var res;
+            // compute what the alignment should be
+            if (!dateAlignment) {
+                var dateIncrement = this.props.dateIncrement;
+                if (dateIncrement) {
+                    // use the smaller of the two units
+                    if (asRoughMs(dateIncrement) < asRoughMs(duration)) {
+                        dateAlignment = greatestDurationDenominator(dateIncrement).unit;
+                    }
+                    else {
+                        dateAlignment = unit;
+                    }
+                }
+                else {
+                    dateAlignment = unit;
+                }
+            }
+            // if the view displays a single day or smaller
+            if (asRoughDays(duration) <= 1) {
+                if (this.isHiddenDay(start)) {
+                    start = this.skipHiddenDays(start, direction);
+                    start = startOfDay(start);
+                }
+            }
+            function computeRes() {
+                start = dateEnv.startOf(date, dateAlignment);
+                end = dateEnv.add(start, duration);
+                res = { start: start, end: end };
+            }
+            computeRes();
+            // if range is completely enveloped by hidden days, go past the hidden days
+            if (!this.trimHiddenDays(res)) {
+                date = this.skipHiddenDays(date, direction);
+                computeRes();
+            }
+            return res;
+        };
+        // Builds the "current" range when a dayCount is specified.
+        DateProfileGenerator.prototype.buildRangeFromDayCount = function (date, direction, dayCount) {
+            var _a = this.props, dateEnv = _a.dateEnv, dateAlignment = _a.dateAlignment;
+            var runningCount = 0;
+            var start = date;
+            var end;
+            if (dateAlignment) {
+                start = dateEnv.startOf(start, dateAlignment);
+            }
+            start = startOfDay(start);
+            start = this.skipHiddenDays(start, direction);
+            end = start;
+            do {
+                end = addDays(end, 1);
+                if (!this.isHiddenDay(end)) {
+                    runningCount += 1;
+                }
+            } while (runningCount < dayCount);
+            return { start: start, end: end };
+        };
+        // Builds a normalized range object for the "visible" range,
+        // which is a way to define the currentRange and activeRange at the same time.
+        DateProfileGenerator.prototype.buildCustomVisibleRange = function (date) {
+            var props = this.props;
+            var input = props.visibleRangeInput;
+            var simpleInput = typeof input === 'function'
+                ? input.call(props.calendarApi, props.dateEnv.toDate(date))
+                : input;
+            var range = this.refineRange(simpleInput);
+            if (range && (range.start == null || range.end == null)) {
+                return null;
+            }
+            return range;
+        };
+        // Computes the range that will represent the element/cells for *rendering*,
+        // but which may have voided days/times.
+        // not responsible for trimming hidden days.
+        DateProfileGenerator.prototype.buildRenderRange = function (currentRange, currentRangeUnit, isRangeAllDay) {
+            return currentRange;
+        };
+        // Compute the duration value that should be added/substracted to the current date
+        // when a prev/next operation happens.
+        DateProfileGenerator.prototype.buildDateIncrement = function (fallback) {
+            var dateIncrement = this.props.dateIncrement;
+            var customAlignment;
+            if (dateIncrement) {
+                return dateIncrement;
+            }
+            if ((customAlignment = this.props.dateAlignment)) {
+                return createDuration(1, customAlignment);
+            }
+            if (fallback) {
+                return fallback;
+            }
+            return createDuration({ days: 1 });
+        };
+        DateProfileGenerator.prototype.refineRange = function (rangeInput) {
+            if (rangeInput) {
+                var range = parseRange(rangeInput, this.props.dateEnv);
+                if (range) {
+                    range = computeVisibleDayRange(range);
+                }
+                return range;
+            }
+            return null;
+        };
+        /* Hidden Days
+        ------------------------------------------------------------------------------------------------------------------*/
+        // Initializes internal variables related to calculating hidden days-of-week
+        DateProfileGenerator.prototype.initHiddenDays = function () {
+            var hiddenDays = this.props.hiddenDays || []; // array of day-of-week indices that are hidden
+            var isHiddenDayHash = []; // is the day-of-week hidden? (hash with day-of-week-index -> bool)
+            var dayCnt = 0;
+            var i;
+            if (this.props.weekends === false) {
+                hiddenDays.push(0, 6); // 0=sunday, 6=saturday
+            }
+            for (i = 0; i < 7; i += 1) {
+                if (!(isHiddenDayHash[i] = hiddenDays.indexOf(i) !== -1)) {
+                    dayCnt += 1;
+                }
+            }
+            if (!dayCnt) {
+                throw new Error('invalid hiddenDays'); // all days were hidden? bad.
+            }
+            this.isHiddenDayHash = isHiddenDayHash;
+        };
+        // Remove days from the beginning and end of the range that are computed as hidden.
+        // If the whole range is trimmed off, returns null
+        DateProfileGenerator.prototype.trimHiddenDays = function (range) {
+            var start = range.start, end = range.end;
+            if (start) {
+                start = this.skipHiddenDays(start);
+            }
+            if (end) {
+                end = this.skipHiddenDays(end, -1, true);
+            }
+            if (start == null || end == null || start < end) {
+                return { start: start, end: end };
+            }
+            return null;
+        };
+        // Is the current day hidden?
+        // `day` is a day-of-week index (0-6), or a Date (used for UTC)
+        DateProfileGenerator.prototype.isHiddenDay = function (day) {
+            if (day instanceof Date) {
+                day = day.getUTCDay();
+            }
+            return this.isHiddenDayHash[day];
+        };
+        // Incrementing the current day until it is no longer a hidden day, returning a copy.
+        // DOES NOT CONSIDER validRange!
+        // If the initial value of `date` is not a hidden day, don't do anything.
+        // Pass `isExclusive` as `true` if you are dealing with an end date.
+        // `inc` defaults to `1` (increment one day forward each time)
+        DateProfileGenerator.prototype.skipHiddenDays = function (date, inc, isExclusive) {
+            if (inc === void 0) { inc = 1; }
+            if (isExclusive === void 0) { isExclusive = false; }
+            while (this.isHiddenDayHash[(date.getUTCDay() + (isExclusive ? inc : 0) + 7) % 7]) {
+                date = addDays(date, inc);
+            }
+            return date;
+        };
+        return DateProfileGenerator;
+    }());
+
+    function reduceViewType(viewType, action) {
+        switch (action.type) {
+            case 'CHANGE_VIEW_TYPE':
+                viewType = action.viewType;
+        }
+        return viewType;
+    }
+
+    function reduceDynamicOptionOverrides(dynamicOptionOverrides, action) {
+        var _a;
+        switch (action.type) {
+            case 'SET_OPTION':
+                return __assign(__assign({}, dynamicOptionOverrides), (_a = {}, _a[action.optionName] = action.rawOptionValue, _a));
+            default:
+                return dynamicOptionOverrides;
+        }
+    }
+
+    function reduceDateProfile(currentDateProfile, action, currentDate, dateProfileGenerator) {
+        var dp;
+        switch (action.type) {
+            case 'CHANGE_VIEW_TYPE':
+                return dateProfileGenerator.build(action.dateMarker || currentDate);
+            case 'CHANGE_DATE':
+                if (!currentDateProfile.activeRange ||
+                    !rangeContainsMarker(currentDateProfile.currentRange, action.dateMarker) // don't move if date already in view
+                ) {
+                    return dateProfileGenerator.build(action.dateMarker);
+                }
+                break;
+            case 'PREV':
+                dp = dateProfileGenerator.buildPrev(currentDateProfile, currentDate);
+                if (dp.isValid) {
+                    return dp;
+                }
+                break;
+            case 'NEXT':
+                dp = dateProfileGenerator.buildNext(currentDateProfile, currentDate);
+                if (dp.isValid) {
+                    return dp;
+                }
+                break;
+        }
+        return currentDateProfile;
+    }
+
+    function initEventSources(calendarOptions, dateProfile, context) {
+        var activeRange = dateProfile ? dateProfile.activeRange : null;
+        return addSources({}, parseInitialSources(calendarOptions, context), activeRange, context);
+    }
+    function reduceEventSources(eventSources, action, dateProfile, context) {
+        var activeRange = dateProfile ? dateProfile.activeRange : null; // need this check?
+        switch (action.type) {
+            case 'ADD_EVENT_SOURCES': // already parsed
+                return addSources(eventSources, action.sources, activeRange, context);
+            case 'REMOVE_EVENT_SOURCE':
+                return removeSource(eventSources, action.sourceId);
+            case 'PREV': // TODO: how do we track all actions that affect dateProfile :(
+            case 'NEXT':
+            case 'CHANGE_DATE':
+            case 'CHANGE_VIEW_TYPE':
+                if (dateProfile) {
+                    return fetchDirtySources(eventSources, activeRange, context);
+                }
+                return eventSources;
+            case 'FETCH_EVENT_SOURCES':
+                return fetchSourcesByIds(eventSources, action.sourceIds ? // why no type?
+                    arrayToHash(action.sourceIds) :
+                    excludeStaticSources(eventSources, context), activeRange, context);
+            case 'RECEIVE_EVENTS':
+            case 'RECEIVE_EVENT_ERROR':
+                return receiveResponse(eventSources, action.sourceId, action.fetchId, action.fetchRange);
+            case 'REMOVE_ALL_EVENT_SOURCES':
+                return {};
+            default:
+                return eventSources;
+        }
+    }
+    function reduceEventSourcesNewTimeZone(eventSources, dateProfile, context) {
+        var activeRange = dateProfile ? dateProfile.activeRange : null; // need this check?
+        return fetchSourcesByIds(eventSources, excludeStaticSources(eventSources, context), activeRange, context);
+    }
+    function computeEventSourcesLoading(eventSources) {
+        for (var sourceId in eventSources) {
+            if (eventSources[sourceId].isFetching) {
+                return true;
+            }
+        }
+        return false;
+    }
+    function addSources(eventSourceHash, sources, fetchRange, context) {
+        var hash = {};
+        for (var _i = 0, sources_1 = sources; _i < sources_1.length; _i++) {
+            var source = sources_1[_i];
+            hash[source.sourceId] = source;
+        }
+        if (fetchRange) {
+            hash = fetchDirtySources(hash, fetchRange, context);
+        }
+        return __assign(__assign({}, eventSourceHash), hash);
+    }
+    function removeSource(eventSourceHash, sourceId) {
+        return filterHash(eventSourceHash, function (eventSource) { return eventSource.sourceId !== sourceId; });
+    }
+    function fetchDirtySources(sourceHash, fetchRange, context) {
+        return fetchSourcesByIds(sourceHash, filterHash(sourceHash, function (eventSource) { return isSourceDirty(eventSource, fetchRange, context); }), fetchRange, context);
+    }
+    function isSourceDirty(eventSource, fetchRange, context) {
+        if (!doesSourceNeedRange(eventSource, context)) {
+            return !eventSource.latestFetchId;
+        }
+        return !context.options.lazyFetching ||
+            !eventSource.fetchRange ||
+            eventSource.isFetching || // always cancel outdated in-progress fetches
+            fetchRange.start < eventSource.fetchRange.start ||
+            fetchRange.end > eventSource.fetchRange.end;
+    }
+    function fetchSourcesByIds(prevSources, sourceIdHash, fetchRange, context) {
+        var nextSources = {};
+        for (var sourceId in prevSources) {
+            var source = prevSources[sourceId];
+            if (sourceIdHash[sourceId]) {
+                nextSources[sourceId] = fetchSource(source, fetchRange, context);
+            }
+            else {
+                nextSources[sourceId] = source;
+            }
+        }
+        return nextSources;
+    }
+    function fetchSource(eventSource, fetchRange, context) {
+        var options = context.options, calendarApi = context.calendarApi;
+        var sourceDef = context.pluginHooks.eventSourceDefs[eventSource.sourceDefId];
+        var fetchId = guid();
+        sourceDef.fetch({
+            eventSource: eventSource,
+            range: fetchRange,
+            context: context,
+        }, function (res) {
+            var rawEvents = res.rawEvents;
+            if (options.eventSourceSuccess) {
+                rawEvents = options.eventSourceSuccess.call(calendarApi, rawEvents, res.xhr) || rawEvents;
+            }
+            if (eventSource.success) {
+                rawEvents = eventSource.success.call(calendarApi, rawEvents, res.xhr) || rawEvents;
+            }
+            context.dispatch({
+                type: 'RECEIVE_EVENTS',
+                sourceId: eventSource.sourceId,
+                fetchId: fetchId,
+                fetchRange: fetchRange,
+                rawEvents: rawEvents,
+            });
+        }, function (error) {
+            console.warn(error.message, error);
+            if (options.eventSourceFailure) {
+                options.eventSourceFailure.call(calendarApi, error);
+            }
+            if (eventSource.failure) {
+                eventSource.failure(error);
+            }
+            context.dispatch({
+                type: 'RECEIVE_EVENT_ERROR',
+                sourceId: eventSource.sourceId,
+                fetchId: fetchId,
+                fetchRange: fetchRange,
+                error: error,
+            });
+        });
+        return __assign(__assign({}, eventSource), { isFetching: true, latestFetchId: fetchId });
+    }
+    function receiveResponse(sourceHash, sourceId, fetchId, fetchRange) {
+        var _a;
+        var eventSource = sourceHash[sourceId];
+        if (eventSource && // not already removed
+            fetchId === eventSource.latestFetchId) {
+            return __assign(__assign({}, sourceHash), (_a = {}, _a[sourceId] = __assign(__assign({}, eventSource), { isFetching: false, fetchRange: fetchRange }), _a));
+        }
+        return sourceHash;
+    }
+    function excludeStaticSources(eventSources, context) {
+        return filterHash(eventSources, function (eventSource) { return doesSourceNeedRange(eventSource, context); });
+    }
+    function parseInitialSources(rawOptions, context) {
+        var refiners = buildEventSourceRefiners(context);
+        var rawSources = [].concat(rawOptions.eventSources || []);
+        var sources = []; // parsed
+        if (rawOptions.initialEvents) {
+            rawSources.unshift(rawOptions.initialEvents);
+        }
+        if (rawOptions.events) {
+            rawSources.unshift(rawOptions.events);
+        }
+        for (var _i = 0, rawSources_1 = rawSources; _i < rawSources_1.length; _i++) {
+            var rawSource = rawSources_1[_i];
+            var source = parseEventSource(rawSource, context, refiners);
+            if (source) {
+                sources.push(source);
+            }
+        }
+        return sources;
+    }
+    function doesSourceNeedRange(eventSource, context) {
+        var defs = context.pluginHooks.eventSourceDefs;
+        return !defs[eventSource.sourceDefId].ignoreRange;
+    }
+
+    function reduceDateSelection(currentSelection, action) {
+        switch (action.type) {
+            case 'UNSELECT_DATES':
+                return null;
+            case 'SELECT_DATES':
+                return action.selection;
+            default:
+                return currentSelection;
+        }
+    }
+
+    function reduceSelectedEvent(currentInstanceId, action) {
+        switch (action.type) {
+            case 'UNSELECT_EVENT':
+                return '';
+            case 'SELECT_EVENT':
+                return action.eventInstanceId;
+            default:
+                return currentInstanceId;
+        }
+    }
+
+    function reduceEventDrag(currentDrag, action) {
+        var newDrag;
+        switch (action.type) {
+            case 'UNSET_EVENT_DRAG':
+                return null;
+            case 'SET_EVENT_DRAG':
+                newDrag = action.state;
+                return {
+                    affectedEvents: newDrag.affectedEvents,
+                    mutatedEvents: newDrag.mutatedEvents,
+                    isEvent: newDrag.isEvent,
+                };
+            default:
+                return currentDrag;
+        }
+    }
+
+    function reduceEventResize(currentResize, action) {
+        var newResize;
+        switch (action.type) {
+            case 'UNSET_EVENT_RESIZE':
+                return null;
+            case 'SET_EVENT_RESIZE':
+                newResize = action.state;
+                return {
+                    affectedEvents: newResize.affectedEvents,
+                    mutatedEvents: newResize.mutatedEvents,
+                    isEvent: newResize.isEvent,
+                };
+            default:
+                return currentResize;
+        }
+    }
+
+    function parseToolbars(calendarOptions, calendarOptionOverrides, theme, viewSpecs, calendarApi) {
+        var viewsWithButtons = [];
+        var headerToolbar = calendarOptions.headerToolbar ? parseToolbar(calendarOptions.headerToolbar, calendarOptions, calendarOptionOverrides, theme, viewSpecs, calendarApi, viewsWithButtons) : null;
+        var footerToolbar = calendarOptions.footerToolbar ? parseToolbar(calendarOptions.footerToolbar, calendarOptions, calendarOptionOverrides, theme, viewSpecs, calendarApi, viewsWithButtons) : null;
+        return { headerToolbar: headerToolbar, footerToolbar: footerToolbar, viewsWithButtons: viewsWithButtons };
+    }
+    function parseToolbar(sectionStrHash, calendarOptions, calendarOptionOverrides, theme, viewSpecs, calendarApi, viewsWithButtons) {
+        return mapHash(sectionStrHash, function (sectionStr) { return parseSection(sectionStr, calendarOptions, calendarOptionOverrides, theme, viewSpecs, calendarApi, viewsWithButtons); });
+    }
+    /*
+    BAD: querying icons and text here. should be done at render time
+    */
+    function parseSection(sectionStr, calendarOptions, calendarOptionOverrides, theme, viewSpecs, calendarApi, viewsWithButtons) {
+        var isRtl = calendarOptions.direction === 'rtl';
+        var calendarCustomButtons = calendarOptions.customButtons || {};
+        var calendarButtonTextOverrides = calendarOptionOverrides.buttonText || {};
+        var calendarButtonText = calendarOptions.buttonText || {};
+        var sectionSubstrs = sectionStr ? sectionStr.split(' ') : [];
+        return sectionSubstrs.map(function (buttonGroupStr) { return (buttonGroupStr.split(',').map(function (buttonName) {
+            if (buttonName === 'title') {
+                return { buttonName: buttonName };
+            }
+            var customButtonProps;
+            var viewSpec;
+            var buttonClick;
+            var buttonIcon; // only one of these will be set
+            var buttonText; // "
+            if ((customButtonProps = calendarCustomButtons[buttonName])) {
+                buttonClick = function (ev) {
+                    if (customButtonProps.click) {
+                        customButtonProps.click.call(ev.target, ev, ev.target);
+                    }
+                };
+                (buttonIcon = theme.getCustomButtonIconClass(customButtonProps)) ||
+                    (buttonIcon = theme.getIconClass(buttonName, isRtl)) ||
+                    (buttonText = customButtonProps.text);
+            }
+            else if ((viewSpec = viewSpecs[buttonName])) {
+                viewsWithButtons.push(buttonName);
+                buttonClick = function () {
+                    calendarApi.changeView(buttonName);
+                };
+                (buttonText = viewSpec.buttonTextOverride) ||
+                    (buttonIcon = theme.getIconClass(buttonName, isRtl)) ||
+                    (buttonText = viewSpec.buttonTextDefault);
+            }
+            else if (calendarApi[buttonName]) { // a calendarApi method
+                buttonClick = function () {
+                    calendarApi[buttonName]();
+                };
+                (buttonText = calendarButtonTextOverrides[buttonName]) ||
+                    (buttonIcon = theme.getIconClass(buttonName, isRtl)) ||
+                    (buttonText = calendarButtonText[buttonName]);
+                //            ^ everything else is considered default
+            }
+            return { buttonName: buttonName, buttonClick: buttonClick, buttonIcon: buttonIcon, buttonText: buttonText };
+        })); });
+    }
+
+    var eventSourceDef = {
+        ignoreRange: true,
+        parseMeta: function (refined) {
+            if (Array.isArray(refined.events)) {
+                return refined.events;
+            }
+            return null;
+        },
+        fetch: function (arg, success) {
+            success({
+                rawEvents: arg.eventSource.meta,
+            });
+        },
+    };
+    var arrayEventSourcePlugin = createPlugin({
+        eventSourceDefs: [eventSourceDef],
+    });
+
+    var eventSourceDef$1 = {
+        parseMeta: function (refined) {
+            if (typeof refined.events === 'function') {
+                return refined.events;
+            }
+            return null;
+        },
+        fetch: function (arg, success, failure) {
+            var dateEnv = arg.context.dateEnv;
+            var func = arg.eventSource.meta;
+            unpromisify(func.bind(null, buildRangeApiWithTimeZone(arg.range, dateEnv)), function (rawEvents) {
+                success({ rawEvents: rawEvents }); // needs an object response
+            }, failure);
+        },
+    };
+    var funcEventSourcePlugin = createPlugin({
+        eventSourceDefs: [eventSourceDef$1],
+    });
+
+    function requestJson(method, url, params, successCallback, failureCallback) {
+        method = method.toUpperCase();
+        var body = null;
+        if (method === 'GET') {
+            url = injectQueryStringParams(url, params);
+        }
+        else {
+            body = encodeParams(params);
+        }
+        var xhr = new XMLHttpRequest();
+        xhr.open(method, url, true);
+        if (method !== 'GET') {
+            xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
+        }
+        xhr.onload = function () {
+            if (xhr.status >= 200 && xhr.status < 400) {
+                var parsed = false;
+                var res = void 0;
+                try {
+                    res = JSON.parse(xhr.responseText);
+                    parsed = true;
+                }
+                catch (err) {
+                    // will handle parsed=false
+                }
+                if (parsed) {
+                    successCallback(res, xhr);
+                }
+                else {
+                    failureCallback('Failure parsing JSON', xhr);
+                }
+            }
+            else {
+                failureCallback('Request failed', xhr);
+            }
+        };
+        xhr.onerror = function () {
+            failureCallback('Request failed', xhr);
+        };
+        xhr.send(body);
+    }
+    function injectQueryStringParams(url, params) {
+        return url +
+            (url.indexOf('?') === -1 ? '?' : '&') +
+            encodeParams(params);
+    }
+    function encodeParams(params) {
+        var parts = [];
+        for (var key in params) {
+            parts.push(encodeURIComponent(key) + "=" + encodeURIComponent(params[key]));
+        }
+        return parts.join('&');
+    }
+
+    var JSON_FEED_EVENT_SOURCE_REFINERS = {
+        method: String,
+        extraParams: identity,
+        startParam: String,
+        endParam: String,
+        timeZoneParam: String,
+    };
+
+    var eventSourceDef$2 = {
+        parseMeta: function (refined) {
+            if (refined.url && (refined.format === 'json' || !refined.format)) {
+                return {
+                    url: refined.url,
+                    format: 'json',
+                    method: (refined.method || 'GET').toUpperCase(),
+                    extraParams: refined.extraParams,
+                    startParam: refined.startParam,
+                    endParam: refined.endParam,
+                    timeZoneParam: refined.timeZoneParam,
+                };
+            }
+            return null;
+        },
+        fetch: function (arg, success, failure) {
+            var meta = arg.eventSource.meta;
+            var requestParams = buildRequestParams(meta, arg.range, arg.context);
+            requestJson(meta.method, meta.url, requestParams, function (rawEvents, xhr) {
+                success({ rawEvents: rawEvents, xhr: xhr });
+            }, function (errorMessage, xhr) {
+                failure({ message: errorMessage, xhr: xhr });
+            });
+        },
+    };
+    var jsonFeedEventSourcePlugin = createPlugin({
+        eventSourceRefiners: JSON_FEED_EVENT_SOURCE_REFINERS,
+        eventSourceDefs: [eventSourceDef$2],
+    });
+    function buildRequestParams(meta, range, context) {
+        var dateEnv = context.dateEnv, options = context.options;
+        var startParam;
+        var endParam;
+        var timeZoneParam;
+        var customRequestParams;
+        var params = {};
+        startParam = meta.startParam;
+        if (startParam == null) {
+            startParam = options.startParam;
+        }
+        endParam = meta.endParam;
+        if (endParam == null) {
+            endParam = options.endParam;
+        }
+        timeZoneParam = meta.timeZoneParam;
+        if (timeZoneParam == null) {
+            timeZoneParam = options.timeZoneParam;
+        }
+        // retrieve any outbound GET/POST data from the options
+        if (typeof meta.extraParams === 'function') {
+            // supplied as a function that returns a key/value object
+            customRequestParams = meta.extraParams();
+        }
+        else {
+            // probably supplied as a straight key/value object
+            customRequestParams = meta.extraParams || {};
+        }
+        __assign(params, customRequestParams);
+        params[startParam] = dateEnv.formatIso(range.start);
+        params[endParam] = dateEnv.formatIso(range.end);
+        if (dateEnv.timeZone !== 'local') {
+            params[timeZoneParam] = dateEnv.timeZone;
+        }
+        return params;
+    }
+
+    var SIMPLE_RECURRING_REFINERS = {
+        daysOfWeek: identity,
+        startTime: createDuration,
+        endTime: createDuration,
+        duration: createDuration,
+        startRecur: identity,
+        endRecur: identity,
+    };
+
+    var recurring = {
+        parse: function (refined, dateEnv) {
+            if (refined.daysOfWeek || refined.startTime || refined.endTime || refined.startRecur || refined.endRecur) {
+                var recurringData = {
+                    daysOfWeek: refined.daysOfWeek || null,
+                    startTime: refined.startTime || null,
+                    endTime: refined.endTime || null,
+                    startRecur: refined.startRecur ? dateEnv.createMarker(refined.startRecur) : null,
+                    endRecur: refined.endRecur ? dateEnv.createMarker(refined.endRecur) : null,
+                };
+                var duration = void 0;
+                if (refined.duration) {
+                    duration = refined.duration;
+                }
+                if (!duration && refined.startTime && refined.endTime) {
+                    duration = subtractDurations(refined.endTime, refined.startTime);
+                }
+                return {
+                    allDayGuess: Boolean(!refined.startTime && !refined.endTime),
+                    duration: duration,
+                    typeData: recurringData,
+                };
+            }
+            return null;
+        },
+        expand: function (typeData, framingRange, dateEnv) {
+            var clippedFramingRange = intersectRanges(framingRange, { start: typeData.startRecur, end: typeData.endRecur });
+            if (clippedFramingRange) {
+                return expandRanges(typeData.daysOfWeek, typeData.startTime, clippedFramingRange, dateEnv);
+            }
+            return [];
+        },
+    };
+    var simpleRecurringEventsPlugin = createPlugin({
+        recurringTypes: [recurring],
+        eventRefiners: SIMPLE_RECURRING_REFINERS,
+    });
+    function expandRanges(daysOfWeek, startTime, framingRange, dateEnv) {
+        var dowHash = daysOfWeek ? arrayToHash(daysOfWeek) : null;
+        var dayMarker = startOfDay(framingRange.start);
+        var endMarker = framingRange.end;
+        var instanceStarts = [];
+        while (dayMarker < endMarker) {
+            var instanceStart 
+            // if everyday, or this particular day-of-week
+            = void 0;
+            // if everyday, or this particular day-of-week
+            if (!dowHash || dowHash[dayMarker.getUTCDay()]) {
+                if (startTime) {
+                    instanceStart = dateEnv.add(dayMarker, startTime);
+                }
+                else {
+                    instanceStart = dayMarker;
+                }
+                instanceStarts.push(instanceStart);
+            }
+            dayMarker = addDays(dayMarker, 1);
+        }
+        return instanceStarts;
+    }
+
+    var changeHandlerPlugin = createPlugin({
+        optionChangeHandlers: {
+            events: function (events, context) {
+                handleEventSources([events], context);
+            },
+            eventSources: handleEventSources,
+        },
+    });
+    /*
+    BUG: if `event` was supplied, all previously-given `eventSources` will be wiped out
+    */
+    function handleEventSources(inputs, context) {
+        var unfoundSources = hashValuesToArray(context.getCurrentData().eventSources);
+        var newInputs = [];
+        for (var _i = 0, inputs_1 = inputs; _i < inputs_1.length; _i++) {
+            var input = inputs_1[_i];
+            var inputFound = false;
+            for (var i = 0; i < unfoundSources.length; i += 1) {
+                if (unfoundSources[i]._raw === input) {
+                    unfoundSources.splice(i, 1); // delete
+                    inputFound = true;
+                    break;
+                }
+            }
+            if (!inputFound) {
+                newInputs.push(input);
+            }
+        }
+        for (var _a = 0, unfoundSources_1 = unfoundSources; _a < unfoundSources_1.length; _a++) {
+            var unfoundSource = unfoundSources_1[_a];
+            context.dispatch({
+                type: 'REMOVE_EVENT_SOURCE',
+                sourceId: unfoundSource.sourceId,
+            });
+        }
+        for (var _b = 0, newInputs_1 = newInputs; _b < newInputs_1.length; _b++) {
+            var newInput = newInputs_1[_b];
+            context.calendarApi.addEventSource(newInput);
+        }
+    }
+
+    function handleDateProfile(dateProfile, context) {
+        context.emitter.trigger('datesSet', __assign(__assign({}, buildRangeApiWithTimeZone(dateProfile.activeRange, context.dateEnv)), { view: context.viewApi }));
+    }
+
+    function handleEventStore(eventStore, context) {
+        var emitter = context.emitter;
+        if (emitter.hasHandlers('eventsSet')) {
+            emitter.trigger('eventsSet', buildEventApis(eventStore, context));
+        }
+    }
+
+    /*
+    this array is exposed on the root namespace so that UMD plugins can add to it.
+    see the rollup-bundles script.
+    */
+    var globalPlugins = [
+        arrayEventSourcePlugin,
+        funcEventSourcePlugin,
+        jsonFeedEventSourcePlugin,
+        simpleRecurringEventsPlugin,
+        changeHandlerPlugin,
+        createPlugin({
+            isLoadingFuncs: [
+                function (state) { return computeEventSourcesLoading(state.eventSources); },
+            ],
+            contentTypeHandlers: {
+                html: function () { return ({ render: injectHtml }); },
+                domNodes: function () { return ({ render: injectDomNodes }); },
+            },
+            propSetHandlers: {
+                dateProfile: handleDateProfile,
+                eventStore: handleEventStore,
+            },
+        }),
+    ];
+    function injectHtml(el, html) {
+        el.innerHTML = html;
+    }
+    function injectDomNodes(el, domNodes) {
+        var oldNodes = Array.prototype.slice.call(el.childNodes); // TODO: use array util
+        var newNodes = Array.prototype.slice.call(domNodes); // TODO: use array util
+        if (!isArraysEqual(oldNodes, newNodes)) {
+            for (var _i = 0, newNodes_1 = newNodes; _i < newNodes_1.length; _i++) {
+                var newNode = newNodes_1[_i];
+                el.appendChild(newNode);
+            }
+            oldNodes.forEach(removeElement);
+        }
+    }
+
+    var DelayedRunner = /** @class */ (function () {
+        function DelayedRunner(drainedOption) {
+            this.drainedOption = drainedOption;
+            this.isRunning = false;
+            this.isDirty = false;
+            this.pauseDepths = {};
+            this.timeoutId = 0;
+        }
+        DelayedRunner.prototype.request = function (delay) {
+            this.isDirty = true;
+            if (!this.isPaused()) {
+                this.clearTimeout();
+                if (delay == null) {
+                    this.tryDrain();
+                }
+                else {
+                    this.timeoutId = setTimeout(// NOT OPTIMAL! TODO: look at debounce
+                    this.tryDrain.bind(this), delay);
+                }
+            }
+        };
+        DelayedRunner.prototype.pause = function (scope) {
+            if (scope === void 0) { scope = ''; }
+            var pauseDepths = this.pauseDepths;
+            pauseDepths[scope] = (pauseDepths[scope] || 0) + 1;
+            this.clearTimeout();
+        };
+        DelayedRunner.prototype.resume = function (scope, force) {
+            if (scope === void 0) { scope = ''; }
+            var pauseDepths = this.pauseDepths;
+            if (scope in pauseDepths) {
+                if (force) {
+                    delete pauseDepths[scope];
+                }
+                else {
+                    pauseDepths[scope] -= 1;
+                    var depth = pauseDepths[scope];
+                    if (depth <= 0) {
+                        delete pauseDepths[scope];
+                    }
+                }
+                this.tryDrain();
+            }
+        };
+        DelayedRunner.prototype.isPaused = function () {
+            return Object.keys(this.pauseDepths).length;
+        };
+        DelayedRunner.prototype.tryDrain = function () {
+            if (!this.isRunning && !this.isPaused()) {
+                this.isRunning = true;
+                while (this.isDirty) {
+                    this.isDirty = false;
+                    this.drained(); // might set isDirty to true again
+                }
+                this.isRunning = false;
+            }
+        };
+        DelayedRunner.prototype.clear = function () {
+            this.clearTimeout();
+            this.isDirty = false;
+            this.pauseDepths = {};
+        };
+        DelayedRunner.prototype.clearTimeout = function () {
+            if (this.timeoutId) {
+                clearTimeout(this.timeoutId);
+                this.timeoutId = 0;
+            }
+        };
+        DelayedRunner.prototype.drained = function () {
+            if (this.drainedOption) {
+                this.drainedOption();
+            }
+        };
+        return DelayedRunner;
+    }());
+
+    var TaskRunner = /** @class */ (function () {
+        function TaskRunner(runTaskOption, drainedOption) {
+            this.runTaskOption = runTaskOption;
+            this.drainedOption = drainedOption;
+            this.queue = [];
+            this.delayedRunner = new DelayedRunner(this.drain.bind(this));
+        }
+        TaskRunner.prototype.request = function (task, delay) {
+            this.queue.push(task);
+            this.delayedRunner.request(delay);
+        };
+        TaskRunner.prototype.pause = function (scope) {
+            this.delayedRunner.pause(scope);
+        };
+        TaskRunner.prototype.resume = function (scope, force) {
+            this.delayedRunner.resume(scope, force);
+        };
+        TaskRunner.prototype.drain = function () {
+            var queue = this.queue;
+            while (queue.length) {
+                var completedTasks = [];
+                var task = void 0;
+                while ((task = queue.shift())) {
+                    this.runTask(task);
+                    completedTasks.push(task);
+                }
+                this.drained(completedTasks);
+            } // keep going, in case new tasks were added in the drained handler
+        };
+        TaskRunner.prototype.runTask = function (task) {
+            if (this.runTaskOption) {
+                this.runTaskOption(task);
+            }
+        };
+        TaskRunner.prototype.drained = function (completedTasks) {
+            if (this.drainedOption) {
+                this.drainedOption(completedTasks);
+            }
+        };
+        return TaskRunner;
+    }());
+
+    // Computes what the title at the top of the calendarApi should be for this view
+    function buildTitle(dateProfile, viewOptions, dateEnv) {
+        var range;
+        // for views that span a large unit of time, show the proper interval, ignoring stray days before and after
+        if (/^(year|month)$/.test(dateProfile.currentRangeUnit)) {
+            range = dateProfile.currentRange;
+        }
+        else { // for day units or smaller, use the actual day range
+            range = dateProfile.activeRange;
+        }
+        return dateEnv.formatRange(range.start, range.end, createFormatter(viewOptions.titleFormat || buildTitleFormat(dateProfile)), {
+            isEndExclusive: dateProfile.isRangeAllDay,
+            defaultSeparator: viewOptions.titleRangeSeparator,
+        });
+    }
+    // Generates the format string that should be used to generate the title for the current date range.
+    // Attempts to compute the most appropriate format if not explicitly specified with `titleFormat`.
+    function buildTitleFormat(dateProfile) {
+        var currentRangeUnit = dateProfile.currentRangeUnit;
+        if (currentRangeUnit === 'year') {
+            return { year: 'numeric' };
+        }
+        if (currentRangeUnit === 'month') {
+            return { year: 'numeric', month: 'long' }; // like "September 2014"
+        }
+        var days = diffWholeDays(dateProfile.currentRange.start, dateProfile.currentRange.end);
+        if (days !== null && days > 1) {
+            // multi-day range. shorter, like "Sep 9 - 10 2014"
+            return { year: 'numeric', month: 'short', day: 'numeric' };
+        }
+        // one day. longer, like "September 9 2014"
+        return { year: 'numeric', month: 'long', day: 'numeric' };
+    }
+
+    // in future refactor, do the redux-style function(state=initial) for initial-state
+    // also, whatever is happening in constructor, have it happen in action queue too
+    var CalendarDataManager = /** @class */ (function () {
+        function CalendarDataManager(props) {
+            var _this = this;
+            this.computeOptionsData = memoize(this._computeOptionsData);
+            this.computeCurrentViewData = memoize(this._computeCurrentViewData);
+            this.organizeRawLocales = memoize(organizeRawLocales);
+            this.buildLocale = memoize(buildLocale);
+            this.buildPluginHooks = buildBuildPluginHooks();
+            this.buildDateEnv = memoize(buildDateEnv$1);
+            this.buildTheme = memoize(buildTheme);
+            this.parseToolbars = memoize(parseToolbars);
+            this.buildViewSpecs = memoize(buildViewSpecs);
+            this.buildDateProfileGenerator = memoizeObjArg(buildDateProfileGenerator);
+            this.buildViewApi = memoize(buildViewApi);
+            this.buildViewUiProps = memoizeObjArg(buildViewUiProps);
+            this.buildEventUiBySource = memoize(buildEventUiBySource, isPropsEqual);
+            this.buildEventUiBases = memoize(buildEventUiBases);
+            this.parseContextBusinessHours = memoizeObjArg(parseContextBusinessHours);
+            this.buildTitle = memoize(buildTitle);
+            this.emitter = new Emitter();
+            this.actionRunner = new TaskRunner(this._handleAction.bind(this), this.updateData.bind(this));
+            this.currentCalendarOptionsInput = {};
+            this.currentCalendarOptionsRefined = {};
+            this.currentViewOptionsInput = {};
+            this.currentViewOptionsRefined = {};
+            this.currentCalendarOptionsRefiners = {};
+            this.getCurrentData = function () { return _this.data; };
+            this.dispatch = function (action) {
+                _this.actionRunner.request(action); // protects against recursive calls to _handleAction
+            };
+            this.props = props;
+            this.actionRunner.pause();
+            var dynamicOptionOverrides = {};
+            var optionsData = this.computeOptionsData(props.optionOverrides, dynamicOptionOverrides, props.calendarApi);
+            var currentViewType = optionsData.calendarOptions.initialView || optionsData.pluginHooks.initialView;
+            var currentViewData = this.computeCurrentViewData(currentViewType, optionsData, props.optionOverrides, dynamicOptionOverrides);
+            // wire things up
+            // TODO: not DRY
+            props.calendarApi.currentDataManager = this;
+            this.emitter.setThisContext(props.calendarApi);
+            this.emitter.setOptions(currentViewData.options);
+            var currentDate = getInitialDate(optionsData.calendarOptions, optionsData.dateEnv);
+            var dateProfile = currentViewData.dateProfileGenerator.build(currentDate);
+            if (!rangeContainsMarker(dateProfile.activeRange, currentDate)) {
+                currentDate = dateProfile.currentRange.start;
+            }
+            var calendarContext = {
+                dateEnv: optionsData.dateEnv,
+                options: optionsData.calendarOptions,
+                pluginHooks: optionsData.pluginHooks,
+                calendarApi: props.calendarApi,
+                dispatch: this.dispatch,
+                emitter: this.emitter,
+                getCurrentData: this.getCurrentData,
+            };
+            // needs to be after setThisContext
+            for (var _i = 0, _a = optionsData.pluginHooks.contextInit; _i < _a.length; _i++) {
+                var callback = _a[_i];
+                callback(calendarContext);
+            }
+            // NOT DRY
+            var eventSources = initEventSources(optionsData.calendarOptions, dateProfile, calendarContext);
+            var initialState = {
+                dynamicOptionOverrides: dynamicOptionOverrides,
+                currentViewType: currentViewType,
+                currentDate: currentDate,
+                dateProfile: dateProfile,
+                businessHours: this.parseContextBusinessHours(calendarContext),
+                eventSources: eventSources,
+                eventUiBases: {},
+                eventStore: createEmptyEventStore(),
+                renderableEventStore: createEmptyEventStore(),
+                dateSelection: null,
+                eventSelection: '',
+                eventDrag: null,
+                eventResize: null,
+                selectionConfig: this.buildViewUiProps(calendarContext).selectionConfig,
+            };
+            var contextAndState = __assign(__assign({}, calendarContext), initialState);
+            for (var _b = 0, _c = optionsData.pluginHooks.reducers; _b < _c.length; _b++) {
+                var reducer = _c[_b];
+                __assign(initialState, reducer(null, null, contextAndState));
+            }
+            if (computeIsLoading(initialState, calendarContext)) {
+                this.emitter.trigger('loading', true); // NOT DRY
+            }
+            this.state = initialState;
+            this.updateData();
+            this.actionRunner.resume();
+        }
+        CalendarDataManager.prototype.resetOptions = function (optionOverrides, append) {
+            var props = this.props;
+            props.optionOverrides = append
+                ? __assign(__assign({}, props.optionOverrides), optionOverrides) : optionOverrides;
+            this.actionRunner.request({
+                type: 'NOTHING',
+            });
+        };
+        CalendarDataManager.prototype._handleAction = function (action) {
+            var _a = this, props = _a.props, state = _a.state, emitter = _a.emitter;
+            var dynamicOptionOverrides = reduceDynamicOptionOverrides(state.dynamicOptionOverrides, action);
+            var optionsData = this.computeOptionsData(props.optionOverrides, dynamicOptionOverrides, props.calendarApi);
+            var currentViewType = reduceViewType(state.currentViewType, action);
+            var currentViewData = this.computeCurrentViewData(currentViewType, optionsData, props.optionOverrides, dynamicOptionOverrides);
+            // wire things up
+            // TODO: not DRY
+            props.calendarApi.currentDataManager = this;
+            emitter.setThisContext(props.calendarApi);
+            emitter.setOptions(currentViewData.options);
+            var calendarContext = {
+                dateEnv: optionsData.dateEnv,
+                options: optionsData.calendarOptions,
+                pluginHooks: optionsData.pluginHooks,
+                calendarApi: props.calendarApi,
+                dispatch: this.dispatch,
+                emitter: emitter,
+                getCurrentData: this.getCurrentData,
+            };
+            var currentDate = state.currentDate, dateProfile = state.dateProfile;
+            if (this.data && this.data.dateProfileGenerator !== currentViewData.dateProfileGenerator) { // hack
+                dateProfile = currentViewData.dateProfileGenerator.build(currentDate);
+            }
+            currentDate = reduceCurrentDate(currentDate, action);
+            dateProfile = reduceDateProfile(dateProfile, action, currentDate, currentViewData.dateProfileGenerator);
+            if (!rangeContainsMarker(dateProfile.currentRange, currentDate)) {
+                currentDate = dateProfile.currentRange.start;
+            }
+            var eventSources = reduceEventSources(state.eventSources, action, dateProfile, calendarContext);
+            var eventStore = reduceEventStore(state.eventStore, action, eventSources, dateProfile, calendarContext);
+            var isEventsLoading = computeEventSourcesLoading(eventSources); // BAD. also called in this func in computeIsLoading
+            var renderableEventStore = (isEventsLoading && !currentViewData.options.progressiveEventRendering) ?
+                (state.renderableEventStore || eventStore) : // try from previous state
+                eventStore;
+            var _b = this.buildViewUiProps(calendarContext), eventUiSingleBase = _b.eventUiSingleBase, selectionConfig = _b.selectionConfig; // will memoize obj
+            var eventUiBySource = this.buildEventUiBySource(eventSources);
+            var eventUiBases = this.buildEventUiBases(renderableEventStore.defs, eventUiSingleBase, eventUiBySource);
+            var newState = {
+                dynamicOptionOverrides: dynamicOptionOverrides,
+                currentViewType: currentViewType,
+                currentDate: currentDate,
+                dateProfile: dateProfile,
+                eventSources: eventSources,
+                eventStore: eventStore,
+                renderableEventStore: renderableEventStore,
+                selectionConfig: selectionConfig,
+                eventUiBases: eventUiBases,
+                businessHours: this.parseContextBusinessHours(calendarContext),
+                dateSelection: reduceDateSelection(state.dateSelection, action),
+                eventSelection: reduceSelectedEvent(state.eventSelection, action),
+                eventDrag: reduceEventDrag(state.eventDrag, action),
+                eventResize: reduceEventResize(state.eventResize, action),
+            };
+            var contextAndState = __assign(__assign({}, calendarContext), newState);
+            for (var _i = 0, _c = optionsData.pluginHooks.reducers; _i < _c.length; _i++) {
+                var reducer = _c[_i];
+                __assign(newState, reducer(state, action, contextAndState)); // give the OLD state, for old value
+            }
+            var wasLoading = computeIsLoading(state, calendarContext);
+            var isLoading = computeIsLoading(newState, calendarContext);
+            // TODO: use propSetHandlers in plugin system
+            if (!wasLoading && isLoading) {
+                emitter.trigger('loading', true);
+            }
+            else if (wasLoading && !isLoading) {
+                emitter.trigger('loading', false);
+            }
+            this.state = newState;
+            if (props.onAction) {
+                props.onAction(action);
+            }
+        };
+        CalendarDataManager.prototype.updateData = function () {
+            var _a = this, props = _a.props, state = _a.state;
+            var oldData = this.data;
+            var optionsData = this.computeOptionsData(props.optionOverrides, state.dynamicOptionOverrides, props.calendarApi);
+            var currentViewData = this.computeCurrentViewData(state.currentViewType, optionsData, props.optionOverrides, state.dynamicOptionOverrides);
+            var data = this.data = __assign(__assign(__assign({ viewTitle: this.buildTitle(state.dateProfile, currentViewData.options, optionsData.dateEnv), calendarApi: props.calendarApi, dispatch: this.dispatch, emitter: this.emitter, getCurrentData: this.getCurrentData }, optionsData), currentViewData), state);
+            var changeHandlers = optionsData.pluginHooks.optionChangeHandlers;
+            var oldCalendarOptions = oldData && oldData.calendarOptions;
+            var newCalendarOptions = optionsData.calendarOptions;
+            if (oldCalendarOptions && oldCalendarOptions !== newCalendarOptions) {
+                if (oldCalendarOptions.timeZone !== newCalendarOptions.timeZone) {
+                    // hack
+                    state.eventSources = data.eventSources = reduceEventSourcesNewTimeZone(data.eventSources, state.dateProfile, data);
+                    state.eventStore = data.eventStore = rezoneEventStoreDates(data.eventStore, oldData.dateEnv, data.dateEnv);
+                }
+                for (var optionName in changeHandlers) {
+                    if (oldCalendarOptions[optionName] !== newCalendarOptions[optionName]) {
+                        changeHandlers[optionName](newCalendarOptions[optionName], data);
+                    }
+                }
+            }
+            if (props.onData) {
+                props.onData(data);
+            }
+        };
+        CalendarDataManager.prototype._computeOptionsData = function (optionOverrides, dynamicOptionOverrides, calendarApi) {
+            // TODO: blacklist options that are handled by optionChangeHandlers
+            var _a = this.processRawCalendarOptions(optionOverrides, dynamicOptionOverrides), refinedOptions = _a.refinedOptions, pluginHooks = _a.pluginHooks, localeDefaults = _a.localeDefaults, availableLocaleData = _a.availableLocaleData, extra = _a.extra;
+            warnUnknownOptions(extra);
+            var dateEnv = this.buildDateEnv(refinedOptions.timeZone, refinedOptions.locale, refinedOptions.weekNumberCalculation, refinedOptions.firstDay, refinedOptions.weekText, pluginHooks, availableLocaleData, refinedOptions.defaultRangeSeparator);
+            var viewSpecs = this.buildViewSpecs(pluginHooks.views, optionOverrides, dynamicOptionOverrides, localeDefaults);
+            var theme = this.buildTheme(refinedOptions, pluginHooks);
+            var toolbarConfig = this.parseToolbars(refinedOptions, optionOverrides, theme, viewSpecs, calendarApi);
+            return {
+                calendarOptions: refinedOptions,
+                pluginHooks: pluginHooks,
+                dateEnv: dateEnv,
+                viewSpecs: viewSpecs,
+                theme: theme,
+                toolbarConfig: toolbarConfig,
+                localeDefaults: localeDefaults,
+                availableRawLocales: availableLocaleData.map,
+            };
+        };
+        // always called from behind a memoizer
+        CalendarDataManager.prototype.processRawCalendarOptions = function (optionOverrides, dynamicOptionOverrides) {
+            var _a = mergeRawOptions([
+                BASE_OPTION_DEFAULTS,
+                optionOverrides,
+                dynamicOptionOverrides,
+            ]), locales = _a.locales, locale = _a.locale;
+            var availableLocaleData = this.organizeRawLocales(locales);
+            var availableRawLocales = availableLocaleData.map;
+            var localeDefaults = this.buildLocale(locale || availableLocaleData.defaultCode, availableRawLocales).options;
+            var pluginHooks = this.buildPluginHooks(optionOverrides.plugins || [], globalPlugins);
+            var refiners = this.currentCalendarOptionsRefiners = __assign(__assign(__assign(__assign(__assign({}, BASE_OPTION_REFINERS), CALENDAR_LISTENER_REFINERS), CALENDAR_OPTION_REFINERS), pluginHooks.listenerRefiners), pluginHooks.optionRefiners);
+            var extra = {};
+            var raw = mergeRawOptions([
+                BASE_OPTION_DEFAULTS,
+                localeDefaults,
+                optionOverrides,
+                dynamicOptionOverrides,
+            ]);
+            var refined = {};
+            var currentRaw = this.currentCalendarOptionsInput;
+            var currentRefined = this.currentCalendarOptionsRefined;
+            var anyChanges = false;
+            for (var optionName in raw) {
+                if (optionName !== 'plugins') { // because plugins is special-cased
+                    if (raw[optionName] === currentRaw[optionName] ||
+                        (COMPLEX_OPTION_COMPARATORS[optionName] &&
+                            (optionName in currentRaw) &&
+                            COMPLEX_OPTION_COMPARATORS[optionName](currentRaw[optionName], raw[optionName]))) {
+                        refined[optionName] = currentRefined[optionName];
+                    }
+                    else if (refiners[optionName]) {
+                        refined[optionName] = refiners[optionName](raw[optionName]);
+                        anyChanges = true;
+                    }
+                    else {
+                        extra[optionName] = currentRaw[optionName];
+                    }
+                }
+            }
+            if (anyChanges) {
+                this.currentCalendarOptionsInput = raw;
+                this.currentCalendarOptionsRefined = refined;
+            }
+            return {
+                rawOptions: this.currentCalendarOptionsInput,
+                refinedOptions: this.currentCalendarOptionsRefined,
+                pluginHooks: pluginHooks,
+                availableLocaleData: availableLocaleData,
+                localeDefaults: localeDefaults,
+                extra: extra,
+            };
+        };
+        CalendarDataManager.prototype._computeCurrentViewData = function (viewType, optionsData, optionOverrides, dynamicOptionOverrides) {
+            var viewSpec = optionsData.viewSpecs[viewType];
+            if (!viewSpec) {
+                throw new Error("viewType \"" + viewType + "\" is not available. Please make sure you've loaded all neccessary plugins");
+            }
+            var _a = this.processRawViewOptions(viewSpec, optionsData.pluginHooks, optionsData.localeDefaults, optionOverrides, dynamicOptionOverrides), refinedOptions = _a.refinedOptions, extra = _a.extra;
+            warnUnknownOptions(extra);
+            var dateProfileGenerator = this.buildDateProfileGenerator({
+                dateProfileGeneratorClass: viewSpec.optionDefaults.dateProfileGeneratorClass,
+                duration: viewSpec.duration,
+                durationUnit: viewSpec.durationUnit,
+                usesMinMaxTime: viewSpec.optionDefaults.usesMinMaxTime,
+                dateEnv: optionsData.dateEnv,
+                calendarApi: this.props.calendarApi,
+                slotMinTime: refinedOptions.slotMinTime,
+                slotMaxTime: refinedOptions.slotMaxTime,
+                showNonCurrentDates: refinedOptions.showNonCurrentDates,
+                dayCount: refinedOptions.dayCount,
+                dateAlignment: refinedOptions.dateAlignment,
+                dateIncrement: refinedOptions.dateIncrement,
+                hiddenDays: refinedOptions.hiddenDays,
+                weekends: refinedOptions.weekends,
+                nowInput: refinedOptions.now,
+                validRangeInput: refinedOptions.validRange,
+                visibleRangeInput: refinedOptions.visibleRange,
+                monthMode: refinedOptions.monthMode,
+                fixedWeekCount: refinedOptions.fixedWeekCount,
+            });
+            var viewApi = this.buildViewApi(viewType, this.getCurrentData, optionsData.dateEnv);
+            return { viewSpec: viewSpec, options: refinedOptions, dateProfileGenerator: dateProfileGenerator, viewApi: viewApi };
+        };
+        CalendarDataManager.prototype.processRawViewOptions = function (viewSpec, pluginHooks, localeDefaults, optionOverrides, dynamicOptionOverrides) {
+            var raw = mergeRawOptions([
+                BASE_OPTION_DEFAULTS,
+                viewSpec.optionDefaults,
+                localeDefaults,
+                optionOverrides,
+                viewSpec.optionOverrides,
+                dynamicOptionOverrides,
+            ]);
+            var refiners = __assign(__assign(__assign(__assign(__assign(__assign({}, BASE_OPTION_REFINERS), CALENDAR_LISTENER_REFINERS), CALENDAR_OPTION_REFINERS), VIEW_OPTION_REFINERS), pluginHooks.listenerRefiners), pluginHooks.optionRefiners);
+            var refined = {};
+            var currentRaw = this.currentViewOptionsInput;
+            var currentRefined = this.currentViewOptionsRefined;
+            var anyChanges = false;
+            var extra = {};
+            for (var optionName in raw) {
+                if (raw[optionName] === currentRaw[optionName]) {
+                    refined[optionName] = currentRefined[optionName];
+                }
+                else {
+                    if (raw[optionName] === this.currentCalendarOptionsInput[optionName]) {
+                        if (optionName in this.currentCalendarOptionsRefined) { // might be an "extra" prop
+                            refined[optionName] = this.currentCalendarOptionsRefined[optionName];
+                        }
+                    }
+                    else if (refiners[optionName]) {
+                        refined[optionName] = refiners[optionName](raw[optionName]);
+                    }
+                    else {
+                        extra[optionName] = raw[optionName];
+                    }
+                    anyChanges = true;
+                }
+            }
+            if (anyChanges) {
+                this.currentViewOptionsInput = raw;
+                this.currentViewOptionsRefined = refined;
+            }
+            return {
+                rawOptions: this.currentViewOptionsInput,
+                refinedOptions: this.currentViewOptionsRefined,
+                extra: extra,
+            };
+        };
+        return CalendarDataManager;
+    }());
+    function buildDateEnv$1(timeZone, explicitLocale, weekNumberCalculation, firstDay, weekText, pluginHooks, availableLocaleData, defaultSeparator) {
+        var locale = buildLocale(explicitLocale || availableLocaleData.defaultCode, availableLocaleData.map);
+        return new DateEnv({
+            calendarSystem: 'gregory',
+            timeZone: timeZone,
+            namedTimeZoneImpl: pluginHooks.namedTimeZonedImpl,
+            locale: locale,
+            weekNumberCalculation: weekNumberCalculation,
+            firstDay: firstDay,
+            weekText: weekText,
+            cmdFormatter: pluginHooks.cmdFormatter,
+            defaultSeparator: defaultSeparator,
+        });
+    }
+    function buildTheme(options, pluginHooks) {
+        var ThemeClass = pluginHooks.themeClasses[options.themeSystem] || StandardTheme;
+        return new ThemeClass(options);
+    }
+    function buildDateProfileGenerator(props) {
+        var DateProfileGeneratorClass = props.dateProfileGeneratorClass || DateProfileGenerator;
+        return new DateProfileGeneratorClass(props);
+    }
+    function buildViewApi(type, getCurrentData, dateEnv) {
+        return new ViewApi(type, getCurrentData, dateEnv);
+    }
+    function buildEventUiBySource(eventSources) {
+        return mapHash(eventSources, function (eventSource) { return eventSource.ui; });
+    }
+    function buildEventUiBases(eventDefs, eventUiSingleBase, eventUiBySource) {
+        var eventUiBases = { '': eventUiSingleBase };
+        for (var defId in eventDefs) {
+            var def = eventDefs[defId];
+            if (def.sourceId && eventUiBySource[def.sourceId]) {
+                eventUiBases[defId] = eventUiBySource[def.sourceId];
+            }
+        }
+        return eventUiBases;
+    }
+    function buildViewUiProps(calendarContext) {
+        var options = calendarContext.options;
+        return {
+            eventUiSingleBase: createEventUi({
+                display: options.eventDisplay,
+                editable: options.editable,
+                startEditable: options.eventStartEditable,
+                durationEditable: options.eventDurationEditable,
+                constraint: options.eventConstraint,
+                overlap: typeof options.eventOverlap === 'boolean' ? options.eventOverlap : undefined,
+                allow: options.eventAllow,
+                backgroundColor: options.eventBackgroundColor,
+                borderColor: options.eventBorderColor,
+                textColor: options.eventTextColor,
+                color: options.eventColor,
+            }, calendarContext),
+            selectionConfig: createEventUi({
+                constraint: options.selectConstraint,
+                overlap: typeof options.selectOverlap === 'boolean' ? options.selectOverlap : undefined,
+                allow: options.selectAllow,
+            }, calendarContext),
+        };
+    }
+    function computeIsLoading(state, context) {
+        for (var _i = 0, _a = context.pluginHooks.isLoadingFuncs; _i < _a.length; _i++) {
+            var isLoadingFunc = _a[_i];
+            if (isLoadingFunc(state)) {
+                return true;
+            }
+        }
+        return false;
+    }
+    function parseContextBusinessHours(calendarContext) {
+        return parseBusinessHours(calendarContext.options.businessHours, calendarContext);
+    }
+    function warnUnknownOptions(options, viewName) {
+        for (var optionName in options) {
+            console.warn("Unknown option '" + optionName + "'" +
+                (viewName ? " for view '" + viewName + "'" : ''));
+        }
+    }
+
+    // TODO: move this to react plugin?
+    var CalendarDataProvider = /** @class */ (function (_super) {
+        __extends(CalendarDataProvider, _super);
+        function CalendarDataProvider(props) {
+            var _this = _super.call(this, props) || this;
+            _this.handleData = function (data) {
+                if (!_this.dataManager) { // still within initial run, before assignment in constructor
+                    // eslint-disable-next-line react/no-direct-mutation-state
+                    _this.state = data; // can't use setState yet
+                }
+                else {
+                    _this.setState(data);
+                }
+            };
+            _this.dataManager = new CalendarDataManager({
+                optionOverrides: props.optionOverrides,
+                calendarApi: props.calendarApi,
+                onData: _this.handleData,
+            });
+            return _this;
+        }
+        CalendarDataProvider.prototype.render = function () {
+            return this.props.children(this.state);
+        };
+        CalendarDataProvider.prototype.componentDidUpdate = function (prevProps) {
+            var newOptionOverrides = this.props.optionOverrides;
+            if (newOptionOverrides !== prevProps.optionOverrides) { // prevent recursive handleData
+                this.dataManager.resetOptions(newOptionOverrides);
+            }
+        };
+        return CalendarDataProvider;
+    }(Component));
+
+    // HELPERS
+    /*
+    if nextDayThreshold is specified, slicing is done in an all-day fashion.
+    you can get nextDayThreshold from context.nextDayThreshold
+    */
+    function sliceEvents(props, allDay) {
+        return sliceEventStore(props.eventStore, props.eventUiBases, props.dateProfile.activeRange, allDay ? props.nextDayThreshold : null).fg;
+    }
+
+    var NamedTimeZoneImpl = /** @class */ (function () {
+        function NamedTimeZoneImpl(timeZoneName) {
+            this.timeZoneName = timeZoneName;
+        }
+        return NamedTimeZoneImpl;
+    }());
+
+    var Interaction = /** @class */ (function () {
+        function Interaction(settings) {
+            this.component = settings.component;
+        }
+        Interaction.prototype.destroy = function () {
+        };
+        return Interaction;
+    }());
+    function parseInteractionSettings(component, input) {
+        return {
+            component: component,
+            el: input.el,
+            useEventCenter: input.useEventCenter != null ? input.useEventCenter : true,
+        };
+    }
+    function interactionSettingsToStore(settings) {
+        var _a;
+        return _a = {},
+            _a[settings.component.uid] = settings,
+            _a;
+    }
+    // global state
+    var interactionSettingsStore = {};
+
+    /*
+    An abstraction for a dragging interaction originating on an event.
+    Does higher-level things than PointerDragger, such as possibly:
+    - a "mirror" that moves with the pointer
+    - a minimum number of pixels or other criteria for a true drag to begin
+
+    subclasses must emit:
+    - pointerdown
+    - dragstart
+    - dragmove
+    - pointerup
+    - dragend
+    */
+    var ElementDragging = /** @class */ (function () {
+        function ElementDragging(el, selector) {
+            this.emitter = new Emitter();
+        }
+        ElementDragging.prototype.destroy = function () {
+        };
+        ElementDragging.prototype.setMirrorIsVisible = function (bool) {
+            // optional if subclass doesn't want to support a mirror
+        };
+        ElementDragging.prototype.setMirrorNeedsRevert = function (bool) {
+            // optional if subclass doesn't want to support a mirror
+        };
+        ElementDragging.prototype.setAutoScrollEnabled = function (bool) {
+            // optional
+        };
+        return ElementDragging;
+    }());
+
+    // TODO: get rid of this in favor of options system,
+    // tho it's really easy to access this globally rather than pass thru options.
+    var config = {};
+
+    /*
+    Information about what will happen when an external element is dragged-and-dropped
+    onto a calendar. Contains information for creating an event.
+    */
+    var DRAG_META_REFINERS = {
+        startTime: createDuration,
+        duration: createDuration,
+        create: Boolean,
+        sourceId: String,
+    };
+    function parseDragMeta(raw) {
+        var _a = refineProps(raw, DRAG_META_REFINERS), refined = _a.refined, extra = _a.extra;
+        return {
+            startTime: refined.startTime || null,
+            duration: refined.duration || null,
+            create: refined.create != null ? refined.create : true,
+            sourceId: refined.sourceId,
+            leftoverProps: extra,
+        };
+    }
+
+    var ToolbarSection = /** @class */ (function (_super) {
+        __extends(ToolbarSection, _super);
+        function ToolbarSection() {
+            return _super !== null && _super.apply(this, arguments) || this;
+        }
+        ToolbarSection.prototype.render = function () {
+            var _this = this;
+            var children = this.props.widgetGroups.map(function (widgetGroup) { return _this.renderWidgetGroup(widgetGroup); });
+            return createElement.apply(void 0, __spreadArrays(['div', { className: 'fc-toolbar-chunk' }], children));
+        };
+        ToolbarSection.prototype.renderWidgetGroup = function (widgetGroup) {
+            var props = this.props;
+            var theme = this.context.theme;
+            var children = [];
+            var isOnlyButtons = true;
+            for (var _i = 0, widgetGroup_1 = widgetGroup; _i < widgetGroup_1.length; _i++) {
+                var widget = widgetGroup_1[_i];
+                var buttonName = widget.buttonName, buttonClick = widget.buttonClick, buttonText = widget.buttonText, buttonIcon = widget.buttonIcon;
+                if (buttonName === 'title') {
+                    isOnlyButtons = false;
+                    children.push(createElement("h2", { className: "fc-toolbar-title" }, props.title));
+                }
+                else {
+                    var ariaAttrs = buttonIcon ? { 'aria-label': buttonName } : {};
+                    var buttonClasses = ["fc-" + buttonName + "-button", theme.getClass('button')];
+                    if (buttonName === props.activeButton) {
+                        buttonClasses.push(theme.getClass('buttonActive'));
+                    }
+                    var isDisabled = (!props.isTodayEnabled && buttonName === 'today') ||
+                        (!props.isPrevEnabled && buttonName === 'prev') ||
+                        (!props.isNextEnabled && buttonName === 'next');
+                    children.push(createElement("button", __assign({ disabled: isDisabled, className: buttonClasses.join(' '), onClick: buttonClick, type: "button" }, ariaAttrs), buttonText || (buttonIcon ? createElement("span", { className: buttonIcon }) : '')));
+                }
+            }
+            if (children.length > 1) {
+                var groupClassName = (isOnlyButtons && theme.getClass('buttonGroup')) || '';
+                return createElement.apply(void 0, __spreadArrays(['div', { className: groupClassName }], children));
+            }
+            return children[0];
+        };
+        return ToolbarSection;
+    }(BaseComponent));
+
+    var Toolbar = /** @class */ (function (_super) {
+        __extends(Toolbar, _super);
+        function Toolbar() {
+            return _super !== null && _super.apply(this, arguments) || this;
+        }
+        Toolbar.prototype.render = function () {
+            var _a = this.props, model = _a.model, extraClassName = _a.extraClassName;
+            var forceLtr = false;
+            var startContent;
+            var endContent;
+            var centerContent = model.center;
+            if (model.left) {
+                forceLtr = true;
+                startContent = model.left;
+            }
+            else {
+                startContent = model.start;
+            }
+            if (model.right) {
+                forceLtr = true;
+                endContent = model.right;
+            }
+            else {
+                endContent = model.end;
+            }
+            var classNames = [
+                extraClassName || '',
+                'fc-toolbar',
+                forceLtr ? 'fc-toolbar-ltr' : '',
+            ];
+            return (createElement("div", { className: classNames.join(' ') },
+                this.renderSection('start', startContent || []),
+                this.renderSection('center', centerContent || []),
+                this.renderSection('end', endContent || [])));
+        };
+        Toolbar.prototype.renderSection = function (key, widgetGroups) {
+            var props = this.props;
+            return (createElement(ToolbarSection, { key: key, widgetGroups: widgetGroups, title: props.title, activeButton: props.activeButton, isTodayEnabled: props.isTodayEnabled, isPrevEnabled: props.isPrevEnabled, isNextEnabled: props.isNextEnabled }));
+        };
+        return Toolbar;
+    }(BaseComponent));
+
+    // TODO: do function component?
+    var ViewContainer = /** @class */ (function (_super) {
+        __extends(ViewContainer, _super);
+        function ViewContainer() {
+            var _this = _super !== null && _super.apply(this, arguments) || this;
+            _this.state = {
+                availableWidth: null,
+            };
+            _this.handleEl = function (el) {
+                _this.el = el;
+                setRef(_this.props.elRef, el);
+                _this.updateAvailableWidth();
+            };
+            _this.handleResize = function () {
+                _this.updateAvailableWidth();
+            };
+            return _this;
+        }
+        ViewContainer.prototype.render = function () {
+            var _a = this, props = _a.props, state = _a.state;
+            var aspectRatio = props.aspectRatio;
+            var classNames = [
+                'fc-view-harness',
+                (aspectRatio || props.liquid || props.height)
+                    ? 'fc-view-harness-active' // harness controls the height
+                    : 'fc-view-harness-passive',
+            ];
+            var height = '';
+            var paddingBottom = '';
+            if (aspectRatio) {
+                if (state.availableWidth !== null) {
+                    height = state.availableWidth / aspectRatio;
+                }
+                else {
+                    // while waiting to know availableWidth, we can't set height to *zero*
+                    // because will cause lots of unnecessary scrollbars within scrollgrid.
+                    // BETTER: don't start rendering ANYTHING yet until we know container width
+                    // NOTE: why not always use paddingBottom? Causes height oscillation (issue 5606)
+                    paddingBottom = (1 / aspectRatio) * 100 + "%";
+                }
+            }
+            else {
+                height = props.height || '';
+            }
+            return (createElement("div", { ref: this.handleEl, onClick: props.onClick, className: classNames.join(' '), style: { height: height, paddingBottom: paddingBottom } }, props.children));
+        };
+        ViewContainer.prototype.componentDidMount = function () {
+            this.context.addResizeHandler(this.handleResize);
+        };
+        ViewContainer.prototype.componentWillUnmount = function () {
+            this.context.removeResizeHandler(this.handleResize);
+        };
+        ViewContainer.prototype.updateAvailableWidth = function () {
+            if (this.el && // needed. but why?
+                this.props.aspectRatio // aspectRatio is the only height setting that needs availableWidth
+            ) {
+                this.setState({ availableWidth: this.el.offsetWidth });
+            }
+        };
+        return ViewContainer;
+    }(BaseComponent));
+
+    /*
+    Detects when the user clicks on an event within a DateComponent
+    */
+    var EventClicking = /** @class */ (function (_super) {
+        __extends(EventClicking, _super);
+        function EventClicking(settings) {
+            var _this = _super.call(this, settings) || this;
+            _this.handleSegClick = function (ev, segEl) {
+                var component = _this.component;
+                var context = component.context;
+                var seg = getElSeg(segEl);
+                if (seg && // might be the <div> surrounding the more link
+                    component.isValidSegDownEl(ev.target)) {
+                    // our way to simulate a link click for elements that can't be <a> tags
+                    // grab before trigger fired in case trigger trashes DOM thru rerendering
+                    var hasUrlContainer = elementClosest(ev.target, '.fc-event-forced-url');
+                    var url = hasUrlContainer ? hasUrlContainer.querySelector('a[href]').href : '';
+                    context.emitter.trigger('eventClick', {
+                        el: segEl,
+                        event: new EventApi(component.context, seg.eventRange.def, seg.eventRange.instance),
+                        jsEvent: ev,
+                        view: context.viewApi,
+                    });
+                    if (url && !ev.defaultPrevented) {
+                        window.location.href = url;
+                    }
+                }
+            };
+            _this.destroy = listenBySelector(settings.el, 'click', '.fc-event', // on both fg and bg events
+            _this.handleSegClick);
+            return _this;
+        }
+        return EventClicking;
+    }(Interaction));
+
+    /*
+    Triggers events and adds/removes core classNames when the user's pointer
+    enters/leaves event-elements of a component.
+    */
+    var EventHovering = /** @class */ (function (_super) {
+        __extends(EventHovering, _super);
+        function EventHovering(settings) {
+            var _this = _super.call(this, settings) || this;
+            // for simulating an eventMouseLeave when the event el is destroyed while mouse is over it
+            _this.handleEventElRemove = function (el) {
+                if (el === _this.currentSegEl) {
+                    _this.handleSegLeave(null, _this.currentSegEl);
+                }
+            };
+            _this.handleSegEnter = function (ev, segEl) {
+                if (getElSeg(segEl)) { // TODO: better way to make sure not hovering over more+ link or its wrapper
+                    _this.currentSegEl = segEl;
+                    _this.triggerEvent('eventMouseEnter', ev, segEl);
+                }
+            };
+            _this.handleSegLeave = function (ev, segEl) {
+                if (_this.currentSegEl) {
+                    _this.currentSegEl = null;
+                    _this.triggerEvent('eventMouseLeave', ev, segEl);
+                }
+            };
+            _this.removeHoverListeners = listenToHoverBySelector(settings.el, '.fc-event', // on both fg and bg events
+            _this.handleSegEnter, _this.handleSegLeave);
+            return _this;
+        }
+        EventHovering.prototype.destroy = function () {
+            this.removeHoverListeners();
+        };
+        EventHovering.prototype.triggerEvent = function (publicEvName, ev, segEl) {
+            var component = this.component;
+            var context = component.context;
+            var seg = getElSeg(segEl);
+            if (!ev || component.isValidSegDownEl(ev.target)) {
+                context.emitter.trigger(publicEvName, {
+                    el: segEl,
+                    event: new EventApi(context, seg.eventRange.def, seg.eventRange.instance),
+                    jsEvent: ev,
+                    view: context.viewApi,
+                });
+            }
+        };
+        return EventHovering;
+    }(Interaction));
+
+    var CalendarContent = /** @class */ (function (_super) {
+        __extends(CalendarContent, _super);
+        function CalendarContent() {
+            var _this = _super !== null && _super.apply(this, arguments) || this;
+            _this.buildViewContext = memoize(buildViewContext);
+            _this.buildViewPropTransformers = memoize(buildViewPropTransformers);
+            _this.buildToolbarProps = memoize(buildToolbarProps);
+            _this.handleNavLinkClick = buildDelegationHandler('a[data-navlink]', _this._handleNavLinkClick.bind(_this));
+            _this.headerRef = createRef();
+            _this.footerRef = createRef();
+            _this.interactionsStore = {};
+            // Component Registration
+            // -----------------------------------------------------------------------------------------------------------------
+            _this.registerInteractiveComponent = function (component, settingsInput) {
+                var settings = parseInteractionSettings(component, settingsInput);
+                var DEFAULT_INTERACTIONS = [
+                    EventClicking,
+                    EventHovering,
+                ];
+                var interactionClasses = DEFAULT_INTERACTIONS.concat(_this.props.pluginHooks.componentInteractions);
+                var interactions = interactionClasses.map(function (TheInteractionClass) { return new TheInteractionClass(settings); });
+                _this.interactionsStore[component.uid] = interactions;
+                interactionSettingsStore[component.uid] = settings;
+            };
+            _this.unregisterInteractiveComponent = function (component) {
+                for (var _i = 0, _a = _this.interactionsStore[component.uid]; _i < _a.length; _i++) {
+                    var listener = _a[_i];
+                    listener.destroy();
+                }
+                delete _this.interactionsStore[component.uid];
+                delete interactionSettingsStore[component.uid];
+            };
+            // Resizing
+            // -----------------------------------------------------------------------------------------------------------------
+            _this.resizeRunner = new DelayedRunner(function () {
+                _this.props.emitter.trigger('_resize', true); // should window resizes be considered "forced" ?
+                _this.props.emitter.trigger('windowResize', { view: _this.props.viewApi });
+            });
+            _this.handleWindowResize = function (ev) {
+                var options = _this.props.options;
+                if (options.handleWindowResize &&
+                    ev.target === window // avoid jqui events
+                ) {
+                    _this.resizeRunner.request(options.windowResizeDelay);
+                }
+            };
+            return _this;
+        }
+        /*
+        renders INSIDE of an outer div
+        */
+        CalendarContent.prototype.render = function () {
+            var props = this.props;
+            var toolbarConfig = props.toolbarConfig, options = props.options;
+            var toolbarProps = this.buildToolbarProps(props.viewSpec, props.dateProfile, props.dateProfileGenerator, props.currentDate, getNow(props.options.now, props.dateEnv), // TODO: use NowTimer????
+            props.viewTitle);
+            var viewVGrow = false;
+            var viewHeight = '';
+            var viewAspectRatio;
+            if (props.isHeightAuto || props.forPrint) {
+                viewHeight = '';
+            }
+            else if (options.height != null) {
+                viewVGrow = true;
+            }
+            else if (options.contentHeight != null) {
+                viewHeight = options.contentHeight;
+            }
+            else {
+                viewAspectRatio = Math.max(options.aspectRatio, 0.5); // prevent from getting too tall
+            }
+            var viewContext = this.buildViewContext(props.viewSpec, props.viewApi, props.options, props.dateProfileGenerator, props.dateEnv, props.theme, props.pluginHooks, props.dispatch, props.getCurrentData, props.emitter, props.calendarApi, this.registerInteractiveComponent, this.unregisterInteractiveComponent);
+            return (createElement(ViewContextType.Provider, { value: viewContext },
+                toolbarConfig.headerToolbar && (createElement(Toolbar, __assign({ ref: this.headerRef, extraClassName: "fc-header-toolbar", model: toolbarConfig.headerToolbar }, toolbarProps))),
+                createElement(ViewContainer, { liquid: viewVGrow, height: viewHeight, aspectRatio: viewAspectRatio, onClick: this.handleNavLinkClick },
+                    this.renderView(props),
+                    this.buildAppendContent()),
+                toolbarConfig.footerToolbar && (createElement(Toolbar, __assign({ ref: this.footerRef, extraClassName: "fc-footer-toolbar", model: toolbarConfig.footerToolbar }, toolbarProps)))));
+        };
+        CalendarContent.prototype.componentDidMount = function () {
+            var props = this.props;
+            this.calendarInteractions = props.pluginHooks.calendarInteractions
+                .map(function (CalendarInteractionClass) { return new CalendarInteractionClass(props); });
+            window.addEventListener('resize', this.handleWindowResize);
+            var propSetHandlers = props.pluginHooks.propSetHandlers;
+            for (var propName in propSetHandlers) {
+                propSetHandlers[propName](props[propName], props);
+            }
+        };
+        CalendarContent.prototype.componentDidUpdate = function (prevProps) {
+            var props = this.props;
+            var propSetHandlers = props.pluginHooks.propSetHandlers;
+            for (var propName in propSetHandlers) {
+                if (props[propName] !== prevProps[propName]) {
+                    propSetHandlers[propName](props[propName], props);
+                }
+            }
+        };
+        CalendarContent.prototype.componentWillUnmount = function () {
+            window.removeEventListener('resize', this.handleWindowResize);
+            this.resizeRunner.clear();
+            for (var _i = 0, _a = this.calendarInteractions; _i < _a.length; _i++) {
+                var interaction = _a[_i];
+                interaction.destroy();
+            }
+            this.props.emitter.trigger('_unmount');
+        };
+        CalendarContent.prototype._handleNavLinkClick = function (ev, anchorEl) {
+            var _a = this.props, dateEnv = _a.dateEnv, options = _a.options, calendarApi = _a.calendarApi;
+            var navLinkOptions = anchorEl.getAttribute('data-navlink');
+            navLinkOptions = navLinkOptions ? JSON.parse(navLinkOptions) : {};
+            var dateMarker = dateEnv.createMarker(navLinkOptions.date);
+            var viewType = navLinkOptions.type;
+            var customAction = viewType === 'day' ? options.navLinkDayClick :
+                viewType === 'week' ? options.navLinkWeekClick : null;
+            if (typeof customAction === 'function') {
+                customAction.call(calendarApi, dateEnv.toDate(dateMarker), ev);
+            }
+            else {
+                if (typeof customAction === 'string') {
+                    viewType = customAction;
+                }
+                calendarApi.zoomTo(dateMarker, viewType);
+            }
+        };
+        CalendarContent.prototype.buildAppendContent = function () {
+            var props = this.props;
+            var children = props.pluginHooks.viewContainerAppends.map(function (buildAppendContent) { return buildAppendContent(props); });
+            return createElement.apply(void 0, __spreadArrays([Fragment, {}], children));
+        };
+        CalendarContent.prototype.renderView = function (props) {
+            var pluginHooks = props.pluginHooks;
+            var viewSpec = props.viewSpec;
+            var viewProps = {
+                dateProfile: props.dateProfile,
+                businessHours: props.businessHours,
+                eventStore: props.renderableEventStore,
+                eventUiBases: props.eventUiBases,
+                dateSelection: props.dateSelection,
+                eventSelection: props.eventSelection,
+                eventDrag: props.eventDrag,
+                eventResize: props.eventResize,
+                isHeightAuto: props.isHeightAuto,
+                forPrint: props.forPrint,
+            };
+            var transformers = this.buildViewPropTransformers(pluginHooks.viewPropsTransformers);
+            for (var _i = 0, transformers_1 = transformers; _i < transformers_1.length; _i++) {
+                var transformer = transformers_1[_i];
+                __assign(viewProps, transformer.transform(viewProps, props));
+            }
+            var ViewComponent = viewSpec.component;
+            return (createElement(ViewComponent, __assign({}, viewProps)));
+        };
+        return CalendarContent;
+    }(PureComponent));
+    function buildToolbarProps(viewSpec, dateProfile, dateProfileGenerator, currentDate, now, title) {
+        // don't force any date-profiles to valid date profiles (the `false`) so that we can tell if it's invalid
+        var todayInfo = dateProfileGenerator.build(now, undefined, false); // TODO: need `undefined` or else INFINITE LOOP for some reason
+        var prevInfo = dateProfileGenerator.buildPrev(dateProfile, currentDate, false);
+        var nextInfo = dateProfileGenerator.buildNext(dateProfile, currentDate, false);
+        return {
+            title: title,
+            activeButton: viewSpec.type,
+            isTodayEnabled: todayInfo.isValid && !rangeContainsMarker(dateProfile.currentRange, now),
+            isPrevEnabled: prevInfo.isValid,
+            isNextEnabled: nextInfo.isValid,
+        };
+    }
+    // Plugin
+    // -----------------------------------------------------------------------------------------------------------------
+    function buildViewPropTransformers(theClasses) {
+        return theClasses.map(function (TheClass) { return new TheClass(); });
+    }
+
+    var CalendarRoot = /** @class */ (function (_super) {
+        __extends(CalendarRoot, _super);
+        function CalendarRoot() {
+            var _this = _super !== null && _super.apply(this, arguments) || this;
+            _this.state = {
+                forPrint: false,
+            };
+            _this.handleBeforePrint = function () {
+                _this.setState({ forPrint: true });
+            };
+            _this.handleAfterPrint = function () {
+                _this.setState({ forPrint: false });
+            };
+            return _this;
+        }
+        CalendarRoot.prototype.render = function () {
+            var props = this.props;
+            var options = props.options;
+            var forPrint = this.state.forPrint;
+            var isHeightAuto = forPrint || options.height === 'auto' || options.contentHeight === 'auto';
+            var height = (!isHeightAuto && options.height != null) ? options.height : '';
+            var classNames = [
+                'fc',
+                forPrint ? 'fc-media-print' : 'fc-media-screen',
+                "fc-direction-" + options.direction,
+                props.theme.getClass('root'),
+            ];
+            if (!getCanVGrowWithinCell()) {
+                classNames.push('fc-liquid-hack');
+            }
+            return props.children(classNames, height, isHeightAuto, forPrint);
+        };
+        CalendarRoot.prototype.componentDidMount = function () {
+            var emitter = this.props.emitter;
+            emitter.on('_beforeprint', this.handleBeforePrint);
+            emitter.on('_afterprint', this.handleAfterPrint);
+        };
+        CalendarRoot.prototype.componentWillUnmount = function () {
+            var emitter = this.props.emitter;
+            emitter.off('_beforeprint', this.handleBeforePrint);
+            emitter.off('_afterprint', this.handleAfterPrint);
+        };
+        return CalendarRoot;
+    }(BaseComponent));
+
+    // Computes a default column header formatting string if `colFormat` is not explicitly defined
+    function computeFallbackHeaderFormat(datesRepDistinctDays, dayCnt) {
+        // if more than one week row, or if there are a lot of columns with not much space,
+        // put just the day numbers will be in each cell
+        if (!datesRepDistinctDays || dayCnt > 10) {
+            return createFormatter({ weekday: 'short' }); // "Sat"
+        }
+        if (dayCnt > 1) {
+            return createFormatter({ weekday: 'short', month: 'numeric', day: 'numeric', omitCommas: true }); // "Sat 11/12"
+        }
+        return createFormatter({ weekday: 'long' }); // "Saturday"
+    }
+
+    var CLASS_NAME = 'fc-col-header-cell'; // do the cushion too? no
+    function renderInner(hookProps) {
+        return hookProps.text;
+    }
+
+    var TableDateCell = /** @class */ (function (_super) {
+        __extends(TableDateCell, _super);
+        function TableDateCell() {
+            return _super !== null && _super.apply(this, arguments) || this;
+        }
+        TableDateCell.prototype.render = function () {
+            var _a = this.context, dateEnv = _a.dateEnv, options = _a.options, theme = _a.theme, viewApi = _a.viewApi;
+            var props = this.props;
+            var date = props.date, dateProfile = props.dateProfile;
+            var dayMeta = getDateMeta(date, props.todayRange, null, dateProfile);
+            var classNames = [CLASS_NAME].concat(getDayClassNames(dayMeta, theme));
+            var text = dateEnv.format(date, props.dayHeaderFormat);
+            // if colCnt is 1, we are already in a day-view and don't need a navlink
+            var navLinkAttrs = (options.navLinks && !dayMeta.isDisabled && props.colCnt > 1)
+                ? { 'data-navlink': buildNavLinkData(date), tabIndex: 0 }
+                : {};
+            var hookProps = __assign(__assign(__assign({ date: dateEnv.toDate(date), view: viewApi }, props.extraHookProps), { text: text }), dayMeta);
+            return (createElement(RenderHook, { hookProps: hookProps, classNames: options.dayHeaderClassNames, content: options.dayHeaderContent, defaultContent: renderInner, didMount: options.dayHeaderDidMount, willUnmount: options.dayHeaderWillUnmount }, function (rootElRef, customClassNames, innerElRef, innerContent) { return (createElement("th", __assign({ ref: rootElRef, className: classNames.concat(customClassNames).join(' '), "data-date": !dayMeta.isDisabled ? formatDayString(date) : undefined, colSpan: props.colSpan }, props.extraDataAttrs),
+                createElement("div", { className: "fc-scrollgrid-sync-inner" }, !dayMeta.isDisabled && (createElement("a", __assign({ ref: innerElRef, className: [
+                        'fc-col-header-cell-cushion',
+                        props.isSticky ? 'fc-sticky' : '',
+                    ].join(' ') }, navLinkAttrs), innerContent))))); }));
+        };
+        return TableDateCell;
+    }(BaseComponent));
+
+    var TableDowCell = /** @class */ (function (_super) {
+        __extends(TableDowCell, _super);
+        function TableDowCell() {
+            return _super !== null && _super.apply(this, arguments) || this;
+        }
+        TableDowCell.prototype.render = function () {
+            var props = this.props;
+            var _a = this.context, dateEnv = _a.dateEnv, theme = _a.theme, viewApi = _a.viewApi, options = _a.options;
+            var date = addDays(new Date(259200000), props.dow); // start with Sun, 04 Jan 1970 00:00:00 GMT
+            var dateMeta = {
+                dow: props.dow,
+                isDisabled: false,
+                isFuture: false,
+                isPast: false,
+                isToday: false,
+                isOther: false,
+            };
+            var classNames = [CLASS_NAME].concat(getDayClassNames(dateMeta, theme), props.extraClassNames || []);
+            var text = dateEnv.format(date, props.dayHeaderFormat);
+            var hookProps = __assign(__assign(__assign(__assign({ // TODO: make this public?
+                date: date }, dateMeta), { view: viewApi }), props.extraHookProps), { text: text });
+            return (createElement(RenderHook, { hookProps: hookProps, classNames: options.dayHeaderClassNames, content: options.dayHeaderContent, defaultContent: renderInner, didMount: options.dayHeaderDidMount, willUnmount: options.dayHeaderWillUnmount }, function (rootElRef, customClassNames, innerElRef, innerContent) { return (createElement("th", __assign({ ref: rootElRef, className: classNames.concat(customClassNames).join(' '), colSpan: props.colSpan }, props.extraDataAttrs),
+                createElement("div", { className: "fc-scrollgrid-sync-inner" },
+                    createElement("a", { className: [
+                            'fc-col-header-cell-cushion',
+                            props.isSticky ? 'fc-sticky' : '',
+                        ].join(' '), ref: innerElRef }, innerContent)))); }));
+        };
+        return TableDowCell;
+    }(BaseComponent));
+
+    var NowTimer = /** @class */ (function (_super) {
+        __extends(NowTimer, _super);
+        function NowTimer(props, context) {
+            var _this = _super.call(this, props, context) || this;
+            _this.initialNowDate = getNow(context.options.now, context.dateEnv);
+            _this.initialNowQueriedMs = new Date().valueOf();
+            _this.state = _this.computeTiming().currentState;
+            return _this;
+        }
+        NowTimer.prototype.render = function () {
+            var _a = this, props = _a.props, state = _a.state;
+            return props.children(state.nowDate, state.todayRange);
+        };
+        NowTimer.prototype.componentDidMount = function () {
+            this.setTimeout();
+        };
+        NowTimer.prototype.componentDidUpdate = function (prevProps) {
+            if (prevProps.unit !== this.props.unit) {
+                this.clearTimeout();
+                this.setTimeout();
+            }
+        };
+        NowTimer.prototype.componentWillUnmount = function () {
+            this.clearTimeout();
+        };
+        NowTimer.prototype.computeTiming = function () {
+            var _a = this, props = _a.props, context = _a.context;
+            var unroundedNow = addMs(this.initialNowDate, new Date().valueOf() - this.initialNowQueriedMs);
+            var currentUnitStart = context.dateEnv.startOf(unroundedNow, props.unit);
+            var nextUnitStart = context.dateEnv.add(currentUnitStart, createDuration(1, props.unit));
+            var waitMs = nextUnitStart.valueOf() - unroundedNow.valueOf();
+            // there is a max setTimeout ms value (https://stackoverflow.com/a/3468650/96342)
+            // ensure no longer than a day
+            waitMs = Math.min(1000 * 60 * 60 * 24, waitMs);
+            return {
+                currentState: { nowDate: currentUnitStart, todayRange: buildDayRange(currentUnitStart) },
+                nextState: { nowDate: nextUnitStart, todayRange: buildDayRange(nextUnitStart) },
+                waitMs: waitMs,
+            };
+        };
+        NowTimer.prototype.setTimeout = function () {
+            var _this = this;
+            var _a = this.computeTiming(), nextState = _a.nextState, waitMs = _a.waitMs;
+            this.timeoutId = setTimeout(function () {
+                _this.setState(nextState, function () {
+                    _this.setTimeout();
+                });
+            }, waitMs);
+        };
+        NowTimer.prototype.clearTimeout = function () {
+            if (this.timeoutId) {
+                clearTimeout(this.timeoutId);
+            }
+        };
+        NowTimer.contextType = ViewContextType;
+        return NowTimer;
+    }(Component));
+    function buildDayRange(date) {
+        var start = startOfDay(date);
+        var end = addDays(start, 1);
+        return { start: start, end: end };
+    }
+
+    var DayHeader = /** @class */ (function (_super) {
+        __extends(DayHeader, _super);
+        function DayHeader() {
+            var _this = _super !== null && _super.apply(this, arguments) || this;
+            _this.createDayHeaderFormatter = memoize(createDayHeaderFormatter);
+            return _this;
+        }
+        DayHeader.prototype.render = function () {
+            var context = this.context;
+            var _a = this.props, dates = _a.dates, dateProfile = _a.dateProfile, datesRepDistinctDays = _a.datesRepDistinctDays, renderIntro = _a.renderIntro;
+            var dayHeaderFormat = this.createDayHeaderFormatter(context.options.dayHeaderFormat, datesRepDistinctDays, dates.length);
+            return (createElement(NowTimer, { unit: "day" }, function (nowDate, todayRange) { return (createElement("tr", null,
+                renderIntro && renderIntro('day'),
+                dates.map(function (date) { return (datesRepDistinctDays ? (createElement(TableDateCell, { key: date.toISOString(), date: date, dateProfile: dateProfile, todayRange: todayRange, colCnt: dates.length, dayHeaderFormat: dayHeaderFormat })) : (createElement(TableDowCell, { key: date.getUTCDay(), dow: date.getUTCDay(), dayHeaderFormat: dayHeaderFormat }))); }))); }));
+        };
+        return DayHeader;
+    }(BaseComponent));
+    function createDayHeaderFormatter(explicitFormat, datesRepDistinctDays, dateCnt) {
+        return explicitFormat || computeFallbackHeaderFormat(datesRepDistinctDays, dateCnt);
+    }
+
+    var DaySeriesModel = /** @class */ (function () {
+        function DaySeriesModel(range, dateProfileGenerator) {
+            var date = range.start;
+            var end = range.end;
+            var indices = [];
+            var dates = [];
+            var dayIndex = -1;
+            while (date < end) { // loop each day from start to end
+                if (dateProfileGenerator.isHiddenDay(date)) {
+                    indices.push(dayIndex + 0.5); // mark that it's between indices
+                }
+                else {
+                    dayIndex += 1;
+                    indices.push(dayIndex);
+                    dates.push(date);
+                }
+                date = addDays(date, 1);
+            }
+            this.dates = dates;
+            this.indices = indices;
+            this.cnt = dates.length;
+        }
+        DaySeriesModel.prototype.sliceRange = function (range) {
+            var firstIndex = this.getDateDayIndex(range.start); // inclusive first index
+            var lastIndex = this.getDateDayIndex(addDays(range.end, -1)); // inclusive last index
+            var clippedFirstIndex = Math.max(0, firstIndex);
+            var clippedLastIndex = Math.min(this.cnt - 1, lastIndex);
+            // deal with in-between indices
+            clippedFirstIndex = Math.ceil(clippedFirstIndex); // in-between starts round to next cell
+            clippedLastIndex = Math.floor(clippedLastIndex); // in-between ends round to prev cell
+            if (clippedFirstIndex <= clippedLastIndex) {
+                return {
+                    firstIndex: clippedFirstIndex,
+                    lastIndex: clippedLastIndex,
+                    isStart: firstIndex === clippedFirstIndex,
+                    isEnd: lastIndex === clippedLastIndex,
+                };
+            }
+            return null;
+        };
+        // Given a date, returns its chronolocial cell-index from the first cell of the grid.
+        // If the date lies between cells (because of hiddenDays), returns a floating-point value between offsets.
+        // If before the first offset, returns a negative number.
+        // If after the last offset, returns an offset past the last cell offset.
+        // Only works for *start* dates of cells. Will not work for exclusive end dates for cells.
+        DaySeriesModel.prototype.getDateDayIndex = function (date) {
+            var indices = this.indices;
+            var dayOffset = Math.floor(diffDays(this.dates[0], date));
+            if (dayOffset < 0) {
+                return indices[0] - 1;
+            }
+            if (dayOffset >= indices.length) {
+                return indices[indices.length - 1] + 1;
+            }
+            return indices[dayOffset];
+        };
+        return DaySeriesModel;
+    }());
+
+    var DayTableModel = /** @class */ (function () {
+        function DayTableModel(daySeries, breakOnWeeks) {
+            var dates = daySeries.dates;
+            var daysPerRow;
+            var firstDay;
+            var rowCnt;
+            if (breakOnWeeks) {
+                // count columns until the day-of-week repeats
+                firstDay = dates[0].getUTCDay();
+                for (daysPerRow = 1; daysPerRow < dates.length; daysPerRow += 1) {
+                    if (dates[daysPerRow].getUTCDay() === firstDay) {
+                        break;
+                    }
+                }
+                rowCnt = Math.ceil(dates.length / daysPerRow);
+            }
+            else {
+                rowCnt = 1;
+                daysPerRow = dates.length;
+            }
+            this.rowCnt = rowCnt;
+            this.colCnt = daysPerRow;
+            this.daySeries = daySeries;
+            this.cells = this.buildCells();
+            this.headerDates = this.buildHeaderDates();
+        }
+        DayTableModel.prototype.buildCells = function () {
+            var rows = [];
+            for (var row = 0; row < this.rowCnt; row += 1) {
+                var cells = [];
+                for (var col = 0; col < this.colCnt; col += 1) {
+                    cells.push(this.buildCell(row, col));
+                }
+                rows.push(cells);
+            }
+            return rows;
+        };
+        DayTableModel.prototype.buildCell = function (row, col) {
+            var date = this.daySeries.dates[row * this.colCnt + col];
+            return {
+                key: date.toISOString(),
+                date: date,
+            };
+        };
+        DayTableModel.prototype.buildHeaderDates = function () {
+            var dates = [];
+            for (var col = 0; col < this.colCnt; col += 1) {
+                dates.push(this.cells[0][col].date);
+            }
+            return dates;
+        };
+        DayTableModel.prototype.sliceRange = function (range) {
+            var colCnt = this.colCnt;
+            var seriesSeg = this.daySeries.sliceRange(range);
+            var segs = [];
+            if (seriesSeg) {
+                var firstIndex = seriesSeg.firstIndex, lastIndex = seriesSeg.lastIndex;
+                var index = firstIndex;
+                while (index <= lastIndex) {
+                    var row = Math.floor(index / colCnt);
+                    var nextIndex = Math.min((row + 1) * colCnt, lastIndex + 1);
+                    segs.push({
+                        row: row,
+                        firstCol: index % colCnt,
+                        lastCol: (nextIndex - 1) % colCnt,
+                        isStart: seriesSeg.isStart && index === firstIndex,
+                        isEnd: seriesSeg.isEnd && (nextIndex - 1) === lastIndex,
+                    });
+                    index = nextIndex;
+                }
+            }
+            return segs;
+        };
+        return DayTableModel;
+    }());
+
+    var Slicer = /** @class */ (function () {
+        function Slicer() {
+            this.sliceBusinessHours = memoize(this._sliceBusinessHours);
+            this.sliceDateSelection = memoize(this._sliceDateSpan);
+            this.sliceEventStore = memoize(this._sliceEventStore);
+            this.sliceEventDrag = memoize(this._sliceInteraction);
+            this.sliceEventResize = memoize(this._sliceInteraction);
+            this.forceDayIfListItem = false; // hack
+        }
+        Slicer.prototype.sliceProps = function (props, dateProfile, nextDayThreshold, context) {
+            var extraArgs = [];
+            for (var _i = 4; _i < arguments.length; _i++) {
+                extraArgs[_i - 4] = arguments[_i];
+            }
+            var eventUiBases = props.eventUiBases;
+            var eventSegs = this.sliceEventStore.apply(this, __spreadArrays([props.eventStore, eventUiBases, dateProfile, nextDayThreshold], extraArgs));
+            return {
+                dateSelectionSegs: this.sliceDateSelection.apply(this, __spreadArrays([props.dateSelection, eventUiBases, context], extraArgs)),
+                businessHourSegs: this.sliceBusinessHours.apply(this, __spreadArrays([props.businessHours, dateProfile, nextDayThreshold, context], extraArgs)),
+                fgEventSegs: eventSegs.fg,
+                bgEventSegs: eventSegs.bg,
+                eventDrag: this.sliceEventDrag.apply(this, __spreadArrays([props.eventDrag, eventUiBases, dateProfile, nextDayThreshold], extraArgs)),
+                eventResize: this.sliceEventResize.apply(this, __spreadArrays([props.eventResize, eventUiBases, dateProfile, nextDayThreshold], extraArgs)),
+                eventSelection: props.eventSelection,
+            }; // TODO: give interactionSegs?
+        };
+        Slicer.prototype.sliceNowDate = function (// does not memoize
+        date, context) {
+            var extraArgs = [];
+            for (var _i = 2; _i < arguments.length; _i++) {
+                extraArgs[_i - 2] = arguments[_i];
+            }
+            return this._sliceDateSpan.apply(this, __spreadArrays([{ range: { start: date, end: addMs(date, 1) }, allDay: false },
+                {},
+                context], extraArgs));
+        };
+        Slicer.prototype._sliceBusinessHours = function (businessHours, dateProfile, nextDayThreshold, context) {
+            var extraArgs = [];
+            for (var _i = 4; _i < arguments.length; _i++) {
+                extraArgs[_i - 4] = arguments[_i];
+            }
+            if (!businessHours) {
+                return [];
+            }
+            return this._sliceEventStore.apply(this, __spreadArrays([expandRecurring(businessHours, computeActiveRange(dateProfile, Boolean(nextDayThreshold)), context),
+                {},
+                dateProfile,
+                nextDayThreshold], extraArgs)).bg;
+        };
+        Slicer.prototype._sliceEventStore = function (eventStore, eventUiBases, dateProfile, nextDayThreshold) {
+            var extraArgs = [];
+            for (var _i = 4; _i < arguments.length; _i++) {
+                extraArgs[_i - 4] = arguments[_i];
+            }
+            if (eventStore) {
+                var rangeRes = sliceEventStore(eventStore, eventUiBases, computeActiveRange(dateProfile, Boolean(nextDayThreshold)), nextDayThreshold);
+                return {
+                    bg: this.sliceEventRanges(rangeRes.bg, extraArgs),
+                    fg: this.sliceEventRanges(rangeRes.fg, extraArgs),
+                };
+            }
+            return { bg: [], fg: [] };
+        };
+        Slicer.prototype._sliceInteraction = function (interaction, eventUiBases, dateProfile, nextDayThreshold) {
+            var extraArgs = [];
+            for (var _i = 4; _i < arguments.length; _i++) {
+                extraArgs[_i - 4] = arguments[_i];
+            }
+            if (!interaction) {
+                return null;
+            }
+            var rangeRes = sliceEventStore(interaction.mutatedEvents, eventUiBases, computeActiveRange(dateProfile, Boolean(nextDayThreshold)), nextDayThreshold);
+            return {
+                segs: this.sliceEventRanges(rangeRes.fg, extraArgs),
+                affectedInstances: interaction.affectedEvents.instances,
+                isEvent: interaction.isEvent,
+            };
+        };
+        Slicer.prototype._sliceDateSpan = function (dateSpan, eventUiBases, context) {
+            var extraArgs = [];
+            for (var _i = 3; _i < arguments.length; _i++) {
+                extraArgs[_i - 3] = arguments[_i];
+            }
+            if (!dateSpan) {
+                return [];
+            }
+            var eventRange = fabricateEventRange(dateSpan, eventUiBases, context);
+            var segs = this.sliceRange.apply(this, __spreadArrays([dateSpan.range], extraArgs));
+            for (var _a = 0, segs_1 = segs; _a < segs_1.length; _a++) {
+                var seg = segs_1[_a];
+                seg.eventRange = eventRange;
+            }
+            return segs;
+        };
+        /*
+        "complete" seg means it has component and eventRange
+        */
+        Slicer.prototype.sliceEventRanges = function (eventRanges, extraArgs) {
+            var segs = [];
+            for (var _i = 0, eventRanges_1 = eventRanges; _i < eventRanges_1.length; _i++) {
+                var eventRange = eventRanges_1[_i];
+                segs.push.apply(segs, this.sliceEventRange(eventRange, extraArgs));
+            }
+            return segs;
+        };
+        /*
+        "complete" seg means it has component and eventRange
+        */
+        Slicer.prototype.sliceEventRange = function (eventRange, extraArgs) {
+            var dateRange = eventRange.range;
+            // hack to make multi-day events that are being force-displayed as list-items to take up only one day
+            if (this.forceDayIfListItem && eventRange.ui.display === 'list-item') {
+                dateRange = {
+                    start: dateRange.start,
+                    end: addDays(dateRange.start, 1),
+                };
+            }
+            var segs = this.sliceRange.apply(this, __spreadArrays([dateRange], extraArgs));
+            for (var _i = 0, segs_2 = segs; _i < segs_2.length; _i++) {
+                var seg = segs_2[_i];
+                seg.eventRange = eventRange;
+                seg.isStart = eventRange.isStart && seg.isStart;
+                seg.isEnd = eventRange.isEnd && seg.isEnd;
+            }
+            return segs;
+        };
+        return Slicer;
+    }());
+    /*
+    for incorporating slotMinTime/slotMaxTime if appropriate
+    TODO: should be part of DateProfile!
+    TimelineDateProfile already does this btw
+    */
+    function computeActiveRange(dateProfile, isComponentAllDay) {
+        var range = dateProfile.activeRange;
+        if (isComponentAllDay) {
+            return range;
+        }
+        return {
+            start: addMs(range.start, dateProfile.slotMinTime.milliseconds),
+            end: addMs(range.end, dateProfile.slotMaxTime.milliseconds - 864e5),
+        };
+    }
+
+    var VISIBLE_HIDDEN_RE = /^(visible|hidden)$/;
+    var Scroller = /** @class */ (function (_super) {
+        __extends(Scroller, _super);
+        function Scroller() {
+            var _this = _super !== null && _super.apply(this, arguments) || this;
+            _this.handleEl = function (el) {
+                _this.el = el;
+                setRef(_this.props.elRef, el);
+            };
+            return _this;
+        }
+        Scroller.prototype.render = function () {
+            var props = this.props;
+            var liquid = props.liquid, liquidIsAbsolute = props.liquidIsAbsolute;
+            var isAbsolute = liquid && liquidIsAbsolute;
+            var className = ['fc-scroller'];
+            if (liquid) {
+                if (liquidIsAbsolute) {
+                    className.push('fc-scroller-liquid-absolute');
+                }
+                else {
+                    className.push('fc-scroller-liquid');
+                }
+            }
+            return (createElement("div", { ref: this.handleEl, className: className.join(' '), style: {
+                    overflowX: props.overflowX,
+                    overflowY: props.overflowY,
+                    left: (isAbsolute && -(props.overcomeLeft || 0)) || '',
+                    right: (isAbsolute && -(props.overcomeRight || 0)) || '',
+                    bottom: (isAbsolute && -(props.overcomeBottom || 0)) || '',
+                    marginLeft: (!isAbsolute && -(props.overcomeLeft || 0)) || '',
+                    marginRight: (!isAbsolute && -(props.overcomeRight || 0)) || '',
+                    marginBottom: (!isAbsolute && -(props.overcomeBottom || 0)) || '',
+                    maxHeight: props.maxHeight || '',
+                } }, props.children));
+        };
+        Scroller.prototype.needsXScrolling = function () {
+            if (VISIBLE_HIDDEN_RE.test(this.props.overflowX)) {
+                return false;
+            }
+            // testing scrollWidth>clientWidth is unreliable cross-browser when pixel heights aren't integers.
+            // much more reliable to see if children are taller than the scroller, even tho doesn't account for
+            // inner-child margins and absolute positioning
+            var el = this.el;
+            var realClientWidth = this.el.getBoundingClientRect().width - this.getYScrollbarWidth();
+            var children = el.children;
+            for (var i = 0; i < children.length; i += 1) {
+                var childEl = children[i];
+                if (childEl.getBoundingClientRect().width > realClientWidth) {
+                    return true;
+                }
+            }
+            return false;
+        };
+        Scroller.prototype.needsYScrolling = function () {
+            if (VISIBLE_HIDDEN_RE.test(this.props.overflowY)) {
+                return false;
+            }
+            // testing scrollHeight>clientHeight is unreliable cross-browser when pixel heights aren't integers.
+            // much more reliable to see if children are taller than the scroller, even tho doesn't account for
+            // inner-child margins and absolute positioning
+            var el = this.el;
+            var realClientHeight = this.el.getBoundingClientRect().height - this.getXScrollbarWidth();
+            var children = el.children;
+            for (var i = 0; i < children.length; i += 1) {
+                var childEl = children[i];
+                if (childEl.getBoundingClientRect().height > realClientHeight) {
+                    return true;
+                }
+            }
+            return false;
+        };
+        Scroller.prototype.getXScrollbarWidth = function () {
+            if (VISIBLE_HIDDEN_RE.test(this.props.overflowX)) {
+                return 0;
+            }
+            return this.el.offsetHeight - this.el.clientHeight; // only works because we guarantee no borders. TODO: add to CSS with important?
+        };
+        Scroller.prototype.getYScrollbarWidth = function () {
+            if (VISIBLE_HIDDEN_RE.test(this.props.overflowY)) {
+                return 0;
+            }
+            return this.el.offsetWidth - this.el.clientWidth; // only works because we guarantee no borders. TODO: add to CSS with important?
+        };
+        return Scroller;
+    }(BaseComponent));
+
+    /*
+    TODO: somehow infer OtherArgs from masterCallback?
+    TODO: infer RefType from masterCallback if provided
+    */
+    var RefMap = /** @class */ (function () {
+        function RefMap(masterCallback) {
+            var _this = this;
+            this.masterCallback = masterCallback;
+            this.currentMap = {};
+            this.depths = {};
+            this.callbackMap = {};
+            this.handleValue = function (val, key) {
+                var _a = _this, depths = _a.depths, currentMap = _a.currentMap;
+                var removed = false;
+                var added = false;
+                if (val !== null) {
+                    // for bug... ACTUALLY: can probably do away with this now that callers don't share numeric indices anymore
+                    removed = (key in currentMap);
+                    currentMap[key] = val;
+                    depths[key] = (depths[key] || 0) + 1;
+                    added = true;
+                }
+                else {
+                    depths[key] -= 1;
+                    if (!depths[key]) {
+                        delete currentMap[key];
+                        delete _this.callbackMap[key];
+                        removed = true;
+                    }
+                }
+                if (_this.masterCallback) {
+                    if (removed) {
+                        _this.masterCallback(null, String(key));
+                    }
+                    if (added) {
+                        _this.masterCallback(val, String(key));
+                    }
+                }
+            };
+        }
+        RefMap.prototype.createRef = function (key) {
+            var _this = this;
+            var refCallback = this.callbackMap[key];
+            if (!refCallback) {
+                refCallback = this.callbackMap[key] = function (val) {
+                    _this.handleValue(val, String(key));
+                };
+            }
+            return refCallback;
+        };
+        // TODO: check callers that don't care about order. should use getAll instead
+        // NOTE: this method has become less valuable now that we are encouraged to map order by some other index
+        // TODO: provide ONE array-export function, buildArray, which fails on non-numeric indexes. caller can manipulate and "collect"
+        RefMap.prototype.collect = function (startIndex, endIndex, step) {
+            return collectFromHash(this.currentMap, startIndex, endIndex, step);
+        };
+        RefMap.prototype.getAll = function () {
+            return hashValuesToArray(this.currentMap);
+        };
+        return RefMap;
+    }());
+
+    function computeShrinkWidth(chunkEls) {
+        var shrinkCells = findElements(chunkEls, '.fc-scrollgrid-shrink');
+        var largestWidth = 0;
+        for (var _i = 0, shrinkCells_1 = shrinkCells; _i < shrinkCells_1.length; _i++) {
+            var shrinkCell = shrinkCells_1[_i];
+            largestWidth = Math.max(largestWidth, computeSmallestCellWidth(shrinkCell));
+        }
+        return Math.ceil(largestWidth); // <table> elements work best with integers. round up to ensure contents fits
+    }
+    function getSectionHasLiquidHeight(props, sectionConfig) {
+        return props.liquid && sectionConfig.liquid; // does the section do liquid-height? (need to have whole scrollgrid liquid-height as well)
+    }
+    function getAllowYScrolling(props, sectionConfig) {
+        return sectionConfig.maxHeight != null || // if its possible for the height to max out, we might need scrollbars
+            getSectionHasLiquidHeight(props, sectionConfig); // if the section is liquid height, it might condense enough to require scrollbars
+    }
+    // TODO: ONLY use `arg`. force out internal function to use same API
+    function renderChunkContent(sectionConfig, chunkConfig, arg) {
+        var expandRows = arg.expandRows;
+        var content = typeof chunkConfig.content === 'function' ?
+            chunkConfig.content(arg) :
+            createElement('table', {
+                className: [
+                    chunkConfig.tableClassName,
+                    sectionConfig.syncRowHeights ? 'fc-scrollgrid-sync-table' : '',
+                ].join(' '),
+                style: {
+                    minWidth: arg.tableMinWidth,
+                    width: arg.clientWidth,
+                    height: expandRows ? arg.clientHeight : '',
+                },
+            }, arg.tableColGroupNode, createElement('tbody', {}, typeof chunkConfig.rowContent === 'function' ? chunkConfig.rowContent(arg) : chunkConfig.rowContent));
+        return content;
+    }
+    function isColPropsEqual(cols0, cols1) {
+        return isArraysEqual(cols0, cols1, isPropsEqual);
+    }
+    function renderMicroColGroup(cols, shrinkWidth) {
+        var colNodes = [];
+        /*
+        for ColProps with spans, it would have been great to make a single <col span="">
+        HOWEVER, Chrome was getting messing up distributing the width to <td>/<th> elements with colspans.
+        SOLUTION: making individual <col> elements makes Chrome behave.
+        */
+        for (var _i = 0, cols_1 = cols; _i < cols_1.length; _i++) {
+            var colProps = cols_1[_i];
+            var span = colProps.span || 1;
+            for (var i = 0; i < span; i += 1) {
+                colNodes.push(createElement("col", { style: {
+                        width: colProps.width === 'shrink' ? sanitizeShrinkWidth(shrinkWidth) : (colProps.width || ''),
+                        minWidth: colProps.minWidth || '',
+                    } }));
+            }
+        }
+        return createElement.apply(void 0, __spreadArrays(['colgroup', {}], colNodes));
+    }
+    function sanitizeShrinkWidth(shrinkWidth) {
+        /* why 4? if we do 0, it will kill any border, which are needed for computeSmallestCellWidth
+        4 accounts for 2 2-pixel borders. TODO: better solution? */
+        return shrinkWidth == null ? 4 : shrinkWidth;
+    }
+    function hasShrinkWidth(cols) {
+        for (var _i = 0, cols_2 = cols; _i < cols_2.length; _i++) {
+            var col = cols_2[_i];
+            if (col.width === 'shrink') {
+                return true;
+            }
+        }
+        return false;
+    }
+    function getScrollGridClassNames(liquid, context) {
+        var classNames = [
+            'fc-scrollgrid',
+            context.theme.getClass('table'),
+        ];
+        if (liquid) {
+            classNames.push('fc-scrollgrid-liquid');
+        }
+        return classNames;
+    }
+    function getSectionClassNames(sectionConfig, wholeTableVGrow) {
+        var classNames = [
+            'fc-scrollgrid-section',
+            "fc-scrollgrid-section-" + sectionConfig.type,
+            sectionConfig.className,
+        ];
+        if (wholeTableVGrow && sectionConfig.liquid && sectionConfig.maxHeight == null) {
+            classNames.push('fc-scrollgrid-section-liquid');
+        }
+        if (sectionConfig.isSticky) {
+            classNames.push('fc-scrollgrid-section-sticky');
+        }
+        return classNames;
+    }
+    function renderScrollShim(arg) {
+        return (createElement("div", { className: "fc-scrollgrid-sticky-shim", style: {
+                width: arg.clientWidth,
+                minWidth: arg.tableMinWidth,
+            } }));
+    }
+    function getStickyHeaderDates(options) {
+        var stickyHeaderDates = options.stickyHeaderDates;
+        if (stickyHeaderDates == null || stickyHeaderDates === 'auto') {
+            stickyHeaderDates = options.height === 'auto' || options.viewHeight === 'auto';
+        }
+        return stickyHeaderDates;
+    }
+    function getStickyFooterScrollbar(options) {
+        var stickyFooterScrollbar = options.stickyFooterScrollbar;
+        if (stickyFooterScrollbar == null || stickyFooterScrollbar === 'auto') {
+            stickyFooterScrollbar = options.height === 'auto' || options.viewHeight === 'auto';
+        }
+        return stickyFooterScrollbar;
+    }
+
+    var SimpleScrollGrid = /** @class */ (function (_super) {
+        __extends(SimpleScrollGrid, _super);
+        function SimpleScrollGrid() {
+            var _this = _super !== null && _super.apply(this, arguments) || this;
+            _this.processCols = memoize(function (a) { return a; }, isColPropsEqual); // so we get same `cols` props every time
+            // yucky to memoize VNodes, but much more efficient for consumers
+            _this.renderMicroColGroup = memoize(renderMicroColGroup);
+            _this.scrollerRefs = new RefMap();
+            _this.scrollerElRefs = new RefMap(_this._handleScrollerEl.bind(_this));
+            _this.state = {
+                shrinkWidth: null,
+                forceYScrollbars: false,
+                scrollerClientWidths: {},
+                scrollerClientHeights: {},
+            };
+            // TODO: can do a really simple print-view. dont need to join rows
+            _this.handleSizing = function () {
+                _this.setState(__assign({ shrinkWidth: _this.computeShrinkWidth() }, _this.computeScrollerDims()));
+            };
+            return _this;
+        }
+        SimpleScrollGrid.prototype.render = function () {
+            var _a = this, props = _a.props, state = _a.state, context = _a.context;
+            var sectionConfigs = props.sections || [];
+            var cols = this.processCols(props.cols);
+            var microColGroupNode = this.renderMicroColGroup(cols, state.shrinkWidth);
+            var classNames = getScrollGridClassNames(props.liquid, context);
+            // TODO: make DRY
+            var configCnt = sectionConfigs.length;
+            var configI = 0;
+            var currentConfig;
+            var headSectionNodes = [];
+            var bodySectionNodes = [];
+            var footSectionNodes = [];
+            while (configI < configCnt && (currentConfig = sectionConfigs[configI]).type === 'header') {
+                headSectionNodes.push(this.renderSection(currentConfig, configI, microColGroupNode));
+                configI += 1;
+            }
+            while (configI < configCnt && (currentConfig = sectionConfigs[configI]).type === 'body') {
+                bodySectionNodes.push(this.renderSection(currentConfig, configI, microColGroupNode));
+                configI += 1;
+            }
+            while (configI < configCnt && (currentConfig = sectionConfigs[configI]).type === 'footer') {
+                footSectionNodes.push(this.renderSection(currentConfig, configI, microColGroupNode));
+                configI += 1;
+            }
+            // firefox bug: when setting height on table and there is a thead or tfoot,
+            // the necessary height:100% on the liquid-height body section forces the *whole* table to be taller. (bug #5524)
+            // use getCanVGrowWithinCell as a way to detect table-stupid firefox.
+            // if so, use a simpler dom structure, jam everything into a lone tbody.
+            var isBuggy = !getCanVGrowWithinCell();
+            return createElement('table', {
+                className: classNames.join(' '),
+                style: { height: props.height },
+            }, Boolean(!isBuggy && headSectionNodes.length) && createElement.apply(void 0, __spreadArrays(['thead', {}], headSectionNodes)), Boolean(!isBuggy && bodySectionNodes.length) && createElement.apply(void 0, __spreadArrays(['tbody', {}], bodySectionNodes)), Boolean(!isBuggy && footSectionNodes.length) && createElement.apply(void 0, __spreadArrays(['tfoot', {}], footSectionNodes)), isBuggy && createElement.apply(void 0, __spreadArrays(['tbody', {}], headSectionNodes, bodySectionNodes, footSectionNodes)));
+        };
+        SimpleScrollGrid.prototype.renderSection = function (sectionConfig, sectionI, microColGroupNode) {
+            if ('outerContent' in sectionConfig) {
+                return (createElement(Fragment, { key: sectionConfig.key }, sectionConfig.outerContent));
+            }
+            return (createElement("tr", { key: sectionConfig.key, className: getSectionClassNames(sectionConfig, this.props.liquid).join(' ') }, this.renderChunkTd(sectionConfig, sectionI, microColGroupNode, sectionConfig.chunk)));
+        };
+        SimpleScrollGrid.prototype.renderChunkTd = function (sectionConfig, sectionI, microColGroupNode, chunkConfig) {
+            if ('outerContent' in chunkConfig) {
+                return chunkConfig.outerContent;
+            }
+            var props = this.props;
+            var _a = this.state, forceYScrollbars = _a.forceYScrollbars, scrollerClientWidths = _a.scrollerClientWidths, scrollerClientHeights = _a.scrollerClientHeights;
+            var needsYScrolling = getAllowYScrolling(props, sectionConfig); // TODO: do lazily. do in section config?
+            var isLiquid = getSectionHasLiquidHeight(props, sectionConfig);
+            // for `!props.liquid` - is WHOLE scrollgrid natural height?
+            // TODO: do same thing in advanced scrollgrid? prolly not b/c always has horizontal scrollbars
+            var overflowY = !props.liquid ? 'visible' :
+                forceYScrollbars ? 'scroll' :
+                    !needsYScrolling ? 'hidden' :
+                        'auto';
+            var content = renderChunkContent(sectionConfig, chunkConfig, {
+                tableColGroupNode: microColGroupNode,
+                tableMinWidth: '',
+                clientWidth: scrollerClientWidths[sectionI] !== undefined ? scrollerClientWidths[sectionI] : null,
+                clientHeight: scrollerClientHeights[sectionI] !== undefined ? scrollerClientHeights[sectionI] : null,
+                expandRows: sectionConfig.expandRows,
+                syncRowHeights: false,
+                rowSyncHeights: [],
+                reportRowHeightChange: function () { },
+            });
+            return (createElement("td", { ref: chunkConfig.elRef },
+                createElement("div", { className: "fc-scroller-harness" + (isLiquid ? ' fc-scroller-harness-liquid' : '') },
+                    createElement(Scroller, { ref: this.scrollerRefs.createRef(sectionI), elRef: this.scrollerElRefs.createRef(sectionI), overflowY: overflowY, overflowX: !props.liquid ? 'visible' : 'hidden' /* natural height? */, maxHeight: sectionConfig.maxHeight, liquid: isLiquid, liquidIsAbsolute // because its within a harness
+                        : true }, content))));
+        };
+        SimpleScrollGrid.prototype._handleScrollerEl = function (scrollerEl, key) {
+            var sectionI = parseInt(key, 10);
+            var chunkConfig = this.props.sections[sectionI].chunk;
+            setRef(chunkConfig.scrollerElRef, scrollerEl);
+        };
+        SimpleScrollGrid.prototype.componentDidMount = function () {
+            this.handleSizing();
+            this.context.addResizeHandler(this.handleSizing);
+        };
+        SimpleScrollGrid.prototype.componentDidUpdate = function () {
+            // TODO: need better solution when state contains non-sizing things
+            this.handleSizing();
+        };
+        SimpleScrollGrid.prototype.componentWillUnmount = function () {
+            this.context.removeResizeHandler(this.handleSizing);
+        };
+        SimpleScrollGrid.prototype.computeShrinkWidth = function () {
+            return hasShrinkWidth(this.props.cols)
+                ? computeShrinkWidth(this.scrollerElRefs.getAll())
+                : 0;
+        };
+        SimpleScrollGrid.prototype.computeScrollerDims = function () {
+            var scrollbarWidth = getScrollbarWidths();
+            var sectionCnt = this.props.sections.length;
+            var _a = this, scrollerRefs = _a.scrollerRefs, scrollerElRefs = _a.scrollerElRefs;
+            var forceYScrollbars = false;
+            var scrollerClientWidths = {};
+            var scrollerClientHeights = {};
+            for (var sectionI = 0; sectionI < sectionCnt; sectionI += 1) { // along edge
+                var scroller = scrollerRefs.currentMap[sectionI];
+                if (scroller && scroller.needsYScrolling()) {
+                    forceYScrollbars = true;
+                    break;
+                }
+            }
+            for (var sectionI = 0; sectionI < sectionCnt; sectionI += 1) { // along edge
+                var scrollerEl = scrollerElRefs.currentMap[sectionI];
+                if (scrollerEl) {
+                    var harnessEl = scrollerEl.parentNode; // TODO: weird way to get this. need harness b/c doesn't include table borders
+                    scrollerClientWidths[sectionI] = Math.floor(harnessEl.getBoundingClientRect().width - (forceYScrollbars
+                        ? scrollbarWidth.y // use global because scroller might not have scrollbars yet but will need them in future
+                        : 0));
+                    scrollerClientHeights[sectionI] = Math.floor(harnessEl.getBoundingClientRect().height);
+                }
+            }
+            return { forceYScrollbars: forceYScrollbars, scrollerClientWidths: scrollerClientWidths, scrollerClientHeights: scrollerClientHeights };
+        };
+        return SimpleScrollGrid;
+    }(BaseComponent));
+    SimpleScrollGrid.addStateEquality({
+        scrollerClientWidths: isPropsEqual,
+        scrollerClientHeights: isPropsEqual,
+    });
+
+    var EventRoot = /** @class */ (function (_super) {
+        __extends(EventRoot, _super);
+        function EventRoot() {
+            var _this = _super !== null && _super.apply(this, arguments) || this;
+            _this.elRef = createRef();
+            return _this;
+        }
+        EventRoot.prototype.render = function () {
+            var _a = this, props = _a.props, context = _a.context;
+            var options = context.options;
+            var seg = props.seg;
+            var eventRange = seg.eventRange;
+            var ui = eventRange.ui;
+            var hookProps = {
+                event: new EventApi(context, eventRange.def, eventRange.instance),
+                view: context.viewApi,
+                timeText: props.timeText,
+                textColor: ui.textColor,
+                backgroundColor: ui.backgroundColor,
+                borderColor: ui.borderColor,
+                isDraggable: !props.disableDragging && computeSegDraggable(seg, context),
+                isStartResizable: !props.disableResizing && computeSegStartResizable(seg, context),
+                isEndResizable: !props.disableResizing && computeSegEndResizable(seg),
+                isMirror: Boolean(props.isDragging || props.isResizing || props.isDateSelecting),
+                isStart: Boolean(seg.isStart),
+                isEnd: Boolean(seg.isEnd),
+                isPast: Boolean(props.isPast),
+                isFuture: Boolean(props.isFuture),
+                isToday: Boolean(props.isToday),
+                isSelected: Boolean(props.isSelected),
+                isDragging: Boolean(props.isDragging),
+                isResizing: Boolean(props.isResizing),
+            };
+            var standardClassNames = getEventClassNames(hookProps).concat(ui.classNames);
+            return (createElement(RenderHook, { hookProps: hookProps, classNames: options.eventClassNames, content: options.eventContent, defaultContent: props.defaultContent, didMount: options.eventDidMount, willUnmount: options.eventWillUnmount, elRef: this.elRef }, function (rootElRef, customClassNames, innerElRef, innerContent) { return props.children(rootElRef, standardClassNames.concat(customClassNames), innerElRef, innerContent, hookProps); }));
+        };
+        EventRoot.prototype.componentDidMount = function () {
+            setElSeg(this.elRef.current, this.props.seg);
+        };
+        /*
+        need to re-assign seg to the element if seg changes, even if the element is the same
+        */
+        EventRoot.prototype.componentDidUpdate = function (prevProps) {
+            var seg = this.props.seg;
+            if (seg !== prevProps.seg) {
+                setElSeg(this.elRef.current, seg);
+            }
+        };
+        return EventRoot;
+    }(BaseComponent));
+
+    // should not be a purecomponent
+    var StandardEvent = /** @class */ (function (_super) {
+        __extends(StandardEvent, _super);
+        function StandardEvent() {
+            return _super !== null && _super.apply(this, arguments) || this;
+        }
+        StandardEvent.prototype.render = function () {
+            var _a = this, props = _a.props, context = _a.context;
+            var seg = props.seg;
+            var timeFormat = context.options.eventTimeFormat || props.defaultTimeFormat;
+            var timeText = buildSegTimeText(seg, timeFormat, context, props.defaultDisplayEventTime, props.defaultDisplayEventEnd);
+            return (createElement(EventRoot, { seg: seg, timeText: timeText, disableDragging: props.disableDragging, disableResizing: props.disableResizing, defaultContent: props.defaultContent || renderInnerContent, isDragging: props.isDragging, isResizing: props.isResizing, isDateSelecting: props.isDateSelecting, isSelected: props.isSelected, isPast: props.isPast, isFuture: props.isFuture, isToday: props.isToday }, function (rootElRef, classNames, innerElRef, innerContent, hookProps) { return (createElement("a", __assign({ className: props.extraClassNames.concat(classNames).join(' '), style: {
+                    borderColor: hookProps.borderColor,
+                    backgroundColor: hookProps.backgroundColor,
+                }, ref: rootElRef }, getSegAnchorAttrs(seg)),
+                createElement("div", { className: "fc-event-main", ref: innerElRef, style: { color: hookProps.textColor } }, innerContent),
+                hookProps.isStartResizable &&
+                    createElement("div", { className: "fc-event-resizer fc-event-resizer-start" }),
+                hookProps.isEndResizable &&
+                    createElement("div", { className: "fc-event-resizer fc-event-resizer-end" }))); }));
+        };
+        return StandardEvent;
+    }(BaseComponent));
+    function renderInnerContent(innerProps) {
+        return (createElement("div", { className: "fc-event-main-frame" },
+            innerProps.timeText && (createElement("div", { className: "fc-event-time" }, innerProps.timeText)),
+            createElement("div", { className: "fc-event-title-container" },
+                createElement("div", { className: "fc-event-title fc-sticky" }, innerProps.event.title || createElement(Fragment, null, "\u00A0")))));
+    }
+    function getSegAnchorAttrs(seg) {
+        var url = seg.eventRange.def.url;
+        return url ? { href: url } : {};
+    }
+
+    var NowIndicatorRoot = function (props) { return (createElement(ViewContextType.Consumer, null, function (context) {
+        var options = context.options;
+        var hookProps = {
+            isAxis: props.isAxis,
+            date: context.dateEnv.toDate(props.date),
+            view: context.viewApi,
+        };
+        return (createElement(RenderHook, { hookProps: hookProps, classNames: options.nowIndicatorClassNames, content: options.nowIndicatorContent, didMount: options.nowIndicatorDidMount, willUnmount: options.nowIndicatorWillUnmount }, props.children));
+    })); };
+
+    var DAY_NUM_FORMAT = createFormatter({ day: 'numeric' });
+    var DayCellContent = /** @class */ (function (_super) {
+        __extends(DayCellContent, _super);
+        function DayCellContent() {
+            return _super !== null && _super.apply(this, arguments) || this;
+        }
+        DayCellContent.prototype.render = function () {
+            var _a = this, props = _a.props, context = _a.context;
+            var options = context.options;
+            var hookProps = refineDayCellHookProps({
+                date: props.date,
+                dateProfile: props.dateProfile,
+                todayRange: props.todayRange,
+                showDayNumber: props.showDayNumber,
+                extraProps: props.extraHookProps,
+                viewApi: context.viewApi,
+                dateEnv: context.dateEnv,
+            });
+            return (createElement(ContentHook, { hookProps: hookProps, content: options.dayCellContent, defaultContent: props.defaultContent }, props.children));
+        };
+        return DayCellContent;
+    }(BaseComponent));
+    function refineDayCellHookProps(raw) {
+        var date = raw.date, dateEnv = raw.dateEnv;
+        var dayMeta = getDateMeta(date, raw.todayRange, null, raw.dateProfile);
+        return __assign(__assign(__assign({ date: dateEnv.toDate(date), view: raw.viewApi }, dayMeta), { dayNumberText: raw.showDayNumber ? dateEnv.format(date, DAY_NUM_FORMAT) : '' }), raw.extraProps);
+    }
+
+    var DayCellRoot = /** @class */ (function (_super) {
+        __extends(DayCellRoot, _super);
+        function DayCellRoot() {
+            var _this = _super !== null && _super.apply(this, arguments) || this;
+            _this.refineHookProps = memoizeObjArg(refineDayCellHookProps);
+            _this.normalizeClassNames = buildClassNameNormalizer();
+            return _this;
+        }
+        DayCellRoot.prototype.render = function () {
+            var _a = this, props = _a.props, context = _a.context;
+            var options = context.options;
+            var hookProps = this.refineHookProps({
+                date: props.date,
+                dateProfile: props.dateProfile,
+                todayRange: props.todayRange,
+                showDayNumber: props.showDayNumber,
+                extraProps: props.extraHookProps,
+                viewApi: context.viewApi,
+                dateEnv: context.dateEnv,
+            });
+            var classNames = getDayClassNames(hookProps, context.theme).concat(hookProps.isDisabled
+                ? [] // don't use custom classNames if disabled
+                : this.normalizeClassNames(options.dayCellClassNames, hookProps));
+            var dataAttrs = hookProps.isDisabled ? {} : {
+                'data-date': formatDayString(props.date),
+            };
+            return (createElement(MountHook, { hookProps: hookProps, didMount: options.dayCellDidMount, willUnmount: options.dayCellWillUnmount, elRef: props.elRef }, function (rootElRef) { return props.children(rootElRef, classNames, dataAttrs, hookProps.isDisabled); }));
+        };
+        return DayCellRoot;
+    }(BaseComponent));
+
+    function renderFill(fillType) {
+        return (createElement("div", { className: "fc-" + fillType }));
+    }
+    var BgEvent = function (props) { return (createElement(EventRoot, { defaultContent: renderInnerContent$1, seg: props.seg /* uselesss i think */, timeText: "", disableDragging: true, disableResizing: true, isDragging: false, isResizing: false, isDateSelecting: false, isSelected: false, isPast: props.isPast, isFuture: props.isFuture, isToday: props.isToday }, function (rootElRef, classNames, innerElRef, innerContent, hookProps) { return (createElement("div", { ref: rootElRef, className: ['fc-bg-event'].concat(classNames).join(' '), style: {
+            backgroundColor: hookProps.backgroundColor,
+        } }, innerContent)); })); };
+    function renderInnerContent$1(props) {
+        var title = props.event.title;
+        return title && (createElement("div", { className: "fc-event-title" }, props.event.title));
+    }
+
+    var WeekNumberRoot = function (props) { return (createElement(ViewContextType.Consumer, null, function (context) {
+        var dateEnv = context.dateEnv, options = context.options;
+        var date = props.date;
+        var format = options.weekNumberFormat || props.defaultFormat;
+        var num = dateEnv.computeWeekNumber(date); // TODO: somehow use for formatting as well?
+        var text = dateEnv.format(date, format);
+        var hookProps = { num: num, text: text, date: date };
+        return (createElement(RenderHook, { hookProps: hookProps, classNames: options.weekNumberClassNames, content: options.weekNumberContent, defaultContent: renderInner$1, didMount: options.weekNumberDidMount, willUnmount: options.weekNumberWillUnmount }, props.children));
+    })); };
+    function renderInner$1(innerProps) {
+        return innerProps.text;
+    }
+
+    // exports
+    // --------------------------------------------------------------------------------------------------
+    var version = '5.5.0'; // important to type it, so .d.ts has generic string
+
+    var Calendar = /** @class */ (function (_super) {
+        __extends(Calendar, _super);
+        function Calendar(el, optionOverrides) {
+            if (optionOverrides === void 0) { optionOverrides = {}; }
+            var _this = _super.call(this) || this;
+            _this.isRendering = false;
+            _this.isRendered = false;
+            _this.currentClassNames = [];
+            _this.customContentRenderId = 0; // will affect custom generated classNames?
+            _this.handleAction = function (action) {
+                // actions we know we want to render immediately
+                switch (action.type) {
+                    case 'SET_EVENT_DRAG':
+                    case 'SET_EVENT_RESIZE':
+                        _this.renderRunner.tryDrain();
+                }
+            };
+            _this.handleData = function (data) {
+                _this.currentData = data;
+                _this.renderRunner.request(data.calendarOptions.rerenderDelay);
+            };
+            _this.handleRenderRequest = function () {
+                if (_this.isRendering) {
+                    _this.isRendered = true;
+                    var currentData_1 = _this.currentData;
+                    render(createElement(CalendarRoot, { options: currentData_1.calendarOptions, theme: currentData_1.theme, emitter: currentData_1.emitter }, function (classNames, height, isHeightAuto, forPrint) {
+                        _this.setClassNames(classNames);
+                        _this.setHeight(height);
+                        return (createElement(CustomContentRenderContext.Provider, { value: _this.customContentRenderId },
+                            createElement(CalendarContent, __assign({ isHeightAuto: isHeightAuto, forPrint: forPrint }, currentData_1))));
+                    }), _this.el);
+                }
+                else if (_this.isRendered) {
+                    _this.isRendered = false;
+                    unmountComponentAtNode$1(_this.el);
+                    _this.setClassNames([]);
+                    _this.setHeight('');
+                }
+                flushToDom$1();
+            };
+            _this.el = el;
+            _this.renderRunner = new DelayedRunner(_this.handleRenderRequest);
+            new CalendarDataManager({
+                optionOverrides: optionOverrides,
+                calendarApi: _this,
+                onAction: _this.handleAction,
+                onData: _this.handleData,
+            });
+            return _this;
+        }
+        Object.defineProperty(Calendar.prototype, "view", {
+            get: function () { return this.currentData.viewApi; } // for public API
+            ,
+            enumerable: false,
+            configurable: true
+        });
+        Calendar.prototype.render = function () {
+            var wasRendering = this.isRendering;
+            if (!wasRendering) {
+                this.isRendering = true;
+            }
+            else {
+                this.customContentRenderId += 1;
+            }
+            this.renderRunner.request();
+            if (wasRendering) {
+                this.updateSize();
+            }
+        };
+        Calendar.prototype.destroy = function () {
+            if (this.isRendering) {
+                this.isRendering = false;
+                this.renderRunner.request();
+            }
+        };
+        Calendar.prototype.updateSize = function () {
+            _super.prototype.updateSize.call(this);
+            flushToDom$1();
+        };
+        Calendar.prototype.batchRendering = function (func) {
+            this.renderRunner.pause('batchRendering');
+            func();
+            this.renderRunner.resume('batchRendering');
+        };
+        Calendar.prototype.pauseRendering = function () {
+            this.renderRunner.pause('pauseRendering');
+        };
+        Calendar.prototype.resumeRendering = function () {
+            this.renderRunner.resume('pauseRendering', true);
+        };
+        Calendar.prototype.resetOptions = function (optionOverrides, append) {
+            this.currentDataManager.resetOptions(optionOverrides, append);
+        };
+        Calendar.prototype.setClassNames = function (classNames) {
+            if (!isArraysEqual(classNames, this.currentClassNames)) {
+                var classList = this.el.classList;
+                for (var _i = 0, _a = this.currentClassNames; _i < _a.length; _i++) {
+                    var className = _a[_i];
+                    classList.remove(className);
+                }
+                for (var _b = 0, classNames_1 = classNames; _b < classNames_1.length; _b++) {
+                    var className = classNames_1[_b];
+                    classList.add(className);
+                }
+                this.currentClassNames = classNames;
+            }
+        };
+        Calendar.prototype.setHeight = function (height) {
+            applyStyleProp(this.el, 'height', height);
+        };
+        return Calendar;
+    }(CalendarApi));
+
+    config.touchMouseIgnoreWait = 500;
+    var ignoreMouseDepth = 0;
+    var listenerCnt = 0;
+    var isWindowTouchMoveCancelled = false;
+    /*
+    Uses a "pointer" abstraction, which monitors UI events for both mouse and touch.
+    Tracks when the pointer "drags" on a certain element, meaning down+move+up.
+
+    Also, tracks if there was touch-scrolling.
+    Also, can prevent touch-scrolling from happening.
+    Also, can fire pointermove events when scrolling happens underneath, even when no real pointer movement.
+
+    emits:
+    - pointerdown
+    - pointermove
+    - pointerup
+    */
+    var PointerDragging = /** @class */ (function () {
+        function PointerDragging(containerEl) {
+            var _this = this;
+            this.subjectEl = null;
+            // options that can be directly assigned by caller
+            this.selector = ''; // will cause subjectEl in all emitted events to be this element
+            this.handleSelector = '';
+            this.shouldIgnoreMove = false;
+            this.shouldWatchScroll = true; // for simulating pointermove on scroll
+            // internal states
+            this.isDragging = false;
+            this.isTouchDragging = false;
+            this.wasTouchScroll = false;
+            // Mouse
+            // ----------------------------------------------------------------------------------------------------
+            this.handleMouseDown = function (ev) {
+                if (!_this.shouldIgnoreMouse() &&
+                    isPrimaryMouseButton(ev) &&
+                    _this.tryStart(ev)) {
+                    var pev = _this.createEventFromMouse(ev, true);
+                    _this.emitter.trigger('pointerdown', pev);
+                    _this.initScrollWatch(pev);
+                    if (!_this.shouldIgnoreMove) {
+                        document.addEventListener('mousemove', _this.handleMouseMove);
+                    }
+                    document.addEventListener('mouseup', _this.handleMouseUp);
+                }
+            };
+            this.handleMouseMove = function (ev) {
+                var pev = _this.createEventFromMouse(ev);
+                _this.recordCoords(pev);
+                _this.emitter.trigger('pointermove', pev);
+            };
+            this.handleMouseUp = function (ev) {
+                document.removeEventListener('mousemove', _this.handleMouseMove);
+                document.removeEventListener('mouseup', _this.handleMouseUp);
+                _this.emitter.trigger('pointerup', _this.createEventFromMouse(ev));
+                _this.cleanup(); // call last so that pointerup has access to props
+            };
+            // Touch
+            // ----------------------------------------------------------------------------------------------------
+            this.handleTouchStart = function (ev) {
+                if (_this.tryStart(ev)) {
+                    _this.isTouchDragging = true;
+                    var pev = _this.createEventFromTouch(ev, true);
+                    _this.emitter.trigger('pointerdown', pev);
+                    _this.initScrollWatch(pev);
+                    // unlike mouse, need to attach to target, not document
+                    // https://stackoverflow.com/a/45760014
+                    var targetEl = ev.target;
+                    if (!_this.shouldIgnoreMove) {
+                        targetEl.addEventListener('touchmove', _this.handleTouchMove);
+                    }
+                    targetEl.addEventListener('touchend', _this.handleTouchEnd);
+                    targetEl.addEventListener('touchcancel', _this.handleTouchEnd); // treat it as a touch end
+                    // attach a handler to get called when ANY scroll action happens on the page.
+                    // this was impossible to do with normal on/off because 'scroll' doesn't bubble.
+                    // http://stackoverflow.com/a/32954565/96342
+                    window.addEventListener('scroll', _this.handleTouchScroll, true);
+                }
+            };
+            this.handleTouchMove = function (ev) {
+                var pev = _this.createEventFromTouch(ev);
+                _this.recordCoords(pev);
+                _this.emitter.trigger('pointermove', pev);
+            };
+            this.handleTouchEnd = function (ev) {
+                if (_this.isDragging) { // done to guard against touchend followed by touchcancel
+                    var targetEl = ev.target;
+                    targetEl.removeEventListener('touchmove', _this.handleTouchMove);
+                    targetEl.removeEventListener('touchend', _this.handleTouchEnd);
+                    targetEl.removeEventListener('touchcancel', _this.handleTouchEnd);
+                    window.removeEventListener('scroll', _this.handleTouchScroll, true); // useCaptured=true
+                    _this.emitter.trigger('pointerup', _this.createEventFromTouch(ev));
+                    _this.cleanup(); // call last so that pointerup has access to props
+                    _this.isTouchDragging = false;
+                    startIgnoringMouse();
+                }
+            };
+            this.handleTouchScroll = function () {
+                _this.wasTouchScroll = true;
+            };
+            this.handleScroll = function (ev) {
+                if (!_this.shouldIgnoreMove) {
+                    var pageX = (window.pageXOffset - _this.prevScrollX) + _this.prevPageX;
+                    var pageY = (window.pageYOffset - _this.prevScrollY) + _this.prevPageY;
+                    _this.emitter.trigger('pointermove', {
+                        origEvent: ev,
+                        isTouch: _this.isTouchDragging,
+                        subjectEl: _this.subjectEl,
+                        pageX: pageX,
+                        pageY: pageY,
+                        deltaX: pageX - _this.origPageX,
+                        deltaY: pageY - _this.origPageY,
+                    });
+                }
+            };
+            this.containerEl = containerEl;
+            this.emitter = new Emitter();
+            containerEl.addEventListener('mousedown', this.handleMouseDown);
+            containerEl.addEventListener('touchstart', this.handleTouchStart, { passive: true });
+            listenerCreated();
+        }
+        PointerDragging.prototype.destroy = function () {
+            this.containerEl.removeEventListener('mousedown', this.handleMouseDown);
+            this.containerEl.removeEventListener('touchstart', this.handleTouchStart, { passive: true });
+            listenerDestroyed();
+        };
+        PointerDragging.prototype.tryStart = function (ev) {
+            var subjectEl = this.querySubjectEl(ev);
+            var downEl = ev.target;
+            if (subjectEl &&
+                (!this.handleSelector || elementClosest(downEl, this.handleSelector))) {
+                this.subjectEl = subjectEl;
+                this.isDragging = true; // do this first so cancelTouchScroll will work
+                this.wasTouchScroll = false;
+                return true;
+            }
+            return false;
+        };
+        PointerDragging.prototype.cleanup = function () {
+            isWindowTouchMoveCancelled = false;
+            this.isDragging = false;
+            this.subjectEl = null;
+            // keep wasTouchScroll around for later access
+            this.destroyScrollWatch();
+        };
+        PointerDragging.prototype.querySubjectEl = function (ev) {
+            if (this.selector) {
+                return elementClosest(ev.target, this.selector);
+            }
+            return this.containerEl;
+        };
+        PointerDragging.prototype.shouldIgnoreMouse = function () {
+            return ignoreMouseDepth || this.isTouchDragging;
+        };
+        // can be called by user of this class, to cancel touch-based scrolling for the current drag
+        PointerDragging.prototype.cancelTouchScroll = function () {
+            if (this.isDragging) {
+                isWindowTouchMoveCancelled = true;
+            }
+        };
+        // Scrolling that simulates pointermoves
+        // ----------------------------------------------------------------------------------------------------
+        PointerDragging.prototype.initScrollWatch = function (ev) {
+            if (this.shouldWatchScroll) {
+                this.recordCoords(ev);
+                window.addEventListener('scroll', this.handleScroll, true); // useCapture=true
+            }
+        };
+        PointerDragging.prototype.recordCoords = function (ev) {
+            if (this.shouldWatchScroll) {
+                this.prevPageX = ev.pageX;
+                this.prevPageY = ev.pageY;
+                this.prevScrollX = window.pageXOffset;
+                this.prevScrollY = window.pageYOffset;
+            }
+        };
+        PointerDragging.prototype.destroyScrollWatch = function () {
+            if (this.shouldWatchScroll) {
+                window.removeEventListener('scroll', this.handleScroll, true); // useCaptured=true
+            }
+        };
+        // Event Normalization
+        // ----------------------------------------------------------------------------------------------------
+        PointerDragging.prototype.createEventFromMouse = function (ev, isFirst) {
+            var deltaX = 0;
+            var deltaY = 0;
+            // TODO: repeat code
+            if (isFirst) {
+                this.origPageX = ev.pageX;
+                this.origPageY = ev.pageY;
+            }
+            else {
+                deltaX = ev.pageX - this.origPageX;
+                deltaY = ev.pageY - this.origPageY;
+            }
+            return {
+                origEvent: ev,
+                isTouch: false,
+                subjectEl: this.subjectEl,
+                pageX: ev.pageX,
+                pageY: ev.pageY,
+                deltaX: deltaX,
+                deltaY: deltaY,
+            };
+        };
+        PointerDragging.prototype.createEventFromTouch = function (ev, isFirst) {
+            var touches = ev.touches;
+            var pageX;
+            var pageY;
+            var deltaX = 0;
+            var deltaY = 0;
+            // if touch coords available, prefer,
+            // because FF would give bad ev.pageX ev.pageY
+            if (touches && touches.length) {
+                pageX = touches[0].pageX;
+                pageY = touches[0].pageY;
+            }
+            else {
+                pageX = ev.pageX;
+                pageY = ev.pageY;
+            }
+            // TODO: repeat code
+            if (isFirst) {
+                this.origPageX = pageX;
+                this.origPageY = pageY;
+            }
+            else {
+                deltaX = pageX - this.origPageX;
+                deltaY = pageY - this.origPageY;
+            }
+            return {
+                origEvent: ev,
+                isTouch: true,
+                subjectEl: this.subjectEl,
+                pageX: pageX,
+                pageY: pageY,
+                deltaX: deltaX,
+                deltaY: deltaY,
+            };
+        };
+        return PointerDragging;
+    }());
+    // Returns a boolean whether this was a left mouse click and no ctrl key (which means right click on Mac)
+    function isPrimaryMouseButton(ev) {
+        return ev.button === 0 && !ev.ctrlKey;
+    }
+    // Ignoring fake mouse events generated by touch
+    // ----------------------------------------------------------------------------------------------------
+    function startIgnoringMouse() {
+        ignoreMouseDepth += 1;
+        setTimeout(function () {
+            ignoreMouseDepth -= 1;
+        }, config.touchMouseIgnoreWait);
+    }
+    // We want to attach touchmove as early as possible for Safari
+    // ----------------------------------------------------------------------------------------------------
+    function listenerCreated() {
+        listenerCnt += 1;
+        if (listenerCnt === 1) {
+            window.addEventListener('touchmove', onWindowTouchMove, { passive: false });
+        }
+    }
+    function listenerDestroyed() {
+        listenerCnt -= 1;
+        if (!listenerCnt) {
+            window.removeEventListener('touchmove', onWindowTouchMove, { passive: false });
+        }
+    }
+    function onWindowTouchMove(ev) {
+        if (isWindowTouchMoveCancelled) {
+            ev.preventDefault();
+        }
+    }
+
+    /*
+    An effect in which an element follows the movement of a pointer across the screen.
+    The moving element is a clone of some other element.
+    Must call start + handleMove + stop.
+    */
+    var ElementMirror = /** @class */ (function () {
+        function ElementMirror() {
+            this.isVisible = false; // must be explicitly enabled
+            this.sourceEl = null;
+            this.mirrorEl = null;
+            this.sourceElRect = null; // screen coords relative to viewport
+            // options that can be set directly by caller
+            this.parentNode = document.body;
+            this.zIndex = 9999;
+            this.revertDuration = 0;
+        }
+        ElementMirror.prototype.start = function (sourceEl, pageX, pageY) {
+            this.sourceEl = sourceEl;
+            this.sourceElRect = this.sourceEl.getBoundingClientRect();
+            this.origScreenX = pageX - window.pageXOffset;
+            this.origScreenY = pageY - window.pageYOffset;
+            this.deltaX = 0;
+            this.deltaY = 0;
+            this.updateElPosition();
+        };
+        ElementMirror.prototype.handleMove = function (pageX, pageY) {
+            this.deltaX = (pageX - window.pageXOffset) - this.origScreenX;
+            this.deltaY = (pageY - window.pageYOffset) - this.origScreenY;
+            this.updateElPosition();
+        };
+        // can be called before start
+        ElementMirror.prototype.setIsVisible = function (bool) {
+            if (bool) {
+                if (!this.isVisible) {
+                    if (this.mirrorEl) {
+                        this.mirrorEl.style.display = '';
+                    }
+                    this.isVisible = bool; // needs to happen before updateElPosition
+                    this.updateElPosition(); // because was not updating the position while invisible
+                }
+            }
+            else if (this.isVisible) {
+                if (this.mirrorEl) {
+                    this.mirrorEl.style.display = 'none';
+                }
+                this.isVisible = bool;
+            }
+        };
+        // always async
+        ElementMirror.prototype.stop = function (needsRevertAnimation, callback) {
+            var _this = this;
+            var done = function () {
+                _this.cleanup();
+                callback();
+            };
+            if (needsRevertAnimation &&
+                this.mirrorEl &&
+                this.isVisible &&
+                this.revertDuration && // if 0, transition won't work
+                (this.deltaX || this.deltaY) // if same coords, transition won't work
+            ) {
+                this.doRevertAnimation(done, this.revertDuration);
+            }
+            else {
+                setTimeout(done, 0);
+            }
+        };
+        ElementMirror.prototype.doRevertAnimation = function (callback, revertDuration) {
+            var mirrorEl = this.mirrorEl;
+            var finalSourceElRect = this.sourceEl.getBoundingClientRect(); // because autoscrolling might have happened
+            mirrorEl.style.transition =
+                'top ' + revertDuration + 'ms,' +
+                    'left ' + revertDuration + 'ms';
+            applyStyle(mirrorEl, {
+                left: finalSourceElRect.left,
+                top: finalSourceElRect.top,
+            });
+            whenTransitionDone(mirrorEl, function () {
+                mirrorEl.style.transition = '';
+                callback();
+            });
+        };
+        ElementMirror.prototype.cleanup = function () {
+            if (this.mirrorEl) {
+                removeElement(this.mirrorEl);
+                this.mirrorEl = null;
+            }
+            this.sourceEl = null;
+        };
+        ElementMirror.prototype.updateElPosition = function () {
+            if (this.sourceEl && this.isVisible) {
+                applyStyle(this.getMirrorEl(), {
+                    left: this.sourceElRect.left + this.deltaX,
+                    top: this.sourceElRect.top + this.deltaY,
+                });
+            }
+        };
+        ElementMirror.prototype.getMirrorEl = function () {
+            var sourceElRect = this.sourceElRect;
+            var mirrorEl = this.mirrorEl;
+            if (!mirrorEl) {
+                mirrorEl = this.mirrorEl = this.sourceEl.cloneNode(true); // cloneChildren=true
+                // we don't want long taps or any mouse interaction causing selection/menus.
+                // would use preventSelection(), but that prevents selectstart, causing problems.
+                mirrorEl.classList.add('fc-unselectable');
+                mirrorEl.classList.add('fc-event-dragging');
+                applyStyle(mirrorEl, {
+                    position: 'fixed',
+                    zIndex: this.zIndex,
+                    visibility: '',
+                    boxSizing: 'border-box',
+                    width: sourceElRect.right - sourceElRect.left,
+                    height: sourceElRect.bottom - sourceElRect.top,
+                    right: 'auto',
+                    bottom: 'auto',
+                    margin: 0,
+                });
+                this.parentNode.appendChild(mirrorEl);
+            }
+            return mirrorEl;
+        };
+        return ElementMirror;
+    }());
+
+    /*
+    Is a cache for a given element's scroll information (all the info that ScrollController stores)
+    in addition the "client rectangle" of the element.. the area within the scrollbars.
+
+    The cache can be in one of two modes:
+    - doesListening:false - ignores when the container is scrolled by someone else
+    - doesListening:true - watch for scrolling and update the cache
+    */
+    var ScrollGeomCache = /** @class */ (function (_super) {
+        __extends(ScrollGeomCache, _super);
+        function ScrollGeomCache(scrollController, doesListening) {
+            var _this = _super.call(this) || this;
+            _this.handleScroll = function () {
+                _this.scrollTop = _this.scrollController.getScrollTop();
+                _this.scrollLeft = _this.scrollController.getScrollLeft();
+                _this.handleScrollChange();
+            };
+            _this.scrollController = scrollController;
+            _this.doesListening = doesListening;
+            _this.scrollTop = _this.origScrollTop = scrollController.getScrollTop();
+            _this.scrollLeft = _this.origScrollLeft = scrollController.getScrollLeft();
+            _this.scrollWidth = scrollController.getScrollWidth();
+            _this.scrollHeight = scrollController.getScrollHeight();
+            _this.clientWidth = scrollController.getClientWidth();
+            _this.clientHeight = scrollController.getClientHeight();
+            _this.clientRect = _this.computeClientRect(); // do last in case it needs cached values
+            if (_this.doesListening) {
+                _this.getEventTarget().addEventListener('scroll', _this.handleScroll);
+            }
+            return _this;
+        }
+        ScrollGeomCache.prototype.destroy = function () {
+            if (this.doesListening) {
+                this.getEventTarget().removeEventListener('scroll', this.handleScroll);
+            }
+        };
+        ScrollGeomCache.prototype.getScrollTop = function () {
+            return this.scrollTop;
+        };
+        ScrollGeomCache.prototype.getScrollLeft = function () {
+            return this.scrollLeft;
+        };
+        ScrollGeomCache.prototype.setScrollTop = function (top) {
+            this.scrollController.setScrollTop(top);
+            if (!this.doesListening) {
+                // we are not relying on the element to normalize out-of-bounds scroll values
+                // so we need to sanitize ourselves
+                this.scrollTop = Math.max(Math.min(top, this.getMaxScrollTop()), 0);
+                this.handleScrollChange();
+            }
+        };
+        ScrollGeomCache.prototype.setScrollLeft = function (top) {
+            this.scrollController.setScrollLeft(top);
+            if (!this.doesListening) {
+                // we are not relying on the element to normalize out-of-bounds scroll values
+                // so we need to sanitize ourselves
+                this.scrollLeft = Math.max(Math.min(top, this.getMaxScrollLeft()), 0);
+                this.handleScrollChange();
+            }
+        };
+        ScrollGeomCache.prototype.getClientWidth = function () {
+            return this.clientWidth;
+        };
+        ScrollGeomCache.prototype.getClientHeight = function () {
+            return this.clientHeight;
+        };
+        ScrollGeomCache.prototype.getScrollWidth = function () {
+            return this.scrollWidth;
+        };
+        ScrollGeomCache.prototype.getScrollHeight = function () {
+            return this.scrollHeight;
+        };
+        ScrollGeomCache.prototype.handleScrollChange = function () {
+        };
+        return ScrollGeomCache;
+    }(ScrollController));
+
+    var ElementScrollGeomCache = /** @class */ (function (_super) {
+        __extends(ElementScrollGeomCache, _super);
+        function ElementScrollGeomCache(el, doesListening) {
+            return _super.call(this, new ElementScrollController(el), doesListening) || this;
+        }
+        ElementScrollGeomCache.prototype.getEventTarget = function () {
+            return this.scrollController.el;
+        };
+        ElementScrollGeomCache.prototype.computeClientRect = function () {
+            return computeInnerRect(this.scrollController.el);
+        };
+        return ElementScrollGeomCache;
+    }(ScrollGeomCache));
+
+    var WindowScrollGeomCache = /** @class */ (function (_super) {
+        __extends(WindowScrollGeomCache, _super);
+        function WindowScrollGeomCache(doesListening) {
+            return _super.call(this, new WindowScrollController(), doesListening) || this;
+        }
+        WindowScrollGeomCache.prototype.getEventTarget = function () {
+            return window;
+        };
+        WindowScrollGeomCache.prototype.computeClientRect = function () {
+            return {
+                left: this.scrollLeft,
+                right: this.scrollLeft + this.clientWidth,
+                top: this.scrollTop,
+                bottom: this.scrollTop + this.clientHeight,
+            };
+        };
+        // the window is the only scroll object that changes it's rectangle relative
+        // to the document's topleft as it scrolls
+        WindowScrollGeomCache.prototype.handleScrollChange = function () {
+            this.clientRect = this.computeClientRect();
+        };
+        return WindowScrollGeomCache;
+    }(ScrollGeomCache));
+
+    // If available we are using native "performance" API instead of "Date"
+    // Read more about it on MDN:
+    // https://developer.mozilla.org/en-US/docs/Web/API/Performance
+    var getTime = typeof performance === 'function' ? performance.now : Date.now;
+    /*
+    For a pointer interaction, automatically scrolls certain scroll containers when the pointer
+    approaches the edge.
+
+    The caller must call start + handleMove + stop.
+    */
+    var AutoScroller = /** @class */ (function () {
+        function AutoScroller() {
+            var _this = this;
+            // options that can be set by caller
+            this.isEnabled = true;
+            this.scrollQuery = [window, '.fc-scroller'];
+            this.edgeThreshold = 50; // pixels
+            this.maxVelocity = 300; // pixels per second
+            // internal state
+            this.pointerScreenX = null;
+            this.pointerScreenY = null;
+            this.isAnimating = false;
+            this.scrollCaches = null;
+            // protect against the initial pointerdown being too close to an edge and starting the scroll
+            this.everMovedUp = false;
+            this.everMovedDown = false;
+            this.everMovedLeft = false;
+            this.everMovedRight = false;
+            this.animate = function () {
+                if (_this.isAnimating) { // wasn't cancelled between animation calls
+                    var edge = _this.computeBestEdge(_this.pointerScreenX + window.pageXOffset, _this.pointerScreenY + window.pageYOffset);
+                    if (edge) {
+                        var now = getTime();
+                        _this.handleSide(edge, (now - _this.msSinceRequest) / 1000);
+                        _this.requestAnimation(now);
+                    }
+                    else {
+                        _this.isAnimating = false; // will stop animation
+                    }
+                }
+            };
+        }
+        AutoScroller.prototype.start = function (pageX, pageY) {
+            if (this.isEnabled) {
+                this.scrollCaches = this.buildCaches();
+                this.pointerScreenX = null;
+                this.pointerScreenY = null;
+                this.everMovedUp = false;
+                this.everMovedDown = false;
+                this.everMovedLeft = false;
+                this.everMovedRight = false;
+                this.handleMove(pageX, pageY);
+            }
+        };
+        AutoScroller.prototype.handleMove = function (pageX, pageY) {
+            if (this.isEnabled) {
+                var pointerScreenX = pageX - window.pageXOffset;
+                var pointerScreenY = pageY - window.pageYOffset;
+                var yDelta = this.pointerScreenY === null ? 0 : pointerScreenY - this.pointerScreenY;
+                var xDelta = this.pointerScreenX === null ? 0 : pointerScreenX - this.pointerScreenX;
+                if (yDelta < 0) {
+                    this.everMovedUp = true;
+                }
+                else if (yDelta > 0) {
+                    this.everMovedDown = true;
+                }
+                if (xDelta < 0) {
+                    this.everMovedLeft = true;
+                }
+                else if (xDelta > 0) {
+                    this.everMovedRight = true;
+                }
+                this.pointerScreenX = pointerScreenX;
+                this.pointerScreenY = pointerScreenY;
+                if (!this.isAnimating) {
+                    this.isAnimating = true;
+                    this.requestAnimation(getTime());
+                }
+            }
+        };
+        AutoScroller.prototype.stop = function () {
+            if (this.isEnabled) {
+                this.isAnimating = false; // will stop animation
+                for (var _i = 0, _a = this.scrollCaches; _i < _a.length; _i++) {
+                    var scrollCache = _a[_i];
+                    scrollCache.destroy();
+                }
+                this.scrollCaches = null;
+            }
+        };
+        AutoScroller.prototype.requestAnimation = function (now) {
+            this.msSinceRequest = now;
+            requestAnimationFrame(this.animate);
+        };
+        AutoScroller.prototype.handleSide = function (edge, seconds) {
+            var scrollCache = edge.scrollCache;
+            var edgeThreshold = this.edgeThreshold;
+            var invDistance = edgeThreshold - edge.distance;
+            var velocity = // the closer to the edge, the faster we scroll
+             ((invDistance * invDistance) / (edgeThreshold * edgeThreshold)) * // quadratic
+                this.maxVelocity * seconds;
+            var sign = 1;
+            switch (edge.name) {
+                case 'left':
+                    sign = -1;
+                // falls through
+                case 'right':
+                    scrollCache.setScrollLeft(scrollCache.getScrollLeft() + velocity * sign);
+                    break;
+                case 'top':
+                    sign = -1;
+                // falls through
+                case 'bottom':
+                    scrollCache.setScrollTop(scrollCache.getScrollTop() + velocity * sign);
+                    break;
+            }
+        };
+        // left/top are relative to document topleft
+        AutoScroller.prototype.computeBestEdge = function (left, top) {
+            var edgeThreshold = this.edgeThreshold;
+            var bestSide = null;
+            for (var _i = 0, _a = this.scrollCaches; _i < _a.length; _i++) {
+                var scrollCache = _a[_i];
+                var rect = scrollCache.clientRect;
+                var leftDist = left - rect.left;
+                var rightDist = rect.right - left;
+                var topDist = top - rect.top;
+                var bottomDist = rect.bottom - top;
+                // completely within the rect?
+                if (leftDist >= 0 && rightDist >= 0 && topDist >= 0 && bottomDist >= 0) {
+                    if (topDist <= edgeThreshold && this.everMovedUp && scrollCache.canScrollUp() &&
+                        (!bestSide || bestSide.distance > topDist)) {
+                        bestSide = { scrollCache: scrollCache, name: 'top', distance: topDist };
+                    }
+                    if (bottomDist <= edgeThreshold && this.everMovedDown && scrollCache.canScrollDown() &&
+                        (!bestSide || bestSide.distance > bottomDist)) {
+                        bestSide = { scrollCache: scrollCache, name: 'bottom', distance: bottomDist };
+                    }
+                    if (leftDist <= edgeThreshold && this.everMovedLeft && scrollCache.canScrollLeft() &&
+                        (!bestSide || bestSide.distance > leftDist)) {
+                        bestSide = { scrollCache: scrollCache, name: 'left', distance: leftDist };
+                    }
+                    if (rightDist <= edgeThreshold && this.everMovedRight && scrollCache.canScrollRight() &&
+                        (!bestSide || bestSide.distance > rightDist)) {
+                        bestSide = { scrollCache: scrollCache, name: 'right', distance: rightDist };
+                    }
+                }
+            }
+            return bestSide;
+        };
+        AutoScroller.prototype.buildCaches = function () {
+            return this.queryScrollEls().map(function (el) {
+                if (el === window) {
+                    return new WindowScrollGeomCache(false); // false = don't listen to user-generated scrolls
+                }
+                return new ElementScrollGeomCache(el, false); // false = don't listen to user-generated scrolls
+            });
+        };
+        AutoScroller.prototype.queryScrollEls = function () {
+            var els = [];
+            for (var _i = 0, _a = this.scrollQuery; _i < _a.length; _i++) {
+                var query = _a[_i];
+                if (typeof query === 'object') {
+                    els.push(query);
+                }
+                else {
+                    els.push.apply(els, Array.prototype.slice.call(document.querySelectorAll(query)));
+                }
+            }
+            return els;
+        };
+        return AutoScroller;
+    }());
+
+    /*
+    Monitors dragging on an element. Has a number of high-level features:
+    - minimum distance required before dragging
+    - minimum wait time ("delay") before dragging
+    - a mirror element that follows the pointer
+    */
+    var FeaturefulElementDragging = /** @class */ (function (_super) {
+        __extends(FeaturefulElementDragging, _super);
+        function FeaturefulElementDragging(containerEl, selector) {
+            var _this = _super.call(this, containerEl) || this;
+            // options that can be directly set by caller
+            // the caller can also set the PointerDragging's options as well
+            _this.delay = null;
+            _this.minDistance = 0;
+            _this.touchScrollAllowed = true; // prevents drag from starting and blocks scrolling during drag
+            _this.mirrorNeedsRevert = false;
+            _this.isInteracting = false; // is the user validly moving the pointer? lasts until pointerup
+            _this.isDragging = false; // is it INTENTFULLY dragging? lasts until after revert animation
+            _this.isDelayEnded = false;
+            _this.isDistanceSurpassed = false;
+            _this.delayTimeoutId = null;
+            _this.onPointerDown = function (ev) {
+                if (!_this.isDragging) { // so new drag doesn't happen while revert animation is going
+                    _this.isInteracting = true;
+                    _this.isDelayEnded = false;
+                    _this.isDistanceSurpassed = false;
+                    preventSelection(document.body);
+                    preventContextMenu(document.body);
+                    // prevent links from being visited if there's an eventual drag.
+                    // also prevents selection in older browsers (maybe?).
+                    // not necessary for touch, besides, browser would complain about passiveness.
+                    if (!ev.isTouch) {
+                        ev.origEvent.preventDefault();
+                    }
+                    _this.emitter.trigger('pointerdown', ev);
+                    if (_this.isInteracting && // not destroyed via pointerdown handler
+                        !_this.pointer.shouldIgnoreMove) {
+                        // actions related to initiating dragstart+dragmove+dragend...
+                        _this.mirror.setIsVisible(false); // reset. caller must set-visible
+                        _this.mirror.start(ev.subjectEl, ev.pageX, ev.pageY); // must happen on first pointer down
+                        _this.startDelay(ev);
+                        if (!_this.minDistance) {
+                            _this.handleDistanceSurpassed(ev);
+                        }
+                    }
+                }
+            };
+            _this.onPointerMove = function (ev) {
+                if (_this.isInteracting) {
+                    _this.emitter.trigger('pointermove', ev);
+                    if (!_this.isDistanceSurpassed) {
+                        var minDistance = _this.minDistance;
+                        var distanceSq = void 0; // current distance from the origin, squared
+                        var deltaX = ev.deltaX, deltaY = ev.deltaY;
+                        distanceSq = deltaX * deltaX + deltaY * deltaY;
+                        if (distanceSq >= minDistance * minDistance) { // use pythagorean theorem
+                            _this.handleDistanceSurpassed(ev);
+                        }
+                    }
+                    if (_this.isDragging) {
+                        // a real pointer move? (not one simulated by scrolling)
+                        if (ev.origEvent.type !== 'scroll') {
+                            _this.mirror.handleMove(ev.pageX, ev.pageY);
+                            _this.autoScroller.handleMove(ev.pageX, ev.pageY);
+                        }
+                        _this.emitter.trigger('dragmove', ev);
+                    }
+                }
+            };
+            _this.onPointerUp = function (ev) {
+                if (_this.isInteracting) {
+                    _this.isInteracting = false;
+                    allowSelection(document.body);
+                    allowContextMenu(document.body);
+                    _this.emitter.trigger('pointerup', ev); // can potentially set mirrorNeedsRevert
+                    if (_this.isDragging) {
+                        _this.autoScroller.stop();
+                        _this.tryStopDrag(ev); // which will stop the mirror
+                    }
+                    if (_this.delayTimeoutId) {
+                        clearTimeout(_this.delayTimeoutId);
+                        _this.delayTimeoutId = null;
+                    }
+                }
+            };
+            var pointer = _this.pointer = new PointerDragging(containerEl);
+            pointer.emitter.on('pointerdown', _this.onPointerDown);
+            pointer.emitter.on('pointermove', _this.onPointerMove);
+            pointer.emitter.on('pointerup', _this.onPointerUp);
+            if (selector) {
+                pointer.selector = selector;
+            }
+            _this.mirror = new ElementMirror();
+            _this.autoScroller = new AutoScroller();
+            return _this;
+        }
+        FeaturefulElementDragging.prototype.destroy = function () {
+            this.pointer.destroy();
+            // HACK: simulate a pointer-up to end the current drag
+            // TODO: fire 'dragend' directly and stop interaction. discourage use of pointerup event (b/c might not fire)
+            this.onPointerUp({});
+        };
+        FeaturefulElementDragging.prototype.startDelay = function (ev) {
+            var _this = this;
+            if (typeof this.delay === 'number') {
+                this.delayTimeoutId = setTimeout(function () {
+                    _this.delayTimeoutId = null;
+                    _this.handleDelayEnd(ev);
+                }, this.delay); // not assignable to number!
+            }
+            else {
+                this.handleDelayEnd(ev);
+            }
+        };
+        FeaturefulElementDragging.prototype.handleDelayEnd = function (ev) {
+            this.isDelayEnded = true;
+            this.tryStartDrag(ev);
+        };
+        FeaturefulElementDragging.prototype.handleDistanceSurpassed = function (ev) {
+            this.isDistanceSurpassed = true;
+            this.tryStartDrag(ev);
+        };
+        FeaturefulElementDragging.prototype.tryStartDrag = function (ev) {
+            if (this.isDelayEnded && this.isDistanceSurpassed) {
+                if (!this.pointer.wasTouchScroll || this.touchScrollAllowed) {
+                    this.isDragging = true;
+                    this.mirrorNeedsRevert = false;
+                    this.autoScroller.start(ev.pageX, ev.pageY);
+                    this.emitter.trigger('dragstart', ev);
+                    if (this.touchScrollAllowed === false) {
+                        this.pointer.cancelTouchScroll();
+                    }
+                }
+            }
+        };
+        FeaturefulElementDragging.prototype.tryStopDrag = function (ev) {
+            // .stop() is ALWAYS asynchronous, which we NEED because we want all pointerup events
+            // that come from the document to fire beforehand. much more convenient this way.
+            this.mirror.stop(this.mirrorNeedsRevert, this.stopDrag.bind(this, ev));
+        };
+        FeaturefulElementDragging.prototype.stopDrag = function (ev) {
+            this.isDragging = false;
+            this.emitter.trigger('dragend', ev);
+        };
+        // fill in the implementations...
+        FeaturefulElementDragging.prototype.setIgnoreMove = function (bool) {
+            this.pointer.shouldIgnoreMove = bool;
+        };
+        FeaturefulElementDragging.prototype.setMirrorIsVisible = function (bool) {
+            this.mirror.setIsVisible(bool);
+        };
+        FeaturefulElementDragging.prototype.setMirrorNeedsRevert = function (bool) {
+            this.mirrorNeedsRevert = bool;
+        };
+        FeaturefulElementDragging.prototype.setAutoScrollEnabled = function (bool) {
+            this.autoScroller.isEnabled = bool;
+        };
+        return FeaturefulElementDragging;
+    }(ElementDragging));
+
+    /*
+    When this class is instantiated, it records the offset of an element (relative to the document topleft),
+    and continues to monitor scrolling, updating the cached coordinates if it needs to.
+    Does not access the DOM after instantiation, so highly performant.
+
+    Also keeps track of all scrolling/overflow:hidden containers that are parents of the given element
+    and an determine if a given point is inside the combined clipping rectangle.
+    */
+    var OffsetTracker = /** @class */ (function () {
+        function OffsetTracker(el) {
+            this.origRect = computeRect(el);
+            // will work fine for divs that have overflow:hidden
+            this.scrollCaches = getClippingParents(el).map(function (scrollEl) { return new ElementScrollGeomCache(scrollEl, true); });
+        }
+        OffsetTracker.prototype.destroy = function () {
+            for (var _i = 0, _a = this.scrollCaches; _i < _a.length; _i++) {
+                var scrollCache = _a[_i];
+                scrollCache.destroy();
+            }
+        };
+        OffsetTracker.prototype.computeLeft = function () {
+            var left = this.origRect.left;
+            for (var _i = 0, _a = this.scrollCaches; _i < _a.length; _i++) {
+                var scrollCache = _a[_i];
+                left += scrollCache.origScrollLeft - scrollCache.getScrollLeft();
+            }
+            return left;
+        };
+        OffsetTracker.prototype.computeTop = function () {
+            var top = this.origRect.top;
+            for (var _i = 0, _a = this.scrollCaches; _i < _a.length; _i++) {
+                var scrollCache = _a[_i];
+                top += scrollCache.origScrollTop - scrollCache.getScrollTop();
+            }
+            return top;
+        };
+        OffsetTracker.prototype.isWithinClipping = function (pageX, pageY) {
+            var point = { left: pageX, top: pageY };
+            for (var _i = 0, _a = this.scrollCaches; _i < _a.length; _i++) {
+                var scrollCache = _a[_i];
+                if (!isIgnoredClipping(scrollCache.getEventTarget()) &&
+                    !pointInsideRect(point, scrollCache.clientRect)) {
+                    return false;
+                }
+            }
+            return true;
+        };
+        return OffsetTracker;
+    }());
+    // certain clipping containers should never constrain interactions, like <html> and <body>
+    // https://github.com/fullcalendar/fullcalendar/issues/3615
+    function isIgnoredClipping(node) {
+        var tagName = node.tagName;
+        return tagName === 'HTML' || tagName === 'BODY';
+    }
+
+    /*
+    Tracks movement over multiple droppable areas (aka "hits")
+    that exist in one or more DateComponents.
+    Relies on an existing draggable.
+
+    emits:
+    - pointerdown
+    - dragstart
+    - hitchange - fires initially, even if not over a hit
+    - pointerup
+    - (hitchange - again, to null, if ended over a hit)
+    - dragend
+    */
+    var HitDragging = /** @class */ (function () {
+        function HitDragging(dragging, droppableStore) {
+            var _this = this;
+            // options that can be set by caller
+            this.useSubjectCenter = false;
+            this.requireInitial = true; // if doesn't start out on a hit, won't emit any events
+            this.initialHit = null;
+            this.movingHit = null;
+            this.finalHit = null; // won't ever be populated if shouldIgnoreMove
+            this.handlePointerDown = function (ev) {
+                var dragging = _this.dragging;
+                _this.initialHit = null;
+                _this.movingHit = null;
+                _this.finalHit = null;
+                _this.prepareHits();
+                _this.processFirstCoord(ev);
+                if (_this.initialHit || !_this.requireInitial) {
+                    dragging.setIgnoreMove(false);
+                    // TODO: fire this before computing processFirstCoord, so listeners can cancel. this gets fired by almost every handler :(
+                    _this.emitter.trigger('pointerdown', ev);
+                }
+                else {
+                    dragging.setIgnoreMove(true);
+                }
+            };
+            this.handleDragStart = function (ev) {
+                _this.emitter.trigger('dragstart', ev);
+                _this.handleMove(ev, true); // force = fire even if initially null
+            };
+            this.handleDragMove = function (ev) {
+                _this.emitter.trigger('dragmove', ev);
+                _this.handleMove(ev);
+            };
+            this.handlePointerUp = function (ev) {
+                _this.releaseHits();
+                _this.emitter.trigger('pointerup', ev);
+            };
+            this.handleDragEnd = function (ev) {
+                if (_this.movingHit) {
+                    _this.emitter.trigger('hitupdate', null, true, ev);
+                }
+                _this.finalHit = _this.movingHit;
+                _this.movingHit = null;
+                _this.emitter.trigger('dragend', ev);
+            };
+            this.droppableStore = droppableStore;
+            dragging.emitter.on('pointerdown', this.handlePointerDown);
+            dragging.emitter.on('dragstart', this.handleDragStart);
+            dragging.emitter.on('dragmove', this.handleDragMove);
+            dragging.emitter.on('pointerup', this.handlePointerUp);
+            dragging.emitter.on('dragend', this.handleDragEnd);
+            this.dragging = dragging;
+            this.emitter = new Emitter();
+        }
+        // sets initialHit
+        // sets coordAdjust
+        HitDragging.prototype.processFirstCoord = function (ev) {
+            var origPoint = { left: ev.pageX, top: ev.pageY };
+            var adjustedPoint = origPoint;
+            var subjectEl = ev.subjectEl;
+            var subjectRect;
+            if (subjectEl !== document) {
+                subjectRect = computeRect(subjectEl);
+                adjustedPoint = constrainPoint(adjustedPoint, subjectRect);
+            }
+            var initialHit = this.initialHit = this.queryHitForOffset(adjustedPoint.left, adjustedPoint.top);
+            if (initialHit) {
+                if (this.useSubjectCenter && subjectRect) {
+                    var slicedSubjectRect = intersectRects(subjectRect, initialHit.rect);
+                    if (slicedSubjectRect) {
+                        adjustedPoint = getRectCenter(slicedSubjectRect);
+                    }
+                }
+                this.coordAdjust = diffPoints(adjustedPoint, origPoint);
+            }
+            else {
+                this.coordAdjust = { left: 0, top: 0 };
+            }
+        };
+        HitDragging.prototype.handleMove = function (ev, forceHandle) {
+            var hit = this.queryHitForOffset(ev.pageX + this.coordAdjust.left, ev.pageY + this.coordAdjust.top);
+            if (forceHandle || !isHitsEqual(this.movingHit, hit)) {
+                this.movingHit = hit;
+                this.emitter.trigger('hitupdate', hit, false, ev);
+            }
+        };
+        HitDragging.prototype.prepareHits = function () {
+            this.offsetTrackers = mapHash(this.droppableStore, function (interactionSettings) {
+                interactionSettings.component.prepareHits();
+                return new OffsetTracker(interactionSettings.el);
+            });
+        };
+        HitDragging.prototype.releaseHits = function () {
+            var offsetTrackers = this.offsetTrackers;
+            for (var id in offsetTrackers) {
+                offsetTrackers[id].destroy();
+            }
+            this.offsetTrackers = {};
+        };
+        HitDragging.prototype.queryHitForOffset = function (offsetLeft, offsetTop) {
+            var _a = this, droppableStore = _a.droppableStore, offsetTrackers = _a.offsetTrackers;
+            var bestHit = null;
+            for (var id in droppableStore) {
+                var component = droppableStore[id].component;
+                var offsetTracker = offsetTrackers[id];
+                if (offsetTracker && // wasn't destroyed mid-drag
+                    offsetTracker.isWithinClipping(offsetLeft, offsetTop)) {
+                    var originLeft = offsetTracker.computeLeft();
+                    var originTop = offsetTracker.computeTop();
+                    var positionLeft = offsetLeft - originLeft;
+                    var positionTop = offsetTop - originTop;
+                    var origRect = offsetTracker.origRect;
+                    var width = origRect.right - origRect.left;
+                    var height = origRect.bottom - origRect.top;
+                    if (
+                    // must be within the element's bounds
+                    positionLeft >= 0 && positionLeft < width &&
+                        positionTop >= 0 && positionTop < height) {
+                        var hit = component.queryHit(positionLeft, positionTop, width, height);
+                        var dateProfile = component.context.getCurrentData().dateProfile;
+                        if (hit &&
+                            (
+                            // make sure the hit is within activeRange, meaning it's not a deal cell
+                            rangeContainsRange(dateProfile.activeRange, hit.dateSpan.range)) &&
+                            (!bestHit || hit.layer > bestHit.layer)) {
+                            // TODO: better way to re-orient rectangle
+                            hit.rect.left += originLeft;
+                            hit.rect.right += originLeft;
+                            hit.rect.top += originTop;
+                            hit.rect.bottom += originTop;
+                            bestHit = hit;
+                        }
+                    }
+                }
+            }
+            return bestHit;
+        };
+        return HitDragging;
+    }());
+    function isHitsEqual(hit0, hit1) {
+        if (!hit0 && !hit1) {
+            return true;
+        }
+        if (Boolean(hit0) !== Boolean(hit1)) {
+            return false;
+        }
+        return isDateSpansEqual(hit0.dateSpan, hit1.dateSpan);
+    }
+
+    function buildDatePointApiWithContext(dateSpan, context) {
+        var props = {};
+        for (var _i = 0, _a = context.pluginHooks.datePointTransforms; _i < _a.length; _i++) {
+            var transform = _a[_i];
+            __assign(props, transform(dateSpan, context));
+        }
+        __assign(props, buildDatePointApi(dateSpan, context.dateEnv));
+        return props;
+    }
+    function buildDatePointApi(span, dateEnv) {
+        return {
+            date: dateEnv.toDate(span.range.start),
+            dateStr: dateEnv.formatIso(span.range.start, { omitTime: span.allDay }),
+            allDay: span.allDay,
+        };
+    }
+
+    /*
+    Monitors when the user clicks on a specific date/time of a component.
+    A pointerdown+pointerup on the same "hit" constitutes a click.
+    */
+    var DateClicking = /** @class */ (function (_super) {
+        __extends(DateClicking, _super);
+        function DateClicking(settings) {
+            var _this = _super.call(this, settings) || this;
+            _this.handlePointerDown = function (pev) {
+                var dragging = _this.dragging;
+                var downEl = pev.origEvent.target;
+                // do this in pointerdown (not dragend) because DOM might be mutated by the time dragend is fired
+                dragging.setIgnoreMove(!_this.component.isValidDateDownEl(downEl));
+            };
+            // won't even fire if moving was ignored
+            _this.handleDragEnd = function (ev) {
+                var component = _this.component;
+                var pointer = _this.dragging.pointer;
+                if (!pointer.wasTouchScroll) {
+                    var _a = _this.hitDragging, initialHit = _a.initialHit, finalHit = _a.finalHit;
+                    if (initialHit && finalHit && isHitsEqual(initialHit, finalHit)) {
+                        var context = component.context;
+                        var arg = __assign(__assign({}, buildDatePointApiWithContext(initialHit.dateSpan, context)), { dayEl: initialHit.dayEl, jsEvent: ev.origEvent, view: context.viewApi || context.calendarApi.view });
+                        context.emitter.trigger('dateClick', arg);
+                    }
+                }
+            };
+            // we DO want to watch pointer moves because otherwise finalHit won't get populated
+            _this.dragging = new FeaturefulElementDragging(settings.el);
+            _this.dragging.autoScroller.isEnabled = false;
+            var hitDragging = _this.hitDragging = new HitDragging(_this.dragging, interactionSettingsToStore(settings));
+            hitDragging.emitter.on('pointerdown', _this.handlePointerDown);
+            hitDragging.emitter.on('dragend', _this.handleDragEnd);
+            return _this;
+        }
+        DateClicking.prototype.destroy = function () {
+            this.dragging.destroy();
+        };
+        return DateClicking;
+    }(Interaction));
+
+    /*
+    Tracks when the user selects a portion of time of a component,
+    constituted by a drag over date cells, with a possible delay at the beginning of the drag.
+    */
+    var DateSelecting = /** @class */ (function (_super) {
+        __extends(DateSelecting, _super);
+        function DateSelecting(settings) {
+            var _this = _super.call(this, settings) || this;
+            _this.dragSelection = null;
+            _this.handlePointerDown = function (ev) {
+                var _a = _this, component = _a.component, dragging = _a.dragging;
+                var options = component.context.options;
+                var canSelect = options.selectable &&
+                    component.isValidDateDownEl(ev.origEvent.target);
+                // don't bother to watch expensive moves if component won't do selection
+                dragging.setIgnoreMove(!canSelect);
+                // if touch, require user to hold down
+                dragging.delay = ev.isTouch ? getComponentTouchDelay(component) : null;
+            };
+            _this.handleDragStart = function (ev) {
+                _this.component.context.calendarApi.unselect(ev); // unselect previous selections
+            };
+            _this.handleHitUpdate = function (hit, isFinal) {
+                var context = _this.component.context;
+                var dragSelection = null;
+                var isInvalid = false;
+                if (hit) {
+                    dragSelection = joinHitsIntoSelection(_this.hitDragging.initialHit, hit, context.pluginHooks.dateSelectionTransformers);
+                    if (!dragSelection || !_this.component.isDateSelectionValid(dragSelection)) {
+                        isInvalid = true;
+                        dragSelection = null;
+                    }
+                }
+                if (dragSelection) {
+                    context.dispatch({ type: 'SELECT_DATES', selection: dragSelection });
+                }
+                else if (!isFinal) { // only unselect if moved away while dragging
+                    context.dispatch({ type: 'UNSELECT_DATES' });
+                }
+                if (!isInvalid) {
+                    enableCursor();
+                }
+                else {
+                    disableCursor();
+                }
+                if (!isFinal) {
+                    _this.dragSelection = dragSelection; // only clear if moved away from all hits while dragging
+                }
+            };
+            _this.handlePointerUp = function (pev) {
+                if (_this.dragSelection) {
+                    // selection is already rendered, so just need to report selection
+                    triggerDateSelect(_this.dragSelection, pev, _this.component.context);
+                    _this.dragSelection = null;
+                }
+            };
+            var component = settings.component;
+            var options = component.context.options;
+            var dragging = _this.dragging = new FeaturefulElementDragging(settings.el);
+            dragging.touchScrollAllowed = false;
+            dragging.minDistance = options.selectMinDistance || 0;
+            dragging.autoScroller.isEnabled = options.dragScroll;
+            var hitDragging = _this.hitDragging = new HitDragging(_this.dragging, interactionSettingsToStore(settings));
+            hitDragging.emitter.on('pointerdown', _this.handlePointerDown);
+            hitDragging.emitter.on('dragstart', _this.handleDragStart);
+            hitDragging.emitter.on('hitupdate', _this.handleHitUpdate);
+            hitDragging.emitter.on('pointerup', _this.handlePointerUp);
+            return _this;
+        }
+        DateSelecting.prototype.destroy = function () {
+            this.dragging.destroy();
+        };
+        return DateSelecting;
+    }(Interaction));
+    function getComponentTouchDelay(component) {
+        var options = component.context.options;
+        var delay = options.selectLongPressDelay;
+        if (delay == null) {
+            delay = options.longPressDelay;
+        }
+        return delay;
+    }
+    function joinHitsIntoSelection(hit0, hit1, dateSelectionTransformers) {
+        var dateSpan0 = hit0.dateSpan;
+        var dateSpan1 = hit1.dateSpan;
+        var ms = [
+            dateSpan0.range.start,
+            dateSpan0.range.end,
+            dateSpan1.range.start,
+            dateSpan1.range.end,
+        ];
+        ms.sort(compareNumbers);
+        var props = {};
+        for (var _i = 0, dateSelectionTransformers_1 = dateSelectionTransformers; _i < dateSelectionTransformers_1.length; _i++) {
+            var transformer = dateSelectionTransformers_1[_i];
+            var res = transformer(hit0, hit1);
+            if (res === false) {
+                return null;
+            }
+            if (res) {
+                __assign(props, res);
+            }
+        }
+        props.range = { start: ms[0], end: ms[3] };
+        props.allDay = dateSpan0.allDay;
+        return props;
+    }
+
+    var EventDragging = /** @class */ (function (_super) {
+        __extends(EventDragging, _super);
+        function EventDragging(settings) {
+            var _this = _super.call(this, settings) || this;
+            // internal state
+            _this.subjectEl = null;
+            _this.subjectSeg = null; // the seg being selected/dragged
+            _this.isDragging = false;
+            _this.eventRange = null;
+            _this.relevantEvents = null; // the events being dragged
+            _this.receivingContext = null;
+            _this.validMutation = null;
+            _this.mutatedRelevantEvents = null;
+            _this.handlePointerDown = function (ev) {
+                var origTarget = ev.origEvent.target;
+                var _a = _this, component = _a.component, dragging = _a.dragging;
+                var mirror = dragging.mirror;
+                var options = component.context.options;
+                var initialContext = component.context;
+                _this.subjectEl = ev.subjectEl;
+                var subjectSeg = _this.subjectSeg = getElSeg(ev.subjectEl);
+                var eventRange = _this.eventRange = subjectSeg.eventRange;
+                var eventInstanceId = eventRange.instance.instanceId;
+                _this.relevantEvents = getRelevantEvents(initialContext.getCurrentData().eventStore, eventInstanceId);
+                dragging.minDistance = ev.isTouch ? 0 : options.eventDragMinDistance;
+                dragging.delay =
+                    // only do a touch delay if touch and this event hasn't been selected yet
+                    (ev.isTouch && eventInstanceId !== component.props.eventSelection) ?
+                        getComponentTouchDelay$1(component) :
+                        null;
+                if (options.fixedMirrorParent) {
+                    mirror.parentNode = options.fixedMirrorParent;
+                }
+                else {
+                    mirror.parentNode = elementClosest(origTarget, '.fc');
+                }
+                mirror.revertDuration = options.dragRevertDuration;
+                var isValid = component.isValidSegDownEl(origTarget) &&
+                    !elementClosest(origTarget, '.fc-event-resizer'); // NOT on a resizer
+                dragging.setIgnoreMove(!isValid);
+                // disable dragging for elements that are resizable (ie, selectable)
+                // but are not draggable
+                _this.isDragging = isValid &&
+                    ev.subjectEl.classList.contains('fc-event-draggable');
+            };
+            _this.handleDragStart = function (ev) {
+                var initialContext = _this.component.context;
+                var eventRange = _this.eventRange;
+                var eventInstanceId = eventRange.instance.instanceId;
+                if (ev.isTouch) {
+                    // need to select a different event?
+                    if (eventInstanceId !== _this.component.props.eventSelection) {
+                        initialContext.dispatch({ type: 'SELECT_EVENT', eventInstanceId: eventInstanceId });
+                    }
+                }
+                else {
+                    // if now using mouse, but was previous touch interaction, clear selected event
+                    initialContext.dispatch({ type: 'UNSELECT_EVENT' });
+                }
+                if (_this.isDragging) {
+                    initialContext.calendarApi.unselect(ev); // unselect *date* selection
+                    initialContext.emitter.trigger('eventDragStart', {
+                        el: _this.subjectEl,
+                        event: new EventApi(initialContext, eventRange.def, eventRange.instance),
+                        jsEvent: ev.origEvent,
+                        view: initialContext.viewApi,
+                    });
+                }
+            };
+            _this.handleHitUpdate = function (hit, isFinal) {
+                if (!_this.isDragging) {
+                    return;
+                }
+                var relevantEvents = _this.relevantEvents;
+                var initialHit = _this.hitDragging.initialHit;
+                var initialContext = _this.component.context;
+                // states based on new hit
+                var receivingContext = null;
+                var mutation = null;
+                var mutatedRelevantEvents = null;
+                var isInvalid = false;
+                var interaction = {
+                    affectedEvents: relevantEvents,
+                    mutatedEvents: createEmptyEventStore(),
+                    isEvent: true,
+                };
+                if (hit) {
+                    var receivingComponent = hit.component;
+                    receivingContext = receivingComponent.context;
+                    var receivingOptions = receivingContext.options;
+                    if (initialContext === receivingContext ||
+                        (receivingOptions.editable && receivingOptions.droppable)) {
+                        mutation = computeEventMutation(initialHit, hit, receivingContext.getCurrentData().pluginHooks.eventDragMutationMassagers);
+                        if (mutation) {
+                            mutatedRelevantEvents = applyMutationToEventStore(relevantEvents, receivingContext.getCurrentData().eventUiBases, mutation, receivingContext);
+                            interaction.mutatedEvents = mutatedRelevantEvents;
+                            if (!receivingComponent.isInteractionValid(interaction)) {
+                                isInvalid = true;
+                                mutation = null;
+                                mutatedRelevantEvents = null;
+                                interaction.mutatedEvents = createEmptyEventStore();
+                            }
+                        }
+                    }
+                    else {
+                        receivingContext = null;
+                    }
+                }
+                _this.displayDrag(receivingContext, interaction);
+                if (!isInvalid) {
+                    enableCursor();
+                }
+                else {
+                    disableCursor();
+                }
+                if (!isFinal) {
+                    if (initialContext === receivingContext && // TODO: write test for this
+                        isHitsEqual(initialHit, hit)) {
+                        mutation = null;
+                    }
+                    _this.dragging.setMirrorNeedsRevert(!mutation);
+                    // render the mirror if no already-rendered mirror
+                    // TODO: wish we could somehow wait for dispatch to guarantee render
+                    _this.dragging.setMirrorIsVisible(!hit || !document.querySelector('.fc-event-mirror'));
+                    // assign states based on new hit
+                    _this.receivingContext = receivingContext;
+                    _this.validMutation = mutation;
+                    _this.mutatedRelevantEvents = mutatedRelevantEvents;
+                }
+            };
+            _this.handlePointerUp = function () {
+                if (!_this.isDragging) {
+                    _this.cleanup(); // because handleDragEnd won't fire
+                }
+            };
+            _this.handleDragEnd = function (ev) {
+                if (_this.isDragging) {
+                    var initialContext_1 = _this.component.context;
+                    var initialView = initialContext_1.viewApi;
+                    var _a = _this, receivingContext_1 = _a.receivingContext, validMutation = _a.validMutation;
+                    var eventDef = _this.eventRange.def;
+                    var eventInstance = _this.eventRange.instance;
+                    var eventApi = new EventApi(initialContext_1, eventDef, eventInstance);
+                    var relevantEvents_1 = _this.relevantEvents;
+                    var mutatedRelevantEvents_1 = _this.mutatedRelevantEvents;
+                    var finalHit = _this.hitDragging.finalHit;
+                    _this.clearDrag(); // must happen after revert animation
+                    initialContext_1.emitter.trigger('eventDragStop', {
+                        el: _this.subjectEl,
+                        event: eventApi,
+                        jsEvent: ev.origEvent,
+                        view: initialView,
+                    });
+                    if (validMutation) {
+                        // dropped within same calendar
+                        if (receivingContext_1 === initialContext_1) {
+                            var updatedEventApi = new EventApi(initialContext_1, mutatedRelevantEvents_1.defs[eventDef.defId], eventInstance ? mutatedRelevantEvents_1.instances[eventInstance.instanceId] : null);
+                            initialContext_1.dispatch({
+                                type: 'MERGE_EVENTS',
+                                eventStore: mutatedRelevantEvents_1,
+                            });
+                            var eventChangeArg = {
+                                oldEvent: eventApi,
+                                event: updatedEventApi,
+                                relatedEvents: buildEventApis(mutatedRelevantEvents_1, initialContext_1, eventInstance),
+                                revert: function () {
+                                    initialContext_1.dispatch({
+                                        type: 'MERGE_EVENTS',
+                                        eventStore: relevantEvents_1,
+                                    });
+                                },
+                            };
+                            var transformed = {};
+                            for (var _i = 0, _b = initialContext_1.getCurrentData().pluginHooks.eventDropTransformers; _i < _b.length; _i++) {
+                                var transformer = _b[_i];
+                                __assign(transformed, transformer(validMutation, initialContext_1));
+                            }
+                            initialContext_1.emitter.trigger('eventDrop', __assign(__assign(__assign({}, eventChangeArg), transformed), { el: ev.subjectEl, delta: validMutation.datesDelta, jsEvent: ev.origEvent, view: initialView }));
+                            initialContext_1.emitter.trigger('eventChange', eventChangeArg);
+                            // dropped in different calendar
+                        }
+                        else if (receivingContext_1) {
+                            var eventRemoveArg = {
+                                event: eventApi,
+                                relatedEvents: buildEventApis(relevantEvents_1, initialContext_1, eventInstance),
+                                revert: function () {
+                                    initialContext_1.dispatch({
+                                        type: 'MERGE_EVENTS',
+                                        eventStore: relevantEvents_1,
+                                    });
+                                },
+                            };
+                            initialContext_1.emitter.trigger('eventLeave', __assign(__assign({}, eventRemoveArg), { draggedEl: ev.subjectEl, view: initialView }));
+                            initialContext_1.dispatch({
+                                type: 'REMOVE_EVENTS',
+                                eventStore: relevantEvents_1,
+                            });
+                            initialContext_1.emitter.trigger('eventRemove', eventRemoveArg);
+                            var addedEventDef = mutatedRelevantEvents_1.defs[eventDef.defId];
+                            var addedEventInstance = mutatedRelevantEvents_1.instances[eventInstance.instanceId];
+                            var addedEventApi = new EventApi(receivingContext_1, addedEventDef, addedEventInstance);
+                            receivingContext_1.dispatch({
+                                type: 'MERGE_EVENTS',
+                                eventStore: mutatedRelevantEvents_1,
+                            });
+                            var eventAddArg = {
+                                event: addedEventApi,
+                                relatedEvents: buildEventApis(mutatedRelevantEvents_1, receivingContext_1, addedEventInstance),
+                                revert: function () {
+                                    receivingContext_1.dispatch({
+                                        type: 'REMOVE_EVENTS',
+                                        eventStore: mutatedRelevantEvents_1,
+                                    });
+                                },
+                            };
+                            receivingContext_1.emitter.trigger('eventAdd', eventAddArg);
+                            if (ev.isTouch) {
+                                receivingContext_1.dispatch({
+                                    type: 'SELECT_EVENT',
+                                    eventInstanceId: eventInstance.instanceId,
+                                });
+                            }
+                            receivingContext_1.emitter.trigger('drop', __assign(__assign({}, buildDatePointApiWithContext(finalHit.dateSpan, receivingContext_1)), { draggedEl: ev.subjectEl, jsEvent: ev.origEvent, view: finalHit.component.context.viewApi }));
+                            receivingContext_1.emitter.trigger('eventReceive', __assign(__assign({}, eventAddArg), { draggedEl: ev.subjectEl, view: finalHit.component.context.viewApi }));
+                        }
+                    }
+                    else {
+                        initialContext_1.emitter.trigger('_noEventDrop');
+                    }
+                }
+                _this.cleanup();
+            };
+            var component = _this.component;
+            var options = component.context.options;
+            var dragging = _this.dragging = new FeaturefulElementDragging(settings.el);
+            dragging.pointer.selector = EventDragging.SELECTOR;
+            dragging.touchScrollAllowed = false;
+            dragging.autoScroller.isEnabled = options.dragScroll;
+            var hitDragging = _this.hitDragging = new HitDragging(_this.dragging, interactionSettingsStore);
+            hitDragging.useSubjectCenter = settings.useEventCenter;
+            hitDragging.emitter.on('pointerdown', _this.handlePointerDown);
+            hitDragging.emitter.on('dragstart', _this.handleDragStart);
+            hitDragging.emitter.on('hitupdate', _this.handleHitUpdate);
+            hitDragging.emitter.on('pointerup', _this.handlePointerUp);
+            hitDragging.emitter.on('dragend', _this.handleDragEnd);
+            return _this;
+        }
+        EventDragging.prototype.destroy = function () {
+            this.dragging.destroy();
+        };
+        // render a drag state on the next receivingCalendar
+        EventDragging.prototype.displayDrag = function (nextContext, state) {
+            var initialContext = this.component.context;
+            var prevContext = this.receivingContext;
+            // does the previous calendar need to be cleared?
+            if (prevContext && prevContext !== nextContext) {
+                // does the initial calendar need to be cleared?
+                // if so, don't clear all the way. we still need to to hide the affectedEvents
+                if (prevContext === initialContext) {
+                    prevContext.dispatch({
+                        type: 'SET_EVENT_DRAG',
+                        state: {
+                            affectedEvents: state.affectedEvents,
+                            mutatedEvents: createEmptyEventStore(),
+                            isEvent: true,
+                        },
+                    });
+                    // completely clear the old calendar if it wasn't the initial
+                }
+                else {
+                    prevContext.dispatch({ type: 'UNSET_EVENT_DRAG' });
+                }
+            }
+            if (nextContext) {
+                nextContext.dispatch({ type: 'SET_EVENT_DRAG', state: state });
+            }
+        };
+        EventDragging.prototype.clearDrag = function () {
+            var initialCalendar = this.component.context;
+            var receivingContext = this.receivingContext;
+            if (receivingContext) {
+                receivingContext.dispatch({ type: 'UNSET_EVENT_DRAG' });
+            }
+            // the initial calendar might have an dummy drag state from displayDrag
+            if (initialCalendar !== receivingContext) {
+                initialCalendar.dispatch({ type: 'UNSET_EVENT_DRAG' });
+            }
+        };
+        EventDragging.prototype.cleanup = function () {
+            this.subjectSeg = null;
+            this.isDragging = false;
+            this.eventRange = null;
+            this.relevantEvents = null;
+            this.receivingContext = null;
+            this.validMutation = null;
+            this.mutatedRelevantEvents = null;
+        };
+        // TODO: test this in IE11
+        // QUESTION: why do we need it on the resizable???
+        EventDragging.SELECTOR = '.fc-event-draggable, .fc-event-resizable';
+        return EventDragging;
+    }(Interaction));
+    function computeEventMutation(hit0, hit1, massagers) {
+        var dateSpan0 = hit0.dateSpan;
+        var dateSpan1 = hit1.dateSpan;
+        var date0 = dateSpan0.range.start;
+        var date1 = dateSpan1.range.start;
+        var standardProps = {};
+        if (dateSpan0.allDay !== dateSpan1.allDay) {
+            standardProps.allDay = dateSpan1.allDay;
+            standardProps.hasEnd = hit1.component.context.options.allDayMaintainDuration;
+            if (dateSpan1.allDay) {
+                // means date1 is already start-of-day,
+                // but date0 needs to be converted
+                date0 = startOfDay(date0);
+            }
+        }
+        var delta = diffDates(date0, date1, hit0.component.context.dateEnv, hit0.component === hit1.component ?
+            hit0.component.largeUnit :
+            null);
+        if (delta.milliseconds) { // has hours/minutes/seconds
+            standardProps.allDay = false;
+        }
+        var mutation = {
+            datesDelta: delta,
+            standardProps: standardProps,
+        };
+        for (var _i = 0, massagers_1 = massagers; _i < massagers_1.length; _i++) {
+            var massager = massagers_1[_i];
+            massager(mutation, hit0, hit1);
+        }
+        return mutation;
+    }
+    function getComponentTouchDelay$1(component) {
+        var options = component.context.options;
+        var delay = options.eventLongPressDelay;
+        if (delay == null) {
+            delay = options.longPressDelay;
+        }
+        return delay;
+    }
+
+    var EventResizing = /** @class */ (function (_super) {
+        __extends(EventResizing, _super);
+        function EventResizing(settings) {
+            var _this = _super.call(this, settings) || this;
+            // internal state
+            _this.draggingSegEl = null;
+            _this.draggingSeg = null; // TODO: rename to resizingSeg? subjectSeg?
+            _this.eventRange = null;
+            _this.relevantEvents = null;
+            _this.validMutation = null;
+            _this.mutatedRelevantEvents = null;
+            _this.handlePointerDown = function (ev) {
+                var component = _this.component;
+                var segEl = _this.querySegEl(ev);
+                var seg = getElSeg(segEl);
+                var eventRange = _this.eventRange = seg.eventRange;
+                _this.dragging.minDistance = component.context.options.eventDragMinDistance;
+                // if touch, need to be working with a selected event
+                _this.dragging.setIgnoreMove(!_this.component.isValidSegDownEl(ev.origEvent.target) ||
+                    (ev.isTouch && _this.component.props.eventSelection !== eventRange.instance.instanceId));
+            };
+            _this.handleDragStart = function (ev) {
+                var context = _this.component.context;
+                var eventRange = _this.eventRange;
+                _this.relevantEvents = getRelevantEvents(context.getCurrentData().eventStore, _this.eventRange.instance.instanceId);
+                var segEl = _this.querySegEl(ev);
+                _this.draggingSegEl = segEl;
+                _this.draggingSeg = getElSeg(segEl);
+                context.calendarApi.unselect();
+                context.emitter.trigger('eventResizeStart', {
+                    el: segEl,
+                    event: new EventApi(context, eventRange.def, eventRange.instance),
+                    jsEvent: ev.origEvent,
+                    view: context.viewApi,
+                });
+            };
+            _this.handleHitUpdate = function (hit, isFinal, ev) {
+                var context = _this.component.context;
+                var relevantEvents = _this.relevantEvents;
+                var initialHit = _this.hitDragging.initialHit;
+                var eventInstance = _this.eventRange.instance;
+                var mutation = null;
+                var mutatedRelevantEvents = null;
+                var isInvalid = false;
+                var interaction = {
+                    affectedEvents: relevantEvents,
+                    mutatedEvents: createEmptyEventStore(),
+                    isEvent: true,
+                };
+                if (hit) {
+                    mutation = computeMutation(initialHit, hit, ev.subjectEl.classList.contains('fc-event-resizer-start'), eventInstance.range, context.pluginHooks.eventResizeJoinTransforms);
+                }
+                if (mutation) {
+                    mutatedRelevantEvents = applyMutationToEventStore(relevantEvents, context.getCurrentData().eventUiBases, mutation, context);
+                    interaction.mutatedEvents = mutatedRelevantEvents;
+                    if (!_this.component.isInteractionValid(interaction)) {
+                        isInvalid = true;
+                        mutation = null;
+                        mutatedRelevantEvents = null;
+                        interaction.mutatedEvents = null;
+                    }
+                }
+                if (mutatedRelevantEvents) {
+                    context.dispatch({
+                        type: 'SET_EVENT_RESIZE',
+                        state: interaction,
+                    });
+                }
+                else {
+                    context.dispatch({ type: 'UNSET_EVENT_RESIZE' });
+                }
+                if (!isInvalid) {
+                    enableCursor();
+                }
+                else {
+                    disableCursor();
+                }
+                if (!isFinal) {
+                    if (mutation && isHitsEqual(initialHit, hit)) {
+                        mutation = null;
+                    }
+                    _this.validMutation = mutation;
+                    _this.mutatedRelevantEvents = mutatedRelevantEvents;
+                }
+            };
+            _this.handleDragEnd = function (ev) {
+                var context = _this.component.context;
+                var eventDef = _this.eventRange.def;
+                var eventInstance = _this.eventRange.instance;
+                var eventApi = new EventApi(context, eventDef, eventInstance);
+                var relevantEvents = _this.relevantEvents;
+                var mutatedRelevantEvents = _this.mutatedRelevantEvents;
+                context.emitter.trigger('eventResizeStop', {
+                    el: _this.draggingSegEl,
+                    event: eventApi,
+                    jsEvent: ev.origEvent,
+                    view: context.viewApi,
+                });
+                if (_this.validMutation) {
+                    var updatedEventApi = new EventApi(context, mutatedRelevantEvents.defs[eventDef.defId], eventInstance ? mutatedRelevantEvents.instances[eventInstance.instanceId] : null);
+                    context.dispatch({
+                        type: 'MERGE_EVENTS',
+                        eventStore: mutatedRelevantEvents,
+                    });
+                    var eventChangeArg = {
+                        oldEvent: eventApi,
+                        event: updatedEventApi,
+                        relatedEvents: buildEventApis(mutatedRelevantEvents, context, eventInstance),
+                        revert: function () {
+                            context.dispatch({
+                                type: 'MERGE_EVENTS',
+                                eventStore: relevantEvents,
+                            });
+                        },
+                    };
+                    context.emitter.trigger('eventResize', __assign(__assign({}, eventChangeArg), { el: _this.draggingSegEl, startDelta: _this.validMutation.startDelta || createDuration(0), endDelta: _this.validMutation.endDelta || createDuration(0), jsEvent: ev.origEvent, view: context.viewApi }));
+                    context.emitter.trigger('eventChange', eventChangeArg);
+                }
+                else {
+                    context.emitter.trigger('_noEventResize');
+                }
+                // reset all internal state
+                _this.draggingSeg = null;
+                _this.relevantEvents = null;
+                _this.validMutation = null;
+                // okay to keep eventInstance around. useful to set it in handlePointerDown
+            };
+            var component = settings.component;
+            var dragging = _this.dragging = new FeaturefulElementDragging(settings.el);
+            dragging.pointer.selector = '.fc-event-resizer';
+            dragging.touchScrollAllowed = false;
+            dragging.autoScroller.isEnabled = component.context.options.dragScroll;
+            var hitDragging = _this.hitDragging = new HitDragging(_this.dragging, interactionSettingsToStore(settings));
+            hitDragging.emitter.on('pointerdown', _this.handlePointerDown);
+            hitDragging.emitter.on('dragstart', _this.handleDragStart);
+            hitDragging.emitter.on('hitupdate', _this.handleHitUpdate);
+            hitDragging.emitter.on('dragend', _this.handleDragEnd);
+            return _this;
+        }
+        EventResizing.prototype.destroy = function () {
+            this.dragging.destroy();
+        };
+        EventResizing.prototype.querySegEl = function (ev) {
+            return elementClosest(ev.subjectEl, '.fc-event');
+        };
+        return EventResizing;
+    }(Interaction));
+    function computeMutation(hit0, hit1, isFromStart, instanceRange, transforms) {
+        var dateEnv = hit0.component.context.dateEnv;
+        var date0 = hit0.dateSpan.range.start;
+        var date1 = hit1.dateSpan.range.start;
+        var delta = diffDates(date0, date1, dateEnv, hit0.component.largeUnit);
+        var props = {};
+        for (var _i = 0, transforms_1 = transforms; _i < transforms_1.length; _i++) {
+            var transform = transforms_1[_i];
+            var res = transform(hit0, hit1);
+            if (res === false) {
+                return null;
+            }
+            if (res) {
+                __assign(props, res);
+            }
+        }
+        if (isFromStart) {
+            if (dateEnv.add(instanceRange.start, delta) < instanceRange.end) {
+                props.startDelta = delta;
+                return props;
+            }
+        }
+        else if (dateEnv.add(instanceRange.end, delta) > instanceRange.start) {
+            props.endDelta = delta;
+            return props;
+        }
+        return null;
+    }
+
+    var UnselectAuto = /** @class */ (function () {
+        function UnselectAuto(context) {
+            var _this = this;
+            this.context = context;
+            this.isRecentPointerDateSelect = false; // wish we could use a selector to detect date selection, but uses hit system
+            this.matchesCancel = false;
+            this.matchesEvent = false;
+            this.onSelect = function (selectInfo) {
+                if (selectInfo.jsEvent) {
+                    _this.isRecentPointerDateSelect = true;
+                }
+            };
+            this.onDocumentPointerDown = function (pev) {
+                var unselectCancel = _this.context.options.unselectCancel;
+                var downEl = pev.origEvent.target;
+                _this.matchesCancel = !!elementClosest(downEl, unselectCancel);
+                _this.matchesEvent = !!elementClosest(downEl, EventDragging.SELECTOR); // interaction started on an event?
+            };
+            this.onDocumentPointerUp = function (pev) {
+                var context = _this.context;
+                var documentPointer = _this.documentPointer;
+                var calendarState = context.getCurrentData();
+                // touch-scrolling should never unfocus any type of selection
+                if (!documentPointer.wasTouchScroll) {
+                    if (calendarState.dateSelection && // an existing date selection?
+                        !_this.isRecentPointerDateSelect // a new pointer-initiated date selection since last onDocumentPointerUp?
+                    ) {
+                        var unselectAuto = context.options.unselectAuto;
+                        if (unselectAuto && (!unselectAuto || !_this.matchesCancel)) {
+                            context.calendarApi.unselect(pev);
+                        }
+                    }
+                    if (calendarState.eventSelection && // an existing event selected?
+                        !_this.matchesEvent // interaction DIDN'T start on an event
+                    ) {
+                        context.dispatch({ type: 'UNSELECT_EVENT' });
+                    }
+                }
+                _this.isRecentPointerDateSelect = false;
+            };
+            var documentPointer = this.documentPointer = new PointerDragging(document);
+            documentPointer.shouldIgnoreMove = true;
+            documentPointer.shouldWatchScroll = false;
+            documentPointer.emitter.on('pointerdown', this.onDocumentPointerDown);
+            documentPointer.emitter.on('pointerup', this.onDocumentPointerUp);
+            /*
+            TODO: better way to know about whether there was a selection with the pointer
+            */
+            context.emitter.on('select', this.onSelect);
+        }
+        UnselectAuto.prototype.destroy = function () {
+            this.context.emitter.off('select', this.onSelect);
+            this.documentPointer.destroy();
+        };
+        return UnselectAuto;
+    }());
+
+    var OPTION_REFINERS = {
+        fixedMirrorParent: identity,
+    };
+    var LISTENER_REFINERS = {
+        dateClick: identity,
+        eventDragStart: identity,
+        eventDragStop: identity,
+        eventDrop: identity,
+        eventResizeStart: identity,
+        eventResizeStop: identity,
+        eventResize: identity,
+        drop: identity,
+        eventReceive: identity,
+        eventLeave: identity,
+    };
+
+    /*
+    Given an already instantiated draggable object for one-or-more elements,
+    Interprets any dragging as an attempt to drag an events that lives outside
+    of a calendar onto a calendar.
+    */
+    var ExternalElementDragging = /** @class */ (function () {
+        function ExternalElementDragging(dragging, suppliedDragMeta) {
+            var _this = this;
+            this.receivingContext = null;
+            this.droppableEvent = null; // will exist for all drags, even if create:false
+            this.suppliedDragMeta = null;
+            this.dragMeta = null;
+            this.handleDragStart = function (ev) {
+                _this.dragMeta = _this.buildDragMeta(ev.subjectEl);
+            };
+            this.handleHitUpdate = function (hit, isFinal, ev) {
+                var dragging = _this.hitDragging.dragging;
+                var receivingContext = null;
+                var droppableEvent = null;
+                var isInvalid = false;
+                var interaction = {
+                    affectedEvents: createEmptyEventStore(),
+                    mutatedEvents: createEmptyEventStore(),
+                    isEvent: _this.dragMeta.create,
+                };
+                if (hit) {
+                    receivingContext = hit.component.context;
+                    if (_this.canDropElOnCalendar(ev.subjectEl, receivingContext)) {
+                        droppableEvent = computeEventForDateSpan(hit.dateSpan, _this.dragMeta, receivingContext);
+                        interaction.mutatedEvents = eventTupleToStore(droppableEvent);
+                        isInvalid = !isInteractionValid(interaction, receivingContext);
+                        if (isInvalid) {
+                            interaction.mutatedEvents = createEmptyEventStore();
+                            droppableEvent = null;
+                        }
+                    }
+                }
+                _this.displayDrag(receivingContext, interaction);
+                // show mirror if no already-rendered mirror element OR if we are shutting down the mirror (?)
+                // TODO: wish we could somehow wait for dispatch to guarantee render
+                dragging.setMirrorIsVisible(isFinal || !droppableEvent || !document.querySelector('.fc-event-mirror'));
+                if (!isInvalid) {
+                    enableCursor();
+                }
+                else {
+                    disableCursor();
+                }
+                if (!isFinal) {
+                    dragging.setMirrorNeedsRevert(!droppableEvent);
+                    _this.receivingContext = receivingContext;
+                    _this.droppableEvent = droppableEvent;
+                }
+            };
+            this.handleDragEnd = function (pev) {
+                var _a = _this, receivingContext = _a.receivingContext, droppableEvent = _a.droppableEvent;
+                _this.clearDrag();
+                if (receivingContext && droppableEvent) {
+                    var finalHit = _this.hitDragging.finalHit;
+                    var finalView = finalHit.component.context.viewApi;
+                    var dragMeta = _this.dragMeta;
+                    receivingContext.emitter.trigger('drop', __assign(__assign({}, buildDatePointApiWithContext(finalHit.dateSpan, receivingContext)), { draggedEl: pev.subjectEl, jsEvent: pev.origEvent, view: finalView }));
+                    if (dragMeta.create) {
+                        var addingEvents_1 = eventTupleToStore(droppableEvent);
+                        receivingContext.dispatch({
+                            type: 'MERGE_EVENTS',
+                            eventStore: addingEvents_1,
+                        });
+                        if (pev.isTouch) {
+                            receivingContext.dispatch({
+                                type: 'SELECT_EVENT',
+                                eventInstanceId: droppableEvent.instance.instanceId,
+                            });
+                        }
+                        // signal that an external event landed
+                        receivingContext.emitter.trigger('eventReceive', {
+                            event: new EventApi(receivingContext, droppableEvent.def, droppableEvent.instance),
+                            relatedEvents: [],
+                            revert: function () {
+                                receivingContext.dispatch({
+                                    type: 'REMOVE_EVENTS',
+                                    eventStore: addingEvents_1,
+                                });
+                            },
+                            draggedEl: pev.subjectEl,
+                            view: finalView,
+                        });
+                    }
+                }
+                _this.receivingContext = null;
+                _this.droppableEvent = null;
+            };
+            var hitDragging = this.hitDragging = new HitDragging(dragging, interactionSettingsStore);
+            hitDragging.requireInitial = false; // will start outside of a component
+            hitDragging.emitter.on('dragstart', this.handleDragStart);
+            hitDragging.emitter.on('hitupdate', this.handleHitUpdate);
+            hitDragging.emitter.on('dragend', this.handleDragEnd);
+            this.suppliedDragMeta = suppliedDragMeta;
+        }
+        ExternalElementDragging.prototype.buildDragMeta = function (subjectEl) {
+            if (typeof this.suppliedDragMeta === 'object') {
+                return parseDragMeta(this.suppliedDragMeta);
+            }
+            if (typeof this.suppliedDragMeta === 'function') {
+                return parseDragMeta(this.suppliedDragMeta(subjectEl));
+            }
+            return getDragMetaFromEl(subjectEl);
+        };
+        ExternalElementDragging.prototype.displayDrag = function (nextContext, state) {
+            var prevContext = this.receivingContext;
+            if (prevContext && prevContext !== nextContext) {
+                prevContext.dispatch({ type: 'UNSET_EVENT_DRAG' });
+            }
+            if (nextContext) {
+                nextContext.dispatch({ type: 'SET_EVENT_DRAG', state: state });
+            }
+        };
+        ExternalElementDragging.prototype.clearDrag = function () {
+            if (this.receivingContext) {
+                this.receivingContext.dispatch({ type: 'UNSET_EVENT_DRAG' });
+            }
+        };
+        ExternalElementDragging.prototype.canDropElOnCalendar = function (el, receivingContext) {
+            var dropAccept = receivingContext.options.dropAccept;
+            if (typeof dropAccept === 'function') {
+                return dropAccept.call(receivingContext.calendarApi, el);
+            }
+            if (typeof dropAccept === 'string' && dropAccept) {
+                return Boolean(elementMatches(el, dropAccept));
+            }
+            return true;
+        };
+        return ExternalElementDragging;
+    }());
+    // Utils for computing event store from the DragMeta
+    // ----------------------------------------------------------------------------------------------------
+    function computeEventForDateSpan(dateSpan, dragMeta, context) {
+        var defProps = __assign({}, dragMeta.leftoverProps);
+        for (var _i = 0, _a = context.pluginHooks.externalDefTransforms; _i < _a.length; _i++) {
+            var transform = _a[_i];
+            __assign(defProps, transform(dateSpan, dragMeta));
+        }
+        var _b = refineEventDef(defProps, context), refined = _b.refined, extra = _b.extra;
+        var def = parseEventDef(refined, extra, dragMeta.sourceId, dateSpan.allDay, context.options.forceEventDuration || Boolean(dragMeta.duration), // hasEnd
+        context);
+        var start = dateSpan.range.start;
+        // only rely on time info if drop zone is all-day,
+        // otherwise, we already know the time
+        if (dateSpan.allDay && dragMeta.startTime) {
+            start = context.dateEnv.add(start, dragMeta.startTime);
+        }
+        var end = dragMeta.duration ?
+            context.dateEnv.add(start, dragMeta.duration) :
+            getDefaultEventEnd(dateSpan.allDay, start, context);
+        var instance = createEventInstance(def.defId, { start: start, end: end });
+        return { def: def, instance: instance };
+    }
+    // Utils for extracting data from element
+    // ----------------------------------------------------------------------------------------------------
+    function getDragMetaFromEl(el) {
+        var str = getEmbeddedElData(el, 'event');
+        var obj = str ?
+            JSON.parse(str) :
+            { create: false }; // if no embedded data, assume no event creation
+        return parseDragMeta(obj);
+    }
+    config.dataAttrPrefix = '';
+    function getEmbeddedElData(el, name) {
+        var prefix = config.dataAttrPrefix;
+        var prefixedName = (prefix ? prefix + '-' : '') + name;
+        return el.getAttribute('data-' + prefixedName) || '';
+    }
+
+    /*
+    Makes an element (that is *external* to any calendar) draggable.
+    Can pass in data that determines how an event will be created when dropped onto a calendar.
+    Leverages FullCalendar's internal drag-n-drop functionality WITHOUT a third-party drag system.
+    */
+    var ExternalDraggable = /** @class */ (function () {
+        function ExternalDraggable(el, settings) {
+            var _this = this;
+            if (settings === void 0) { settings = {}; }
+            this.handlePointerDown = function (ev) {
+                var dragging = _this.dragging;
+                var _a = _this.settings, minDistance = _a.minDistance, longPressDelay = _a.longPressDelay;
+                dragging.minDistance =
+                    minDistance != null ?
+                        minDistance :
+                        (ev.isTouch ? 0 : BASE_OPTION_DEFAULTS.eventDragMinDistance);
+                dragging.delay =
+                    ev.isTouch ? // TODO: eventually read eventLongPressDelay instead vvv
+                        (longPressDelay != null ? longPressDelay : BASE_OPTION_DEFAULTS.longPressDelay) :
+                        0;
+            };
+            this.handleDragStart = function (ev) {
+                if (ev.isTouch &&
+                    _this.dragging.delay &&
+                    ev.subjectEl.classList.contains('fc-event')) {
+                    _this.dragging.mirror.getMirrorEl().classList.add('fc-event-selected');
+                }
+            };
+            this.settings = settings;
+            var dragging = this.dragging = new FeaturefulElementDragging(el);
+            dragging.touchScrollAllowed = false;
+            if (settings.itemSelector != null) {
+                dragging.pointer.selector = settings.itemSelector;
+            }
+            if (settings.appendTo != null) {
+                dragging.mirror.parentNode = settings.appendTo; // TODO: write tests
+            }
+            dragging.emitter.on('pointerdown', this.handlePointerDown);
+            dragging.emitter.on('dragstart', this.handleDragStart);
+            new ExternalElementDragging(dragging, settings.eventData); // eslint-disable-line no-new
+        }
+        ExternalDraggable.prototype.destroy = function () {
+            this.dragging.destroy();
+        };
+        return ExternalDraggable;
+    }());
+
+    /*
+    Detects when a *THIRD-PARTY* drag-n-drop system interacts with elements.
+    The third-party system is responsible for drawing the visuals effects of the drag.
+    This class simply monitors for pointer movements and fires events.
+    It also has the ability to hide the moving element (the "mirror") during the drag.
+    */
+    var InferredElementDragging = /** @class */ (function (_super) {
+        __extends(InferredElementDragging, _super);
+        function InferredElementDragging(containerEl) {
+            var _this = _super.call(this, containerEl) || this;
+            _this.shouldIgnoreMove = false;
+            _this.mirrorSelector = '';
+            _this.currentMirrorEl = null;
+            _this.handlePointerDown = function (ev) {
+                _this.emitter.trigger('pointerdown', ev);
+                if (!_this.shouldIgnoreMove) {
+                    // fire dragstart right away. does not support delay or min-distance
+                    _this.emitter.trigger('dragstart', ev);
+                }
+            };
+            _this.handlePointerMove = function (ev) {
+                if (!_this.shouldIgnoreMove) {
+                    _this.emitter.trigger('dragmove', ev);
+                }
+            };
+            _this.handlePointerUp = function (ev) {
+                _this.emitter.trigger('pointerup', ev);
+                if (!_this.shouldIgnoreMove) {
+                    // fire dragend right away. does not support a revert animation
+                    _this.emitter.trigger('dragend', ev);
+                }
+            };
+            var pointer = _this.pointer = new PointerDragging(containerEl);
+            pointer.emitter.on('pointerdown', _this.handlePointerDown);
+            pointer.emitter.on('pointermove', _this.handlePointerMove);
+            pointer.emitter.on('pointerup', _this.handlePointerUp);
+            return _this;
+        }
+        InferredElementDragging.prototype.destroy = function () {
+            this.pointer.destroy();
+        };
+        InferredElementDragging.prototype.setIgnoreMove = function (bool) {
+            this.shouldIgnoreMove = bool;
+        };
+        InferredElementDragging.prototype.setMirrorIsVisible = function (bool) {
+            if (bool) {
+                // restore a previously hidden element.
+                // use the reference in case the selector class has already been removed.
+                if (this.currentMirrorEl) {
+                    this.currentMirrorEl.style.visibility = '';
+                    this.currentMirrorEl = null;
+                }
+            }
+            else {
+                var mirrorEl = this.mirrorSelector ?
+                    document.querySelector(this.mirrorSelector) :
+                    null;
+                if (mirrorEl) {
+                    this.currentMirrorEl = mirrorEl;
+                    mirrorEl.style.visibility = 'hidden';
+                }
+            }
+        };
+        return InferredElementDragging;
+    }(ElementDragging));
+
+    /*
+    Bridges third-party drag-n-drop systems with FullCalendar.
+    Must be instantiated and destroyed by caller.
+    */
+    var ThirdPartyDraggable = /** @class */ (function () {
+        function ThirdPartyDraggable(containerOrSettings, settings) {
+            var containerEl = document;
+            if (
+            // wish we could just test instanceof EventTarget, but doesn't work in IE11
+            containerOrSettings === document ||
+                containerOrSettings instanceof Element) {
+                containerEl = containerOrSettings;
+                settings = settings || {};
+            }
+            else {
+                settings = (containerOrSettings || {});
+            }
+            var dragging = this.dragging = new InferredElementDragging(containerEl);
+            if (typeof settings.itemSelector === 'string') {
+                dragging.pointer.selector = settings.itemSelector;
+            }
+            else if (containerEl === document) {
+                dragging.pointer.selector = '[data-event]';
+            }
+            if (typeof settings.mirrorSelector === 'string') {
+                dragging.mirrorSelector = settings.mirrorSelector;
+            }
+            new ExternalElementDragging(dragging, settings.eventData); // eslint-disable-line no-new
+        }
+        ThirdPartyDraggable.prototype.destroy = function () {
+            this.dragging.destroy();
+        };
+        return ThirdPartyDraggable;
+    }());
+
+    var interactionPlugin = createPlugin({
+        componentInteractions: [DateClicking, DateSelecting, EventDragging, EventResizing],
+        calendarInteractions: [UnselectAuto],
+        elementDraggingImpl: FeaturefulElementDragging,
+        optionRefiners: OPTION_REFINERS,
+        listenerRefiners: LISTENER_REFINERS,
+    });
+
+    /* An abstract class for the daygrid views, as well as month view. Renders one or more rows of day cells.
+    ----------------------------------------------------------------------------------------------------------------------*/
+    // It is a manager for a Table subcomponent, which does most of the heavy lifting.
+    // It is responsible for managing width/height.
+    var TableView = /** @class */ (function (_super) {
+        __extends(TableView, _super);
+        function TableView() {
+            var _this = _super !== null && _super.apply(this, arguments) || this;
+            _this.headerElRef = createRef();
+            return _this;
+        }
+        TableView.prototype.renderSimpleLayout = function (headerRowContent, bodyContent) {
+            var _a = this, props = _a.props, context = _a.context;
+            var sections = [];
+            var stickyHeaderDates = getStickyHeaderDates(context.options);
+            if (headerRowContent) {
+                sections.push({
+                    type: 'header',
+                    key: 'header',
+                    isSticky: stickyHeaderDates,
+                    chunk: {
+                        elRef: this.headerElRef,
+                        tableClassName: 'fc-col-header',
+                        rowContent: headerRowContent,
+                    },
+                });
+            }
+            sections.push({
+                type: 'body',
+                key: 'body',
+                liquid: true,
+                chunk: { content: bodyContent },
+            });
+            return (createElement(ViewRoot, { viewSpec: context.viewSpec }, function (rootElRef, classNames) { return (createElement("div", { ref: rootElRef, className: ['fc-daygrid'].concat(classNames).join(' ') },
+                createElement(SimpleScrollGrid, { liquid: !props.isHeightAuto && !props.forPrint, cols: [] /* TODO: make optional? */, sections: sections }))); }));
+        };
+        TableView.prototype.renderHScrollLayout = function (headerRowContent, bodyContent, colCnt, dayMinWidth) {
+            var ScrollGrid = this.context.pluginHooks.scrollGridImpl;
+            if (!ScrollGrid) {
+                throw new Error('No ScrollGrid implementation');
+            }
+            var _a = this, props = _a.props, context = _a.context;
+            var stickyHeaderDates = !props.forPrint && getStickyHeaderDates(context.options);
+            var stickyFooterScrollbar = !props.forPrint && getStickyFooterScrollbar(context.options);
+            var sections = [];
+            if (headerRowContent) {
+                sections.push({
+                    type: 'header',
+                    key: 'header',
+                    isSticky: stickyHeaderDates,
+                    chunks: [{
+                            key: 'main',
+                            elRef: this.headerElRef,
+                            tableClassName: 'fc-col-header',
+                            rowContent: headerRowContent,
+                        }],
+                });
+            }
+            sections.push({
+                type: 'body',
+                key: 'body',
+                liquid: true,
+                chunks: [{
+                        key: 'main',
+                        content: bodyContent,
+                    }],
+            });
+            if (stickyFooterScrollbar) {
+                sections.push({
+                    type: 'footer',
+                    key: 'footer',
+                    isSticky: true,
+                    chunks: [{
+                            key: 'main',
+                            content: renderScrollShim,
+                        }],
+                });
+            }
+            return (createElement(ViewRoot, { viewSpec: context.viewSpec }, function (rootElRef, classNames) { return (createElement("div", { ref: rootElRef, className: ['fc-daygrid'].concat(classNames).join(' ') },
+                createElement(ScrollGrid, { liquid: !props.isHeightAuto && !props.forPrint, colGroups: [{ cols: [{ span: colCnt, minWidth: dayMinWidth }] }], sections: sections }))); }));
+        };
+        return TableView;
+    }(DateComponent));
+
+    function splitSegsByRow(segs, rowCnt) {
+        var byRow = [];
+        for (var i = 0; i < rowCnt; i += 1) {
+            byRow[i] = [];
+        }
+        for (var _i = 0, segs_1 = segs; _i < segs_1.length; _i++) {
+            var seg = segs_1[_i];
+            byRow[seg.row].push(seg);
+        }
+        return byRow;
+    }
+    function splitSegsByFirstCol(segs, colCnt) {
+        var byCol = [];
+        for (var i = 0; i < colCnt; i += 1) {
+            byCol[i] = [];
+        }
+        for (var _i = 0, segs_2 = segs; _i < segs_2.length; _i++) {
+            var seg = segs_2[_i];
+            byCol[seg.firstCol].push(seg);
+        }
+        return byCol;
+    }
+    function splitInteractionByRow(ui, rowCnt) {
+        var byRow = [];
+        if (!ui) {
+            for (var i = 0; i < rowCnt; i += 1) {
+                byRow[i] = null;
+            }
+        }
+        else {
+            for (var i = 0; i < rowCnt; i += 1) {
+                byRow[i] = {
+                    affectedInstances: ui.affectedInstances,
+                    isEvent: ui.isEvent,
+                    segs: [],
+                };
+            }
+            for (var _i = 0, _a = ui.segs; _i < _a.length; _i++) {
+                var seg = _a[_i];
+                byRow[seg.row].segs.push(seg);
+            }
+        }
+        return byRow;
+    }
+
+    var TableCellTop = /** @class */ (function (_super) {
+        __extends(TableCellTop, _super);
+        function TableCellTop() {
+            return _super !== null && _super.apply(this, arguments) || this;
+        }
+        TableCellTop.prototype.render = function () {
+            var props = this.props;
+            var navLinkAttrs = this.context.options.navLinks
+                ? { 'data-navlink': buildNavLinkData(props.date), tabIndex: 0 }
+                : {};
+            return (createElement(DayCellContent, { date: props.date, dateProfile: props.dateProfile, todayRange: props.todayRange, showDayNumber: props.showDayNumber, extraHookProps: props.extraHookProps, defaultContent: renderTopInner }, function (innerElRef, innerContent) { return ((innerContent || props.forceDayTop) && (createElement("div", { className: "fc-daygrid-day-top", ref: innerElRef },
+                createElement("a", __assign({ className: "fc-daygrid-day-number" }, navLinkAttrs), innerContent || createElement(Fragment, null, "\u00A0"))))); }));
+        };
+        return TableCellTop;
+    }(BaseComponent));
+    function renderTopInner(props) {
+        return props.dayNumberText;
+    }
+
+    var DEFAULT_WEEK_NUM_FORMAT = createFormatter({ week: 'narrow' });
+    var TableCell = /** @class */ (function (_super) {
+        __extends(TableCell, _super);
+        function TableCell() {
+            var _this = _super !== null && _super.apply(this, arguments) || this;
+            _this.handleRootEl = function (el) {
+                _this.rootEl = el;
+                setRef(_this.props.elRef, el);
+            };
+            _this.handleMoreLinkClick = function (ev) {
+                var props = _this.props;
+                if (props.onMoreClick) {
+                    var allSegs = props.segsByEachCol;
+                    var hiddenSegs = allSegs.filter(function (seg) { return props.segIsHidden[seg.eventRange.instance.instanceId]; });
+                    props.onMoreClick({
+                        date: props.date,
+                        allSegs: allSegs,
+                        hiddenSegs: hiddenSegs,
+                        moreCnt: props.moreCnt,
+                        dayEl: _this.rootEl,
+                        ev: ev,
+                    });
+                }
+            };
+            return _this;
+        }
+        TableCell.prototype.render = function () {
+            var _this = this;
+            var _a = this.context, options = _a.options, viewApi = _a.viewApi;
+            var props = this.props;
+            var date = props.date, dateProfile = props.dateProfile;
+            var hookProps = {
+                num: props.moreCnt,
+                text: props.buildMoreLinkText(props.moreCnt),
+                view: viewApi,
+            };
+            var navLinkAttrs = options.navLinks
+                ? { 'data-navlink': buildNavLinkData(date, 'week'), tabIndex: 0 }
+                : {};
+            return (createElement(DayCellRoot, { date: date, dateProfile: dateProfile, todayRange: props.todayRange, showDayNumber: props.showDayNumber, extraHookProps: props.extraHookProps, elRef: this.handleRootEl }, function (dayElRef, dayClassNames, rootDataAttrs, isDisabled) { return (createElement("td", __assign({ ref: dayElRef, className: ['fc-daygrid-day'].concat(dayClassNames, props.extraClassNames || []).join(' ') }, rootDataAttrs, props.extraDataAttrs),
+                createElement("div", { className: "fc-daygrid-day-frame fc-scrollgrid-sync-inner", ref: props.innerElRef /* different from hook system! RENAME */ },
+                    props.showWeekNumber && (createElement(WeekNumberRoot, { date: date, defaultFormat: DEFAULT_WEEK_NUM_FORMAT }, function (weekElRef, weekClassNames, innerElRef, innerContent) { return (createElement("a", __assign({ ref: weekElRef, className: ['fc-daygrid-week-number'].concat(weekClassNames).join(' ') }, navLinkAttrs), innerContent)); })),
+                    !isDisabled && (createElement(TableCellTop, { date: date, dateProfile: dateProfile, showDayNumber: props.showDayNumber, forceDayTop: props.forceDayTop, todayRange: props.todayRange, extraHookProps: props.extraHookProps })),
+                    createElement("div", { className: "fc-daygrid-day-events", ref: props.fgContentElRef, style: { paddingBottom: props.fgPaddingBottom } },
+                        props.fgContent,
+                        Boolean(props.moreCnt) && (createElement("div", { className: "fc-daygrid-day-bottom", style: { marginTop: props.moreMarginTop } },
+                            createElement(RenderHook, { hookProps: hookProps, classNames: options.moreLinkClassNames, content: options.moreLinkContent, defaultContent: renderMoreLinkInner, didMount: options.moreLinkDidMount, willUnmount: options.moreLinkWillUnmount }, function (rootElRef, classNames, innerElRef, innerContent) { return (createElement("a", { ref: rootElRef, className: ['fc-daygrid-more-link'].concat(classNames).join(' '), onClick: _this.handleMoreLinkClick }, innerContent)); })))),
+                    createElement("div", { className: "fc-daygrid-day-bg" }, props.bgContent)))); }));
+        };
+        return TableCell;
+    }(DateComponent));
+    TableCell.addPropsEquality({
+        onMoreClick: true,
+    });
+    function renderMoreLinkInner(props) {
+        return props.text;
+    }
+
+    var DEFAULT_TABLE_EVENT_TIME_FORMAT = createFormatter({
+        hour: 'numeric',
+        minute: '2-digit',
+        omitZeroMinute: true,
+        meridiem: 'narrow',
+    });
+    function hasListItemDisplay(seg) {
+        var display = seg.eventRange.ui.display;
+        return display === 'list-item' || (display === 'auto' &&
+            !seg.eventRange.def.allDay &&
+            seg.firstCol === seg.lastCol && // can't be multi-day
+            seg.isStart && // "
+            seg.isEnd // "
+        );
+    }
+
+    var TableListItemEvent = /** @class */ (function (_super) {
+        __extends(TableListItemEvent, _super);
+        function TableListItemEvent() {
+            return _super !== null && _super.apply(this, arguments) || this;
+        }
+        TableListItemEvent.prototype.render = function () {
+            var _a = this, props = _a.props, context = _a.context;
+            var timeFormat = context.options.eventTimeFormat || DEFAULT_TABLE_EVENT_TIME_FORMAT;
+            var timeText = buildSegTimeText(props.seg, timeFormat, context, true, props.defaultDisplayEventEnd);
+            return (createElement(EventRoot, { seg: props.seg, timeText: timeText, defaultContent: renderInnerContent$2, isDragging: props.isDragging, isResizing: false, isDateSelecting: false, isSelected: props.isSelected, isPast: props.isPast, isFuture: props.isFuture, isToday: props.isToday }, function (rootElRef, classNames, innerElRef, innerContent) { return ( // we don't use styles!
+            createElement("a", __assign({ className: ['fc-daygrid-event', 'fc-daygrid-dot-event'].concat(classNames).join(' '), ref: rootElRef }, getSegAnchorAttrs$1(props.seg)), innerContent)); }));
+        };
+        return TableListItemEvent;
+    }(BaseComponent));
+    function renderInnerContent$2(innerProps) {
+        return (createElement(Fragment, null,
+            createElement("div", { className: "fc-daygrid-event-dot", style: { borderColor: innerProps.borderColor || innerProps.backgroundColor } }),
+            innerProps.timeText && (createElement("div", { className: "fc-event-time" }, innerProps.timeText)),
+            createElement("div", { className: "fc-event-title" }, innerProps.event.title || createElement(Fragment, null, "\u00A0"))));
+    }
+    function getSegAnchorAttrs$1(seg) {
+        var url = seg.eventRange.def.url;
+        return url ? { href: url } : {};
+    }
+
+    var TableBlockEvent = /** @class */ (function (_super) {
+        __extends(TableBlockEvent, _super);
+        function TableBlockEvent() {
+            return _super !== null && _super.apply(this, arguments) || this;
+        }
+        TableBlockEvent.prototype.render = function () {
+            var props = this.props;
+            return (createElement(StandardEvent, __assign({}, props, { extraClassNames: ['fc-daygrid-event', 'fc-daygrid-block-event', 'fc-h-event'], defaultTimeFormat: DEFAULT_TABLE_EVENT_TIME_FORMAT, defaultDisplayEventEnd: props.defaultDisplayEventEnd, disableResizing: !props.seg.eventRange.def.allDay })));
+        };
+        return TableBlockEvent;
+    }(BaseComponent));
+
+    function computeFgSegPlacement(// for one row. TODO: print mode?
+    cellModels, segs, dayMaxEvents, dayMaxEventRows, eventHeights, maxContentHeight, colCnt, eventOrderSpecs) {
+        var colPlacements = []; // if event spans multiple cols, its present in each col
+        var moreCnts = []; // by-col
+        var segIsHidden = {};
+        var segTops = {}; // always populated for each seg
+        var segMarginTops = {}; // simetimes populated for each seg
+        var moreTops = {};
+        var paddingBottoms = {}; // for each cell's inner-wrapper div
+        for (var i = 0; i < colCnt; i += 1) {
+            colPlacements.push([]);
+            moreCnts.push(0);
+        }
+        segs = sortEventSegs(segs, eventOrderSpecs);
+        for (var _i = 0, segs_1 = segs; _i < segs_1.length; _i++) {
+            var seg = segs_1[_i];
+            var instanceId = seg.eventRange.instance.instanceId;
+            var eventHeight = eventHeights[instanceId + ':' + seg.firstCol];
+            placeSeg(seg, eventHeight || 0); // will keep colPlacements sorted by top
+        }
+        if (dayMaxEvents === true || dayMaxEventRows === true) {
+            limitByMaxHeight(moreCnts, segIsHidden, colPlacements, maxContentHeight); // populates moreCnts/segIsHidden
+        }
+        else if (typeof dayMaxEvents === 'number') {
+            limitByMaxEvents(moreCnts, segIsHidden, colPlacements, dayMaxEvents); // populates moreCnts/segIsHidden
+        }
+        else if (typeof dayMaxEventRows === 'number') {
+            limitByMaxRows(moreCnts, segIsHidden, colPlacements, dayMaxEventRows); // populates moreCnts/segIsHidden
+        }
+        // computes segTops/segMarginTops/moreTops/paddingBottoms
+        for (var col = 0; col < colCnt; col += 1) {
+            var placements = colPlacements[col];
+            var currentNonAbsBottom = 0;
+            var currentAbsHeight = 0;
+            for (var _a = 0, placements_1 = placements; _a < placements_1.length; _a++) {
+                var placement = placements_1[_a];
+                var seg = placement.seg;
+                if (!segIsHidden[seg.eventRange.instance.instanceId]) {
+                    segTops[seg.eventRange.instance.instanceId] = placement.top; // from top of container
+                    if (seg.firstCol === seg.lastCol && seg.isStart && seg.isEnd) { // TODO: simpler way? NOT DRY
+                        segMarginTops[seg.eventRange.instance.instanceId] =
+                            placement.top - currentNonAbsBottom; // from previous seg bottom
+                        currentAbsHeight = 0;
+                        currentNonAbsBottom = placement.bottom;
+                    }
+                    else { // multi-col event, abs positioned
+                        currentAbsHeight = placement.bottom - currentNonAbsBottom;
+                    }
+                }
+            }
+            if (currentAbsHeight) {
+                if (moreCnts[col]) {
+                    moreTops[col] = currentAbsHeight;
+                }
+                else {
+                    paddingBottoms[col] = currentAbsHeight;
+                }
+            }
+        }
+        function placeSeg(seg, segHeight) {
+            if (!tryPlaceSegAt(seg, segHeight, 0)) {
+                for (var col = seg.firstCol; col <= seg.lastCol; col += 1) {
+                    for (var _i = 0, _a = colPlacements[col]; _i < _a.length; _i++) { // will repeat multi-day segs!!!!!!! bad!!!!!!
+                        var placement = _a[_i];
+                        if (tryPlaceSegAt(seg, segHeight, placement.bottom)) {
+                            return;
+                        }
+                    }
+                }
+            }
+        }
+        function tryPlaceSegAt(seg, segHeight, top) {
+            if (canPlaceSegAt(seg, segHeight, top)) {
+                for (var col = seg.firstCol; col <= seg.lastCol; col += 1) {
+                    var placements = colPlacements[col];
+                    var insertionIndex = 0;
+                    while (insertionIndex < placements.length &&
+                        top >= placements[insertionIndex].top) {
+                        insertionIndex += 1;
+                    }
+                    placements.splice(insertionIndex, 0, {
+                        seg: seg,
+                        top: top,
+                        bottom: top + segHeight,
+                    });
+                }
+                return true;
+            }
+            return false;
+        }
+        function canPlaceSegAt(seg, segHeight, top) {
+            for (var col = seg.firstCol; col <= seg.lastCol; col += 1) {
+                for (var _i = 0, _a = colPlacements[col]; _i < _a.length; _i++) {
+                    var placement = _a[_i];
+                    if (top < placement.bottom && top + segHeight > placement.top) { // collide?
+                        return false;
+                    }
+                }
+            }
+            return true;
+        }
+        // what does this do!?
+        for (var instanceIdAndFirstCol in eventHeights) {
+            if (!eventHeights[instanceIdAndFirstCol]) {
+                segIsHidden[instanceIdAndFirstCol.split(':')[0]] = true;
+            }
+        }
+        var segsByFirstCol = colPlacements.map(extractFirstColSegs); // operates on the sorted cols
+        var segsByEachCol = colPlacements.map(function (placements, col) {
+            var segsForCols = extractAllColSegs(placements);
+            segsForCols = resliceDaySegs(segsForCols, cellModels[col].date, col);
+            return segsForCols;
+        });
+        return {
+            segsByFirstCol: segsByFirstCol,
+            segsByEachCol: segsByEachCol,
+            segIsHidden: segIsHidden,
+            segTops: segTops,
+            segMarginTops: segMarginTops,
+            moreCnts: moreCnts,
+            moreTops: moreTops,
+            paddingBottoms: paddingBottoms,
+        };
+    }
+    function extractFirstColSegs(oneColPlacements, col) {
+        var segs = [];
+        for (var _i = 0, oneColPlacements_1 = oneColPlacements; _i < oneColPlacements_1.length; _i++) {
+            var placement = oneColPlacements_1[_i];
+            if (placement.seg.firstCol === col) {
+                segs.push(placement.seg);
+            }
+        }
+        return segs;
+    }
+    function extractAllColSegs(oneColPlacements) {
+        var segs = [];
+        for (var _i = 0, oneColPlacements_2 = oneColPlacements; _i < oneColPlacements_2.length; _i++) {
+            var placement = oneColPlacements_2[_i];
+            segs.push(placement.seg);
+        }
+        return segs;
+    }
+    function limitByMaxHeight(hiddenCnts, segIsHidden, colPlacements, maxContentHeight) {
+        limitEvents(hiddenCnts, segIsHidden, colPlacements, true, function (placement) { return placement.bottom <= maxContentHeight; });
+    }
+    function limitByMaxEvents(hiddenCnts, segIsHidden, colPlacements, dayMaxEvents) {
+        limitEvents(hiddenCnts, segIsHidden, colPlacements, false, function (placement, levelIndex) { return levelIndex < dayMaxEvents; });
+    }
+    function limitByMaxRows(hiddenCnts, segIsHidden, colPlacements, dayMaxEventRows) {
+        limitEvents(hiddenCnts, segIsHidden, colPlacements, true, function (placement, levelIndex) { return levelIndex < dayMaxEventRows; });
+    }
+    /*
+    populates the given hiddenCnts/segIsHidden, which are supplied empty.
+    TODO: return them instead
+    */
+    function limitEvents(hiddenCnts, segIsHidden, colPlacements, _moreLinkConsumesLevel, isPlacementInBounds) {
+        var colCnt = hiddenCnts.length;
+        var segIsVisible = {}; // TODO: instead, use segIsHidden with true/false?
+        var visibleColPlacements = []; // will mirror colPlacements
+        for (var col = 0; col < colCnt; col += 1) {
+            visibleColPlacements.push([]);
+        }
+        for (var col = 0; col < colCnt; col += 1) {
+            var placements = colPlacements[col];
+            var level = 0;
+            for (var _i = 0, placements_2 = placements; _i < placements_2.length; _i++) {
+                var placement = placements_2[_i];
+                if (isPlacementInBounds(placement, level)) {
+                    recordVisible(placement);
+                }
+                else {
+                    recordHidden(placement, level, _moreLinkConsumesLevel);
+                }
+                // only considered a level if the seg had height
+                if (placement.top !== placement.bottom) {
+                    level += 1;
+                }
+            }
+        }
+        function recordVisible(placement) {
+            var seg = placement.seg;
+            var instanceId = seg.eventRange.instance.instanceId;
+            if (!segIsVisible[instanceId]) {
+                segIsVisible[instanceId] = true;
+                for (var col = seg.firstCol; col <= seg.lastCol; col += 1) {
+                    var destPlacements = visibleColPlacements[col];
+                    var newPosition = 0;
+                    // insert while keeping top sorted in each column
+                    while (newPosition < destPlacements.length &&
+                        placement.top >= destPlacements[newPosition].top) {
+                        newPosition += 1;
+                    }
+                    destPlacements.splice(newPosition, 0, placement);
+                }
+            }
+        }
+        function recordHidden(placement, currentLevel, moreLinkConsumesLevel) {
+            var seg = placement.seg;
+            var instanceId = seg.eventRange.instance.instanceId;
+            if (!segIsHidden[instanceId]) {
+                segIsHidden[instanceId] = true;
+                for (var col = seg.firstCol; col <= seg.lastCol; col += 1) {
+                    hiddenCnts[col] += 1;
+                    var hiddenCnt = hiddenCnts[col];
+                    if (moreLinkConsumesLevel && hiddenCnt === 1 && currentLevel > 0) {
+                        var doomedLevel = currentLevel - 1;
+                        while (visibleColPlacements[col].length > doomedLevel) {
+                            recordHidden(visibleColPlacements[col].pop(), // removes
+                            visibleColPlacements[col].length, // will execute after the pop. will be the index of the removed placement
+                            false);
+                        }
+                    }
+                }
+            }
+        }
+    }
+    // Given the events within an array of segment objects, reslice them to be in a single day
+    function resliceDaySegs(segs, dayDate, colIndex) {
+        var dayStart = dayDate;
+        var dayEnd = addDays(dayStart, 1);
+        var dayRange = { start: dayStart, end: dayEnd };
+        var newSegs = [];
+        for (var _i = 0, segs_2 = segs; _i < segs_2.length; _i++) {
+            var seg = segs_2[_i];
+            var eventRange = seg.eventRange;
+            var origRange = eventRange.range;
+            var slicedRange = intersectRanges(origRange, dayRange);
+            if (slicedRange) {
+                newSegs.push(__assign(__assign({}, seg), { firstCol: colIndex, lastCol: colIndex, eventRange: {
+                        def: eventRange.def,
+                        ui: __assign(__assign({}, eventRange.ui), { durationEditable: false }),
+                        instance: eventRange.instance,
+                        range: slicedRange,
+                    }, isStart: seg.isStart && slicedRange.start.valueOf() === origRange.start.valueOf(), isEnd: seg.isEnd && slicedRange.end.valueOf() === origRange.end.valueOf() }));
+            }
+        }
+        return newSegs;
+    }
+
+    var TableRow = /** @class */ (function (_super) {
+        __extends(TableRow, _super);
+        function TableRow() {
+            var _this = _super !== null && _super.apply(this, arguments) || this;
+            _this.cellElRefs = new RefMap(); // the <td>
+            _this.frameElRefs = new RefMap(); // the fc-daygrid-day-frame
+            _this.fgElRefs = new RefMap(); // the fc-daygrid-day-events
+            _this.segHarnessRefs = new RefMap(); // indexed by "instanceId:firstCol"
+            _this.rootElRef = createRef();
+            _this.state = {
+                framePositions: null,
+                maxContentHeight: null,
+                segHeights: {},
+            };
+            return _this;
+        }
+        TableRow.prototype.render = function () {
+            var _this = this;
+            var _a = this, props = _a.props, state = _a.state, context = _a.context;
+            var colCnt = props.cells.length;
+            var businessHoursByCol = splitSegsByFirstCol(props.businessHourSegs, colCnt);
+            var bgEventSegsByCol = splitSegsByFirstCol(props.bgEventSegs, colCnt);
+            var highlightSegsByCol = splitSegsByFirstCol(this.getHighlightSegs(), colCnt);
+            var mirrorSegsByCol = splitSegsByFirstCol(this.getMirrorSegs(), colCnt);
+            var _b = computeFgSegPlacement(props.cells, props.fgEventSegs, props.dayMaxEvents, props.dayMaxEventRows, state.segHeights, state.maxContentHeight, colCnt, context.options.eventOrder), paddingBottoms = _b.paddingBottoms, segsByFirstCol = _b.segsByFirstCol, segsByEachCol = _b.segsByEachCol, segIsHidden = _b.segIsHidden, segTops = _b.segTops, segMarginTops = _b.segMarginTops, moreCnts = _b.moreCnts, moreTops = _b.moreTops;
+            var selectedInstanceHash = // TODO: messy way to compute this
+             (props.eventDrag && props.eventDrag.affectedInstances) ||
+                (props.eventResize && props.eventResize.affectedInstances) ||
+                {};
+            return (createElement("tr", { ref: this.rootElRef },
+                props.renderIntro && props.renderIntro(),
+                props.cells.map(function (cell, col) {
+                    var normalFgNodes = _this.renderFgSegs(segsByFirstCol[col], segIsHidden, segTops, segMarginTops, selectedInstanceHash, props.todayRange);
+                    var mirrorFgNodes = _this.renderFgSegs(mirrorSegsByCol[col], {}, segTops, // use same tops as real rendering
+                    {}, {}, props.todayRange, Boolean(props.eventDrag), Boolean(props.eventResize), false);
+                    return (createElement(TableCell, { key: cell.key, elRef: _this.cellElRefs.createRef(cell.key), innerElRef: _this.frameElRefs.createRef(cell.key) /* FF <td> problem, but okay to use for left/right. TODO: rename prop */, dateProfile: props.dateProfile, date: cell.date, showDayNumber: props.showDayNumbers, showWeekNumber: props.showWeekNumbers && col === 0, forceDayTop: props.showWeekNumbers /* even displaying weeknum for row, not necessarily day */, todayRange: props.todayRange, extraHookProps: cell.extraHookProps, extraDataAttrs: cell.extraDataAttrs, extraClassNames: cell.extraClassNames, moreCnt: moreCnts[col], buildMoreLinkText: props.buildMoreLinkText, onMoreClick: function (arg) {
+                            props.onMoreClick(__assign(__assign({}, arg), { fromCol: col }));
+                        }, segIsHidden: segIsHidden, moreMarginTop: moreTops[col] /* rename */, segsByEachCol: segsByEachCol[col], fgPaddingBottom: paddingBottoms[col], fgContentElRef: _this.fgElRefs.createRef(cell.key), fgContent: ( // Fragment scopes the keys
+                        createElement(Fragment, null,
+                            createElement(Fragment, null, normalFgNodes),
+                            createElement(Fragment, null, mirrorFgNodes))), bgContent: ( // Fragment scopes the keys
+                        createElement(Fragment, null,
+                            _this.renderFillSegs(highlightSegsByCol[col], 'highlight'),
+                            _this.renderFillSegs(businessHoursByCol[col], 'non-business'),
+                            _this.renderFillSegs(bgEventSegsByCol[col], 'bg-event'))) }));
+                })));
+        };
+        TableRow.prototype.componentDidMount = function () {
+            this.updateSizing(true);
+        };
+        TableRow.prototype.componentDidUpdate = function (prevProps, prevState) {
+            var currentProps = this.props;
+            this.updateSizing(!isPropsEqual(prevProps, currentProps));
+        };
+        TableRow.prototype.getHighlightSegs = function () {
+            var props = this.props;
+            if (props.eventDrag && props.eventDrag.segs.length) { // messy check
+                return props.eventDrag.segs;
+            }
+            if (props.eventResize && props.eventResize.segs.length) { // messy check
+                return props.eventResize.segs;
+            }
+            return props.dateSelectionSegs;
+        };
+        TableRow.prototype.getMirrorSegs = function () {
+            var props = this.props;
+            if (props.eventResize && props.eventResize.segs.length) { // messy check
+                return props.eventResize.segs;
+            }
+            return [];
+        };
+        TableRow.prototype.renderFgSegs = function (segs, segIsHidden, // does NOT mean display:hidden
+        segTops, segMarginTops, selectedInstanceHash, todayRange, isDragging, isResizing, isDateSelecting) {
+            var context = this.context;
+            var eventSelection = this.props.eventSelection;
+            var framePositions = this.state.framePositions;
+            var defaultDisplayEventEnd = this.props.cells.length === 1; // colCnt === 1
+            var nodes = [];
+            if (framePositions) {
+                for (var _i = 0, segs_1 = segs; _i < segs_1.length; _i++) {
+                    var seg = segs_1[_i];
+                    var instanceId = seg.eventRange.instance.instanceId;
+                    var isMirror = isDragging || isResizing || isDateSelecting;
+                    var isSelected = selectedInstanceHash[instanceId];
+                    var isInvisible = segIsHidden[instanceId] || isSelected;
+                    // TODO: simpler way? NOT DRY
+                    var isAbsolute = segIsHidden[instanceId] || isMirror || seg.firstCol !== seg.lastCol || !seg.isStart || !seg.isEnd;
+                    var marginTop = void 0;
+                    var top_1 = void 0;
+                    var left = void 0;
+                    var right = void 0;
+                    if (isAbsolute) {
+                        top_1 = segTops[instanceId];
+                        if (context.isRtl) {
+                            right = 0;
+                            left = framePositions.lefts[seg.lastCol] - framePositions.lefts[seg.firstCol];
+                        }
+                        else {
+                            left = 0;
+                            right = framePositions.rights[seg.firstCol] - framePositions.rights[seg.lastCol];
+                        }
+                    }
+                    else {
+                        marginTop = segMarginTops[instanceId];
+                    }
+                    /*
+                    known bug: events that are force to be list-item but span multiple days still take up space in later columns
+                    */
+                    nodes.push(createElement("div", { className: 'fc-daygrid-event-harness' + (isAbsolute ? ' fc-daygrid-event-harness-abs' : ''), key: instanceId, 
+                        // in print mode when in mult cols, could collide
+                        ref: isMirror ? null : this.segHarnessRefs.createRef(instanceId + ':' + seg.firstCol), style: {
+                            visibility: isInvisible ? 'hidden' : '',
+                            marginTop: marginTop || '',
+                            top: top_1 || '',
+                            left: left || '',
+                            right: right || '',
+                        } }, hasListItemDisplay(seg) ? (createElement(TableListItemEvent, __assign({ seg: seg, isDragging: isDragging, isSelected: instanceId === eventSelection, defaultDisplayEventEnd: defaultDisplayEventEnd }, getSegMeta(seg, todayRange)))) : (createElement(TableBlockEvent, __assign({ seg: seg, isDragging: isDragging, isResizing: isResizing, isDateSelecting: isDateSelecting, isSelected: instanceId === eventSelection, defaultDisplayEventEnd: defaultDisplayEventEnd }, getSegMeta(seg, todayRange))))));
+                }
+            }
+            return nodes;
+        };
+        TableRow.prototype.renderFillSegs = function (segs, fillType) {
+            var isRtl = this.context.isRtl;
+            var todayRange = this.props.todayRange;
+            var framePositions = this.state.framePositions;
+            var nodes = [];
+            if (framePositions) {
+                for (var _i = 0, segs_2 = segs; _i < segs_2.length; _i++) {
+                    var seg = segs_2[_i];
+                    var leftRightCss = isRtl ? {
+                        right: 0,
+                        left: framePositions.lefts[seg.lastCol] - framePositions.lefts[seg.firstCol],
+                    } : {
+                        left: 0,
+                        right: framePositions.rights[seg.firstCol] - framePositions.rights[seg.lastCol],
+                    };
+                    nodes.push(createElement("div", { key: buildEventRangeKey(seg.eventRange), className: "fc-daygrid-bg-harness", style: leftRightCss }, fillType === 'bg-event' ?
+                        createElement(BgEvent, __assign({ seg: seg }, getSegMeta(seg, todayRange))) :
+                        renderFill(fillType)));
+                }
+            }
+            return createElement.apply(void 0, __spreadArrays([Fragment, {}], nodes));
+        };
+        TableRow.prototype.updateSizing = function (isExternalSizingChange) {
+            var _a = this, props = _a.props, frameElRefs = _a.frameElRefs;
+            if (props.clientWidth !== null) { // positioning ready?
+                if (isExternalSizingChange) {
+                    var frameEls = props.cells.map(function (cell) { return frameElRefs.currentMap[cell.key]; });
+                    if (frameEls.length) {
+                        var originEl = this.rootElRef.current;
+                        this.setState({
+                            framePositions: new PositionCache(originEl, frameEls, true, // isHorizontal
+                            false),
+                        });
+                    }
+                }
+                var limitByContentHeight = props.dayMaxEvents === true || props.dayMaxEventRows === true;
+                this.setState({
+                    segHeights: this.computeSegHeights(),
+                    maxContentHeight: limitByContentHeight ? this.computeMaxContentHeight() : null,
+                });
+            }
+        };
+        TableRow.prototype.computeSegHeights = function () {
+            return mapHash(this.segHarnessRefs.currentMap, function (eventHarnessEl) { return (eventHarnessEl.getBoundingClientRect().height); });
+        };
+        TableRow.prototype.computeMaxContentHeight = function () {
+            var firstKey = this.props.cells[0].key;
+            var cellEl = this.cellElRefs.currentMap[firstKey];
+            var fcContainerEl = this.fgElRefs.currentMap[firstKey];
+            return cellEl.getBoundingClientRect().bottom - fcContainerEl.getBoundingClientRect().top;
+        };
+        TableRow.prototype.getCellEls = function () {
+            var elMap = this.cellElRefs.currentMap;
+            return this.props.cells.map(function (cell) { return elMap[cell.key]; });
+        };
+        return TableRow;
+    }(DateComponent));
+    TableRow.addPropsEquality({
+        onMoreClick: true,
+    });
+    TableRow.addStateEquality({
+        segHeights: isPropsEqual,
+    });
+
+    var PADDING_FROM_VIEWPORT = 10;
+    var SCROLL_DEBOUNCE = 10;
+    var Popover = /** @class */ (function (_super) {
+        __extends(Popover, _super);
+        function Popover() {
+            var _this = _super !== null && _super.apply(this, arguments) || this;
+            _this.repositioner = new DelayedRunner(_this.updateSize.bind(_this));
+            _this.handleRootEl = function (el) {
+                _this.rootEl = el;
+                if (_this.props.elRef) {
+                    setRef(_this.props.elRef, el);
+                }
+            };
+            // Triggered when the user clicks *anywhere* in the document, for the autoHide feature
+            _this.handleDocumentMousedown = function (ev) {
+                var onClose = _this.props.onClose;
+                // only hide the popover if the click happened outside the popover
+                if (onClose && !_this.rootEl.contains(ev.target)) {
+                    onClose();
+                }
+            };
+            _this.handleDocumentScroll = function () {
+                _this.repositioner.request(SCROLL_DEBOUNCE);
+            };
+            _this.handleCloseClick = function () {
+                var onClose = _this.props.onClose;
+                if (onClose) {
+                    onClose();
+                }
+            };
+            return _this;
+        }
+        Popover.prototype.render = function () {
+            var theme = this.context.theme;
+            var props = this.props;
+            var classNames = [
+                'fc-popover',
+                theme.getClass('popover'),
+            ].concat(props.extraClassNames || []);
+            return (createElement("div", __assign({ className: classNames.join(' ') }, props.extraAttrs, { ref: this.handleRootEl }),
+                createElement("div", { className: 'fc-popover-header ' + theme.getClass('popoverHeader') },
+                    createElement("span", { className: "fc-popover-title" }, props.title),
+                    createElement("span", { className: 'fc-popover-close ' + theme.getIconClass('close'), onClick: this.handleCloseClick })),
+                createElement("div", { className: 'fc-popover-body ' + theme.getClass('popoverContent') }, props.children)));
+        };
+        Popover.prototype.componentDidMount = function () {
+            document.addEventListener('mousedown', this.handleDocumentMousedown);
+            document.addEventListener('scroll', this.handleDocumentScroll);
+            this.updateSize();
+        };
+        Popover.prototype.componentWillUnmount = function () {
+            document.removeEventListener('mousedown', this.handleDocumentMousedown);
+            document.removeEventListener('scroll', this.handleDocumentScroll);
+        };
+        // TODO: adjust on window resize
+        /*
+        NOTE: the popover is position:fixed, so coordinates are relative to the viewport
+        NOTE: the PARENT calls this as well, on window resize. we would have wanted to use the repositioner,
+              but need to ensure that all other components have updated size first (for alignmentEl)
+        */
+        Popover.prototype.updateSize = function () {
+            var _a = this.props, alignmentEl = _a.alignmentEl, topAlignmentEl = _a.topAlignmentEl;
+            var rootEl = this.rootEl;
+            if (!rootEl) {
+                return; // not sure why this was null, but we shouldn't let external components call updateSize() anyway
+            }
+            var dims = rootEl.getBoundingClientRect(); // only used for width,height
+            var alignment = alignmentEl.getBoundingClientRect();
+            var top = topAlignmentEl ? topAlignmentEl.getBoundingClientRect().top : alignment.top;
+            top = Math.min(top, window.innerHeight - dims.height - PADDING_FROM_VIEWPORT);
+            top = Math.max(top, PADDING_FROM_VIEWPORT);
+            var left;
+            if (this.context.isRtl) {
+                left = alignment.right - dims.width;
+            }
+            else {
+                left = alignment.left;
+            }
+            left = Math.min(left, window.innerWidth - dims.width - PADDING_FROM_VIEWPORT);
+            left = Math.max(left, PADDING_FROM_VIEWPORT);
+            applyStyle(rootEl, { top: top, left: left });
+        };
+        return Popover;
+    }(BaseComponent));
+
+    var MorePopover = /** @class */ (function (_super) {
+        __extends(MorePopover, _super);
+        function MorePopover() {
+            var _this = _super !== null && _super.apply(this, arguments) || this;
+            _this.rootElRef = createRef();
+            return _this;
+        }
+        MorePopover.prototype.render = function () {
+            var _a = this.context, options = _a.options, dateEnv = _a.dateEnv;
+            var props = this.props;
+            var date = props.date, hiddenInstances = props.hiddenInstances, todayRange = props.todayRange, dateProfile = props.dateProfile, selectedInstanceId = props.selectedInstanceId;
+            var title = dateEnv.format(date, options.dayPopoverFormat);
+            return (createElement(DayCellRoot, { date: date, dateProfile: dateProfile, todayRange: todayRange, elRef: this.rootElRef }, function (rootElRef, dayClassNames, dataAttrs) { return (createElement(Popover, { elRef: rootElRef, title: title, extraClassNames: ['fc-more-popover'].concat(dayClassNames), extraAttrs: dataAttrs, onClose: props.onCloseClick, alignmentEl: props.alignmentEl, topAlignmentEl: props.topAlignmentEl },
+                createElement(DayCellContent, { date: date, dateProfile: dateProfile, todayRange: todayRange }, function (innerElRef, innerContent) { return (innerContent &&
+                    createElement("div", { className: "fc-more-popover-misc", ref: innerElRef }, innerContent)); }),
+                props.segs.map(function (seg) {
+                    var instanceId = seg.eventRange.instance.instanceId;
+                    return (createElement("div", { className: "fc-daygrid-event-harness", key: instanceId, style: {
+                            visibility: hiddenInstances[instanceId] ? 'hidden' : '',
+                        } }, hasListItemDisplay(seg) ? (createElement(TableListItemEvent, __assign({ seg: seg, isDragging: false, isSelected: instanceId === selectedInstanceId, defaultDisplayEventEnd: false }, getSegMeta(seg, todayRange)))) : (createElement(TableBlockEvent, __assign({ seg: seg, isDragging: false, isResizing: false, isDateSelecting: false, isSelected: instanceId === selectedInstanceId, defaultDisplayEventEnd: false }, getSegMeta(seg, todayRange))))));
+                }))); }));
+        };
+        MorePopover.prototype.positionToHit = function (positionLeft, positionTop, originEl) {
+            var rootEl = this.rootElRef.current;
+            if (!originEl || !rootEl) { // why?
+                return null;
+            }
+            var originRect = originEl.getBoundingClientRect();
+            var elRect = rootEl.getBoundingClientRect();
+            var newOriginLeft = elRect.left - originRect.left;
+            var newOriginTop = elRect.top - originRect.top;
+            var localLeft = positionLeft - newOriginLeft;
+            var localTop = positionTop - newOriginTop;
+            var date = this.props.date;
+            if ( // ugly way to detect intersection
+            localLeft >= 0 && localLeft < elRect.width &&
+                localTop >= 0 && localTop < elRect.height) {
+                return {
+                    dateSpan: {
+                        allDay: true,
+                        range: { start: date, end: addDays(date, 1) },
+                    },
+                    dayEl: rootEl,
+                    relativeRect: {
+                        left: newOriginLeft,
+                        top: newOriginTop,
+                        right: elRect.width,
+                        bottom: elRect.height,
+                    },
+                    layer: 1,
+                };
+            }
+            return null;
+        };
+        return MorePopover;
+    }(DateComponent));
+
+    var Table = /** @class */ (function (_super) {
+        __extends(Table, _super);
+        function Table() {
+            var _this = _super !== null && _super.apply(this, arguments) || this;
+            _this.splitBusinessHourSegs = memoize(splitSegsByRow);
+            _this.splitBgEventSegs = memoize(splitSegsByRow);
+            _this.splitFgEventSegs = memoize(splitSegsByRow);
+            _this.splitDateSelectionSegs = memoize(splitSegsByRow);
+            _this.splitEventDrag = memoize(splitInteractionByRow);
+            _this.splitEventResize = memoize(splitInteractionByRow);
+            _this.buildBuildMoreLinkText = memoize(buildBuildMoreLinkText);
+            _this.morePopoverRef = createRef();
+            _this.rowRefs = new RefMap();
+            _this.state = {
+                morePopoverState: null,
+            };
+            _this.handleRootEl = function (rootEl) {
+                _this.rootEl = rootEl;
+                setRef(_this.props.elRef, rootEl);
+            };
+            // TODO: bad names "more link click" versus "more click"
+            _this.handleMoreLinkClick = function (arg) {
+                var context = _this.context;
+                var dateEnv = context.dateEnv;
+                var clickOption = context.options.moreLinkClick;
+                function segForPublic(seg) {
+                    var _a = seg.eventRange, def = _a.def, instance = _a.instance, range = _a.range;
+                    return {
+                        event: new EventApi(context, def, instance),
+                        start: dateEnv.toDate(range.start),
+                        end: dateEnv.toDate(range.end),
+                        isStart: seg.isStart,
+                        isEnd: seg.isEnd,
+                    };
+                }
+                if (typeof clickOption === 'function') {
+                    clickOption = clickOption({
+                        date: dateEnv.toDate(arg.date),
+                        allDay: true,
+                        allSegs: arg.allSegs.map(segForPublic),
+                        hiddenSegs: arg.hiddenSegs.map(segForPublic),
+                        jsEvent: arg.ev,
+                        view: context.viewApi,
+                    }); // hack to handle void
+                }
+                if (!clickOption || clickOption === 'popover') {
+                    _this.setState({
+                        morePopoverState: __assign(__assign({}, arg), { currentFgEventSegs: _this.props.fgEventSegs, fromRow: arg.fromRow, fromCol: arg.fromCol }),
+                    });
+                }
+                else if (typeof clickOption === 'string') { // a view name
+                    context.calendarApi.zoomTo(arg.date, clickOption);
+                }
+            };
+            _this.handleMorePopoverClose = function () {
+                _this.setState({
+                    morePopoverState: null,
+                });
+            };
+            return _this;
+        }
+        Table.prototype.render = function () {
+            var _this = this;
+            var props = this.props;
+            var dateProfile = props.dateProfile, dayMaxEventRows = props.dayMaxEventRows, dayMaxEvents = props.dayMaxEvents, expandRows = props.expandRows;
+            var morePopoverState = this.state.morePopoverState;
+            var rowCnt = props.cells.length;
+            var businessHourSegsByRow = this.splitBusinessHourSegs(props.businessHourSegs, rowCnt);
+            var bgEventSegsByRow = this.splitBgEventSegs(props.bgEventSegs, rowCnt);
+            var fgEventSegsByRow = this.splitFgEventSegs(props.fgEventSegs, rowCnt);
+            var dateSelectionSegsByRow = this.splitDateSelectionSegs(props.dateSelectionSegs, rowCnt);
+            var eventDragByRow = this.splitEventDrag(props.eventDrag, rowCnt);
+            var eventResizeByRow = this.splitEventResize(props.eventResize, rowCnt);
+            var buildMoreLinkText = this.buildBuildMoreLinkText(this.context.options.moreLinkText);
+            var limitViaBalanced = dayMaxEvents === true || dayMaxEventRows === true;
+            // if rows can't expand to fill fixed height, can't do balanced-height event limit
+            // TODO: best place to normalize these options?
+            if (limitViaBalanced && !expandRows) {
+                limitViaBalanced = false;
+                dayMaxEventRows = null;
+                dayMaxEvents = null;
+            }
+            var classNames = [
+                'fc-daygrid-body',
+                limitViaBalanced ? 'fc-daygrid-body-balanced' : 'fc-daygrid-body-unbalanced',
+                expandRows ? '' : 'fc-daygrid-body-natural',
+            ];
+            return (createElement("div", { className: classNames.join(' '), ref: this.handleRootEl, style: {
+                    // these props are important to give this wrapper correct dimensions for interactions
+                    // TODO: if we set it here, can we avoid giving to inner tables?
+                    width: props.clientWidth,
+                    minWidth: props.tableMinWidth,
+                } },
+                createElement(NowTimer, { unit: "day" }, function (nowDate, todayRange) { return (createElement(Fragment, null,
+                    createElement("table", { className: "fc-scrollgrid-sync-table", style: {
+                            width: props.clientWidth,
+                            minWidth: props.tableMinWidth,
+                            height: expandRows ? props.clientHeight : '',
+                        } },
+                        props.colGroupNode,
+                        createElement("tbody", null, props.cells.map(function (cells, row) { return (createElement(TableRow, { ref: _this.rowRefs.createRef(row), key: cells.length
+                                ? cells[0].date.toISOString() /* best? or put key on cell? or use diff formatter? */
+                                : row // in case there are no cells (like when resource view is loading)
+                            , showDayNumbers: rowCnt > 1, showWeekNumbers: props.showWeekNumbers, todayRange: todayRange, dateProfile: dateProfile, cells: cells, renderIntro: props.renderRowIntro, businessHourSegs: businessHourSegsByRow[row], eventSelection: props.eventSelection, bgEventSegs: bgEventSegsByRow[row].filter(isSegAllDay) /* hack */, fgEventSegs: fgEventSegsByRow[row], dateSelectionSegs: dateSelectionSegsByRow[row], eventDrag: eventDragByRow[row], eventResize: eventResizeByRow[row], dayMaxEvents: dayMaxEvents, dayMaxEventRows: dayMaxEventRows, clientWidth: props.clientWidth, clientHeight: props.clientHeight, buildMoreLinkText: buildMoreLinkText, onMoreClick: function (arg) {
+                                _this.handleMoreLinkClick(__assign(__assign({}, arg), { fromRow: row }));
+                            } })); }))),
+                    (!props.forPrint && morePopoverState && morePopoverState.currentFgEventSegs === props.fgEventSegs) && (createElement(MorePopover, { ref: _this.morePopoverRef, date: morePopoverState.date, dateProfile: dateProfile, segs: morePopoverState.allSegs, alignmentEl: morePopoverState.dayEl, topAlignmentEl: rowCnt === 1 ? props.headerAlignElRef.current : null, onCloseClick: _this.handleMorePopoverClose, selectedInstanceId: props.eventSelection, hiddenInstances: // yuck
+                        (props.eventDrag ? props.eventDrag.affectedInstances : null) ||
+                            (props.eventResize ? props.eventResize.affectedInstances : null) ||
+                            {}, todayRange: todayRange })))); })));
+        };
+        // Hit System
+        // ----------------------------------------------------------------------------------------------------
+        Table.prototype.prepareHits = function () {
+            this.rowPositions = new PositionCache(this.rootEl, this.rowRefs.collect().map(function (rowObj) { return rowObj.getCellEls()[0]; }), // first cell el in each row. TODO: not optimal
+            false, true);
+            this.colPositions = new PositionCache(this.rootEl, this.rowRefs.currentMap[0].getCellEls(), // cell els in first row
+            true, // horizontal
+            false);
+        };
+        Table.prototype.positionToHit = function (leftPosition, topPosition) {
+            var morePopover = this.morePopoverRef.current;
+            var morePopoverHit = morePopover ? morePopover.positionToHit(leftPosition, topPosition, this.rootEl) : null;
+            var morePopoverState = this.state.morePopoverState;
+            if (morePopoverHit) {
+                return __assign({ row: morePopoverState.fromRow, col: morePopoverState.fromCol }, morePopoverHit);
+            }
+            var _a = this, colPositions = _a.colPositions, rowPositions = _a.rowPositions;
+            var col = colPositions.leftToIndex(leftPosition);
+            var row = rowPositions.topToIndex(topPosition);
+            if (row != null && col != null) {
+                return {
+                    row: row,
+                    col: col,
+                    dateSpan: {
+                        range: this.getCellRange(row, col),
+                        allDay: true,
+                    },
+                    dayEl: this.getCellEl(row, col),
+                    relativeRect: {
+                        left: colPositions.lefts[col],
+                        right: colPositions.rights[col],
+                        top: rowPositions.tops[row],
+                        bottom: rowPositions.bottoms[row],
+                    },
+                };
+            }
+            return null;
+        };
+        Table.prototype.getCellEl = function (row, col) {
+            return this.rowRefs.currentMap[row].getCellEls()[col]; // TODO: not optimal
+        };
+        Table.prototype.getCellRange = function (row, col) {
+            var start = this.props.cells[row][col].date;
+            var end = addDays(start, 1);
+            return { start: start, end: end };
+        };
+        return Table;
+    }(DateComponent));
+    function buildBuildMoreLinkText(moreLinkTextInput) {
+        if (typeof moreLinkTextInput === 'function') {
+            return moreLinkTextInput;
+        }
+        return function (num) { return "+" + num + " " + moreLinkTextInput; };
+    }
+    function isSegAllDay(seg) {
+        return seg.eventRange.def.allDay;
+    }
+
+    var DayTableSlicer = /** @class */ (function (_super) {
+        __extends(DayTableSlicer, _super);
+        function DayTableSlicer() {
+            var _this = _super !== null && _super.apply(this, arguments) || this;
+            _this.forceDayIfListItem = true;
+            return _this;
+        }
+        DayTableSlicer.prototype.sliceRange = function (dateRange, dayTableModel) {
+            return dayTableModel.sliceRange(dateRange);
+        };
+        return DayTableSlicer;
+    }(Slicer));
+
+    var DayTable = /** @class */ (function (_super) {
+        __extends(DayTable, _super);
+        function DayTable() {
+            var _this = _super !== null && _super.apply(this, arguments) || this;
+            _this.slicer = new DayTableSlicer();
+            _this.tableRef = createRef();
+            _this.handleRootEl = function (rootEl) {
+                if (rootEl) {
+                    _this.context.registerInteractiveComponent(_this, { el: rootEl });
+                }
+                else {
+                    _this.context.unregisterInteractiveComponent(_this);
+                }
+            };
+            return _this;
+        }
+        DayTable.prototype.render = function () {
+            var _a = this, props = _a.props, context = _a.context;
+            return (createElement(Table, __assign({ ref: this.tableRef, elRef: this.handleRootEl }, this.slicer.sliceProps(props, props.dateProfile, props.nextDayThreshold, context, props.dayTableModel), { dateProfile: props.dateProfile, cells: props.dayTableModel.cells, colGroupNode: props.colGroupNode, tableMinWidth: props.tableMinWidth, renderRowIntro: props.renderRowIntro, dayMaxEvents: props.dayMaxEvents, dayMaxEventRows: props.dayMaxEventRows, showWeekNumbers: props.showWeekNumbers, expandRows: props.expandRows, headerAlignElRef: props.headerAlignElRef, clientWidth: props.clientWidth, clientHeight: props.clientHeight, forPrint: props.forPrint })));
+        };
+        DayTable.prototype.prepareHits = function () {
+            this.tableRef.current.prepareHits();
+        };
+        DayTable.prototype.queryHit = function (positionLeft, positionTop) {
+            var rawHit = this.tableRef.current.positionToHit(positionLeft, positionTop);
+            if (rawHit) {
+                return {
+                    component: this,
+                    dateSpan: rawHit.dateSpan,
+                    dayEl: rawHit.dayEl,
+                    rect: {
+                        left: rawHit.relativeRect.left,
+                        right: rawHit.relativeRect.right,
+                        top: rawHit.relativeRect.top,
+                        bottom: rawHit.relativeRect.bottom,
+                    },
+                    layer: 0,
+                };
+            }
+            return null;
+        };
+        return DayTable;
+    }(DateComponent));
+
+    var DayTableView = /** @class */ (function (_super) {
+        __extends(DayTableView, _super);
+        function DayTableView() {
+            var _this = _super !== null && _super.apply(this, arguments) || this;
+            _this.buildDayTableModel = memoize(buildDayTableModel);
+            _this.headerRef = createRef();
+            _this.tableRef = createRef();
+            return _this;
+        }
+        DayTableView.prototype.render = function () {
+            var _this = this;
+            var _a = this.context, options = _a.options, dateProfileGenerator = _a.dateProfileGenerator;
+            var props = this.props;
+            var dayTableModel = this.buildDayTableModel(props.dateProfile, dateProfileGenerator);
+            var headerContent = options.dayHeaders && (createElement(DayHeader, { ref: this.headerRef, dateProfile: props.dateProfile, dates: dayTableModel.headerDates, datesRepDistinctDays: dayTableModel.rowCnt === 1 }));
+            var bodyContent = function (contentArg) { return (createElement(DayTable, { ref: _this.tableRef, dateProfile: props.dateProfile, dayTableModel: dayTableModel, businessHours: props.businessHours, dateSelection: props.dateSelection, eventStore: props.eventStore, eventUiBases: props.eventUiBases, eventSelection: props.eventSelection, eventDrag: props.eventDrag, eventResize: props.eventResize, nextDayThreshold: options.nextDayThreshold, colGroupNode: contentArg.tableColGroupNode, tableMinWidth: contentArg.tableMinWidth, dayMaxEvents: options.dayMaxEvents, dayMaxEventRows: options.dayMaxEventRows, showWeekNumbers: options.weekNumbers, expandRows: !props.isHeightAuto, headerAlignElRef: _this.headerElRef, clientWidth: contentArg.clientWidth, clientHeight: contentArg.clientHeight, forPrint: props.forPrint })); };
+            return options.dayMinWidth
+                ? this.renderHScrollLayout(headerContent, bodyContent, dayTableModel.colCnt, options.dayMinWidth)
+                : this.renderSimpleLayout(headerContent, bodyContent);
+        };
+        return DayTableView;
+    }(TableView));
+    function buildDayTableModel(dateProfile, dateProfileGenerator) {
+        var daySeries = new DaySeriesModel(dateProfile.renderRange, dateProfileGenerator);
+        return new DayTableModel(daySeries, /year|month|week/.test(dateProfile.currentRangeUnit));
+    }
+
+    var TableDateProfileGenerator = /** @class */ (function (_super) {
+        __extends(TableDateProfileGenerator, _super);
+        function TableDateProfileGenerator() {
+            return _super !== null && _super.apply(this, arguments) || this;
+        }
+        // Computes the date range that will be rendered.
+        TableDateProfileGenerator.prototype.buildRenderRange = function (currentRange, currentRangeUnit, isRangeAllDay) {
+            var dateEnv = this.props.dateEnv;
+            var renderRange = _super.prototype.buildRenderRange.call(this, currentRange, currentRangeUnit, isRangeAllDay);
+            var start = renderRange.start;
+            var end = renderRange.end;
+            var endOfWeek;
+            // year and month views should be aligned with weeks. this is already done for week
+            if (/^(year|month)$/.test(currentRangeUnit)) {
+                start = dateEnv.startOfWeek(start);
+                // make end-of-week if not already
+                endOfWeek = dateEnv.startOfWeek(end);
+                if (endOfWeek.valueOf() !== end.valueOf()) {
+                    end = addWeeks(endOfWeek, 1);
+                }
+            }
+            // ensure 6 weeks
+            if (this.props.monthMode &&
+                this.props.fixedWeekCount) {
+                var rowCnt = Math.ceil(// could be partial weeks due to hiddenDays
+                diffWeeks(start, end));
+                end = addWeeks(end, 6 - rowCnt);
+            }
+            return { start: start, end: end };
+        };
+        return TableDateProfileGenerator;
+    }(DateProfileGenerator));
+
+    var OPTION_REFINERS$1 = {
+        moreLinkClick: identity,
+        moreLinkClassNames: identity,
+        moreLinkContent: identity,
+        moreLinkDidMount: identity,
+        moreLinkWillUnmount: identity,
+    };
+
+    var dayGridPlugin = createPlugin({
+        initialView: 'dayGridMonth',
+        optionRefiners: OPTION_REFINERS$1,
+        views: {
+            dayGrid: {
+                component: DayTableView,
+                dateProfileGeneratorClass: TableDateProfileGenerator,
+            },
+            dayGridDay: {
+                type: 'dayGrid',
+                duration: { days: 1 },
+            },
+            dayGridWeek: {
+                type: 'dayGrid',
+                duration: { weeks: 1 },
+            },
+            dayGridMonth: {
+                type: 'dayGrid',
+                duration: { months: 1 },
+                monthMode: true,
+                fixedWeekCount: true,
+            },
+        },
+    });
+
+    var AllDaySplitter = /** @class */ (function (_super) {
+        __extends(AllDaySplitter, _super);
+        function AllDaySplitter() {
+            return _super !== null && _super.apply(this, arguments) || this;
+        }
+        AllDaySplitter.prototype.getKeyInfo = function () {
+            return {
+                allDay: {},
+                timed: {},
+            };
+        };
+        AllDaySplitter.prototype.getKeysForDateSpan = function (dateSpan) {
+            if (dateSpan.allDay) {
+                return ['allDay'];
+            }
+            return ['timed'];
+        };
+        AllDaySplitter.prototype.getKeysForEventDef = function (eventDef) {
+            if (!eventDef.allDay) {
+                return ['timed'];
+            }
+            if (hasBgRendering(eventDef)) {
+                return ['timed', 'allDay'];
+            }
+            return ['allDay'];
+        };
+        return AllDaySplitter;
+    }(Splitter));
+
+    var DEFAULT_SLAT_LABEL_FORMAT = createFormatter({
+        hour: 'numeric',
+        minute: '2-digit',
+        omitZeroMinute: true,
+        meridiem: 'short',
+    });
+    function TimeColsAxisCell(props) {
+        var classNames = [
+            'fc-timegrid-slot',
+            'fc-timegrid-slot-label',
+            props.isLabeled ? 'fc-scrollgrid-shrink' : 'fc-timegrid-slot-minor',
+        ];
+        return (createElement(ViewContextType.Consumer, null, function (context) {
+            if (!props.isLabeled) {
+                return (createElement("td", { className: classNames.join(' '), "data-time": props.isoTimeStr }));
+            }
+            var dateEnv = context.dateEnv, options = context.options, viewApi = context.viewApi;
+            var labelFormat = // TODO: fully pre-parse
+             options.slotLabelFormat == null ? DEFAULT_SLAT_LABEL_FORMAT :
+                Array.isArray(options.slotLabelFormat) ? createFormatter(options.slotLabelFormat[0]) :
+                    createFormatter(options.slotLabelFormat);
+            var hookProps = {
+                level: 0,
+                time: props.time,
+                date: dateEnv.toDate(props.date),
+                view: viewApi,
+                text: dateEnv.format(props.date, labelFormat),
+            };
+            return (createElement(RenderHook, { hookProps: hookProps, classNames: options.slotLabelClassNames, content: options.slotLabelContent, defaultContent: renderInnerContent$3, didMount: options.slotLabelDidMount, willUnmount: options.slotLabelWillUnmount }, function (rootElRef, customClassNames, innerElRef, innerContent) { return (createElement("td", { ref: rootElRef, className: classNames.concat(customClassNames).join(' '), "data-time": props.isoTimeStr },
+                createElement("div", { className: "fc-timegrid-slot-label-frame fc-scrollgrid-shrink-frame" },
+                    createElement("div", { className: "fc-timegrid-slot-label-cushion fc-scrollgrid-shrink-cushion", ref: innerElRef }, innerContent)))); }));
+        }));
+    }
+    function renderInnerContent$3(props) {
+        return props.text;
+    }
+
+    var TimeBodyAxis = /** @class */ (function (_super) {
+        __extends(TimeBodyAxis, _super);
+        function TimeBodyAxis() {
+            return _super !== null && _super.apply(this, arguments) || this;
+        }
+        TimeBodyAxis.prototype.render = function () {
+            return this.props.slatMetas.map(function (slatMeta) { return (createElement("tr", { key: slatMeta.key },
+                createElement(TimeColsAxisCell, __assign({}, slatMeta)))); });
+        };
+        return TimeBodyAxis;
+    }(BaseComponent));
+
+    var DEFAULT_WEEK_NUM_FORMAT$1 = createFormatter({ week: 'short' });
+    var AUTO_ALL_DAY_MAX_EVENT_ROWS = 5;
+    var TimeColsView = /** @class */ (function (_super) {
+        __extends(TimeColsView, _super);
+        function TimeColsView() {
+            var _this = _super !== null && _super.apply(this, arguments) || this;
+            _this.allDaySplitter = new AllDaySplitter(); // for use by subclasses
+            _this.headerElRef = createRef();
+            _this.rootElRef = createRef();
+            _this.scrollerElRef = createRef();
+            _this.state = {
+                slatCoords: null,
+            };
+            _this.handleScrollTopRequest = function (scrollTop) {
+                var scrollerEl = _this.scrollerElRef.current;
+                if (scrollerEl) { // TODO: not sure how this could ever be null. weirdness with the reducer
+                    scrollerEl.scrollTop = scrollTop;
+                }
+            };
+            /* Header Render Methods
+            ------------------------------------------------------------------------------------------------------------------*/
+            _this.renderHeadAxis = function (rowKey, frameHeight) {
+                if (frameHeight === void 0) { frameHeight = ''; }
+                var options = _this.context.options;
+                var dateProfile = _this.props.dateProfile;
+                var range = dateProfile.renderRange;
+                var dayCnt = diffDays(range.start, range.end);
+                var navLinkAttrs = (options.navLinks && dayCnt === 1) // only do in day views (to avoid doing in week views that dont need it)
+                    ? { 'data-navlink': buildNavLinkData(range.start, 'week'), tabIndex: 0 }
+                    : {};
+                if (options.weekNumbers && rowKey === 'day') {
+                    return (createElement(WeekNumberRoot, { date: range.start, defaultFormat: DEFAULT_WEEK_NUM_FORMAT$1 }, function (rootElRef, classNames, innerElRef, innerContent) { return (createElement("th", { ref: rootElRef, className: [
+                            'fc-timegrid-axis',
+                            'fc-scrollgrid-shrink',
+                        ].concat(classNames).join(' ') },
+                        createElement("div", { className: "fc-timegrid-axis-frame fc-scrollgrid-shrink-frame fc-timegrid-axis-frame-liquid", style: { height: frameHeight } },
+                            createElement("a", __assign({ ref: innerElRef, className: "fc-timegrid-axis-cushion fc-scrollgrid-shrink-cushion fc-scrollgrid-sync-inner" }, navLinkAttrs), innerContent)))); }));
+                }
+                return (createElement("th", { className: "fc-timegrid-axis" },
+                    createElement("div", { className: "fc-timegrid-axis-frame", style: { height: frameHeight } })));
+            };
+            /* Table Component Render Methods
+            ------------------------------------------------------------------------------------------------------------------*/
+            // only a one-way height sync. we don't send the axis inner-content height to the DayGrid,
+            // but DayGrid still needs to have classNames on inner elements in order to measure.
+            _this.renderTableRowAxis = function (rowHeight) {
+                var _a = _this.context, options = _a.options, viewApi = _a.viewApi;
+                var hookProps = {
+                    text: options.allDayText,
+                    view: viewApi,
+                };
+                return (
+                // TODO: make reusable hook. used in list view too
+                createElement(RenderHook, { hookProps: hookProps, classNames: options.allDayClassNames, content: options.allDayContent, defaultContent: renderAllDayInner, didMount: options.allDayDidMount, willUnmount: options.allDayWillUnmount }, function (rootElRef, classNames, innerElRef, innerContent) { return (createElement("td", { ref: rootElRef, className: [
+                        'fc-timegrid-axis',
+                        'fc-scrollgrid-shrink',
+                    ].concat(classNames).join(' ') },
+                    createElement("div", { className: 'fc-timegrid-axis-frame fc-scrollgrid-shrink-frame' + (rowHeight == null ? ' fc-timegrid-axis-frame-liquid' : ''), style: { height: rowHeight } },
+                        createElement("span", { className: "fc-timegrid-axis-cushion fc-scrollgrid-shrink-cushion fc-scrollgrid-sync-inner", ref: innerElRef }, innerContent)))); }));
+            };
+            _this.handleSlatCoords = function (slatCoords) {
+                _this.setState({ slatCoords: slatCoords });
+            };
+            return _this;
+        }
+        // rendering
+        // ----------------------------------------------------------------------------------------------------
+        TimeColsView.prototype.renderSimpleLayout = function (headerRowContent, allDayContent, timeContent) {
+            var _a = this, context = _a.context, props = _a.props;
+            var sections = [];
+            var stickyHeaderDates = getStickyHeaderDates(context.options);
+            if (headerRowContent) {
+                sections.push({
+                    type: 'header',
+                    key: 'header',
+                    isSticky: stickyHeaderDates,
+                    chunk: {
+                        elRef: this.headerElRef,
+                        tableClassName: 'fc-col-header',
+                        rowContent: headerRowContent,
+                    },
+                });
+            }
+            if (allDayContent) {
+                sections.push({
+                    type: 'body',
+                    key: 'all-day',
+                    chunk: { content: allDayContent },
+                });
+                sections.push({
+                    type: 'body',
+                    key: 'all-day-divider',
+                    outerContent: ( // TODO: rename to cellContent so don't need to define <tr>?
+                    createElement("tr", { className: "fc-scrollgrid-section" },
+                        createElement("td", { className: 'fc-timegrid-divider ' + context.theme.getClass('tableCellShaded') }))),
+                });
+            }
+            sections.push({
+                type: 'body',
+                key: 'body',
+                liquid: true,
+                expandRows: Boolean(context.options.expandRows),
+                chunk: {
+                    scrollerElRef: this.scrollerElRef,
+                    content: timeContent,
+                },
+            });
+            return (createElement(ViewRoot, { viewSpec: context.viewSpec, elRef: this.rootElRef }, function (rootElRef, classNames) { return (createElement("div", { className: ['fc-timegrid'].concat(classNames).join(' '), ref: rootElRef },
+                createElement(SimpleScrollGrid, { liquid: !props.isHeightAuto && !props.forPrint, cols: [{ width: 'shrink' }], sections: sections }))); }));
+        };
+        TimeColsView.prototype.renderHScrollLayout = function (headerRowContent, allDayContent, timeContent, colCnt, dayMinWidth, slatMetas, slatCoords) {
+            var _this = this;
+            var ScrollGrid = this.context.pluginHooks.scrollGridImpl;
+            if (!ScrollGrid) {
+                throw new Error('No ScrollGrid implementation');
+            }
+            var _a = this, context = _a.context, props = _a.props;
+            var stickyHeaderDates = !props.forPrint && getStickyHeaderDates(context.options);
+            var stickyFooterScrollbar = !props.forPrint && getStickyFooterScrollbar(context.options);
+            var sections = [];
+            if (headerRowContent) {
+                sections.push({
+                    type: 'header',
+                    key: 'header',
+                    isSticky: stickyHeaderDates,
+                    syncRowHeights: true,
+                    chunks: [
+                        {
+                            key: 'axis',
+                            rowContent: function (arg) { return (createElement("tr", null, _this.renderHeadAxis('day', arg.rowSyncHeights[0]))); },
+                        },
+                        {
+                            key: 'cols',
+                            elRef: this.headerElRef,
+                            tableClassName: 'fc-col-header',
+                            rowContent: headerRowContent,
+                        },
+                    ],
+                });
+            }
+            if (allDayContent) {
+                sections.push({
+                    type: 'body',
+                    key: 'all-day',
+                    syncRowHeights: true,
+                    chunks: [
+                        {
+                            key: 'axis',
+                            rowContent: function (contentArg) { return (createElement("tr", null, _this.renderTableRowAxis(contentArg.rowSyncHeights[0]))); },
+                        },
+                        {
+                            key: 'cols',
+                            content: allDayContent,
+                        },
+                    ],
+                });
+                sections.push({
+                    key: 'all-day-divider',
+                    type: 'body',
+                    outerContent: ( // TODO: rename to cellContent so don't need to define <tr>?
+                    createElement("tr", { className: "fc-scrollgrid-section" },
+                        createElement("td", { colSpan: 2, className: 'fc-timegrid-divider ' + context.theme.getClass('tableCellShaded') }))),
+                });
+            }
+            var isNowIndicator = context.options.nowIndicator;
+            sections.push({
+                type: 'body',
+                key: 'body',
+                liquid: true,
+                expandRows: Boolean(context.options.expandRows),
+                chunks: [
+                    {
+                        key: 'axis',
+                        content: function (arg) { return (
+                        // TODO: make this now-indicator arrow more DRY with TimeColsContent
+                        createElement("div", { className: "fc-timegrid-axis-chunk" },
+                            createElement("table", { style: { height: arg.expandRows ? arg.clientHeight : '' } },
+                                arg.tableColGroupNode,
+                                createElement("tbody", null,
+                                    createElement(TimeBodyAxis, { slatMetas: slatMetas }))),
+                            createElement("div", { className: "fc-timegrid-now-indicator-container" },
+                                createElement(NowTimer, { unit: isNowIndicator ? 'minute' : 'day' /* hacky */ }, function (nowDate) {
+                                    var nowIndicatorTop = isNowIndicator &&
+                                        slatCoords &&
+                                        slatCoords.safeComputeTop(nowDate); // might return void
+                                    if (typeof nowIndicatorTop === 'number') {
+                                        return (createElement(NowIndicatorRoot, { isAxis: true, date: nowDate }, function (rootElRef, classNames, innerElRef, innerContent) { return (createElement("div", { ref: rootElRef, className: ['fc-timegrid-now-indicator-arrow'].concat(classNames).join(' '), style: { top: nowIndicatorTop } }, innerContent)); }));
+                                    }
+                                    return null;
+                                })))); },
+                    },
+                    {
+                        key: 'cols',
+                        scrollerElRef: this.scrollerElRef,
+                        content: timeContent,
+                    },
+                ],
+            });
+            if (stickyFooterScrollbar) {
+                sections.push({
+                    key: 'footer',
+                    type: 'footer',
+                    isSticky: true,
+                    chunks: [
+                        {
+                            key: 'axis',
+                            content: renderScrollShim,
+                        },
+                        {
+                            key: 'cols',
+                            content: renderScrollShim,
+                        },
+                    ],
+                });
+            }
+            return (createElement(ViewRoot, { viewSpec: context.viewSpec, elRef: this.rootElRef }, function (rootElRef, classNames) { return (createElement("div", { className: ['fc-timegrid'].concat(classNames).join(' '), ref: rootElRef },
+                createElement(ScrollGrid, { liquid: !props.isHeightAuto && !props.forPrint, colGroups: [
+                        { width: 'shrink', cols: [{ width: 'shrink' }] },
+                        { cols: [{ span: colCnt, minWidth: dayMinWidth }] },
+                    ], sections: sections }))); }));
+        };
+        /* Dimensions
+        ------------------------------------------------------------------------------------------------------------------*/
+        TimeColsView.prototype.getAllDayMaxEventProps = function () {
+            var _a = this.context.options, dayMaxEvents = _a.dayMaxEvents, dayMaxEventRows = _a.dayMaxEventRows;
+            if (dayMaxEvents === true || dayMaxEventRows === true) { // is auto?
+                dayMaxEvents = undefined;
+                dayMaxEventRows = AUTO_ALL_DAY_MAX_EVENT_ROWS; // make sure "auto" goes to a real number
+            }
+            return { dayMaxEvents: dayMaxEvents, dayMaxEventRows: dayMaxEventRows };
+        };
+        return TimeColsView;
+    }(DateComponent));
+    function renderAllDayInner(hookProps) {
+        return hookProps.text;
+    }
+
+    var TimeColsSlatsCoords = /** @class */ (function () {
+        function TimeColsSlatsCoords(positions, dateProfile, slatMetas) {
+            this.positions = positions;
+            this.dateProfile = dateProfile;
+            this.slatMetas = slatMetas;
+        }
+        TimeColsSlatsCoords.prototype.safeComputeTop = function (date) {
+            var dateProfile = this.dateProfile;
+            if (rangeContainsMarker(dateProfile.currentRange, date)) {
+                var startOfDayDate = startOfDay(date);
+                var timeMs = date.valueOf() - startOfDayDate.valueOf();
+                if (timeMs >= asRoughMs(dateProfile.slotMinTime) &&
+                    timeMs < asRoughMs(dateProfile.slotMaxTime)) {
+                    return this.computeTimeTop(createDuration(timeMs));
+                }
+            }
+            return null;
+        };
+        // Computes the top coordinate, relative to the bounds of the grid, of the given date.
+        // A `startOfDayDate` must be given for avoiding ambiguity over how to treat midnight.
+        TimeColsSlatsCoords.prototype.computeDateTop = function (when, startOfDayDate) {
+            if (!startOfDayDate) {
+                startOfDayDate = startOfDay(when);
+            }
+            return this.computeTimeTop(createDuration(when.valueOf() - startOfDayDate.valueOf()));
+        };
+        // Computes the top coordinate, relative to the bounds of the grid, of the given time (a Duration).
+        // This is a makeshify way to compute the time-top. Assumes all slatMetas dates are uniform.
+        // Eventually allow computation with arbirary slat dates.
+        TimeColsSlatsCoords.prototype.computeTimeTop = function (duration) {
+            var _a = this, positions = _a.positions, dateProfile = _a.dateProfile, slatMetas = _a.slatMetas;
+            var len = positions.els.length;
+            // we assume dates are uniform
+            var slotDurationMs = slatMetas[1].date.valueOf() - slatMetas[0].date.valueOf();
+            // floating-point value of # of slots covered
+            var slatCoverage = (duration.milliseconds - asRoughMs(dateProfile.slotMinTime)) / slotDurationMs;
+            var slatIndex;
+            var slatRemainder;
+            // compute a floating-point number for how many slats should be progressed through.
+            // from 0 to number of slats (inclusive)
+            // constrained because slotMinTime/slotMaxTime might be customized.
+            slatCoverage = Math.max(0, slatCoverage);
+            slatCoverage = Math.min(len, slatCoverage);
+            // an integer index of the furthest whole slat
+            // from 0 to number slats (*exclusive*, so len-1)
+            slatIndex = Math.floor(slatCoverage);
+            slatIndex = Math.min(slatIndex, len - 1);
+            // how much further through the slatIndex slat (from 0.0-1.0) must be covered in addition.
+            // could be 1.0 if slatCoverage is covering *all* the slots
+            slatRemainder = slatCoverage - slatIndex;
+            return positions.tops[slatIndex] +
+                positions.getHeight(slatIndex) * slatRemainder;
+        };
+        return TimeColsSlatsCoords;
+    }());
+
+    var TimeColsSlatsBody = /** @class */ (function (_super) {
+        __extends(TimeColsSlatsBody, _super);
+        function TimeColsSlatsBody() {
+            return _super !== null && _super.apply(this, arguments) || this;
+        }
+        TimeColsSlatsBody.prototype.render = function () {
+            var _a = this, props = _a.props, context = _a.context;
+            var options = context.options;
+            var slatElRefs = props.slatElRefs;
+            return (createElement("tbody", null, props.slatMetas.map(function (slatMeta, i) {
+                var hookProps = {
+                    time: slatMeta.time,
+                    date: context.dateEnv.toDate(slatMeta.date),
+                    view: context.viewApi,
+                };
+                var classNames = [
+                    'fc-timegrid-slot',
+                    'fc-timegrid-slot-lane',
+                    slatMeta.isLabeled ? '' : 'fc-timegrid-slot-minor',
+                ];
+                return (createElement("tr", { key: slatMeta.key, ref: slatElRefs.createRef(slatMeta.key) },
+                    props.axis && (createElement(TimeColsAxisCell, __assign({}, slatMeta))),
+                    createElement(RenderHook, { hookProps: hookProps, classNames: options.slotLaneClassNames, content: options.slotLaneContent, didMount: options.slotLaneDidMount, willUnmount: options.slotLaneWillUnmount }, function (rootElRef, customClassNames, innerElRef, innerContent) { return (createElement("td", { ref: rootElRef, className: classNames.concat(customClassNames).join(' '), "data-time": slatMeta.isoTimeStr }, innerContent)); })));
+            })));
+        };
+        return TimeColsSlatsBody;
+    }(BaseComponent));
+
+    /*
+    for the horizontal "slats" that run width-wise. Has a time axis on a side. Depends on RTL.
+    */
+    var TimeColsSlats = /** @class */ (function (_super) {
+        __extends(TimeColsSlats, _super);
+        function TimeColsSlats() {
+            var _this = _super !== null && _super.apply(this, arguments) || this;
+            _this.rootElRef = createRef();
+            _this.slatElRefs = new RefMap();
+            return _this;
+        }
+        TimeColsSlats.prototype.render = function () {
+            var _a = this, props = _a.props, context = _a.context;
+            return (createElement("div", { className: "fc-timegrid-slots", ref: this.rootElRef },
+                createElement("table", { className: context.theme.getClass('table'), style: {
+                        minWidth: props.tableMinWidth,
+                        width: props.clientWidth,
+                        height: props.minHeight,
+                    } },
+                    props.tableColGroupNode /* relies on there only being a single <col> for the axis */,
+                    createElement(TimeColsSlatsBody, { slatElRefs: this.slatElRefs, axis: props.axis, slatMetas: props.slatMetas }))));
+        };
+        TimeColsSlats.prototype.componentDidMount = function () {
+            this.updateSizing();
+        };
+        TimeColsSlats.prototype.componentDidUpdate = function () {
+            this.updateSizing();
+        };
+        TimeColsSlats.prototype.componentWillUnmount = function () {
+            if (this.props.onCoords) {
+                this.props.onCoords(null);
+            }
+        };
+        TimeColsSlats.prototype.updateSizing = function () {
+            var props = this.props;
+            if (props.onCoords &&
+                props.clientWidth !== null // means sizing has stabilized
+            ) {
+                var rootEl = this.rootElRef.current;
+                if (rootEl.offsetHeight) { // not hidden by css
+                    props.onCoords(new TimeColsSlatsCoords(new PositionCache(this.rootElRef.current, collectSlatEls(this.slatElRefs.currentMap, props.slatMetas), false, true), this.props.dateProfile, props.slatMetas));
+                }
+            }
+        };
+        return TimeColsSlats;
+    }(BaseComponent));
+    function collectSlatEls(elMap, slatMetas) {
+        return slatMetas.map(function (slatMeta) { return elMap[slatMeta.key]; });
+    }
+
+    function splitSegsByCol(segs, colCnt) {
+        var segsByCol = [];
+        var i;
+        for (i = 0; i < colCnt; i += 1) {
+            segsByCol.push([]);
+        }
+        if (segs) {
+            for (i = 0; i < segs.length; i += 1) {
+                segsByCol[segs[i].col].push(segs[i]);
+            }
+        }
+        return segsByCol;
+    }
+    function splitInteractionByCol(ui, colCnt) {
+        var byRow = [];
+        if (!ui) {
+            for (var i = 0; i < colCnt; i += 1) {
+                byRow[i] = null;
+            }
+        }
+        else {
+            for (var i = 0; i < colCnt; i += 1) {
+                byRow[i] = {
+                    affectedInstances: ui.affectedInstances,
+                    isEvent: ui.isEvent,
+                    segs: [],
+                };
+            }
+            for (var _i = 0, _a = ui.segs; _i < _a.length; _i++) {
+                var seg = _a[_i];
+                byRow[seg.col].segs.push(seg);
+            }
+        }
+        return byRow;
+    }
+
+    // UNFORTUNATELY, assigns results to the top/bottom/level/forwardCoord/backwardCoord props of the actual segs.
+    // TODO: return hash (by instanceId) of results
+    function computeSegCoords(segs, dayDate, slatCoords, eventMinHeight, eventOrderSpecs) {
+        computeSegVerticals(segs, dayDate, slatCoords, eventMinHeight);
+        return computeSegHorizontals(segs, eventOrderSpecs); // requires top/bottom from computeSegVerticals
+    }
+    // For each segment in an array, computes and assigns its top and bottom properties
+    function computeSegVerticals(segs, dayDate, slatCoords, eventMinHeight) {
+        for (var _i = 0, segs_1 = segs; _i < segs_1.length; _i++) {
+            var seg = segs_1[_i];
+            seg.top = slatCoords.computeDateTop(seg.start, dayDate);
+            seg.bottom = Math.max(seg.top + (eventMinHeight || 0), // yuck
+            slatCoords.computeDateTop(seg.end, dayDate));
+        }
+    }
+    // Given an array of segments that are all in the same column, sets the backwardCoord and forwardCoord on each.
+    // Assumed the segs are already ordered.
+    // NOTE: Also reorders the given array by date!
+    function computeSegHorizontals(segs, eventOrderSpecs) {
+        // IMPORTANT TO CLEAR OLD RESULTS :(
+        for (var _i = 0, segs_2 = segs; _i < segs_2.length; _i++) {
+            var seg = segs_2[_i];
+            seg.level = null;
+            seg.forwardCoord = null;
+            seg.backwardCoord = null;
+            seg.forwardPressure = null;
+        }
+        segs = sortEventSegs(segs, eventOrderSpecs);
+        var level0;
+        var levels = buildSlotSegLevels(segs);
+        computeForwardSlotSegs(levels);
+        if ((level0 = levels[0])) {
+            for (var _a = 0, level0_1 = level0; _a < level0_1.length; _a++) {
+                var seg = level0_1[_a];
+                computeSlotSegPressures(seg);
+            }
+            for (var _b = 0, level0_2 = level0; _b < level0_2.length; _b++) {
+                var seg = level0_2[_b];
+                computeSegForwardBack(seg, 0, 0, eventOrderSpecs);
+            }
+        }
+        return segs;
+    }
+    // Builds an array of segments "levels". The first level will be the leftmost tier of segments if the calendar is
+    // left-to-right, or the rightmost if the calendar is right-to-left. Assumes the segments are already ordered by date.
+    function buildSlotSegLevels(segs) {
+        var levels = [];
+        var i;
+        var seg;
+        var j;
+        for (i = 0; i < segs.length; i += 1) {
+            seg = segs[i];
+            // go through all the levels and stop on the first level where there are no collisions
+            for (j = 0; j < levels.length; j += 1) {
+                if (!computeSlotSegCollisions(seg, levels[j]).length) {
+                    break;
+                }
+            }
+            seg.level = j;
+            (levels[j] || (levels[j] = [])).push(seg);
+        }
+        return levels;
+    }
+    // Find all the segments in `otherSegs` that vertically collide with `seg`.
+    // Append into an optionally-supplied `results` array and return.
+    function computeSlotSegCollisions(seg, otherSegs, results) {
+        if (results === void 0) { results = []; }
+        for (var i = 0; i < otherSegs.length; i += 1) {
+            if (isSlotSegCollision(seg, otherSegs[i])) {
+                results.push(otherSegs[i]);
+            }
+        }
+        return results;
+    }
+    // Do these segments occupy the same vertical space?
+    function isSlotSegCollision(seg1, seg2) {
+        return seg1.bottom > seg2.top && seg1.top < seg2.bottom;
+    }
+    // For every segment, figure out the other segments that are in subsequent
+    // levels that also occupy the same vertical space. Accumulate in seg.forwardSegs
+    function computeForwardSlotSegs(levels) {
+        var i;
+        var level;
+        var j;
+        var seg;
+        var k;
+        for (i = 0; i < levels.length; i += 1) {
+            level = levels[i];
+            for (j = 0; j < level.length; j += 1) {
+                seg = level[j];
+                seg.forwardSegs = [];
+                for (k = i + 1; k < levels.length; k += 1) {
+                    computeSlotSegCollisions(seg, levels[k], seg.forwardSegs);
+                }
+            }
+        }
+    }
+    // Figure out which path forward (via seg.forwardSegs) results in the longest path until
+    // the furthest edge is reached. The number of segments in this path will be seg.forwardPressure
+    function computeSlotSegPressures(seg) {
+        var forwardSegs = seg.forwardSegs;
+        var forwardPressure = 0;
+        var i;
+        var forwardSeg;
+        if (seg.forwardPressure == null) { // not already computed
+            for (i = 0; i < forwardSegs.length; i += 1) {
+                forwardSeg = forwardSegs[i];
+                // figure out the child's maximum forward path
+                computeSlotSegPressures(forwardSeg);
+                // either use the existing maximum, or use the child's forward pressure
+                // plus one (for the forwardSeg itself)
+                forwardPressure = Math.max(forwardPressure, 1 + forwardSeg.forwardPressure);
+            }
+            seg.forwardPressure = forwardPressure;
+        }
+    }
+    // Calculate seg.forwardCoord and seg.backwardCoord for the segment, where both values range
+    // from 0 to 1. If the calendar is left-to-right, the seg.backwardCoord maps to "left" and
+    // seg.forwardCoord maps to "right" (via percentage). Vice-versa if the calendar is right-to-left.
+    //
+    // The segment might be part of a "series", which means consecutive segments with the same pressure
+    // who's width is unknown until an edge has been hit. `seriesBackwardPressure` is the number of
+    // segments behind this one in the current series, and `seriesBackwardCoord` is the starting
+    // coordinate of the first segment in the series.
+    function computeSegForwardBack(seg, seriesBackwardPressure, seriesBackwardCoord, eventOrderSpecs) {
+        var forwardSegs = seg.forwardSegs;
+        var i;
+        if (seg.forwardCoord == null) { // not already computed
+            if (!forwardSegs.length) {
+                // if there are no forward segments, this segment should butt up against the edge
+                seg.forwardCoord = 1;
+            }
+            else {
+                // sort highest pressure first
+                sortForwardSegs(forwardSegs, eventOrderSpecs);
+                // this segment's forwardCoord will be calculated from the backwardCoord of the
+                // highest-pressure forward segment.
+                computeSegForwardBack(forwardSegs[0], seriesBackwardPressure + 1, seriesBackwardCoord, eventOrderSpecs);
+                seg.forwardCoord = forwardSegs[0].backwardCoord;
+            }
+            // calculate the backwardCoord from the forwardCoord. consider the series
+            seg.backwardCoord = seg.forwardCoord -
+                (seg.forwardCoord - seriesBackwardCoord) / // available width for series
+                    (seriesBackwardPressure + 1); // # of segments in the series
+            // use this segment's coordinates to computed the coordinates of the less-pressurized
+            // forward segments
+            for (i = 0; i < forwardSegs.length; i += 1) {
+                computeSegForwardBack(forwardSegs[i], 0, seg.forwardCoord, eventOrderSpecs);
+            }
+        }
+    }
+    function sortForwardSegs(forwardSegs, eventOrderSpecs) {
+        var objs = forwardSegs.map(buildTimeGridSegCompareObj);
+        var specs = [
+            // put higher-pressure first
+            { field: 'forwardPressure', order: -1 },
+            // put segments that are closer to initial edge first (and favor ones with no coords yet)
+            { field: 'backwardCoord', order: 1 },
+        ].concat(eventOrderSpecs);
+        objs.sort(function (obj0, obj1) { return compareByFieldSpecs(obj0, obj1, specs); });
+        return objs.map(function (c) { return c._seg; });
+    }
+    function buildTimeGridSegCompareObj(seg) {
+        var obj = buildSegCompareObj(seg);
+        obj.forwardPressure = seg.forwardPressure;
+        obj.backwardCoord = seg.backwardCoord;
+        return obj;
+    }
+
+    var DEFAULT_TIME_FORMAT = createFormatter({
+        hour: 'numeric',
+        minute: '2-digit',
+        meridiem: false,
+    });
+    var TimeColEvent = /** @class */ (function (_super) {
+        __extends(TimeColEvent, _super);
+        function TimeColEvent() {
+            return _super !== null && _super.apply(this, arguments) || this;
+        }
+        TimeColEvent.prototype.render = function () {
+            var classNames = [
+                'fc-timegrid-event',
+                'fc-v-event',
+            ];
+            if (this.props.isCondensed) {
+                classNames.push('fc-timegrid-event-condensed');
+            }
+            return (createElement(StandardEvent, __assign({}, this.props, { defaultTimeFormat: DEFAULT_TIME_FORMAT, extraClassNames: classNames })));
+        };
+        return TimeColEvent;
+    }(BaseComponent));
+
+    var TimeColMisc = /** @class */ (function (_super) {
+        __extends(TimeColMisc, _super);
+        function TimeColMisc() {
+            return _super !== null && _super.apply(this, arguments) || this;
+        }
+        TimeColMisc.prototype.render = function () {
+            var props = this.props;
+            return (createElement(DayCellContent, { date: props.date, dateProfile: props.dateProfile, todayRange: props.todayRange, extraHookProps: props.extraHookProps }, function (innerElRef, innerContent) { return (innerContent &&
+                createElement("div", { className: "fc-timegrid-col-misc", ref: innerElRef }, innerContent)); }));
+        };
+        return TimeColMisc;
+    }(BaseComponent));
+
+    config.timeGridEventCondensedHeight = 30;
+    var TimeCol = /** @class */ (function (_super) {
+        __extends(TimeCol, _super);
+        function TimeCol() {
+            return _super !== null && _super.apply(this, arguments) || this;
+        }
+        TimeCol.prototype.render = function () {
+            var _this = this;
+            var _a = this, props = _a.props, context = _a.context;
+            var isSelectMirror = context.options.selectMirror;
+            var mirrorSegs = (props.eventDrag && props.eventDrag.segs) ||
+                (props.eventResize && props.eventResize.segs) ||
+                (isSelectMirror && props.dateSelectionSegs) ||
+                [];
+            var interactionAffectedInstances = // TODO: messy way to compute this
+             (props.eventDrag && props.eventDrag.affectedInstances) ||
+                (props.eventResize && props.eventResize.affectedInstances) ||
+                {};
+            return (createElement(DayCellRoot, { elRef: props.elRef, date: props.date, dateProfile: props.dateProfile, todayRange: props.todayRange, extraHookProps: props.extraHookProps }, function (rootElRef, classNames, dataAttrs) { return (createElement("td", __assign({ ref: rootElRef, className: ['fc-timegrid-col'].concat(classNames, props.extraClassNames || []).join(' ') }, dataAttrs, props.extraDataAttrs),
+                createElement("div", { className: "fc-timegrid-col-frame" },
+                    createElement("div", { className: "fc-timegrid-col-bg" },
+                        _this.renderFillSegs(props.businessHourSegs, 'non-business'),
+                        _this.renderFillSegs(props.bgEventSegs, 'bg-event'),
+                        _this.renderFillSegs(props.dateSelectionSegs, 'highlight')),
+                    createElement("div", { className: "fc-timegrid-col-events" }, _this.renderFgSegs(props.fgEventSegs, interactionAffectedInstances)),
+                    createElement("div", { className: "fc-timegrid-col-events" }, _this.renderFgSegs(mirrorSegs, {}, Boolean(props.eventDrag), Boolean(props.eventResize), Boolean(isSelectMirror))),
+                    createElement("div", { className: "fc-timegrid-now-indicator-container" }, _this.renderNowIndicator(props.nowIndicatorSegs)),
+                    createElement(TimeColMisc, { date: props.date, dateProfile: props.dateProfile, todayRange: props.todayRange, extraHookProps: props.extraHookProps })))); }));
+        };
+        TimeCol.prototype.renderFgSegs = function (segs, segIsInvisible, isDragging, isResizing, isDateSelecting) {
+            var props = this.props;
+            if (props.forPrint) {
+                return this.renderPrintFgSegs(segs);
+            }
+            if (props.slatCoords) {
+                return this.renderPositionedFgSegs(segs, segIsInvisible, isDragging, isResizing, isDateSelecting);
+            }
+            return null;
+        };
+        TimeCol.prototype.renderPrintFgSegs = function (segs) {
+            var _a = this, props = _a.props, context = _a.context;
+            // not DRY
+            segs = sortEventSegs(segs, context.options.eventOrder);
+            return segs.map(function (seg) { return (createElement("div", { className: "fc-timegrid-event-harness", key: seg.eventRange.instance.instanceId },
+                createElement(TimeColEvent, __assign({ seg: seg, isDragging: false, isResizing: false, isDateSelecting: false, isSelected: false, isCondensed: false }, getSegMeta(seg, props.todayRange, props.nowDate))))); });
+        };
+        TimeCol.prototype.renderPositionedFgSegs = function (segs, segIsInvisible, isDragging, isResizing, isDateSelecting) {
+            var _this = this;
+            var _a = this, context = _a.context, props = _a.props;
+            // assigns TO THE SEGS THEMSELVES
+            // also, receives resorted array
+            segs = computeSegCoords(segs, props.date, props.slatCoords, context.options.eventMinHeight, context.options.eventOrder);
+            return segs.map(function (seg) {
+                var instanceId = seg.eventRange.instance.instanceId;
+                var isMirror = isDragging || isResizing || isDateSelecting;
+                var positionCss = isMirror
+                    // will span entire column width
+                    // also, won't assign z-index, which is good, fc-event-mirror will overpower other harnesses
+                    ? __assign({ left: 0, right: 0 }, _this.computeSegTopBottomCss(seg)) : _this.computeFgSegPositionCss(seg);
+                return (createElement("div", { className: 'fc-timegrid-event-harness' + (seg.level > 0 ? ' fc-timegrid-event-harness-inset' : ''), key: instanceId, style: __assign({ visibility: segIsInvisible[instanceId] ? 'hidden' : '' }, positionCss) },
+                    createElement(TimeColEvent, __assign({ seg: seg, isDragging: isDragging, isResizing: isResizing, isDateSelecting: isDateSelecting, isSelected: instanceId === props.eventSelection, isCondensed: (seg.bottom - seg.top) < config.timeGridEventCondensedHeight }, getSegMeta(seg, props.todayRange, props.nowDate)))));
+            });
+        };
+        TimeCol.prototype.renderFillSegs = function (segs, fillType) {
+            var _this = this;
+            var _a = this, context = _a.context, props = _a.props;
+            if (!props.slatCoords) {
+                return null;
+            }
+            // BAD: assigns TO THE SEGS THEMSELVES
+            computeSegVerticals(segs, props.date, props.slatCoords, context.options.eventMinHeight);
+            var children = segs.map(function (seg) { return (createElement("div", { key: buildEventRangeKey(seg.eventRange), className: "fc-timegrid-bg-harness", style: _this.computeSegTopBottomCss(seg) }, fillType === 'bg-event' ?
+                createElement(BgEvent, __assign({ seg: seg }, getSegMeta(seg, props.todayRange, props.nowDate))) :
+                renderFill(fillType))); });
+            return createElement(Fragment, null, children);
+        };
+        TimeCol.prototype.renderNowIndicator = function (segs) {
+            var _a = this.props, slatCoords = _a.slatCoords, date = _a.date;
+            if (!slatCoords) {
+                return null;
+            }
+            return segs.map(function (seg, i) { return (createElement(NowIndicatorRoot, { isAxis: false, date: date, 
+                // key doesn't matter. will only ever be one
+                key: i }, function (rootElRef, classNames, innerElRef, innerContent) { return (createElement("div", { ref: rootElRef, className: ['fc-timegrid-now-indicator-line'].concat(classNames).join(' '), style: { top: slatCoords.computeDateTop(seg.start, date) } }, innerContent)); })); });
+        };
+        TimeCol.prototype.computeFgSegPositionCss = function (seg) {
+            var _a = this.context, isRtl = _a.isRtl, options = _a.options;
+            var shouldOverlap = options.slotEventOverlap;
+            var backwardCoord = seg.backwardCoord; // the left side if LTR. the right side if RTL. floating-point
+            var forwardCoord = seg.forwardCoord; // the right side if LTR. the left side if RTL. floating-point
+            var left; // amount of space from left edge, a fraction of the total width
+            var right; // amount of space from right edge, a fraction of the total width
+            if (shouldOverlap) {
+                // double the width, but don't go beyond the maximum forward coordinate (1.0)
+                forwardCoord = Math.min(1, backwardCoord + (forwardCoord - backwardCoord) * 2);
+            }
+            if (isRtl) {
+                left = 1 - forwardCoord;
+                right = backwardCoord;
+            }
+            else {
+                left = backwardCoord;
+                right = 1 - forwardCoord;
+            }
+            var props = {
+                zIndex: seg.level + 1,
+                left: left * 100 + '%',
+                right: right * 100 + '%',
+            };
+            if (shouldOverlap && seg.forwardPressure) {
+                // add padding to the edge so that forward stacked events don't cover the resizer's icon
+                props[isRtl ? 'marginLeft' : 'marginRight'] = 10 * 2; // 10 is a guesstimate of the icon's width
+            }
+            return __assign(__assign({}, props), this.computeSegTopBottomCss(seg));
+        };
+        TimeCol.prototype.computeSegTopBottomCss = function (seg) {
+            return {
+                top: seg.top,
+                bottom: -seg.bottom,
+            };
+        };
+        return TimeCol;
+    }(BaseComponent));
+
+    var TimeColsContent = /** @class */ (function (_super) {
+        __extends(TimeColsContent, _super);
+        function TimeColsContent() {
+            var _this = _super !== null && _super.apply(this, arguments) || this;
+            _this.splitFgEventSegs = memoize(splitSegsByCol);
+            _this.splitBgEventSegs = memoize(splitSegsByCol);
+            _this.splitBusinessHourSegs = memoize(splitSegsByCol);
+            _this.splitNowIndicatorSegs = memoize(splitSegsByCol);
+            _this.splitDateSelectionSegs = memoize(splitSegsByCol);
+            _this.splitEventDrag = memoize(splitInteractionByCol);
+            _this.splitEventResize = memoize(splitInteractionByCol);
+            _this.rootElRef = createRef();
+            _this.cellElRefs = new RefMap();
+            return _this;
+        }
+        TimeColsContent.prototype.render = function () {
+            var _this = this;
+            var _a = this, props = _a.props, context = _a.context;
+            var nowIndicatorTop = context.options.nowIndicator &&
+                props.slatCoords &&
+                props.slatCoords.safeComputeTop(props.nowDate); // might return void
+            var colCnt = props.cells.length;
+            var fgEventSegsByRow = this.splitFgEventSegs(props.fgEventSegs, colCnt);
+            var bgEventSegsByRow = this.splitBgEventSegs(props.bgEventSegs, colCnt);
+            var businessHourSegsByRow = this.splitBusinessHourSegs(props.businessHourSegs, colCnt);
+            var nowIndicatorSegsByRow = this.splitNowIndicatorSegs(props.nowIndicatorSegs, colCnt);
+            var dateSelectionSegsByRow = this.splitDateSelectionSegs(props.dateSelectionSegs, colCnt);
+            var eventDragByRow = this.splitEventDrag(props.eventDrag, colCnt);
+            var eventResizeByRow = this.splitEventResize(props.eventResize, colCnt);
+            return (createElement("div", { className: "fc-timegrid-cols", ref: this.rootElRef },
+                createElement("table", { style: {
+                        minWidth: props.tableMinWidth,
+                        width: props.clientWidth,
+                    } },
+                    props.tableColGroupNode,
+                    createElement("tbody", null,
+                        createElement("tr", null,
+                            props.axis && (createElement("td", { className: "fc-timegrid-col fc-timegrid-axis" },
+                                createElement("div", { className: "fc-timegrid-col-frame" },
+                                    createElement("div", { className: "fc-timegrid-now-indicator-container" }, typeof nowIndicatorTop === 'number' && (createElement(NowIndicatorRoot, { isAxis: true, date: props.nowDate }, function (rootElRef, classNames, innerElRef, innerContent) { return (createElement("div", { ref: rootElRef, className: ['fc-timegrid-now-indicator-arrow'].concat(classNames).join(' '), style: { top: nowIndicatorTop } }, innerContent)); })))))),
+                            props.cells.map(function (cell, i) { return (createElement(TimeCol, { key: cell.key, elRef: _this.cellElRefs.createRef(cell.key), dateProfile: props.dateProfile, date: cell.date, nowDate: props.nowDate, todayRange: props.todayRange, extraHookProps: cell.extraHookProps, extraDataAttrs: cell.extraDataAttrs, extraClassNames: cell.extraClassNames, fgEventSegs: fgEventSegsByRow[i], bgEventSegs: bgEventSegsByRow[i], businessHourSegs: businessHourSegsByRow[i], nowIndicatorSegs: nowIndicatorSegsByRow[i], dateSelectionSegs: dateSelectionSegsByRow[i], eventDrag: eventDragByRow[i], eventResize: eventResizeByRow[i], slatCoords: props.slatCoords, eventSelection: props.eventSelection, forPrint: props.forPrint })); }))))));
+        };
+        TimeColsContent.prototype.componentDidMount = function () {
+            this.updateCoords();
+        };
+        TimeColsContent.prototype.componentDidUpdate = function () {
+            this.updateCoords();
+        };
+        TimeColsContent.prototype.updateCoords = function () {
+            var props = this.props;
+            if (props.onColCoords &&
+                props.clientWidth !== null // means sizing has stabilized
+            ) {
+                props.onColCoords(new PositionCache(this.rootElRef.current, collectCellEls(this.cellElRefs.currentMap, props.cells), true, // horizontal
+                false));
+            }
+        };
+        return TimeColsContent;
+    }(BaseComponent));
+    function collectCellEls(elMap, cells) {
+        return cells.map(function (cell) { return elMap[cell.key]; });
+    }
+
+    /* A component that renders one or more columns of vertical time slots
+    ----------------------------------------------------------------------------------------------------------------------*/
+    var TimeCols = /** @class */ (function (_super) {
+        __extends(TimeCols, _super);
+        function TimeCols() {
+            var _this = _super !== null && _super.apply(this, arguments) || this;
+            _this.processSlotOptions = memoize(processSlotOptions);
+            _this.state = {
+                slatCoords: null,
+            };
+            _this.handleScrollRequest = function (request) {
+                var onScrollTopRequest = _this.props.onScrollTopRequest;
+                var slatCoords = _this.state.slatCoords;
+                if (onScrollTopRequest && slatCoords) {
+                    if (request.time) {
+                        var top_1 = slatCoords.computeTimeTop(request.time);
+                        top_1 = Math.ceil(top_1); // zoom can give weird floating-point values. rather scroll a little bit further
+                        if (top_1) {
+                            top_1 += 1; // to overcome top border that slots beyond the first have. looks better
+                        }
+                        onScrollTopRequest(top_1);
+                    }
+                    return true;
+                }
+                return false;
+            };
+            _this.handleColCoords = function (colCoords) {
+                _this.colCoords = colCoords;
+            };
+            _this.handleSlatCoords = function (slatCoords) {
+                _this.setState({ slatCoords: slatCoords });
+                if (_this.props.onSlatCoords) {
+                    _this.props.onSlatCoords(slatCoords);
+                }
+            };
+            return _this;
+        }
+        TimeCols.prototype.render = function () {
+            var _a = this, props = _a.props, state = _a.state;
+            return (createElement("div", { className: "fc-timegrid-body", ref: props.rootElRef, style: {
+                    // these props are important to give this wrapper correct dimensions for interactions
+                    // TODO: if we set it here, can we avoid giving to inner tables?
+                    width: props.clientWidth,
+                    minWidth: props.tableMinWidth,
+                } },
+                createElement(TimeColsSlats, { axis: props.axis, dateProfile: props.dateProfile, slatMetas: props.slatMetas, clientWidth: props.clientWidth, minHeight: props.expandRows ? props.clientHeight : '', tableMinWidth: props.tableMinWidth, tableColGroupNode: props.axis ? props.tableColGroupNode : null /* axis depends on the colgroup's shrinking */, onCoords: this.handleSlatCoords }),
+                createElement(TimeColsContent, { cells: props.cells, axis: props.axis, dateProfile: props.dateProfile, businessHourSegs: props.businessHourSegs, bgEventSegs: props.bgEventSegs, fgEventSegs: props.fgEventSegs, dateSelectionSegs: props.dateSelectionSegs, eventSelection: props.eventSelection, eventDrag: props.eventDrag, eventResize: props.eventResize, todayRange: props.todayRange, nowDate: props.nowDate, nowIndicatorSegs: props.nowIndicatorSegs, clientWidth: props.clientWidth, tableMinWidth: props.tableMinWidth, tableColGroupNode: props.tableColGroupNode, slatCoords: state.slatCoords, onColCoords: this.handleColCoords, forPrint: props.forPrint })));
+        };
+        TimeCols.prototype.componentDidMount = function () {
+            this.scrollResponder = this.context.createScrollResponder(this.handleScrollRequest);
+        };
+        TimeCols.prototype.componentDidUpdate = function (prevProps) {
+            this.scrollResponder.update(prevProps.dateProfile !== this.props.dateProfile);
+        };
+        TimeCols.prototype.componentWillUnmount = function () {
+            this.scrollResponder.detach();
+        };
+        TimeCols.prototype.positionToHit = function (positionLeft, positionTop) {
+            var _a = this.context, dateEnv = _a.dateEnv, options = _a.options;
+            var colCoords = this.colCoords;
+            var dateProfile = this.props.dateProfile;
+            var slatCoords = this.state.slatCoords;
+            var _b = this.processSlotOptions(this.props.slotDuration, options.snapDuration), snapDuration = _b.snapDuration, snapsPerSlot = _b.snapsPerSlot;
+            var colIndex = colCoords.leftToIndex(positionLeft);
+            var slatIndex = slatCoords.positions.topToIndex(positionTop);
+            if (colIndex != null && slatIndex != null) {
+                var slatTop = slatCoords.positions.tops[slatIndex];
+                var slatHeight = slatCoords.positions.getHeight(slatIndex);
+                var partial = (positionTop - slatTop) / slatHeight; // floating point number between 0 and 1
+                var localSnapIndex = Math.floor(partial * snapsPerSlot); // the snap # relative to start of slat
+                var snapIndex = slatIndex * snapsPerSlot + localSnapIndex;
+                var dayDate = this.props.cells[colIndex].date;
+                var time = addDurations(dateProfile.slotMinTime, multiplyDuration(snapDuration, snapIndex));
+                var start = dateEnv.add(dayDate, time);
+                var end = dateEnv.add(start, snapDuration);
+                return {
+                    col: colIndex,
+                    dateSpan: {
+                        range: { start: start, end: end },
+                        allDay: false,
+                    },
+                    dayEl: colCoords.els[colIndex],
+                    relativeRect: {
+                        left: colCoords.lefts[colIndex],
+                        right: colCoords.rights[colIndex],
+                        top: slatTop,
+                        bottom: slatTop + slatHeight,
+                    },
+                };
+            }
+            return null;
+        };
+        return TimeCols;
+    }(BaseComponent));
+    function processSlotOptions(slotDuration, snapDurationOverride) {
+        var snapDuration = snapDurationOverride || slotDuration;
+        var snapsPerSlot = wholeDivideDurations(slotDuration, snapDuration);
+        if (snapsPerSlot === null) {
+            snapDuration = slotDuration;
+            snapsPerSlot = 1;
+            // TODO: say warning?
+        }
+        return { snapDuration: snapDuration, snapsPerSlot: snapsPerSlot };
+    }
+
+    var DayTimeColsSlicer = /** @class */ (function (_super) {
+        __extends(DayTimeColsSlicer, _super);
+        function DayTimeColsSlicer() {
+            return _super !== null && _super.apply(this, arguments) || this;
+        }
+        DayTimeColsSlicer.prototype.sliceRange = function (range, dayRanges) {
+            var segs = [];
+            for (var col = 0; col < dayRanges.length; col += 1) {
+                var segRange = intersectRanges(range, dayRanges[col]);
+                if (segRange) {
+                    segs.push({
+                        start: segRange.start,
+                        end: segRange.end,
+                        isStart: segRange.start.valueOf() === range.start.valueOf(),
+                        isEnd: segRange.end.valueOf() === range.end.valueOf(),
+                        col: col,
+                    });
+                }
+            }
+            return segs;
+        };
+        return DayTimeColsSlicer;
+    }(Slicer));
+
+    var DayTimeCols = /** @class */ (function (_super) {
+        __extends(DayTimeCols, _super);
+        function DayTimeCols() {
+            var _this = _super !== null && _super.apply(this, arguments) || this;
+            _this.buildDayRanges = memoize(buildDayRanges);
+            _this.slicer = new DayTimeColsSlicer();
+            _this.timeColsRef = createRef();
+            _this.handleRootEl = function (rootEl) {
+                if (rootEl) {
+                    _this.context.registerInteractiveComponent(_this, { el: rootEl });
+                }
+                else {
+                    _this.context.unregisterInteractiveComponent(_this);
+                }
+            };
+            return _this;
+        }
+        DayTimeCols.prototype.render = function () {
+            var _this = this;
+            var _a = this, props = _a.props, context = _a.context;
+            var dateProfile = props.dateProfile, dayTableModel = props.dayTableModel;
+            var isNowIndicator = context.options.nowIndicator;
+            var dayRanges = this.buildDayRanges(dayTableModel, dateProfile, context.dateEnv);
+            // give it the first row of cells
+            // TODO: would move this further down hierarchy, but sliceNowDate needs it
+            return (createElement(NowTimer, { unit: isNowIndicator ? 'minute' : 'day' }, function (nowDate, todayRange) { return (createElement(TimeCols, __assign({ ref: _this.timeColsRef, rootElRef: _this.handleRootEl }, _this.slicer.sliceProps(props, dateProfile, null, context, dayRanges), { forPrint: props.forPrint, axis: props.axis, dateProfile: dateProfile, slatMetas: props.slatMetas, slotDuration: props.slotDuration, cells: dayTableModel.cells[0], tableColGroupNode: props.tableColGroupNode, tableMinWidth: props.tableMinWidth, clientWidth: props.clientWidth, clientHeight: props.clientHeight, expandRows: props.expandRows, nowDate: nowDate, nowIndicatorSegs: isNowIndicator && _this.slicer.sliceNowDate(nowDate, context, dayRanges), todayRange: todayRange, onScrollTopRequest: props.onScrollTopRequest, onSlatCoords: props.onSlatCoords }))); }));
+        };
+        DayTimeCols.prototype.queryHit = function (positionLeft, positionTop) {
+            var rawHit = this.timeColsRef.current.positionToHit(positionLeft, positionTop);
+            if (rawHit) {
+                return {
+                    component: this,
+                    dateSpan: rawHit.dateSpan,
+                    dayEl: rawHit.dayEl,
+                    rect: {
+                        left: rawHit.relativeRect.left,
+                        right: rawHit.relativeRect.right,
+                        top: rawHit.relativeRect.top,
+                        bottom: rawHit.relativeRect.bottom,
+                    },
+                    layer: 0,
+                };
+            }
+            return null;
+        };
+        return DayTimeCols;
+    }(DateComponent));
+    function buildDayRanges(dayTableModel, dateProfile, dateEnv) {
+        var ranges = [];
+        for (var _i = 0, _a = dayTableModel.headerDates; _i < _a.length; _i++) {
+            var date = _a[_i];
+            ranges.push({
+                start: dateEnv.add(date, dateProfile.slotMinTime),
+                end: dateEnv.add(date, dateProfile.slotMaxTime),
+            });
+        }
+        return ranges;
+    }
+
+    // potential nice values for the slot-duration and interval-duration
+    // from largest to smallest
+    var STOCK_SUB_DURATIONS = [
+        { hours: 1 },
+        { minutes: 30 },
+        { minutes: 15 },
+        { seconds: 30 },
+        { seconds: 15 },
+    ];
+    function buildSlatMetas(slotMinTime, slotMaxTime, explicitLabelInterval, slotDuration, dateEnv) {
+        var dayStart = new Date(0);
+        var slatTime = slotMinTime;
+        var slatIterator = createDuration(0);
+        var labelInterval = explicitLabelInterval || computeLabelInterval(slotDuration);
+        var metas = [];
+        while (asRoughMs(slatTime) < asRoughMs(slotMaxTime)) {
+            var date = dateEnv.add(dayStart, slatTime);
+            var isLabeled = wholeDivideDurations(slatIterator, labelInterval) !== null;
+            metas.push({
+                date: date,
+                time: slatTime,
+                key: date.toISOString(),
+                isoTimeStr: formatIsoTimeString(date),
+                isLabeled: isLabeled,
+            });
+            slatTime = addDurations(slatTime, slotDuration);
+            slatIterator = addDurations(slatIterator, slotDuration);
+        }
+        return metas;
+    }
+    // Computes an automatic value for slotLabelInterval
+    function computeLabelInterval(slotDuration) {
+        var i;
+        var labelInterval;
+        var slotsPerLabel;
+        // find the smallest stock label interval that results in more than one slots-per-label
+        for (i = STOCK_SUB_DURATIONS.length - 1; i >= 0; i -= 1) {
+            labelInterval = createDuration(STOCK_SUB_DURATIONS[i]);
+            slotsPerLabel = wholeDivideDurations(labelInterval, slotDuration);
+            if (slotsPerLabel !== null && slotsPerLabel > 1) {
+                return labelInterval;
+            }
+        }
+        return slotDuration; // fall back
+    }
+
+    var DayTimeColsView = /** @class */ (function (_super) {
+        __extends(DayTimeColsView, _super);
+        function DayTimeColsView() {
+            var _this = _super !== null && _super.apply(this, arguments) || this;
+            _this.buildTimeColsModel = memoize(buildTimeColsModel);
+            _this.buildSlatMetas = memoize(buildSlatMetas);
+            return _this;
+        }
+        DayTimeColsView.prototype.render = function () {
+            var _this = this;
+            var _a = this.context, options = _a.options, dateEnv = _a.dateEnv, dateProfileGenerator = _a.dateProfileGenerator;
+            var props = this.props;
+            var dateProfile = props.dateProfile;
+            var dayTableModel = this.buildTimeColsModel(dateProfile, dateProfileGenerator);
+            var splitProps = this.allDaySplitter.splitProps(props);
+            var slatMetas = this.buildSlatMetas(dateProfile.slotMinTime, dateProfile.slotMaxTime, options.slotLabelInterval, options.slotDuration, dateEnv);
+            var dayMinWidth = options.dayMinWidth;
+            var hasAttachedAxis = !dayMinWidth;
+            var hasDetachedAxis = dayMinWidth;
+            var headerContent = options.dayHeaders && (createElement(DayHeader, { dates: dayTableModel.headerDates, dateProfile: dateProfile, datesRepDistinctDays: true, renderIntro: hasAttachedAxis ? this.renderHeadAxis : null }));
+            var allDayContent = (options.allDaySlot !== false) && (function (contentArg) { return (createElement(DayTable, __assign({}, splitProps.allDay, { dateProfile: dateProfile, dayTableModel: dayTableModel, nextDayThreshold: options.nextDayThreshold, tableMinWidth: contentArg.tableMinWidth, colGroupNode: contentArg.tableColGroupNode, renderRowIntro: hasAttachedAxis ? _this.renderTableRowAxis : null, showWeekNumbers: false, expandRows: false, headerAlignElRef: _this.headerElRef, clientWidth: contentArg.clientWidth, clientHeight: contentArg.clientHeight, forPrint: props.forPrint }, _this.getAllDayMaxEventProps()))); });
+            var timeGridContent = function (contentArg) { return (createElement(DayTimeCols, __assign({}, splitProps.timed, { dayTableModel: dayTableModel, dateProfile: dateProfile, axis: hasAttachedAxis, slotDuration: options.slotDuration, slatMetas: slatMetas, forPrint: props.forPrint, tableColGroupNode: contentArg.tableColGroupNode, tableMinWidth: contentArg.tableMinWidth, clientWidth: contentArg.clientWidth, clientHeight: contentArg.clientHeight, onSlatCoords: _this.handleSlatCoords, expandRows: contentArg.expandRows, onScrollTopRequest: _this.handleScrollTopRequest }))); };
+            return hasDetachedAxis
+                ? this.renderHScrollLayout(headerContent, allDayContent, timeGridContent, dayTableModel.colCnt, dayMinWidth, slatMetas, this.state.slatCoords)
+                : this.renderSimpleLayout(headerContent, allDayContent, timeGridContent);
+        };
+        return DayTimeColsView;
+    }(TimeColsView));
+    function buildTimeColsModel(dateProfile, dateProfileGenerator) {
+        var daySeries = new DaySeriesModel(dateProfile.renderRange, dateProfileGenerator);
+        return new DayTableModel(daySeries, false);
+    }
+
+    var OPTION_REFINERS$2 = {
+        allDaySlot: Boolean,
+    };
+
+    var timeGridPlugin = createPlugin({
+        initialView: 'timeGridWeek',
+        optionRefiners: OPTION_REFINERS$2,
+        views: {
+            timeGrid: {
+                component: DayTimeColsView,
+                usesMinMaxTime: true,
+                allDaySlot: true,
+                slotDuration: '00:30:00',
+                slotEventOverlap: true,
+            },
+            timeGridDay: {
+                type: 'timeGrid',
+                duration: { days: 1 },
+            },
+            timeGridWeek: {
+                type: 'timeGrid',
+                duration: { weeks: 1 },
+            },
+        },
+    });
+
+    var ListViewHeaderRow = /** @class */ (function (_super) {
+        __extends(ListViewHeaderRow, _super);
+        function ListViewHeaderRow() {
+            return _super !== null && _super.apply(this, arguments) || this;
+        }
+        ListViewHeaderRow.prototype.render = function () {
+            var _a = this.props, dayDate = _a.dayDate, todayRange = _a.todayRange;
+            var _b = this.context, theme = _b.theme, dateEnv = _b.dateEnv, options = _b.options, viewApi = _b.viewApi;
+            var dayMeta = getDateMeta(dayDate, todayRange);
+            // will ever be falsy?
+            var text = options.listDayFormat ? dateEnv.format(dayDate, options.listDayFormat) : '';
+            // will ever be falsy? also, BAD NAME "alt"
+            var sideText = options.listDaySideFormat ? dateEnv.format(dayDate, options.listDaySideFormat) : '';
+            var navLinkData = options.navLinks
+                ? buildNavLinkData(dayDate)
+                : null;
+            var hookProps = __assign({ date: dateEnv.toDate(dayDate), view: viewApi, text: text,
+                sideText: sideText,
+                navLinkData: navLinkData }, dayMeta);
+            var classNames = ['fc-list-day'].concat(getDayClassNames(dayMeta, theme));
+            // TODO: make a reusable HOC for dayHeader (used in daygrid/timegrid too)
+            return (createElement(RenderHook, { hookProps: hookProps, classNames: options.dayHeaderClassNames, content: options.dayHeaderContent, defaultContent: renderInnerContent$4, didMount: options.dayHeaderDidMount, willUnmount: options.dayHeaderWillUnmount }, function (rootElRef, customClassNames, innerElRef, innerContent) { return (createElement("tr", { ref: rootElRef, className: classNames.concat(customClassNames).join(' '), "data-date": formatDayString(dayDate) },
+                createElement("th", { colSpan: 3 },
+                    createElement("div", { className: 'fc-list-day-cushion ' + theme.getClass('tableCellShaded'), ref: innerElRef }, innerContent)))); }));
+        };
+        return ListViewHeaderRow;
+    }(BaseComponent));
+    function renderInnerContent$4(props) {
+        var navLinkAttrs = props.navLinkData // is there a type for this?
+            ? { 'data-navlink': props.navLinkData, tabIndex: 0 }
+            : {};
+        return (createElement(Fragment, null,
+            props.text && (createElement("a", __assign({ className: "fc-list-day-text" }, navLinkAttrs), props.text)),
+            props.sideText && (createElement("a", __assign({ className: "fc-list-day-side-text" }, navLinkAttrs), props.sideText))));
+    }
+
+    var DEFAULT_TIME_FORMAT$1 = createFormatter({
+        hour: 'numeric',
+        minute: '2-digit',
+        meridiem: 'short',
+    });
+    var ListViewEventRow = /** @class */ (function (_super) {
+        __extends(ListViewEventRow, _super);
+        function ListViewEventRow() {
+            return _super !== null && _super.apply(this, arguments) || this;
+        }
+        ListViewEventRow.prototype.render = function () {
+            var _a = this, props = _a.props, context = _a.context;
+            var seg = props.seg;
+            var timeFormat = context.options.eventTimeFormat || DEFAULT_TIME_FORMAT$1;
+            return (createElement(EventRoot, { seg: seg, timeText: "" // BAD. because of all-day content
+                , disableDragging: true, disableResizing: true, defaultContent: renderEventInnerContent, isPast: props.isPast, isFuture: props.isFuture, isToday: props.isToday, isSelected: props.isSelected, isDragging: props.isDragging, isResizing: props.isResizing, isDateSelecting: props.isDateSelecting }, function (rootElRef, classNames, innerElRef, innerContent, hookProps) { return (createElement("tr", { className: ['fc-list-event', hookProps.event.url ? 'fc-event-forced-url' : ''].concat(classNames).join(' '), ref: rootElRef },
+                buildTimeContent(seg, timeFormat, context),
+                createElement("td", { className: "fc-list-event-graphic" },
+                    createElement("span", { className: "fc-list-event-dot", style: { borderColor: hookProps.borderColor || hookProps.backgroundColor } })),
+                createElement("td", { className: "fc-list-event-title", ref: innerElRef }, innerContent))); }));
+        };
+        return ListViewEventRow;
+    }(BaseComponent));
+    function renderEventInnerContent(props) {
+        var event = props.event;
+        var url = event.url;
+        var anchorAttrs = url ? { href: url } : {};
+        return (createElement("a", __assign({}, anchorAttrs), event.title));
+    }
+    function buildTimeContent(seg, timeFormat, context) {
+        var options = context.options;
+        if (options.displayEventTime !== false) {
+            var eventDef = seg.eventRange.def;
+            var eventInstance = seg.eventRange.instance;
+            var doAllDay = false;
+            var timeText = void 0;
+            if (eventDef.allDay) {
+                doAllDay = true;
+            }
+            else if (isMultiDayRange(seg.eventRange.range)) { // TODO: use (!isStart || !isEnd) instead?
+                if (seg.isStart) {
+                    timeText = buildSegTimeText(seg, timeFormat, context, null, null, eventInstance.range.start, seg.end);
+                }
+                else if (seg.isEnd) {
+                    timeText = buildSegTimeText(seg, timeFormat, context, null, null, seg.start, eventInstance.range.end);
+                }
+                else {
+                    doAllDay = true;
+                }
+            }
+            else {
+                timeText = buildSegTimeText(seg, timeFormat, context);
+            }
+            if (doAllDay) {
+                var hookProps = {
+                    text: context.options.allDayText,
+                    view: context.viewApi,
+                };
+                return (createElement(RenderHook, { hookProps: hookProps, classNames: options.allDayClassNames, content: options.allDayContent, defaultContent: renderAllDayInner$1, didMount: options.allDayDidMount, willUnmount: options.allDayWillUnmount }, function (rootElRef, classNames, innerElRef, innerContent) { return (createElement("td", { className: ['fc-list-event-time'].concat(classNames).join(' '), ref: rootElRef }, innerContent)); }));
+            }
+            return (createElement("td", { className: "fc-list-event-time" }, timeText));
+        }
+        return null;
+    }
+    function renderAllDayInner$1(hookProps) {
+        return hookProps.text;
+    }
+
+    /*
+    Responsible for the scroller, and forwarding event-related actions into the "grid".
+    */
+    var ListView = /** @class */ (function (_super) {
+        __extends(ListView, _super);
+        function ListView() {
+            var _this = _super !== null && _super.apply(this, arguments) || this;
+            _this.computeDateVars = memoize(computeDateVars);
+            _this.eventStoreToSegs = memoize(_this._eventStoreToSegs);
+            _this.setRootEl = function (rootEl) {
+                if (rootEl) {
+                    _this.context.registerInteractiveComponent(_this, {
+                        el: rootEl,
+                    });
+                }
+                else {
+                    _this.context.unregisterInteractiveComponent(_this);
+                }
+            };
+            return _this;
+        }
+        ListView.prototype.render = function () {
+            var _this = this;
+            var _a = this, props = _a.props, context = _a.context;
+            var extraClassNames = [
+                'fc-list',
+                context.theme.getClass('table'),
+                context.options.stickyHeaderDates !== false ? 'fc-list-sticky' : '',
+            ];
+            var _b = this.computeDateVars(props.dateProfile), dayDates = _b.dayDates, dayRanges = _b.dayRanges;
+            var eventSegs = this.eventStoreToSegs(props.eventStore, props.eventUiBases, dayRanges);
+            return (createElement(ViewRoot, { viewSpec: context.viewSpec, elRef: this.setRootEl }, function (rootElRef, classNames) { return (createElement("div", { ref: rootElRef, className: extraClassNames.concat(classNames).join(' ') },
+                createElement(Scroller, { liquid: !props.isHeightAuto, overflowX: props.isHeightAuto ? 'visible' : 'hidden', overflowY: props.isHeightAuto ? 'visible' : 'auto' }, eventSegs.length > 0 ?
+                    _this.renderSegList(eventSegs, dayDates) :
+                    _this.renderEmptyMessage()))); }));
+        };
+        ListView.prototype.renderEmptyMessage = function () {
+            var _a = this.context, options = _a.options, viewApi = _a.viewApi;
+            var hookProps = {
+                text: options.noEventsText,
+                view: viewApi,
+            };
+            return (createElement(RenderHook, { hookProps: hookProps, classNames: options.noEventsClassNames, content: options.noEventsContent, defaultContent: renderNoEventsInner, didMount: options.noEventsDidMount, willUnmount: options.noEventsWillUnmount }, function (rootElRef, classNames, innerElRef, innerContent) { return (createElement("div", { className: ['fc-list-empty'].concat(classNames).join(' '), ref: rootElRef },
+                createElement("div", { className: "fc-list-empty-cushion", ref: innerElRef }, innerContent))); }));
+        };
+        ListView.prototype.renderSegList = function (allSegs, dayDates) {
+            var _a = this.context, theme = _a.theme, options = _a.options;
+            var segsByDay = groupSegsByDay(allSegs); // sparse array
+            return (createElement(NowTimer, { unit: "day" }, function (nowDate, todayRange) {
+                var innerNodes = [];
+                for (var dayIndex = 0; dayIndex < segsByDay.length; dayIndex += 1) {
+                    var daySegs = segsByDay[dayIndex];
+                    if (daySegs) { // sparse array, so might be undefined
+                        var dayStr = dayDates[dayIndex].toISOString();
+                        // append a day header
+                        innerNodes.push(createElement(ListViewHeaderRow, { key: dayStr, dayDate: dayDates[dayIndex], todayRange: todayRange }));
+                        daySegs = sortEventSegs(daySegs, options.eventOrder);
+                        for (var _i = 0, daySegs_1 = daySegs; _i < daySegs_1.length; _i++) {
+                            var seg = daySegs_1[_i];
+                            innerNodes.push(createElement(ListViewEventRow, __assign({ key: dayStr + ':' + seg.eventRange.instance.instanceId /* are multiple segs for an instanceId */, seg: seg, isDragging: false, isResizing: false, isDateSelecting: false, isSelected: false }, getSegMeta(seg, todayRange, nowDate))));
+                        }
+                    }
+                }
+                return (createElement("table", { className: 'fc-list-table ' + theme.getClass('table') },
+                    createElement("tbody", null, innerNodes)));
+            }));
+        };
+        ListView.prototype._eventStoreToSegs = function (eventStore, eventUiBases, dayRanges) {
+            return this.eventRangesToSegs(sliceEventStore(eventStore, eventUiBases, this.props.dateProfile.activeRange, this.context.options.nextDayThreshold).fg, dayRanges);
+        };
+        ListView.prototype.eventRangesToSegs = function (eventRanges, dayRanges) {
+            var segs = [];
+            for (var _i = 0, eventRanges_1 = eventRanges; _i < eventRanges_1.length; _i++) {
+                var eventRange = eventRanges_1[_i];
+                segs.push.apply(segs, this.eventRangeToSegs(eventRange, dayRanges));
+            }
+            return segs;
+        };
+        ListView.prototype.eventRangeToSegs = function (eventRange, dayRanges) {
+            var dateEnv = this.context.dateEnv;
+            var nextDayThreshold = this.context.options.nextDayThreshold;
+            var range = eventRange.range;
+            var allDay = eventRange.def.allDay;
+            var dayIndex;
+            var segRange;
+            var seg;
+            var segs = [];
+            for (dayIndex = 0; dayIndex < dayRanges.length; dayIndex += 1) {
+                segRange = intersectRanges(range, dayRanges[dayIndex]);
+                if (segRange) {
+                    seg = {
+                        component: this,
+                        eventRange: eventRange,
+                        start: segRange.start,
+                        end: segRange.end,
+                        isStart: eventRange.isStart && segRange.start.valueOf() === range.start.valueOf(),
+                        isEnd: eventRange.isEnd && segRange.end.valueOf() === range.end.valueOf(),
+                        dayIndex: dayIndex,
+                    };
+                    segs.push(seg);
+                    // detect when range won't go fully into the next day,
+                    // and mutate the latest seg to the be the end.
+                    if (!seg.isEnd && !allDay &&
+                        dayIndex + 1 < dayRanges.length &&
+                        range.end <
+                            dateEnv.add(dayRanges[dayIndex + 1].start, nextDayThreshold)) {
+                        seg.end = range.end;
+                        seg.isEnd = true;
+                        break;
+                    }
+                }
+            }
+            return segs;
+        };
+        return ListView;
+    }(DateComponent));
+    function renderNoEventsInner(hookProps) {
+        return hookProps.text;
+    }
+    function computeDateVars(dateProfile) {
+        var dayStart = startOfDay(dateProfile.renderRange.start);
+        var viewEnd = dateProfile.renderRange.end;
+        var dayDates = [];
+        var dayRanges = [];
+        while (dayStart < viewEnd) {
+            dayDates.push(dayStart);
+            dayRanges.push({
+                start: dayStart,
+                end: addDays(dayStart, 1),
+            });
+            dayStart = addDays(dayStart, 1);
+        }
+        return { dayDates: dayDates, dayRanges: dayRanges };
+    }
+    // Returns a sparse array of arrays, segs grouped by their dayIndex
+    function groupSegsByDay(segs) {
+        var segsByDay = []; // sparse array
+        var i;
+        var seg;
+        for (i = 0; i < segs.length; i += 1) {
+            seg = segs[i];
+            (segsByDay[seg.dayIndex] || (segsByDay[seg.dayIndex] = []))
+                .push(seg);
+        }
+        return segsByDay;
+    }
+
+    var OPTION_REFINERS$3 = {
+        listDayFormat: createFalsableFormatter,
+        listDaySideFormat: createFalsableFormatter,
+        noEventsClassNames: identity,
+        noEventsContent: identity,
+        noEventsDidMount: identity,
+        noEventsWillUnmount: identity,
+    };
+    function createFalsableFormatter(input) {
+        return input === false ? null : createFormatter(input);
+    }
+
+    var listPlugin = createPlugin({
+        optionRefiners: OPTION_REFINERS$3,
+        views: {
+            list: {
+                component: ListView,
+                buttonTextKey: 'list',
+                listDayFormat: { month: 'long', day: 'numeric', year: 'numeric' },
+            },
+            listDay: {
+                type: 'list',
+                duration: { days: 1 },
+                listDayFormat: { weekday: 'long' },
+            },
+            listWeek: {
+                type: 'list',
+                duration: { weeks: 1 },
+                listDayFormat: { weekday: 'long' },
+                listDaySideFormat: { month: 'long', day: 'numeric', year: 'numeric' },
+            },
+            listMonth: {
+                type: 'list',
+                duration: { month: 1 },
+                listDaySideFormat: { weekday: 'long' },
+            },
+            listYear: {
+                type: 'list',
+                duration: { year: 1 },
+                listDaySideFormat: { weekday: 'long' },
+            },
+        },
+    });
+
+    var BootstrapTheme = /** @class */ (function (_super) {
+        __extends(BootstrapTheme, _super);
+        function BootstrapTheme() {
+            return _super !== null && _super.apply(this, arguments) || this;
+        }
+        return BootstrapTheme;
+    }(Theme));
+    BootstrapTheme.prototype.classes = {
+        root: 'fc-theme-bootstrap',
+        table: 'table-bordered',
+        tableCellShaded: 'table-active',
+        buttonGroup: 'btn-group',
+        button: 'btn btn-primary',
+        buttonActive: 'active',
+        popover: 'popover',
+        popoverHeader: 'popover-header',
+        popoverContent: 'popover-body',
+    };
+    BootstrapTheme.prototype.baseIconClass = 'fa';
+    BootstrapTheme.prototype.iconClasses = {
+        close: 'fa-times',
+        prev: 'fa-chevron-left',
+        next: 'fa-chevron-right',
+        prevYear: 'fa-angle-double-left',
+        nextYear: 'fa-angle-double-right',
+    };
+    BootstrapTheme.prototype.rtlIconClasses = {
+        prev: 'fa-chevron-right',
+        next: 'fa-chevron-left',
+        prevYear: 'fa-angle-double-right',
+        nextYear: 'fa-angle-double-left',
+    };
+    BootstrapTheme.prototype.iconOverrideOption = 'bootstrapFontAwesome'; // TODO: make TS-friendly. move the option-processing into this plugin
+    BootstrapTheme.prototype.iconOverrideCustomButtonOption = 'bootstrapFontAwesome';
+    BootstrapTheme.prototype.iconOverridePrefix = 'fa-';
+    var plugin = createPlugin({
+        themeClasses: {
+            bootstrap: BootstrapTheme,
+        },
+    });
+
+    // rename this file to options.ts like other packages?
+    var OPTION_REFINERS$4 = {
+        googleCalendarApiKey: String,
+    };
+
+    var EVENT_SOURCE_REFINERS$1 = {
+        googleCalendarApiKey: String,
+        googleCalendarId: String,
+        googleCalendarApiBase: String,
+        extraParams: identity,
+    };
+
+    // TODO: expose somehow
+    var API_BASE = 'https://www.googleapis.com/calendar/v3/calendars';
+    var eventSourceDef$3 = {
+        parseMeta: function (refined) {
+            var googleCalendarId = refined.googleCalendarId;
+            if (!googleCalendarId && refined.url) {
+                googleCalendarId = parseGoogleCalendarId(refined.url);
+            }
+            if (googleCalendarId) {
+                return {
+                    googleCalendarId: googleCalendarId,
+                    googleCalendarApiKey: refined.googleCalendarApiKey,
+                    googleCalendarApiBase: refined.googleCalendarApiBase,
+                    extraParams: refined.extraParams,
+                };
+            }
+            return null;
+        },
+        fetch: function (arg, onSuccess, onFailure) {
+            var _a = arg.context, dateEnv = _a.dateEnv, options = _a.options;
+            var meta = arg.eventSource.meta;
+            var apiKey = meta.googleCalendarApiKey || options.googleCalendarApiKey;
+            if (!apiKey) {
+                onFailure({
+                    message: 'Specify a googleCalendarApiKey. See http://fullcalendar.io/docs/google_calendar/',
+                });
+            }
+            else {
+                var url = buildUrl(meta);
+                // TODO: make DRY with json-feed-event-source
+                var extraParams = meta.extraParams;
+                var extraParamsObj = typeof extraParams === 'function' ? extraParams() : extraParams;
+                var requestParams_1 = buildRequestParams$1(arg.range, apiKey, extraParamsObj, dateEnv);
+                requestJson('GET', url, requestParams_1, function (body, xhr) {
+                    if (body.error) {
+                        onFailure({
+                            message: 'Google Calendar API: ' + body.error.message,
+                            errors: body.error.errors,
+                            xhr: xhr,
+                        });
+                    }
+                    else {
+                        onSuccess({
+                            rawEvents: gcalItemsToRawEventDefs(body.items, requestParams_1.timeZone),
+                            xhr: xhr,
+                        });
+                    }
+                }, function (message, xhr) {
+                    onFailure({ message: message, xhr: xhr });
+                });
+            }
+        },
+    };
+    function parseGoogleCalendarId(url) {
+        var match;
+        // detect if the ID was specified as a single string.
+        // will match calendars like "asdf1234@calendar.google.com" in addition to person email calendars.
+        if (/^[^/]+@([^/.]+\.)*(google|googlemail|gmail)\.com$/.test(url)) {
+            return url;
+        }
+        if ((match = /^https:\/\/www.googleapis.com\/calendar\/v3\/calendars\/([^/]*)/.exec(url)) ||
+            (match = /^https?:\/\/www.google.com\/calendar\/feeds\/([^/]*)/.exec(url))) {
+            return decodeURIComponent(match[1]);
+        }
+        return null;
+    }
+    function buildUrl(meta) {
+        var apiBase = meta.googleCalendarApiBase;
+        if (!apiBase) {
+            apiBase = API_BASE;
+        }
+        return apiBase + '/' + encodeURIComponent(meta.googleCalendarId) + '/events';
+    }
+    function buildRequestParams$1(range, apiKey, extraParams, dateEnv) {
+        var params;
+        var startStr;
+        var endStr;
+        if (dateEnv.canComputeOffset) {
+            // strings will naturally have offsets, which GCal needs
+            startStr = dateEnv.formatIso(range.start);
+            endStr = dateEnv.formatIso(range.end);
+        }
+        else {
+            // when timezone isn't known, we don't know what the UTC offset should be, so ask for +/- 1 day
+            // from the UTC day-start to guarantee we're getting all the events
+            // (start/end will be UTC-coerced dates, so toISOString is okay)
+            startStr = addDays(range.start, -1).toISOString();
+            endStr = addDays(range.end, 1).toISOString();
+        }
+        params = __assign(__assign({}, (extraParams || {})), { key: apiKey, timeMin: startStr, timeMax: endStr, singleEvents: true, maxResults: 9999 });
+        if (dateEnv.timeZone !== 'local') {
+            params.timeZone = dateEnv.timeZone;
+        }
+        return params;
+    }
+    function gcalItemsToRawEventDefs(items, gcalTimezone) {
+        return items.map(function (item) { return gcalItemToRawEventDef(item, gcalTimezone); });
+    }
+    function gcalItemToRawEventDef(item, gcalTimezone) {
+        var url = item.htmlLink || null;
+        // make the URLs for each event show times in the correct timezone
+        if (url && gcalTimezone) {
+            url = injectQsComponent(url, 'ctz=' + gcalTimezone);
+        }
+        return {
+            id: item.id,
+            title: item.summary,
+            start: item.start.dateTime || item.start.date,
+            end: item.end.dateTime || item.end.date,
+            url: url,
+            location: item.location,
+            description: item.description,
+        };
+    }
+    // Injects a string like "arg=value" into the querystring of a URL
+    // TODO: move to a general util file?
+    function injectQsComponent(url, component) {
+        // inject it after the querystring but before the fragment
+        return url.replace(/(\?.*?)?(#|$)/, function (whole, qs, hash) { return (qs ? qs + '&' : '?') + component + hash; });
+    }
+    var googleCalendarPlugin = createPlugin({
+        eventSourceDefs: [eventSourceDef$3],
+        optionRefiners: OPTION_REFINERS$4,
+        eventSourceRefiners: EVENT_SOURCE_REFINERS$1,
+    });
+
+    globalPlugins.push(interactionPlugin, dayGridPlugin, timeGridPlugin, listPlugin, plugin, googleCalendarPlugin);
+
+    exports.BASE_OPTION_DEFAULTS = BASE_OPTION_DEFAULTS;
+    exports.BASE_OPTION_REFINERS = BASE_OPTION_REFINERS;
+    exports.BaseComponent = BaseComponent;
+    exports.BgEvent = BgEvent;
+    exports.BootstrapTheme = BootstrapTheme;
+    exports.Calendar = Calendar;
+    exports.CalendarApi = CalendarApi;
+    exports.CalendarContent = CalendarContent;
+    exports.CalendarDataManager = CalendarDataManager;
+    exports.CalendarDataProvider = CalendarDataProvider;
+    exports.CalendarRoot = CalendarRoot;
+    exports.Component = Component;
+    exports.ContentHook = ContentHook;
+    exports.CustomContentRenderContext = CustomContentRenderContext;
+    exports.DateComponent = DateComponent;
+    exports.DateEnv = DateEnv;
+    exports.DateProfileGenerator = DateProfileGenerator;
+    exports.DayCellContent = DayCellContent;
+    exports.DayCellRoot = DayCellRoot;
+    exports.DayGridView = DayTableView;
+    exports.DayHeader = DayHeader;
+    exports.DaySeriesModel = DaySeriesModel;
+    exports.DayTable = DayTable;
+    exports.DayTableModel = DayTableModel;
+    exports.DayTableSlicer = DayTableSlicer;
+    exports.DayTimeCols = DayTimeCols;
+    exports.DayTimeColsSlicer = DayTimeColsSlicer;
+    exports.DayTimeColsView = DayTimeColsView;
+    exports.DelayedRunner = DelayedRunner;
+    exports.Draggable = ExternalDraggable;
+    exports.ElementDragging = ElementDragging;
+    exports.ElementScrollController = ElementScrollController;
+    exports.Emitter = Emitter;
+    exports.EventApi = EventApi;
+    exports.EventRoot = EventRoot;
+    exports.EventSourceApi = EventSourceApi;
+    exports.FeaturefulElementDragging = FeaturefulElementDragging;
+    exports.Fragment = Fragment;
+    exports.Interaction = Interaction;
+    exports.ListView = ListView;
+    exports.MountHook = MountHook;
+    exports.NamedTimeZoneImpl = NamedTimeZoneImpl;
+    exports.NowIndicatorRoot = NowIndicatorRoot;
+    exports.NowTimer = NowTimer;
+    exports.PointerDragging = PointerDragging;
+    exports.PositionCache = PositionCache;
+    exports.RefMap = RefMap;
+    exports.RenderHook = RenderHook;
+    exports.ScrollController = ScrollController;
+    exports.ScrollResponder = ScrollResponder;
+    exports.Scroller = Scroller;
+    exports.SimpleScrollGrid = SimpleScrollGrid;
+    exports.Slicer = Slicer;
+    exports.Splitter = Splitter;
+    exports.StandardEvent = StandardEvent;
+    exports.Table = Table;
+    exports.TableDateCell = TableDateCell;
+    exports.TableDowCell = TableDowCell;
+    exports.TableView = TableView;
+    exports.Theme = Theme;
+    exports.ThirdPartyDraggable = ThirdPartyDraggable;
+    exports.TimeCols = TimeCols;
+    exports.TimeColsSlatsCoords = TimeColsSlatsCoords;
+    exports.TimeColsView = TimeColsView;
+    exports.ViewApi = ViewApi;
+    exports.ViewContextType = ViewContextType;
+    exports.ViewRoot = ViewRoot;
+    exports.WeekNumberRoot = WeekNumberRoot;
+    exports.WindowScrollController = WindowScrollController;
+    exports.addDays = addDays;
+    exports.addDurations = addDurations;
+    exports.addMs = addMs;
+    exports.addWeeks = addWeeks;
+    exports.allowContextMenu = allowContextMenu;
+    exports.allowSelection = allowSelection;
+    exports.applyMutationToEventStore = applyMutationToEventStore;
+    exports.applyStyle = applyStyle;
+    exports.applyStyleProp = applyStyleProp;
+    exports.asCleanDays = asCleanDays;
+    exports.asRoughMinutes = asRoughMinutes;
+    exports.asRoughMs = asRoughMs;
+    exports.asRoughSeconds = asRoughSeconds;
+    exports.buildClassNameNormalizer = buildClassNameNormalizer;
+    exports.buildDayRanges = buildDayRanges;
+    exports.buildDayTableModel = buildDayTableModel;
+    exports.buildEventApis = buildEventApis;
+    exports.buildEventRangeKey = buildEventRangeKey;
+    exports.buildHashFromArray = buildHashFromArray;
+    exports.buildNavLinkData = buildNavLinkData;
+    exports.buildSegCompareObj = buildSegCompareObj;
+    exports.buildSegTimeText = buildSegTimeText;
+    exports.buildSlatMetas = buildSlatMetas;
+    exports.buildTimeColsModel = buildTimeColsModel;
+    exports.collectFromHash = collectFromHash;
+    exports.combineEventUis = combineEventUis;
+    exports.compareByFieldSpec = compareByFieldSpec;
+    exports.compareByFieldSpecs = compareByFieldSpecs;
+    exports.compareNumbers = compareNumbers;
+    exports.compareObjs = compareObjs;
+    exports.computeEdges = computeEdges;
+    exports.computeFallbackHeaderFormat = computeFallbackHeaderFormat;
+    exports.computeHeightAndMargins = computeHeightAndMargins;
+    exports.computeInnerRect = computeInnerRect;
+    exports.computeRect = computeRect;
+    exports.computeSegDraggable = computeSegDraggable;
+    exports.computeSegEndResizable = computeSegEndResizable;
+    exports.computeSegStartResizable = computeSegStartResizable;
+    exports.computeShrinkWidth = computeShrinkWidth;
+    exports.computeSmallestCellWidth = computeSmallestCellWidth;
+    exports.computeVisibleDayRange = computeVisibleDayRange;
+    exports.config = config;
+    exports.constrainPoint = constrainPoint;
+    exports.createContext = createContext$1;
+    exports.createDuration = createDuration;
+    exports.createElement = createElement;
+    exports.createEmptyEventStore = createEmptyEventStore;
+    exports.createEventInstance = createEventInstance;
+    exports.createEventUi = createEventUi;
+    exports.createFormatter = createFormatter;
+    exports.createPlugin = createPlugin;
+    exports.createRef = createRef;
+    exports.diffDates = diffDates;
+    exports.diffDayAndTime = diffDayAndTime;
+    exports.diffDays = diffDays;
+    exports.diffPoints = diffPoints;
+    exports.diffWeeks = diffWeeks;
+    exports.diffWholeDays = diffWholeDays;
+    exports.diffWholeWeeks = diffWholeWeeks;
+    exports.disableCursor = disableCursor;
+    exports.elementClosest = elementClosest;
+    exports.elementMatches = elementMatches;
+    exports.enableCursor = enableCursor;
+    exports.eventTupleToStore = eventTupleToStore;
+    exports.filterEventStoreDefs = filterEventStoreDefs;
+    exports.filterHash = filterHash;
+    exports.findDirectChildren = findDirectChildren;
+    exports.findElements = findElements;
+    exports.flexibleCompare = flexibleCompare;
+    exports.flushToDom = flushToDom$1;
+    exports.formatDate = formatDate;
+    exports.formatDayString = formatDayString;
+    exports.formatIsoTimeString = formatIsoTimeString;
+    exports.formatRange = formatRange;
+    exports.getAllowYScrolling = getAllowYScrolling;
+    exports.getCanVGrowWithinCell = getCanVGrowWithinCell;
+    exports.getClippingParents = getClippingParents;
+    exports.getDateMeta = getDateMeta;
+    exports.getDayClassNames = getDayClassNames;
+    exports.getDefaultEventEnd = getDefaultEventEnd;
+    exports.getElSeg = getElSeg;
+    exports.getEventClassNames = getEventClassNames;
+    exports.getIsRtlScrollbarOnLeft = getIsRtlScrollbarOnLeft;
+    exports.getRectCenter = getRectCenter;
+    exports.getRelevantEvents = getRelevantEvents;
+    exports.getScrollGridClassNames = getScrollGridClassNames;
+    exports.getScrollbarWidths = getScrollbarWidths;
+    exports.getSectionClassNames = getSectionClassNames;
+    exports.getSectionHasLiquidHeight = getSectionHasLiquidHeight;
+    exports.getSegMeta = getSegMeta;
+    exports.getSlotClassNames = getSlotClassNames;
+    exports.getStickyFooterScrollbar = getStickyFooterScrollbar;
+    exports.getStickyHeaderDates = getStickyHeaderDates;
+    exports.getUnequalProps = getUnequalProps;
+    exports.globalLocales = globalLocales;
+    exports.globalPlugins = globalPlugins;
+    exports.greatestDurationDenominator = greatestDurationDenominator;
+    exports.guid = guid;
+    exports.hasBgRendering = hasBgRendering;
+    exports.hasShrinkWidth = hasShrinkWidth;
+    exports.identity = identity;
+    exports.interactionSettingsStore = interactionSettingsStore;
+    exports.interactionSettingsToStore = interactionSettingsToStore;
+    exports.intersectRanges = intersectRanges;
+    exports.intersectRects = intersectRects;
+    exports.isArraysEqual = isArraysEqual;
+    exports.isColPropsEqual = isColPropsEqual;
+    exports.isDateSpansEqual = isDateSpansEqual;
+    exports.isInt = isInt;
+    exports.isInteractionValid = isInteractionValid;
+    exports.isMultiDayRange = isMultiDayRange;
+    exports.isPropsEqual = isPropsEqual;
+    exports.isPropsValid = isPropsValid;
+    exports.isValidDate = isValidDate;
+    exports.listenBySelector = listenBySelector;
+    exports.mapHash = mapHash;
+    exports.memoize = memoize;
+    exports.memoizeArraylike = memoizeArraylike;
+    exports.memoizeHashlike = memoizeHashlike;
+    exports.memoizeObjArg = memoizeObjArg;
+    exports.mergeEventStores = mergeEventStores;
+    exports.multiplyDuration = multiplyDuration;
+    exports.padStart = padStart;
+    exports.parseBusinessHours = parseBusinessHours;
+    exports.parseClassNames = parseClassNames;
+    exports.parseDragMeta = parseDragMeta;
+    exports.parseEventDef = parseEventDef;
+    exports.parseFieldSpecs = parseFieldSpecs;
+    exports.parseMarker = parse;
+    exports.pointInsideRect = pointInsideRect;
+    exports.preventContextMenu = preventContextMenu;
+    exports.preventDefault = preventDefault;
+    exports.preventSelection = preventSelection;
+    exports.rangeContainsMarker = rangeContainsMarker;
+    exports.rangeContainsRange = rangeContainsRange;
+    exports.rangesEqual = rangesEqual;
+    exports.rangesIntersect = rangesIntersect;
+    exports.refineEventDef = refineEventDef;
+    exports.refineProps = refineProps;
+    exports.removeElement = removeElement;
+    exports.removeExact = removeExact;
+    exports.render = render;
+    exports.renderChunkContent = renderChunkContent;
+    exports.renderFill = renderFill;
+    exports.renderMicroColGroup = renderMicroColGroup;
+    exports.renderScrollShim = renderScrollShim;
+    exports.requestJson = requestJson;
+    exports.sanitizeShrinkWidth = sanitizeShrinkWidth;
+    exports.setElSeg = setElSeg;
+    exports.setRef = setRef;
+    exports.sliceEventStore = sliceEventStore;
+    exports.sliceEvents = sliceEvents;
+    exports.sortEventSegs = sortEventSegs;
+    exports.startOfDay = startOfDay;
+    exports.translateRect = translateRect;
+    exports.triggerDateSelect = triggerDateSelect;
+    exports.unmountComponentAtNode = unmountComponentAtNode$1;
+    exports.unpromisify = unpromisify;
+    exports.version = version;
+    exports.whenTransitionDone = whenTransitionDone;
+    exports.wholeDivideDurations = wholeDivideDurations;
+
+    Object.defineProperty(exports, '__esModule', { value: true });
+
+    return exports;
+
+}({}));
diff --git a/InvenTree/InvenTree/static/script/inventree/modals.js b/InvenTree/InvenTree/static/script/inventree/modals.js
index f731a5238b..12a496c481 100644
--- a/InvenTree/InvenTree/static/script/inventree/modals.js
+++ b/InvenTree/InvenTree/static/script/inventree/modals.js
@@ -151,12 +151,17 @@ function enableField(fieldName, enabled, options={}) {
 }
 
 function clearField(fieldName, options={}) {
+    
+    setFieldValue(fieldName, '', options);
+}
+
+function setFieldValue(fieldName, value, options={}) {
 
     var modal = options.modal || '#modal-form';
 
     var field = getFieldByName(modal, fieldName);
 
-    field.val("");
+    field.val(value);
 }
 
 
diff --git a/InvenTree/InvenTree/status_codes.py b/InvenTree/InvenTree/status_codes.py
index 1d5335ad2f..916b3341cb 100644
--- a/InvenTree/InvenTree/status_codes.py
+++ b/InvenTree/InvenTree/status_codes.py
@@ -152,6 +152,11 @@ class SalesOrderStatus(StatusCode):
         PENDING,
     ]
 
+    # Completed orders
+    COMPLETE = [
+        SHIPPED,
+    ]
+
 
 class StockStatus(StatusCode):
 
diff --git a/InvenTree/InvenTree/urls.py b/InvenTree/InvenTree/urls.py
index 944c6432b5..79c662182e 100644
--- a/InvenTree/InvenTree/urls.py
+++ b/InvenTree/InvenTree/urls.py
@@ -93,6 +93,7 @@ dynamic_javascript_urls = [
     url(r'^barcode.js', DynamicJsView.as_view(template_name='js/barcode.js'), name='barcode.js'),
     url(r'^bom.js', DynamicJsView.as_view(template_name='js/bom.js'), name='bom.js'),
     url(r'^build.js', DynamicJsView.as_view(template_name='js/build.js'), name='build.js'),
+    url(r'^calendar.js', DynamicJsView.as_view(template_name='js/calendar.js'), name='calendar.js'),
     url(r'^company.js', DynamicJsView.as_view(template_name='js/company.js'), name='company.js'),
     url(r'^order.js', DynamicJsView.as_view(template_name='js/order.js'), name='order.js'),
     url(r'^part.js', DynamicJsView.as_view(template_name='js/part.js'), name='part.js'),
diff --git a/InvenTree/build/api.py b/InvenTree/build/api.py
index ffc8d97a92..d76c0a4b51 100644
--- a/InvenTree/build/api.py
+++ b/InvenTree/build/api.py
@@ -90,6 +90,13 @@ class BuildList(generics.ListCreateAPIView):
         if part is not None:
             queryset = queryset.filter(part=part)
 
+        # Filter by 'date range'
+        min_date = params.get('min_date', None)
+        max_date = params.get('max_date', None)
+
+        if min_date is not None and max_date is not None:
+            queryset = Build.filterByDate(queryset, min_date, max_date)
+
         return queryset
 
     def get_serializer(self, *args, **kwargs):
diff --git a/InvenTree/build/forms.py b/InvenTree/build/forms.py
index 70deeef7b1..136d21d553 100644
--- a/InvenTree/build/forms.py
+++ b/InvenTree/build/forms.py
@@ -10,6 +10,7 @@ from django import forms
 
 from InvenTree.forms import HelperForm
 from InvenTree.fields import RoundingDecimalFormField
+from InvenTree.fields import DatePickerFormField
 
 from .models import Build, BuildItem, BuildOrderAttachment
 
@@ -26,18 +27,16 @@ class EditBuildForm(HelperForm):
         'batch': 'fa-layer-group',
         'serial-numbers': 'fa-hashtag',
         'location': 'fa-map-marker-alt',
+        'target_date': 'fa-calendar-alt',
     }
 
     field_placeholder = {
-        'reference': _('Build Order reference')
+        'reference': _('Build Order reference'),
+        'target_date': _('Order target date'),
     }
 
-    # TODO: Make this a more "presentable" date picker
-    # TODO: Currently does not render super nicely with crispy forms
-    target_date = forms.DateField(
-        widget=forms.DateInput(
-            attrs={'type': 'date'}
-        )
+    target_date = DatePickerFormField(
+        help_text=_('Target date for build completion. Build will be overdue after this date.')
     )
 
     class Meta:
diff --git a/InvenTree/build/models.py b/InvenTree/build/models.py
index 488a8b79e8..10b3b00259 100644
--- a/InvenTree/build/models.py
+++ b/InvenTree/build/models.py
@@ -27,6 +27,8 @@ from InvenTree.helpers import increment, getSetting, normalize
 from InvenTree.validators import validate_build_order_reference
 from InvenTree.models import InvenTreeAttachment
 
+import common.models
+
 import InvenTree.fields
 
 from stock import models as StockModels
@@ -59,6 +61,37 @@ class Build(MPTTModel):
         verbose_name = _("Build Order")
         verbose_name_plural = _("Build Orders")
 
+    @staticmethod
+    def filterByDate(queryset, min_date, max_date):
+        """
+        Filter by 'minimum and maximum date range'
+
+        - Specified as min_date, max_date
+        - Both must be specified for filter to be applied
+        """
+
+        date_fmt = '%Y-%m-%d'  # ISO format date string
+
+        # Ensure that both dates are valid
+        try:
+            min_date = datetime.strptime(str(min_date), date_fmt).date()
+            max_date = datetime.strptime(str(max_date), date_fmt).date()
+        except (ValueError, TypeError):
+            # Date processing error, return queryset unchanged
+            return queryset
+
+        # Order was completed within the specified range
+        completed = Q(status=BuildStatus.COMPLETE) & Q(completion_date__gte=min_date) & Q(completion_date__lte=max_date)
+
+        # Order target date falls witin specified range
+        pending = Q(status__in=BuildStatus.ACTIVE_CODES) & ~Q(target_date=None) & Q(target_date__gte=min_date) & Q(target_date__lte=max_date)
+
+        # TODO - Construct a queryset for "overdue" orders
+
+        queryset = queryset.filter(completed | pending)
+
+        return queryset
+
     def __str__(self):
 
         prefix = getSetting("BUILDORDER_REFERENCE_PREFIX")
@@ -819,6 +852,10 @@ class Build(MPTTModel):
                 location__in=[loc for loc in self.take_from.getUniqueChildren()]
             )
 
+        # Exclude expired stock items
+        if not common.models.InvenTreeSetting.get_setting('STOCK_ALLOW_EXPIRED_BUILD'):
+            items = items.exclude(StockModels.StockItem.EXPIRED_FILTER)
+
         return items
 
     @property
diff --git a/InvenTree/build/serializers.py b/InvenTree/build/serializers.py
index b71aaecc61..550a3c3a85 100644
--- a/InvenTree/build/serializers.py
+++ b/InvenTree/build/serializers.py
@@ -28,7 +28,7 @@ class BuildSerializer(InvenTreeModelSerializer):
 
     quantity = serializers.FloatField()
 
-    overdue = serializers.BooleanField()
+    overdue = serializers.BooleanField(required=False, read_only=True)
 
     @staticmethod
     def annotate_queryset(queryset):
diff --git a/InvenTree/build/templates/build/build_base.html b/InvenTree/build/templates/build/build_base.html
index 1124dd16c0..9ca3fff818 100644
--- a/InvenTree/build/templates/build/build_base.html
+++ b/InvenTree/build/templates/build/build_base.html
@@ -88,11 +88,20 @@ src="{% static 'img/blank_image.png' %}"
         <td>{% trans "Status" %}</td>
         <td>
             {% build_status_label build.status %}
+        </td>
+    </tr>
+    {% if build.target_date %}
+    <tr>
+        <td><span class='fas fa-calendar-alt'></span></td>
+        <td>{% trans "Target Date" %}</td>
+        <td>
+            {{ build.target_date }}
             {% if build.is_overdue %}
             <span title='{% trans "This build was due on" %} {{ build.target_date }}' class='label label-red'>{% trans "Overdue" %}</span>
             {% endif %}
         </td>
     </tr>
+    {% endif %}
     <tr>
         <td><span class='fas fa-spinner'></span></td>
         <td>{% trans "Progress" %}</td>
diff --git a/InvenTree/build/templates/build/index.html b/InvenTree/build/templates/build/index.html
index 562dc746fb..37993107a7 100644
--- a/InvenTree/build/templates/build/index.html
+++ b/InvenTree/build/templates/build/index.html
@@ -1,4 +1,6 @@
 {% extends "base.html" %}
+
+{% load inventree_extras %}
 {% load static %}
 {% load i18n %}
 
@@ -8,7 +10,6 @@ InvenTree | {% trans "Build Orders" %}
 
 {% block content %}
 
-
 <div class='row'>
     <div class='col-sm-6'>
         <h3>{% trans "Build Orders" %}</h3>
@@ -21,8 +22,17 @@ InvenTree | {% trans "Build Orders" %}
     
     <div id='button-toolbar'>
         <div class='button-toolbar container-fluid' style='float: right;'>
+            {% if roles.build.add %}
             <button type='button' class="btn btn-success" id='new-build'>
-                <span class='fas fa-tools'></span> {% trans "New Build Order" %}</button>
+                <span class='fas fa-tools'></span> {% trans "New Build Order" %}
+            </button>
+            {% endif %}
+            <button class='btn btn-default' type='button' id='view-calendar' title='{% trans "Display calendar view" %}'>
+                <span class='fas fa-calendar-alt'></span>
+            </button>
+            <button class='btn btn-default' type='button' id='view-list' title='{% trans "Display list view" %}'>
+                <span class='fas fa-th-list'></span>
+            </button>
             <div class='filter-list' id='filter-list-build'>
                 <!-- An empty div in which the filter list will be constructed -->
             </div>
@@ -33,11 +43,120 @@ InvenTree | {% trans "Build Orders" %}
 <table class='table table-striped table-condensed' id='build-table' data-toolbar='#button-toolbar'>
 </table>
 
+<div id='build-order-calendar'></div>
+
+{% endblock %}
+
+{% block js_load %}
+{{ block.super }}
+
+<script type='text/javascript'>
+    function loadOrderEvents(calendar) {
+        
+        var start = startDate(calendar);
+        var end = endDate(calendar);
+
+        clearEvents(calendar);
+
+        // Request build orders from the server within specified date range
+        inventreeGet(
+            '{% url "api-build-list" %}',
+            {
+                min_date: start,
+                max_date: end,
+                part_detail: true,
+            },
+            {
+                success: function(response) {
+                    var prefix = '{% settings_value "BUILDORDER_REFERENCE_PREFIX" %}';
+
+                    for (var idx = 0; idx < response.length; idx++) {
+                        
+                        var order = response[idx];
+
+                        var date = order.creation_date;
+
+                        if (order.completion_date) {
+                            date = order.completion_date;
+                        } else if (order.target_date) {
+                            date = order.target_date;
+                        }
+
+                        var title = `${prefix}${order.reference}`; //- ${order.quantity} x ${order.part_detail.name}`;
+
+                        var color = '#4c68f5';
+
+                        if (order.completed) {
+                            color = '#25c234';
+                        } else if (order.overdue) {
+                            color = '#c22525';
+                        }
+
+                        var event = {
+                            title: title,
+                            start: date,
+                            end: date,
+                            url: `/build/${order.pk}/`,
+                            backgroundColor: color,
+                        };
+
+                        calendar.addEvent(event);
+                    }
+                }
+            }
+        );
+    }
+
+    var calendar = null;
+
+    document.addEventListener('DOMContentLoaded', function() {
+        var el = document.getElementById('build-order-calendar');
+
+        calendar = new FullCalendar.Calendar(el, {
+            initialView: 'dayGridMonth',
+            nowIndicator: true,
+            aspectRatio: 2.5,
+            datesSet: function() {
+                loadOrderEvents(calendar);
+            }
+        });
+
+        calendar.render();
+    });
+
+</script>
 {% endblock %}
 
 {% block js_ready %}
 {{ block.super }}
 
+$('#build-order-calendar').hide();
+$('#view-list').hide();
+
+$('#view-calendar').click(function() {
+    // Hide the list view, show the calendar view
+    $("#build-table").hide();
+    $("#view-calendar").hide();
+    $(".fixed-table-pagination").hide();
+    $(".columns-right").hide();
+    $(".search").hide();
+    
+    $("#build-order-calendar").show();
+    $("#view-list").show();
+});
+
+$("#view-list").click(function() {
+    // Hide the calendar view, show the list view
+    $("#build-order-calendar").hide();
+    $("#view-list").hide();
+    
+    $(".fixed-table-pagination").show();
+    $(".columns-right").show();
+    $(".search").show();
+    $("#build-table").show();
+    $("#view-calendar").show();
+});
+
     $("#collapse-item-active").collapse().show();
 
     $("#new-build").click(function() {
diff --git a/InvenTree/build/views.py b/InvenTree/build/views.py
index 69d1dd9415..0887c49397 100644
--- a/InvenTree/build/views.py
+++ b/InvenTree/build/views.py
@@ -903,6 +903,7 @@ class BuildItemCreate(AjaxCreateView):
 
         if self.build and self.part:
             available_items = self.build.availableStockItems(self.part, self.output)
+
             form.fields['stock_item'].queryset = available_items
 
         self.available_stock = form.fields['stock_item'].queryset.all()
diff --git a/InvenTree/common/models.py b/InvenTree/common/models.py
index c78bd9cb77..d5fa135b09 100644
--- a/InvenTree/common/models.py
+++ b/InvenTree/common/models.py
@@ -110,7 +110,21 @@ class InvenTreeSetting(models.Model):
             'default': True,
             'validator': bool
         },
-      
+
+        'PART_TEMPLATE': {
+            'name': _('Template'),
+            'description': _('Parts are templates by default'),
+            'default': False,
+            'validator': bool,
+        },
+
+        'PART_ASSEMBLY': {
+            'name': _('Assembly'),
+            'description': _('Parts can be assembled from other components by default'),
+            'default': False,
+            'validator': bool,
+        },
+
         'PART_COMPONENT': {
             'name': _('Component'),
             'description': _('Parts can be used as sub-components by default'),
@@ -139,7 +153,43 @@ class InvenTreeSetting(models.Model):
             'validator': bool,
         },
 
-        'STOCK_OWNERSHIP_CONTROL': {
+        'PART_VIRTUAL': {
+            'name': _('Virtual'),
+            'description': _('Parts are virtual by default'),
+            'default': False,
+            'validator': bool,
+        },
+
+        'STOCK_ENABLE_EXPIRY': {
+            'name': _('Stock Expiry'),
+            'description': _('Enable stock expiry functionality'),
+            'default': False,
+            'validator': bool,
+        },
+
+        'STOCK_ALLOW_EXPIRED_SALE': {
+            'name': _('Sell Expired Stock'),
+            'description': _('Allow sale of expired stock'),
+            'default': False,
+            'validator': bool,
+        },
+
+        'STOCK_STALE_DAYS': {
+            'name': _('Stock Stale Time'),
+            'description': _('Number of days stock items are considered stale before expiring'),
+            'default': 0,
+            'units': _('days'),
+            'validator': [int],
+        },
+
+        'STOCK_ALLOW_EXPIRED_BUILD': {
+            'name': _('Build Expired Stock'),
+            'description': _('Allow building with expired stock'),
+            'default': False,
+            'validator': bool,
+        },
+
+	'STOCK_OWNERSHIP_CONTROL': {
             'name': _('Stock Ownership Control'),
             'description': _('Enable ownership control over stock locations and items'),
             'default': False,
@@ -345,6 +395,12 @@ class InvenTreeSetting(models.Model):
             if setting.is_bool():
                 value = InvenTree.helpers.str2bool(value)
 
+            if setting.is_int():
+                try:
+                    value = int(value)
+                except (ValueError, TypeError):
+                    value = backup_value
+
         else:
             value = backup_value
 
@@ -433,18 +489,26 @@ class InvenTreeSetting(models.Model):
             
             return
 
-        # Check if a 'type' has been specified for this value
-        if type(validator) == type:
+        # Boolean validator
+        if validator == bool:
+            # Value must "look like" a boolean value
+            if InvenTree.helpers.is_bool(self.value):
+                # Coerce into either "True" or "False"
+                self.value = str(InvenTree.helpers.str2bool(self.value))
+            else:
+                raise ValidationError({
+                    'value': _('Value must be a boolean value')
+                })
 
-            if validator == bool:
-                # Value must "look like" a boolean value
-                if InvenTree.helpers.is_bool(self.value):
-                    # Coerce into either "True" or "False"
-                    self.value = str(InvenTree.helpers.str2bool(self.value))
-                else:
-                    raise ValidationError({
-                        'value': _('Value must be a boolean value')
-                    })
+        # Integer validator
+        if validator == int:
+            try:
+                # Coerce into an integer value
+                self.value = str(int(self.value))
+            except (ValueError, TypeError):
+                raise ValidationError({
+                    'value': _('Value must be an integer value'),
+                })
 
     def validate_unique(self, exclude=None):
         """ Ensure that the key:value pair is unique.
@@ -486,6 +550,35 @@ class InvenTreeSetting(models.Model):
 
         return InvenTree.helpers.str2bool(self.value)
 
+    def is_int(self):
+        """
+        Check if the setting is required to be an integer value:
+        """
+
+        validator = InvenTreeSetting.get_setting_validator(self.key)
+
+        if validator == int:
+            return True
+        
+        if type(validator) in [list, tuple]:
+            for v in validator:
+                if v == int:
+                    return True
+
+    def as_int(self):
+        """
+        Return the value of this setting converted to a boolean value.
+        
+        If an error occurs, return the default value
+        """
+
+        try:
+            value = int(self.value)
+        except (ValueError, TypeError):
+            value = self.default_value()
+
+        return value
+        
 
 class PriceBreak(models.Model):
     """
diff --git a/InvenTree/common/settings.py b/InvenTree/common/settings.py
index 832a07f040..134d3f3f7c 100644
--- a/InvenTree/common/settings.py
+++ b/InvenTree/common/settings.py
@@ -21,3 +21,11 @@ def currency_code_default():
         code = 'USD'
     
     return code
+
+
+def stock_expiry_enabled():
+    """
+    Returns True if the stock expiry feature is enabled
+    """
+
+    return InvenTreeSetting.get_setting('STOCK_ENABLE_EXPIRY')
diff --git a/InvenTree/common/tests.py b/InvenTree/common/tests.py
index 6d6e517ef5..d8777785f9 100644
--- a/InvenTree/common/tests.py
+++ b/InvenTree/common/tests.py
@@ -31,7 +31,7 @@ class SettingsTest(TestCase):
         # There should be two settings objects in the database
         settings = InvenTreeSetting.objects.all()
 
-        self.assertEqual(settings.count(), 2)
+        self.assertTrue(settings.count() >= 2)
 
         instance_name = InvenTreeSetting.objects.get(pk=1)
         self.assertEqual(instance_name.key, 'INVENTREE_INSTANCE')
diff --git a/InvenTree/company/admin.py b/InvenTree/company/admin.py
index 45dd769d67..b339a65c0e 100644
--- a/InvenTree/company/admin.py
+++ b/InvenTree/company/admin.py
@@ -38,7 +38,9 @@ class CompanyAdmin(ImportExportModelAdmin):
 
 
 class SupplierPartResource(ModelResource):
-    """ Class for managing SupplierPart data import/export """
+    """
+    Class for managing SupplierPart data import/export
+    """
 
     part = Field(attribute='part', widget=widgets.ForeignKeyWidget(Part))
 
@@ -51,7 +53,7 @@ class SupplierPartResource(ModelResource):
     class Meta:
         model = SupplierPart
         skip_unchanged = True
-        report_skipped = False
+        report_skipped = True
         clean_model_instances = True
 
 
diff --git a/InvenTree/company/migrations/0031_auto_20210103_2215.py b/InvenTree/company/migrations/0031_auto_20210103_2215.py
new file mode 100644
index 0000000000..a8ff0d9d63
--- /dev/null
+++ b/InvenTree/company/migrations/0031_auto_20210103_2215.py
@@ -0,0 +1,61 @@
+# Generated by Django 3.0.7 on 2021-01-03 11:15
+
+import InvenTree.fields
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('part', '0060_merge_20201112_1722'),
+        ('company', '0030_auto_20201112_1112'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='supplierpart',
+            name='MPN',
+            field=models.CharField(blank=True, help_text='Manufacturer part number', max_length=100, null=True, verbose_name='MPN'),
+        ),
+        migrations.AlterField(
+            model_name='supplierpart',
+            name='SKU',
+            field=models.CharField(help_text='Supplier stock keeping unit', max_length=100, verbose_name='SKU'),
+        ),
+        migrations.AlterField(
+            model_name='supplierpart',
+            name='description',
+            field=models.CharField(blank=True, help_text='Supplier part description', max_length=250, null=True, verbose_name='Description'),
+        ),
+        migrations.AlterField(
+            model_name='supplierpart',
+            name='link',
+            field=InvenTree.fields.InvenTreeURLField(blank=True, help_text='URL for external supplier part link', null=True, verbose_name='Link'),
+        ),
+        migrations.AlterField(
+            model_name='supplierpart',
+            name='manufacturer',
+            field=models.ForeignKey(blank=True, help_text='Select manufacturer', limit_choices_to={'is_manufacturer': True}, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='manufactured_parts', to='company.Company', verbose_name='Manufacturer'),
+        ),
+        migrations.AlterField(
+            model_name='supplierpart',
+            name='note',
+            field=models.CharField(blank=True, help_text='Notes', max_length=100, null=True, verbose_name='Note'),
+        ),
+        migrations.AlterField(
+            model_name='supplierpart',
+            name='packaging',
+            field=models.CharField(blank=True, help_text='Part packaging', max_length=50, null=True),
+        ),
+        migrations.AlterField(
+            model_name='supplierpart',
+            name='part',
+            field=models.ForeignKey(help_text='Select part', limit_choices_to={'purchaseable': True}, on_delete=django.db.models.deletion.CASCADE, related_name='supplier_parts', to='part.Part', verbose_name='Base Part'),
+        ),
+        migrations.AlterField(
+            model_name='supplierpart',
+            name='supplier',
+            field=models.ForeignKey(help_text='Select supplier', limit_choices_to={'is_supplier': True}, on_delete=django.db.models.deletion.CASCADE, related_name='supplied_parts', to='company.Company', verbose_name='Supplier'),
+        ),
+    ]
diff --git a/InvenTree/company/models.py b/InvenTree/company/models.py
index 81718a9acd..e4386712c8 100644
--- a/InvenTree/company/models.py
+++ b/InvenTree/company/models.py
@@ -313,7 +313,6 @@ class SupplierPart(models.Model):
                              verbose_name=_('Base Part'),
                              limit_choices_to={
                                  'purchaseable': True,
-                                 'is_template': False,
                              },
                              help_text=_('Select part'),
                              )
@@ -321,31 +320,55 @@ class SupplierPart(models.Model):
     supplier = models.ForeignKey(Company, on_delete=models.CASCADE,
                                  related_name='supplied_parts',
                                  limit_choices_to={'is_supplier': True},
+                                 verbose_name=_('Supplier'),
                                  help_text=_('Select supplier'),
                                  )
 
-    SKU = models.CharField(max_length=100, help_text=_('Supplier stock keeping unit'))
+    SKU = models.CharField(
+        max_length=100,
+        verbose_name=_('SKU'),
+        help_text=_('Supplier stock keeping unit')
+    )
 
     manufacturer = models.ForeignKey(
         Company,
         on_delete=models.SET_NULL,
         related_name='manufactured_parts',
-        limit_choices_to={'is_manufacturer': True},
+        limit_choices_to={
+            'is_manufacturer': True
+        },
+        verbose_name=_('Manufacturer'),
         help_text=_('Select manufacturer'),
         null=True, blank=True
     )
 
-    MPN = models.CharField(max_length=100, blank=True, help_text=_('Manufacturer part number'))
+    MPN = models.CharField(
+        max_length=100, blank=True, null=True,
+        verbose_name=_('MPN'),
+        help_text=_('Manufacturer part number')
+    )
 
-    link = InvenTreeURLField(blank=True, help_text=_('URL for external supplier part link'))
+    link = InvenTreeURLField(
+        blank=True, null=True,
+        verbose_name=_('Link'),
+        help_text=_('URL for external supplier part link')
+    )
 
-    description = models.CharField(max_length=250, blank=True, help_text=_('Supplier part description'))
+    description = models.CharField(
+        max_length=250, blank=True, null=True,
+        verbose_name=_('Description'),
+        help_text=_('Supplier part description')
+    )
 
-    note = models.CharField(max_length=100, blank=True, help_text=_('Notes'))
+    note = models.CharField(
+        max_length=100, blank=True, null=True,
+        verbose_name=_('Note'),
+        help_text=_('Notes')
+    )
 
     base_cost = models.DecimalField(max_digits=10, decimal_places=3, default=0, validators=[MinValueValidator(0)], help_text=_('Minimum charge (e.g. stocking fee)'))
 
-    packaging = models.CharField(max_length=50, blank=True, help_text=_('Part packaging'))
+    packaging = models.CharField(max_length=50, blank=True, null=True, help_text=_('Part packaging'))
     
     multiple = models.PositiveIntegerField(default=1, validators=[MinValueValidator(1)], help_text=('Order multiple'))
 
diff --git a/InvenTree/locale/de/LC_MESSAGES/django.po b/InvenTree/locale/de/LC_MESSAGES/django.po
index 5b18692564..31e3f80413 100644
--- a/InvenTree/locale/de/LC_MESSAGES/django.po
+++ b/InvenTree/locale/de/LC_MESSAGES/django.po
@@ -6,7 +6,7 @@ msgid ""
 msgstr ""
 "Project-Id-Version: \n"
 "Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2020-12-16 19:08+1100\n"
+"POT-Creation-Date: 2021-01-07 23:48+1100\n"
 "PO-Revision-Date: 2020-05-03 11:32+0200\n"
 "Last-Translator: Christian Schlüter <chschlue@gmail.com>\n"
 "Language-Team: C <kde-i18n-doc@kde.org>\n"
@@ -25,7 +25,13 @@ msgstr "Keine Aktion angegeben"
 msgid "No matching action found"
 msgstr "Keine passende Aktion gefunden"
 
-#: InvenTree/forms.py:110 build/forms.py:91 build/forms.py:179
+#: InvenTree/fields.py:44
+#, fuzzy
+#| msgid "Entry notes"
+msgid "Enter date"
+msgstr "Eintrags-Notizen"
+
+#: InvenTree/forms.py:110 build/forms.py:90 build/forms.py:178
 msgid "Confirm"
 msgstr "Bestätigen"
 
@@ -55,8 +61,8 @@ msgstr ""
 msgid "Select Category"
 msgstr "Teilkategorie auswählen"
 
-#: InvenTree/helpers.py:361 order/models.py:189 order/models.py:271
-#: stock/views.py:1646
+#: InvenTree/helpers.py:361 order/models.py:216 order/models.py:298
+#: stock/views.py:1660
 msgid "Invalid quantity provided"
 msgstr "Keine gültige Menge"
 
@@ -99,12 +105,12 @@ msgstr "Datei zum Anhängen auswählen"
 msgid "File comment"
 msgstr "Datei-Kommentar"
 
-#: InvenTree/models.py:68 templates/js/stock.js:738
+#: InvenTree/models.py:68 templates/js/stock.js:759
 msgid "User"
 msgstr "Benutzer"
 
-#: InvenTree/models.py:106 part/templates/part/params.html:24
-#: templates/js/part.js:129
+#: InvenTree/models.py:106 part/models.py:647
+#: part/templates/part/params.html:24 templates/js/part.js:129
 msgid "Name"
 msgstr "Name"
 
@@ -141,7 +147,7 @@ msgid "InvenTree system health checks failed"
 msgstr "Instanzname"
 
 #: InvenTree/status_codes.py:94 InvenTree/status_codes.py:135
-#: InvenTree/status_codes.py:223
+#: InvenTree/status_codes.py:228
 msgid "Pending"
 msgstr "Ausstehend"
 
@@ -149,51 +155,51 @@ msgstr "Ausstehend"
 msgid "Placed"
 msgstr "Platziert"
 
-#: InvenTree/status_codes.py:96 InvenTree/status_codes.py:226
+#: InvenTree/status_codes.py:96 InvenTree/status_codes.py:231
 msgid "Complete"
 msgstr "Fertig"
 
 #: InvenTree/status_codes.py:97 InvenTree/status_codes.py:137
-#: InvenTree/status_codes.py:225
+#: InvenTree/status_codes.py:230
 msgid "Cancelled"
 msgstr "Storniert"
 
 #: InvenTree/status_codes.py:98 InvenTree/status_codes.py:138
-#: InvenTree/status_codes.py:175
+#: InvenTree/status_codes.py:180
 msgid "Lost"
 msgstr "Verloren"
 
 #: InvenTree/status_codes.py:99 InvenTree/status_codes.py:139
-#: InvenTree/status_codes.py:177
+#: InvenTree/status_codes.py:182
 msgid "Returned"
 msgstr "Zurückgegeben"
 
 #: InvenTree/status_codes.py:136
-#: order/templates/order/sales_order_base.html:106
+#: order/templates/order/sales_order_base.html:121
 msgid "Shipped"
 msgstr "Versendet"
 
-#: InvenTree/status_codes.py:171
+#: InvenTree/status_codes.py:176
 msgid "OK"
 msgstr "OK"
 
-#: InvenTree/status_codes.py:172
+#: InvenTree/status_codes.py:177
 msgid "Attention needed"
 msgstr "erfordert Eingriff"
 
-#: InvenTree/status_codes.py:173
+#: InvenTree/status_codes.py:178
 msgid "Damaged"
 msgstr "Beschädigt"
 
-#: InvenTree/status_codes.py:174
+#: InvenTree/status_codes.py:179
 msgid "Destroyed"
 msgstr "Zerstört"
 
-#: InvenTree/status_codes.py:176
+#: InvenTree/status_codes.py:181
 msgid "Rejected"
 msgstr ""
 
-#: InvenTree/status_codes.py:224
+#: InvenTree/status_codes.py:229
 #, fuzzy
 #| msgid "Location"
 msgid "Production"
@@ -313,15 +319,26 @@ msgstr ""
 msgid "Barcode associated with StockItem"
 msgstr "Neues Lagerobjekt hinzufügen"
 
-#: build/forms.py:32
+#: build/forms.py:34
 #, fuzzy
 #| msgid "Order reference"
 msgid "Build Order reference"
 msgstr "Bestell-Referenz"
 
-#: build/forms.py:79 build/templates/build/auto_allocate.html:17
+#: build/forms.py:35
+#, fuzzy
+#| msgid "No destination set"
+msgid "Order target date"
+msgstr "Kein Ziel gesetzt"
+
+#: build/forms.py:39 build/models.py:206
+msgid ""
+"Target date for build completion. Build will be overdue after this date."
+msgstr ""
+
+#: build/forms.py:78 build/templates/build/auto_allocate.html:17
 #: build/templates/build/build_base.html:83
-#: build/templates/build/detail.html:29 common/models.py:494
+#: build/templates/build/detail.html:29 common/models.py:589
 #: company/forms.py:112 company/templates/company/supplier_part_pricing.html:75
 #: order/templates/order/order_wizard/select_parts.html:32
 #: order/templates/order/purchase_order_detail.html:179
@@ -329,173 +346,174 @@ msgstr "Bestell-Referenz"
 #: order/templates/order/sales_order_detail.html:156
 #: part/templates/part/allocation.html:16
 #: part/templates/part/allocation.html:49
-#: part/templates/part/sale_prices.html:82 stock/forms.py:298
+#: part/templates/part/sale_prices.html:82 stock/forms.py:304
 #: stock/templates/stock/item_base.html:40
 #: stock/templates/stock/item_base.html:46
-#: stock/templates/stock/item_base.html:197
+#: stock/templates/stock/item_base.html:214
 #: stock/templates/stock/stock_adjust.html:18 templates/js/barcode.js:338
-#: templates/js/bom.js:195 templates/js/build.js:420 templates/js/stock.js:729
-#: templates/js/stock.js:957
+#: templates/js/bom.js:195 templates/js/build.js:420 templates/js/stock.js:750
+#: templates/js/stock.js:989
 msgid "Quantity"
 msgstr "Anzahl"
 
-#: build/forms.py:80
+#: build/forms.py:79
 #, fuzzy
 #| msgid "Serial number for this item"
 msgid "Enter quantity for build output"
 msgstr "Seriennummer für dieses Teil"
 
-#: build/forms.py:84 stock/forms.py:111
+#: build/forms.py:83 stock/forms.py:116
 #, fuzzy
 #| msgid "Serial Number"
 msgid "Serial numbers"
 msgstr "Seriennummer"
 
-#: build/forms.py:86
+#: build/forms.py:85
 #, fuzzy
 #| msgid "Serial number for this item"
 msgid "Enter serial numbers for build outputs"
 msgstr "Seriennummer für dieses Teil"
 
-#: build/forms.py:92
+#: build/forms.py:91
 #, fuzzy
 #| msgid "Confirm completion of build"
 msgid "Confirm creation of build outut"
 msgstr "Baufertigstellung bestätigen"
 
-#: build/forms.py:112
+#: build/forms.py:111
 #, fuzzy
 #| msgid "Confirm completion of build"
 msgid "Confirm deletion of build output"
 msgstr "Baufertigstellung bestätigen"
 
-#: build/forms.py:133
+#: build/forms.py:132
 #, fuzzy
 #| msgid "Confirm unallocation of build stock"
 msgid "Confirm unallocation of stock"
 msgstr "Zuweisungsaufhebung bestätigen"
 
-#: build/forms.py:157
+#: build/forms.py:156
 msgid "Confirm stock allocation"
 msgstr "Lagerbestandszuordnung bestätigen"
 
-#: build/forms.py:180
+#: build/forms.py:179
 #, fuzzy
 #| msgid "Mark order as complete"
 msgid "Mark build as complete"
 msgstr "Bestellung als vollständig markieren"
 
-#: build/forms.py:204
+#: build/forms.py:203
 #, fuzzy
 #| msgid "Location Details"
 msgid "Location of completed parts"
 msgstr "Standort-Details"
 
-#: build/forms.py:209
+#: build/forms.py:208
 #, fuzzy
 #| msgid "Confirm stock allocation"
 msgid "Confirm completion with incomplete stock allocation"
 msgstr "Lagerbestandszuordnung bestätigen"
 
-#: build/forms.py:212
+#: build/forms.py:211
 msgid "Confirm build completion"
 msgstr "Bau-Fertigstellung bestätigen"
 
-#: build/forms.py:232 build/views.py:68
+#: build/forms.py:231 build/views.py:68
 msgid "Confirm build cancellation"
 msgstr "Bauabbruch bestätigen"
 
-#: build/forms.py:246
+#: build/forms.py:245
 #, fuzzy
 #| msgid "Select stock item to allocate"
 msgid "Select quantity of stock to allocate"
 msgstr "Lagerobjekt für Zuordnung auswählen"
 
-#: build/models.py:59 build/templates/build/build_base.html:8
+#: build/models.py:61 build/templates/build/build_base.html:8
 #: build/templates/build/build_base.html:35
 #: part/templates/part/allocation.html:20
 msgid "Build Order"
 msgstr "Bauauftrag"
 
-#: build/models.py:60 build/templates/build/index.html:6
-#: build/templates/build/index.html:14 order/templates/order/so_builds.html:11
+#: build/models.py:62 build/templates/build/index.html:8
+#: build/templates/build/index.html:15 order/templates/order/so_builds.html:11
 #: order/templates/order/so_tabs.html:9 part/templates/part/tabs.html:31
 #: templates/InvenTree/settings/tabs.html:28 users/models.py:30
 msgid "Build Orders"
 msgstr "Bauaufträge"
 
-#: build/models.py:75
+#: build/models.py:108
 #, fuzzy
 #| msgid "Order Reference"
 msgid "Build Order Reference"
 msgstr "Bestellreferenz"
 
-#: build/models.py:76 order/templates/order/purchase_order_detail.html:174
+#: build/models.py:109 order/templates/order/purchase_order_detail.html:174
 #: templates/js/bom.js:187 templates/js/build.js:509
 msgid "Reference"
 msgstr "Referenz"
 
-#: build/models.py:83 build/templates/build/detail.html:19
-#: company/templates/company/detail.html:23
+#: build/models.py:116 build/templates/build/detail.html:19
+#: company/models.py:359 company/templates/company/detail.html:23
 #: company/templates/company/supplier_part_base.html:61
 #: company/templates/company/supplier_part_detail.html:27
-#: order/templates/order/purchase_order_detail.html:161
+#: order/templates/order/purchase_order_detail.html:161 part/models.py:671
 #: part/templates/part/detail.html:51 part/templates/part/set_category.html:14
-#: templates/InvenTree/search.html:147 templates/js/bom.js:180
+#: templates/InvenTree/search.html:147
+#: templates/InvenTree/settings/header.html:9 templates/js/bom.js:180
 #: templates/js/bom.js:517 templates/js/build.js:664 templates/js/company.js:56
-#: templates/js/order.js:175 templates/js/order.js:257 templates/js/part.js:188
+#: templates/js/order.js:175 templates/js/order.js:263 templates/js/part.js:188
 #: templates/js/part.js:271 templates/js/part.js:391 templates/js/part.js:572
-#: templates/js/stock.js:494 templates/js/stock.js:710
+#: templates/js/stock.js:501 templates/js/stock.js:731
 msgid "Description"
 msgstr "Beschreibung"
 
-#: build/models.py:86
+#: build/models.py:119
 msgid "Brief description of the build"
 msgstr "Kurze Beschreibung des Baus"
 
-#: build/models.py:95 build/templates/build/build_base.html:104
+#: build/models.py:128 build/templates/build/build_base.html:113
 #: build/templates/build/detail.html:75
 msgid "Parent Build"
 msgstr "Eltern-Bau"
 
-#: build/models.py:96
+#: build/models.py:129
 #, fuzzy
 #| msgid "SalesOrder to which this build is allocated"
 msgid "BuildOrder to which this build is allocated"
 msgstr "Bestellung, die diesem Bau zugwiesen ist"
 
-#: build/models.py:101 build/templates/build/auto_allocate.html:16
+#: build/models.py:134 build/templates/build/auto_allocate.html:16
 #: build/templates/build/build_base.html:78
-#: build/templates/build/detail.html:24 order/models.py:530
+#: build/templates/build/detail.html:24 order/models.py:623
 #: order/templates/order/order_wizard/select_parts.html:30
 #: order/templates/order/purchase_order_detail.html:148
-#: order/templates/order/receive_parts.html:19 part/models.py:315
+#: order/templates/order/receive_parts.html:19 part/models.py:316
 #: part/templates/part/part_app_base.html:7 part/templates/part/related.html:26
 #: part/templates/part/set_category.html:13 templates/InvenTree/search.html:133
 #: templates/js/barcode.js:336 templates/js/bom.js:153 templates/js/bom.js:502
 #: templates/js/build.js:669 templates/js/company.js:138
-#: templates/js/part.js:252 templates/js/part.js:357 templates/js/stock.js:468
-#: templates/js/stock.js:1029
+#: templates/js/part.js:252 templates/js/part.js:357 templates/js/stock.js:475
+#: templates/js/stock.js:1061
 msgid "Part"
 msgstr "Teil"
 
-#: build/models.py:109
+#: build/models.py:142
 msgid "Select part to build"
 msgstr "Teil für den Bau wählen"
 
-#: build/models.py:114
+#: build/models.py:147
 msgid "Sales Order Reference"
 msgstr "Bestellungsreferenz"
 
-#: build/models.py:118
+#: build/models.py:151
 msgid "SalesOrder to which this build is allocated"
 msgstr "Bestellung, die diesem Bau zugwiesen ist"
 
-#: build/models.py:123
+#: build/models.py:156
 msgid "Source Location"
 msgstr "Quell-Standort"
 
-#: build/models.py:127
+#: build/models.py:160
 msgid ""
 "Select location to take stock from for this build (leave blank to take from "
 "any stock location)"
@@ -503,160 +521,155 @@ msgstr ""
 "Lager-Entnahmestandort für diesen Bau wählen (oder leer lassen für einen "
 "beliebigen Lager-Standort)"
 
-#: build/models.py:132
+#: build/models.py:165
 #, fuzzy
 #| msgid "Destination stock location"
 msgid "Destination Location"
 msgstr "Ziel-Lagerbestand"
 
-#: build/models.py:136
+#: build/models.py:169
 msgid "Select location where the completed items will be stored"
 msgstr ""
 
-#: build/models.py:140
+#: build/models.py:173
 msgid "Build Quantity"
 msgstr "Bau-Anzahl"
 
-#: build/models.py:143
+#: build/models.py:176
 #, fuzzy
 #| msgid "Number of parts to build"
 msgid "Number of stock items to build"
 msgstr "Anzahl der zu bauenden Teile"
 
-#: build/models.py:147
+#: build/models.py:180
 #, fuzzy
 #| msgid "Completed"
 msgid "Completed items"
 msgstr "Fertig"
 
-#: build/models.py:149
+#: build/models.py:182
 #, fuzzy
 #| msgid "Delete this Stock Item when stock is depleted"
 msgid "Number of stock items which have been completed"
 msgstr "Objekt löschen wenn Lagerbestand aufgebraucht"
 
-#: build/models.py:153 part/templates/part/part_base.html:155
+#: build/models.py:186 part/templates/part/part_base.html:155
 msgid "Build Status"
 msgstr "Bau-Status"
 
-#: build/models.py:157
+#: build/models.py:190
 msgid "Build status code"
 msgstr "Bau-Statuscode"
 
-#: build/models.py:161 stock/models.py:390
+#: build/models.py:194 stock/models.py:397
 msgid "Batch Code"
 msgstr "Losnummer"
 
-#: build/models.py:165
+#: build/models.py:198
 msgid "Batch code for this build output"
 msgstr "Chargennummer für diese Bau-Ausgabe"
 
-#: build/models.py:172
+#: build/models.py:205 order/models.py:404
 msgid "Target completion date"
 msgstr ""
 
-#: build/models.py:173
-msgid ""
-"Target date for build completion. Build will be overdue after this date."
-msgstr ""
-
-#: build/models.py:186 build/templates/build/detail.html:89
+#: build/models.py:219 build/templates/build/detail.html:89
 #: company/templates/company/supplier_part_base.html:68
 #: company/templates/company/supplier_part_detail.html:24
 #: part/templates/part/detail.html:80 part/templates/part/part_base.html:102
-#: stock/models.py:384 stock/templates/stock/item_base.html:280
+#: stock/models.py:391 stock/templates/stock/item_base.html:297
 msgid "External Link"
 msgstr "Externer Link"
 
-#: build/models.py:187 part/models.py:672 stock/models.py:386
+#: build/models.py:220 part/models.py:705 stock/models.py:393
 msgid "Link to external URL"
 msgstr "Link zu einer externen URL"
 
-#: build/models.py:191 build/templates/build/tabs.html:23 company/models.py:344
+#: build/models.py:224 build/templates/build/tabs.html:23 company/models.py:366
 #: company/templates/company/tabs.html:33 order/templates/order/po_tabs.html:18
 #: order/templates/order/purchase_order_detail.html:213
-#: order/templates/order/so_tabs.html:23 part/templates/part/tabs.html:73
-#: stock/forms.py:307 stock/forms.py:339 stock/forms.py:367 stock/models.py:448
-#: stock/models.py:1433 stock/templates/stock/tabs.html:26
-#: templates/js/barcode.js:391 templates/js/bom.js:263
-#: templates/js/stock.js:116 templates/js/stock.js:582
+#: order/templates/order/so_tabs.html:23 part/models.py:831
+#: part/templates/part/tabs.html:73 stock/forms.py:313 stock/forms.py:345
+#: stock/forms.py:373 stock/models.py:463 stock/models.py:1512
+#: stock/templates/stock/tabs.html:26 templates/js/barcode.js:391
+#: templates/js/bom.js:263 templates/js/stock.js:117 templates/js/stock.js:603
 msgid "Notes"
 msgstr "Notizen"
 
-#: build/models.py:192
+#: build/models.py:225
 msgid "Extra build notes"
 msgstr "Notizen für den Bau"
 
-#: build/models.py:577
+#: build/models.py:610
 #, fuzzy
 #| msgid "No action specified"
 msgid "No build output specified"
 msgstr "Keine Aktion angegeben"
 
-#: build/models.py:580
+#: build/models.py:613
 msgid "Build output is already completed"
 msgstr ""
 
-#: build/models.py:583
+#: build/models.py:616
 #, fuzzy
 #| msgid "Quantity does not match serial numbers"
 msgid "Build output does not match Build Order"
 msgstr "Anzahl stimmt nicht mit den Seriennummern überein"
 
-#: build/models.py:658
+#: build/models.py:691
 #, fuzzy
 #| msgid "Complete Build"
 msgid "Completed build output"
 msgstr "Bau fertigstellen"
 
-#: build/models.py:896
+#: build/models.py:933
 msgid "BuildItem must be unique for build, stock_item and install_into"
 msgstr ""
 
-#: build/models.py:918
+#: build/models.py:955
 #, fuzzy
 #| msgid "Allocate Stock to Build"
 msgid "Build item must specify a build output"
 msgstr "Lagerbestand dem Bau zuweisen"
 
-#: build/models.py:923
+#: build/models.py:960
 #, python-brace-format
 msgid "Selected stock item not found in BOM for part '{p}'"
 msgstr "Ausgewähltes Lagerobjekt nicht in BOM für Teil '{p}' gefunden"
 
-#: build/models.py:927
+#: build/models.py:964
 #, python-brace-format
 msgid "Allocated quantity ({n}) must not exceed available quantity ({q})"
 msgstr ""
 "zugewiesene Anzahl ({n}) darf nicht die verfügbare ({q}) Anzahl überschreiten"
 
-#: build/models.py:934 order/models.py:614
+#: build/models.py:971 order/models.py:707
 msgid "StockItem is over-allocated"
 msgstr "Zu viele Lagerobjekte zugewiesen"
 
-#: build/models.py:938 order/models.py:617
+#: build/models.py:975 order/models.py:710
 msgid "Allocation quantity must be greater than zero"
 msgstr "Anzahl muss größer null sein"
 
-#: build/models.py:942
+#: build/models.py:979
 msgid "Quantity must be 1 for serialized stock"
 msgstr "Anzahl muss 1 für Objekte mit Seriennummer sein"
 
-#: build/models.py:982
+#: build/models.py:1019
 msgid "Build to allocate parts"
 msgstr "Bau starten um Teile zuzuweisen"
 
-#: build/models.py:989
+#: build/models.py:1026
 #, fuzzy
 #| msgid "Remove stock"
 msgid "Source stock item"
 msgstr "Bestand entfernen"
 
-#: build/models.py:1001
+#: build/models.py:1038
 msgid "Stock quantity to allocate to build"
 msgstr "Lagerobjekt-Anzahl dem Bau zuweisen"
 
-#: build/models.py:1009
+#: build/models.py:1046
 #, fuzzy
 #| msgid "Destination stock location"
 msgid "Destination stock item"
@@ -693,7 +706,7 @@ msgid "Order required parts"
 msgstr "Teil bestellen"
 
 #: build/templates/build/allocate.html:30
-#: company/templates/company/detail_part.html:28 order/views.py:803
+#: company/templates/company/detail_part.html:28 order/views.py:805
 #: part/templates/part/category.html:125
 msgid "Order Parts"
 msgstr "Teile bestellen"
@@ -741,11 +754,11 @@ msgid ""
 "The following stock items will be allocated to the specified build output"
 msgstr "Lagerobjekt dem Bau zuweisen"
 
-#: build/templates/build/auto_allocate.html:18 stock/forms.py:337
-#: stock/templates/stock/item_base.html:227
+#: build/templates/build/auto_allocate.html:18 stock/forms.py:343
+#: stock/templates/stock/item_base.html:244
 #: stock/templates/stock/stock_adjust.html:17
 #: templates/InvenTree/search.html:183 templates/js/barcode.js:337
-#: templates/js/build.js:434 templates/js/stock.js:574
+#: templates/js/build.js:434 templates/js/stock.js:587
 msgid "Location"
 msgstr "Standort"
 
@@ -778,7 +791,7 @@ msgstr "Dieser Bau ist Kind von Bau"
 #: order/templates/order/order_base.html:26
 #: order/templates/order/sales_order_base.html:35
 #: part/templates/part/category.html:13 part/templates/part/part_base.html:32
-#: stock/templates/stock/item_base.html:90
+#: stock/templates/stock/item_base.html:97
 #: stock/templates/stock/location.html:12
 #, fuzzy
 #| msgid "Admin"
@@ -786,7 +799,10 @@ msgid "Admin view"
 msgstr "Admin"
 
 #: build/templates/build/build_base.html:43
-#: build/templates/build/build_base.html:92 templates/js/table_filters.js:190
+#: build/templates/build/build_base.html:100
+#: order/templates/order/sales_order_base.html:41
+#: order/templates/order/sales_order_base.html:83
+#: templates/js/table_filters.js:200 templates/js/table_filters.js:232
 msgid "Overdue"
 msgstr ""
 
@@ -815,30 +831,39 @@ msgstr "Bau-Status"
 #: build/templates/build/build_base.html:88
 #: build/templates/build/detail.html:57
 #: order/templates/order/receive_parts.html:24
-#: stock/templates/stock/item_base.html:312 templates/InvenTree/search.html:175
+#: stock/templates/stock/item_base.html:343 templates/InvenTree/search.html:175
 #: templates/js/barcode.js:42 templates/js/build.js:697
-#: templates/js/order.js:180 templates/js/order.js:262
-#: templates/js/stock.js:561 templates/js/stock.js:965
+#: templates/js/order.js:180 templates/js/order.js:268
+#: templates/js/stock.js:574 templates/js/stock.js:997
 msgid "Status"
 msgstr "Status"
 
-#: build/templates/build/build_base.html:92
+#: build/templates/build/build_base.html:96
+#: build/templates/build/detail.html:100
+#: order/templates/order/sales_order_base.html:114 templates/js/build.js:710
+#: templates/js/order.js:281
+#, fuzzy
+#| msgid "Shipment Date"
+msgid "Target Date"
+msgstr "Versanddatum"
+
+#: build/templates/build/build_base.html:100
 msgid "This build was due on"
 msgstr ""
 
-#: build/templates/build/build_base.html:98
+#: build/templates/build/build_base.html:107
 #: build/templates/build/detail.html:62
 msgid "Progress"
 msgstr ""
 
-#: build/templates/build/build_base.html:111
-#: build/templates/build/detail.html:82 order/models.py:528
+#: build/templates/build/build_base.html:120
+#: build/templates/build/detail.html:82 order/models.py:621
 #: order/templates/order/sales_order_base.html:9
 #: order/templates/order/sales_order_base.html:33
 #: order/templates/order/sales_order_notes.html:10
 #: order/templates/order/sales_order_ship.html:25
 #: part/templates/part/allocation.html:27
-#: stock/templates/stock/item_base.html:221 templates/js/order.js:229
+#: stock/templates/stock/item_base.html:238 templates/js/order.js:229
 msgid "Sales Order"
 msgstr "Bestellung"
 
@@ -959,7 +984,7 @@ msgstr "Lagerobjekt"
 msgid "Stock can be taken from any available location."
 msgstr "Bestand kann jedem verfügbaren Lagerort entnommen werden."
 
-#: build/templates/build/detail.html:44 stock/forms.py:365
+#: build/templates/build/detail.html:44 stock/forms.py:371
 #, fuzzy
 #| msgid "Description"
 msgid "Destination"
@@ -972,24 +997,18 @@ msgid "Destination location not specified"
 msgstr "Hat dieses Teil Tracking für einzelne Objekte?"
 
 #: build/templates/build/detail.html:68
-#: stock/templates/stock/item_base.html:245 templates/js/stock.js:569
-#: templates/js/stock.js:972 templates/js/table_filters.js:80
-#: templates/js/table_filters.js:151
+#: stock/templates/stock/item_base.html:262 templates/js/stock.js:582
+#: templates/js/stock.js:1004 templates/js/table_filters.js:80
+#: templates/js/table_filters.js:161
 msgid "Batch"
 msgstr "Los"
 
 #: build/templates/build/detail.html:95
 #: order/templates/order/order_base.html:98
-#: order/templates/order/sales_order_base.html:100 templates/js/build.js:705
+#: order/templates/order/sales_order_base.html:108 templates/js/build.js:705
 msgid "Created"
 msgstr "Erstellt"
 
-#: build/templates/build/detail.html:100 templates/js/build.js:710
-#, fuzzy
-#| msgid "Shipment Date"
-msgid "Target Date"
-msgstr "Versanddatum"
-
 #: build/templates/build/detail.html:106
 #, fuzzy
 #| msgid "No destination set"
@@ -1013,10 +1032,22 @@ msgstr "Bau-Zuweisung ist vollständig"
 msgid "Alter the quantity of stock allocated to the build output"
 msgstr "Lagerobjekt-Anzahl dem Bau zuweisen"
 
-#: build/templates/build/index.html:25 build/views.py:658
+#: build/templates/build/index.html:27 build/views.py:658
 msgid "New Build Order"
 msgstr "Neuer Bauauftrag"
 
+#: build/templates/build/index.html:30
+#: order/templates/order/purchase_orders.html:22
+#: order/templates/order/sales_orders.html:22
+msgid "Display calendar view"
+msgstr ""
+
+#: build/templates/build/index.html:33
+#: order/templates/order/purchase_orders.html:25
+#: order/templates/order/sales_orders.html:25
+msgid "Display list view"
+msgstr ""
+
 #: build/templates/build/notes.html:13 build/templates/build/notes.html:30
 msgid "Build Notes"
 msgstr "Bau-Bemerkungen"
@@ -1083,7 +1114,7 @@ msgstr "Lagerbestand dem Bau zuweisen"
 msgid "Create Build Output"
 msgstr "Bau-Ausgabe"
 
-#: build/views.py:207 stock/models.py:828 stock/views.py:1667
+#: build/views.py:207 stock/models.py:872 stock/views.py:1681
 #, fuzzy
 #| msgid "Serial numbers already exist: "
 msgid "Serial numbers already exist"
@@ -1216,38 +1247,38 @@ msgstr "verfügbar"
 msgid "Stock item must be selected"
 msgstr "Lagerobjekt wurde zugewiesen"
 
-#: build/views.py:1011
+#: build/views.py:1012
 msgid "Edit Stock Allocation"
 msgstr "Teilzuordnung bearbeiten"
 
-#: build/views.py:1016
+#: build/views.py:1017
 msgid "Updated Build Item"
 msgstr "Bauobjekt aktualisiert"
 
-#: build/views.py:1045
+#: build/views.py:1046
 #, fuzzy
 #| msgid "Add Sales Order Attachment"
 msgid "Add Build Order Attachment"
 msgstr "Auftragsanhang hinzufügen"
 
-#: build/views.py:1059 order/views.py:111 order/views.py:164 part/views.py:168
+#: build/views.py:1060 order/views.py:113 order/views.py:166 part/views.py:170
 #: stock/views.py:180
 msgid "Added attachment"
 msgstr "Anhang hinzugefügt"
 
-#: build/views.py:1095 order/views.py:191 order/views.py:213
+#: build/views.py:1096 order/views.py:193 order/views.py:215
 msgid "Edit Attachment"
 msgstr "Anhang bearbeiten"
 
-#: build/views.py:1106 order/views.py:196 order/views.py:218
+#: build/views.py:1107 order/views.py:198 order/views.py:220
 msgid "Attachment updated"
 msgstr "Anhang aktualisiert"
 
-#: build/views.py:1116 order/views.py:233 order/views.py:248
+#: build/views.py:1117 order/views.py:235 order/views.py:250
 msgid "Delete Attachment"
 msgstr "Anhang löschen"
 
-#: build/views.py:1122 order/views.py:240 order/views.py:255 stock/views.py:238
+#: build/views.py:1123 order/views.py:242 order/views.py:257 stock/views.py:238
 msgid "Deleted attachment"
 msgstr "Anhang gelöscht"
 
@@ -1343,125 +1374,208 @@ msgstr "Teilparametervorlage bearbeiten"
 msgid "Copy category parameter templates when creating a part"
 msgstr ""
 
-#: common/models.py:115 part/models.py:743 part/templates/part/detail.html:168
-#: templates/js/table_filters.js:268
+#: common/models.py:115 part/templates/part/detail.html:155 stock/forms.py:255
+#: templates/js/table_filters.js:23 templates/js/table_filters.js:266
+msgid "Template"
+msgstr "Vorlage"
+
+#: common/models.py:116
+#, fuzzy
+#| msgid "Part is not a virtual part"
+msgid "Parts are templates by default"
+msgstr "Teil ist nicht virtuell"
+
+#: common/models.py:122 part/models.py:794 part/templates/part/detail.html:165
+#: templates/js/table_filters.js:278
+msgid "Assembly"
+msgstr "Baugruppe"
+
+#: common/models.py:123
+#, fuzzy
+#| msgid "Part can be assembled from other parts"
+msgid "Parts can be assembled from other components by default"
+msgstr "Teil kann aus anderen Teilen angefertigt werden"
+
+#: common/models.py:129 part/models.py:800 part/templates/part/detail.html:175
+#: templates/js/table_filters.js:282
 msgid "Component"
 msgstr "Komponente"
 
-#: common/models.py:116
+#: common/models.py:130
 #, fuzzy
 #| msgid "Part can be used in assemblies"
 msgid "Parts can be used as sub-components by default"
 msgstr "Teil kann in Baugruppen benutzt werden"
 
-#: common/models.py:122 part/models.py:754 part/templates/part/detail.html:188
+#: common/models.py:136 part/models.py:811 part/templates/part/detail.html:195
 msgid "Purchaseable"
 msgstr "Kaufbar"
 
-#: common/models.py:123
+#: common/models.py:137
 msgid "Parts are purchaseable by default"
 msgstr ""
 
-#: common/models.py:129 part/models.py:759 part/templates/part/detail.html:198
-#: templates/js/table_filters.js:276
+#: common/models.py:143 part/models.py:816 part/templates/part/detail.html:205
+#: templates/js/table_filters.js:290
 msgid "Salable"
 msgstr "Verkäuflich"
 
-#: common/models.py:130
+#: common/models.py:144
 msgid "Parts are salable by default"
 msgstr ""
 
-#: common/models.py:136 part/models.py:749 part/templates/part/detail.html:178
-#: templates/js/table_filters.js:31 templates/js/table_filters.js:280
+#: common/models.py:150 part/models.py:806 part/templates/part/detail.html:185
+#: templates/js/table_filters.js:31 templates/js/table_filters.js:294
 msgid "Trackable"
 msgstr "nachverfolgbar"
 
-#: common/models.py:137
+#: common/models.py:151
 msgid "Parts are trackable by default"
 msgstr ""
 
-#: common/models.py:143
+#: common/models.py:157 part/models.py:826 part/templates/part/detail.html:145
+#: templates/js/table_filters.js:27
+msgid "Virtual"
+msgstr "Virtuell"
+
+#: common/models.py:158
+#, fuzzy
+#| msgid "Part is not a virtual part"
+msgid "Parts are virtual by default"
+msgstr "Teil ist nicht virtuell"
+
+#: common/models.py:164
+#, fuzzy
+#| msgid "Stock Export Options"
+msgid "Stock Expiry"
+msgstr "Lagerbestandsexportoptionen"
+
+#: common/models.py:165
+msgid "Enable stock expiry functionality"
+msgstr ""
+
+#: common/models.py:171
+#, fuzzy
+#| msgid "Serialize Stock"
+msgid "Sell Expired Stock"
+msgstr "Lagerbestand erfassen"
+
+#: common/models.py:172
+msgid "Allow sale of expired stock"
+msgstr ""
+
+#: common/models.py:178
+#, fuzzy
+#| msgid "Stock Item"
+msgid "Stock Stale Time"
+msgstr "Lagerobjekt"
+
+#: common/models.py:179
+msgid "Number of days stock items are considered stale before expiring"
+msgstr ""
+
+#: common/models.py:181 part/templates/part/detail.html:116
+msgid "days"
+msgstr ""
+
+#: common/models.py:186
+#, fuzzy
+#| msgid "Builds"
+msgid "Build Expired Stock"
+msgstr "Baue"
+
+#: common/models.py:187
+msgid "Allow building with expired stock"
+msgstr ""
+
+#: common/models.py:193
 #, fuzzy
 #| msgid "Order Reference"
 msgid "Build Order Reference Prefix"
 msgstr "Bestellreferenz"
 
-#: common/models.py:144
+#: common/models.py:194
 #, fuzzy
 #| msgid "Order reference"
 msgid "Prefix value for build order reference"
 msgstr "Bestell-Referenz"
 
-#: common/models.py:149
+#: common/models.py:199
 #, fuzzy
 #| msgid "Order Reference"
 msgid "Build Order Reference Regex"
 msgstr "Bestellreferenz"
 
-#: common/models.py:150
+#: common/models.py:200
 msgid "Regular expression pattern for matching build order reference"
 msgstr ""
 
-#: common/models.py:154
+#: common/models.py:204
 #, fuzzy
 #| msgid "Sales Order Reference"
 msgid "Sales Order Reference Prefix"
 msgstr "Bestellungsreferenz"
 
-#: common/models.py:155
+#: common/models.py:205
 #, fuzzy
 #| msgid "Order reference"
 msgid "Prefix value for sales order reference"
 msgstr "Bestell-Referenz"
 
-#: common/models.py:159
+#: common/models.py:210
 #, fuzzy
 #| msgid "Order reference"
 msgid "Purchase Order Reference Prefix"
 msgstr "Bestell-Referenz"
 
-#: common/models.py:160
+#: common/models.py:211
 #, fuzzy
 #| msgid "Order reference"
 msgid "Prefix value for purchase order reference"
 msgstr "Bestell-Referenz"
 
-#: common/models.py:376
+#: common/models.py:434
 msgid "Settings key (must be unique - case insensitive"
 msgstr ""
 "Einstellungs-Schlüssel (muss einzigartig sein, Groß-/ Kleinschreibung wird "
 "nicht beachtet)"
 
-#: common/models.py:378
+#: common/models.py:436
 msgid "Settings value"
 msgstr "Einstellungs-Wert"
 
-#: common/models.py:437
+#: common/models.py:493
 msgid "Value must be a boolean value"
 msgstr ""
 
-#: common/models.py:451
+#: common/models.py:503
+#, fuzzy
+#| msgid "Must enter integer value"
+msgid "Value must be an integer value"
+msgstr "Nur Ganzzahl eingeben"
+
+#: common/models.py:517
 msgid "Key string must be unique"
 msgstr "Schlüsseltext muss eindeutig sein"
 
-#: common/models.py:495 company/forms.py:113
+#: common/models.py:590 company/forms.py:113
 #, fuzzy
 #| msgid "Price Breaks"
 msgid "Price break quantity"
 msgstr "Preisstaffelung"
 
-#: common/models.py:503 company/templates/company/supplier_part_pricing.html:80
+#: common/models.py:598 company/templates/company/supplier_part_pricing.html:80
 #: part/templates/part/sale_prices.html:87 templates/js/bom.js:246
 msgid "Price"
 msgstr "Preis"
 
-#: common/models.py:504
+#: common/models.py:599
 #, fuzzy
 #| msgid "Enter a valid quantity"
 msgid "Unit price at specified quantity"
 msgstr "Bitte eine gültige Anzahl eingeben"
 
-#: common/models.py:527
+#: common/models.py:622
 #, fuzzy
 #| msgid "Default Location"
 msgid "Default"
@@ -1582,44 +1696,81 @@ msgstr "Produziert diese Firma Teile?"
 msgid "Currency"
 msgstr "Währung bearbeiten"
 
-#: company/models.py:313 stock/models.py:338
-#: stock/templates/stock/item_base.html:177
+#: company/models.py:313 stock/models.py:345
+#: stock/templates/stock/item_base.html:194
 msgid "Base Part"
 msgstr "Basisteil"
 
-#: company/models.py:318
+#: company/models.py:317
 msgid "Select part"
 msgstr "Teil auswählen"
 
+#: company/models.py:323 company/templates/company/detail.html:57
+#: company/templates/company/supplier_part_base.html:74
+#: company/templates/company/supplier_part_detail.html:21
+#: order/templates/order/order_base.html:79
+#: order/templates/order/order_wizard/select_pos.html:30 part/bom.py:170
+#: stock/templates/stock/item_base.html:304 templates/js/company.js:48
+#: templates/js/company.js:164 templates/js/order.js:162
+msgid "Supplier"
+msgstr "Zulieferer"
+
 #: company/models.py:324
 msgid "Select supplier"
 msgstr "Zulieferer auswählen"
 
-#: company/models.py:327
+#: company/models.py:329 company/templates/company/supplier_part_base.html:78
+#: company/templates/company/supplier_part_detail.html:22 part/bom.py:171
+msgid "SKU"
+msgstr "SKU"
+
+#: company/models.py:330
 msgid "Supplier stock keeping unit"
 msgstr "Stock Keeping Units (SKU) des Zulieferers"
 
-#: company/models.py:334
+#: company/models.py:340 company/templates/company/detail.html:52
+#: company/templates/company/supplier_part_base.html:84
+#: company/templates/company/supplier_part_detail.html:30 part/bom.py:172
+#: templates/js/company.js:44 templates/js/company.js:188
+msgid "Manufacturer"
+msgstr "Hersteller"
+
+#: company/models.py:341
 msgid "Select manufacturer"
 msgstr "Hersteller auswählen"
 
-#: company/models.py:338
+#: company/models.py:347 company/templates/company/supplier_part_base.html:88
+#: company/templates/company/supplier_part_detail.html:31 part/bom.py:173
+#: templates/js/company.js:204
+msgid "MPN"
+msgstr "MPN"
+
+#: company/models.py:348
 msgid "Manufacturer part number"
 msgstr "Hersteller-Teilenummer"
 
-#: company/models.py:340
+#: company/models.py:353 part/models.py:704 templates/js/company.js:208
+msgid "Link"
+msgstr "Link"
+
+#: company/models.py:354
 msgid "URL for external supplier part link"
 msgstr "Teil-URL des Zulieferers"
 
-#: company/models.py:342
+#: company/models.py:360
 msgid "Supplier part description"
 msgstr "Zuliefererbeschreibung des Teils"
 
-#: company/models.py:346
+#: company/models.py:365 company/templates/company/supplier_part_base.html:95
+#: company/templates/company/supplier_part_detail.html:34
+msgid "Note"
+msgstr "Notiz"
+
+#: company/models.py:369
 msgid "Minimum charge (e.g. stocking fee)"
 msgstr "Mindestpreis"
 
-#: company/models.py:348
+#: company/models.py:371
 msgid "Part packaging"
 msgstr "Teile-Packaging"
 
@@ -1662,27 +1813,10 @@ msgstr "Keine Zeilen angegeben"
 msgid "Uses default currency"
 msgstr "Währung entfernen"
 
-#: company/templates/company/detail.html:52
-#: company/templates/company/supplier_part_base.html:84
-#: company/templates/company/supplier_part_detail.html:30 part/bom.py:172
-#: templates/js/company.js:44 templates/js/company.js:188
-msgid "Manufacturer"
-msgstr "Hersteller"
-
-#: company/templates/company/detail.html:57
-#: company/templates/company/supplier_part_base.html:74
-#: company/templates/company/supplier_part_detail.html:21
-#: order/templates/order/order_base.html:79
-#: order/templates/order/order_wizard/select_pos.html:30 part/bom.py:170
-#: stock/templates/stock/item_base.html:287 templates/js/company.js:48
-#: templates/js/company.js:164 templates/js/order.js:162
-msgid "Supplier"
-msgstr "Zulieferer"
-
 #: company/templates/company/detail.html:62
-#: order/templates/order/sales_order_base.html:81 stock/models.py:373
-#: stock/models.py:374 stock/templates/stock/item_base.html:204
-#: templates/js/company.js:40 templates/js/order.js:244
+#: order/templates/order/sales_order_base.html:89 stock/models.py:380
+#: stock/models.py:381 stock/templates/stock/item_base.html:221
+#: templates/js/company.js:40 templates/js/order.js:250
 msgid "Customer"
 msgstr "Kunde"
 
@@ -1697,7 +1831,7 @@ msgstr "Neues Zuliefererteil anlegen"
 
 #: company/templates/company/detail_part.html:18
 #: order/templates/order/purchase_order_detail.html:68
-#: part/templates/part/supplier.html:14 templates/js/stock.js:849
+#: part/templates/part/supplier.html:14 templates/js/stock.js:881
 msgid "New Supplier Part"
 msgstr "Neues Zulieferer-Teil"
 
@@ -1725,7 +1859,7 @@ msgid "Delete Parts"
 msgstr "Teile löschen"
 
 #: company/templates/company/detail_part.html:63
-#: part/templates/part/category.html:116 templates/js/stock.js:843
+#: part/templates/part/category.html:116 templates/js/stock.js:875
 msgid "New Part"
 msgstr "Neues Teil"
 
@@ -1779,8 +1913,8 @@ msgstr ""
 
 #: company/templates/company/purchase_orders.html:9
 #: company/templates/company/tabs.html:17
-#: order/templates/order/purchase_orders.html:7
-#: order/templates/order/purchase_orders.html:12
+#: order/templates/order/purchase_orders.html:8
+#: order/templates/order/purchase_orders.html:13
 #: part/templates/part/orders.html:9 part/templates/part/tabs.html:48
 #: templates/InvenTree/settings/tabs.html:31 templates/navbar.html:33
 #: users/models.py:31
@@ -1788,19 +1922,19 @@ msgid "Purchase Orders"
 msgstr "Bestellungen"
 
 #: company/templates/company/purchase_orders.html:15
-#: order/templates/order/purchase_orders.html:18
+#: order/templates/order/purchase_orders.html:19
 msgid "Create new purchase order"
 msgstr "Neue Bestellung anlegen"
 
 #: company/templates/company/purchase_orders.html:16
-#: order/templates/order/purchase_orders.html:19
+#: order/templates/order/purchase_orders.html:20
 msgid "New Purchase Order"
 msgstr "Neue Bestellung"
 
 #: company/templates/company/sales_orders.html:9
 #: company/templates/company/tabs.html:22
-#: order/templates/order/sales_orders.html:7
-#: order/templates/order/sales_orders.html:12
+#: order/templates/order/sales_orders.html:8
+#: order/templates/order/sales_orders.html:13
 #: part/templates/part/sales_orders.html:9 part/templates/part/tabs.html:56
 #: templates/InvenTree/settings/tabs.html:34 templates/navbar.html:42
 #: users/models.py:32
@@ -1808,18 +1942,18 @@ msgid "Sales Orders"
 msgstr "Bestellungen"
 
 #: company/templates/company/sales_orders.html:15
-#: order/templates/order/sales_orders.html:18
+#: order/templates/order/sales_orders.html:19
 msgid "Create new sales order"
 msgstr "Neuen Auftrag anlegen"
 
 #: company/templates/company/sales_orders.html:16
-#: order/templates/order/sales_orders.html:19
+#: order/templates/order/sales_orders.html:20
 msgid "New Sales Order"
 msgstr "Neuer Auftrag"
 
 #: company/templates/company/supplier_part_base.html:6
-#: company/templates/company/supplier_part_base.html:19 stock/models.py:347
-#: stock/templates/stock/item_base.html:292 templates/js/company.js:180
+#: company/templates/company/supplier_part_base.html:19 stock/models.py:354
+#: stock/templates/stock/item_base.html:309 templates/js/company.js:180
 msgid "Supplier Part"
 msgstr "Zulieferer-Teil"
 
@@ -1846,22 +1980,6 @@ msgstr "Zuliefererteildetails"
 msgid "Internal Part"
 msgstr "Internes Teil"
 
-#: company/templates/company/supplier_part_base.html:78
-#: company/templates/company/supplier_part_detail.html:22 part/bom.py:171
-msgid "SKU"
-msgstr "SKU"
-
-#: company/templates/company/supplier_part_base.html:88
-#: company/templates/company/supplier_part_detail.html:31 part/bom.py:173
-#: templates/js/company.js:204
-msgid "MPN"
-msgstr "MPN"
-
-#: company/templates/company/supplier_part_base.html:95
-#: company/templates/company/supplier_part_detail.html:34
-msgid "Note"
-msgstr "Notiz"
-
 #: company/templates/company/supplier_part_orders.html:9
 msgid "Supplier Part Orders"
 msgstr "Zuliefererbestellungen"
@@ -1876,7 +1994,7 @@ msgid "Pricing Information"
 msgstr "Preisinformationen ansehen"
 
 #: company/templates/company/supplier_part_pricing.html:17 company/views.py:486
-#: part/templates/part/sale_prices.html:14 part/views.py:2555
+#: part/templates/part/sale_prices.html:14 part/views.py:2565
 msgid "Add Price Break"
 msgstr "Preisstaffel hinzufügen"
 
@@ -1913,7 +2031,7 @@ msgstr "Bepreisung"
 #: company/templates/company/tabs.html:12 part/templates/part/tabs.html:18
 #: stock/templates/stock/location.html:17 templates/InvenTree/search.html:155
 #: templates/InvenTree/settings/tabs.html:25 templates/js/part.js:192
-#: templates/js/part.js:418 templates/js/stock.js:502 templates/navbar.html:22
+#: templates/js/part.js:418 templates/js/stock.js:509 templates/navbar.html:22
 #: users/models.py:29
 msgid "Stock"
 msgstr "Lagerbestand"
@@ -1923,7 +2041,7 @@ msgid "Orders"
 msgstr "Bestellungen"
 
 #: company/templates/company/tabs.html:9
-#: order/templates/order/receive_parts.html:14 part/models.py:316
+#: order/templates/order/receive_parts.html:14 part/models.py:317
 #: part/templates/part/cat_link.html:7 part/templates/part/category.html:94
 #: part/templates/part/category_tabs.html:6
 #: templates/InvenTree/settings/tabs.html:22 templates/navbar.html:19
@@ -1996,7 +2114,7 @@ msgstr "Firma gelöscht"
 msgid "Edit Supplier Part"
 msgstr "Zuliefererteil bearbeiten"
 
-#: company/views.py:295 templates/js/stock.js:850
+#: company/views.py:295 templates/js/stock.js:882
 msgid "Create new Supplier Part"
 msgstr "Neues Zuliefererteil anlegen"
 
@@ -2004,17 +2122,17 @@ msgstr "Neues Zuliefererteil anlegen"
 msgid "Delete Supplier Part"
 msgstr "Zuliefererteil entfernen"
 
-#: company/views.py:492 part/views.py:2561
+#: company/views.py:492 part/views.py:2571
 #, fuzzy
 #| msgid "Add Price Break"
 msgid "Added new price break"
 msgstr "Preisstaffel hinzufügen"
 
-#: company/views.py:548 part/views.py:2605
+#: company/views.py:548 part/views.py:2615
 msgid "Edit Price Break"
 msgstr "Preisstaffel bearbeiten"
 
-#: company/views.py:564 part/views.py:2621
+#: company/views.py:564 part/views.py:2631
 msgid "Delete Price Break"
 msgstr "Preisstaffel löschen"
 
@@ -2046,164 +2164,169 @@ msgstr ""
 msgid "Enabled"
 msgstr ""
 
-#: order/forms.py:24 order/templates/order/order_base.html:39
+#: order/forms.py:25 order/templates/order/order_base.html:39
 msgid "Place order"
 msgstr "Bestellung aufgeben"
 
-#: order/forms.py:35 order/templates/order/order_base.html:46
+#: order/forms.py:36 order/templates/order/order_base.html:46
 msgid "Mark order as complete"
 msgstr "Bestellung als vollständig markieren"
 
-#: order/forms.py:46 order/forms.py:57 order/templates/order/order_base.html:51
-#: order/templates/order/sales_order_base.html:53
+#: order/forms.py:47 order/forms.py:58 order/templates/order/order_base.html:51
+#: order/templates/order/sales_order_base.html:56
 msgid "Cancel order"
 msgstr "Bestellung stornieren"
 
-#: order/forms.py:68 order/templates/order/sales_order_base.html:50
+#: order/forms.py:69 order/templates/order/sales_order_base.html:53
 msgid "Ship order"
 msgstr "Bestellung versenden"
 
-#: order/forms.py:79
+#: order/forms.py:80
 msgid "Receive parts to this location"
 msgstr "Teile in diesen Ort empfangen"
 
-#: order/forms.py:99
+#: order/forms.py:100
 #, fuzzy
 #| msgid "Order reference"
 msgid "Purchase Order reference"
 msgstr "Bestell-Referenz"
 
-#: order/forms.py:126
+#: order/forms.py:128
 #, fuzzy
 #| msgid "Cancel sales order"
 msgid "Enter sales order number"
 msgstr "Auftrag stornieren"
 
-#: order/models.py:110
+#: order/forms.py:134 order/models.py:405
+msgid ""
+"Target date for order completion. Order will be overdue after this date."
+msgstr ""
+
+#: order/models.py:98
 msgid "Order reference"
 msgstr "Bestell-Referenz"
 
-#: order/models.py:112
+#: order/models.py:100
 msgid "Order description"
 msgstr "Bestellungs-Beschreibung"
 
-#: order/models.py:114
+#: order/models.py:102
 msgid "Link to external page"
 msgstr "Link auf externe Seite"
 
-#: order/models.py:124
+#: order/models.py:112
 msgid "Order notes"
 msgstr "Bestell-Notizen"
 
-#: order/models.py:142 order/models.py:328
+#: order/models.py:169 order/models.py:398
 #, fuzzy
 #| msgid "Purchase Order Details"
 msgid "Purchase order status"
 msgstr "Bestelldetails"
 
-#: order/models.py:150
+#: order/models.py:177
 msgid "Company from which the items are being ordered"
 msgstr ""
 
-#: order/models.py:153
+#: order/models.py:180
 msgid "Supplier order reference code"
 msgstr "Bestellreferenz"
 
-#: order/models.py:162
+#: order/models.py:189
 msgid "Date order was issued"
 msgstr ""
 
-#: order/models.py:164
+#: order/models.py:191
 #, fuzzy
 #| msgid "Mark order as complete"
 msgid "Date order was completed"
 msgstr "Bestellung als vollständig markieren"
 
-#: order/models.py:187 order/models.py:269 part/views.py:1494
-#: stock/models.py:244 stock/models.py:812
+#: order/models.py:214 order/models.py:296 part/views.py:1504
+#: stock/models.py:251 stock/models.py:856
 msgid "Quantity must be greater than zero"
 msgstr "Anzahl muss größer Null sein"
 
-#: order/models.py:192
+#: order/models.py:219
 msgid "Part supplier must match PO supplier"
 msgstr "Teile-Zulieferer muss dem Zulieferer des Kaufvertrags entsprechen"
 
-#: order/models.py:264
+#: order/models.py:291
 msgid "Lines can only be received against an order marked as 'Placed'"
 msgstr "Nur Teile aufgegebener Bestllungen können empfangen werden"
 
-#: order/models.py:324
+#: order/models.py:394
 msgid "Company to which the items are being sold"
 msgstr ""
 
-#: order/models.py:330
+#: order/models.py:400
 msgid "Customer order reference code"
 msgstr "Bestellreferenz"
 
-#: order/models.py:369
+#: order/models.py:462
 msgid "SalesOrder cannot be shipped as it is not currently pending"
 msgstr "Bestellung kann nicht versendet werden weil sie nicht anhängig ist"
 
-#: order/models.py:456
+#: order/models.py:549
 msgid "Item quantity"
 msgstr "Anzahl"
 
-#: order/models.py:458
+#: order/models.py:551
 msgid "Line item reference"
 msgstr "Position - Referenz"
 
-#: order/models.py:460
+#: order/models.py:553
 msgid "Line item notes"
 msgstr "Position - Notizen"
 
-#: order/models.py:486 order/templates/order/order_base.html:9
+#: order/models.py:579 order/templates/order/order_base.html:9
 #: order/templates/order/order_base.html:24
-#: stock/templates/stock/item_base.html:259 templates/js/order.js:146
+#: stock/templates/stock/item_base.html:276 templates/js/order.js:146
 msgid "Purchase Order"
 msgstr "Kaufvertrag"
 
-#: order/models.py:499
+#: order/models.py:592
 msgid "Supplier part"
 msgstr "Zulieferer-Teil"
 
-#: order/models.py:502
+#: order/models.py:595
 msgid "Number of items received"
 msgstr "Empfangene Objekt-Anzahl"
 
-#: order/models.py:509 stock/models.py:458
-#: stock/templates/stock/item_base.html:266
+#: order/models.py:602 stock/models.py:473
+#: stock/templates/stock/item_base.html:283
 #, fuzzy
 #| msgid "Purchase Order"
 msgid "Purchase Price"
 msgstr "Kaufvertrag"
 
-#: order/models.py:510
+#: order/models.py:603
 #, fuzzy
 #| msgid "Purchase Order"
 msgid "Unit purchase price"
 msgstr "Kaufvertrag"
 
-#: order/models.py:605
+#: order/models.py:698
 msgid "Cannot allocate stock item to a line with a different part"
 msgstr "Kann Lagerobjekt keiner Zeile mit einem anderen Teil hinzufügen"
 
-#: order/models.py:607
+#: order/models.py:700
 msgid "Cannot allocate stock to a line without a part"
 msgstr "Kann Lagerobjekt keiner Zeile ohne Teil hinzufügen"
 
-#: order/models.py:610
+#: order/models.py:703
 msgid "Allocation quantity cannot exceed stock quantity"
 msgstr "zugewiesene Anzahl darf nicht die verfügbare Anzahl überschreiten"
 
-#: order/models.py:620
+#: order/models.py:713
 msgid "Quantity must be 1 for serialized stock item"
 msgstr "Anzahl muss 1 für Objekte mit Seriennummer sein"
 
-#: order/models.py:636
+#: order/models.py:729
 msgid "Select stock item to allocate"
 msgstr "Lagerobjekt für Zuordnung auswählen"
 
-#: order/models.py:639
+#: order/models.py:732
 msgid "Enter stock allocation quantity"
 msgstr "Zuordnungsanzahl eingeben"
 
@@ -2234,12 +2357,12 @@ msgid "Purchase Order Details"
 msgstr "Bestelldetails"
 
 #: order/templates/order/order_base.html:69
-#: order/templates/order/sales_order_base.html:71
+#: order/templates/order/sales_order_base.html:74
 msgid "Order Reference"
 msgstr "Bestellreferenz"
 
 #: order/templates/order/order_base.html:74
-#: order/templates/order/sales_order_base.html:76
+#: order/templates/order/sales_order_base.html:79
 msgid "Order Status"
 msgstr "Bestellstatus"
 
@@ -2254,7 +2377,7 @@ msgstr "Aufgegeben"
 #: order/templates/order/order_base.html:111
 #: order/templates/order/purchase_order_detail.html:193
 #: order/templates/order/receive_parts.html:22
-#: order/templates/order/sales_order_base.html:113
+#: order/templates/order/sales_order_base.html:128
 msgid "Received"
 msgstr "Empfangen"
 
@@ -2304,7 +2427,7 @@ msgid "Select existing purchase orders, or create new orders."
 msgstr "Bestellungen auswählen oder anlegen."
 
 #: order/templates/order/order_wizard/select_pos.html:31
-#: templates/js/order.js:193 templates/js/order.js:280
+#: templates/js/order.js:193 templates/js/order.js:291
 msgid "Items"
 msgstr "Positionen"
 
@@ -2334,8 +2457,8 @@ msgid "Line Items"
 msgstr "Position hinzufügen"
 
 #: order/templates/order/purchase_order_detail.html:17
-#: order/templates/order/sales_order_detail.html:19 order/views.py:1117
-#: order/views.py:1201
+#: order/templates/order/sales_order_detail.html:19 order/views.py:1119
+#: order/views.py:1203
 msgid "Add Line Item"
 msgstr "Position hinzufügen"
 
@@ -2346,7 +2469,7 @@ msgstr "Bestellpositionen"
 #: order/templates/order/purchase_order_detail.html:39
 #: order/templates/order/purchase_order_detail.html:119
 #: part/templates/part/category.html:173 part/templates/part/category.html:215
-#: templates/js/stock.js:855
+#: templates/js/stock.js:627 templates/js/stock.js:887
 msgid "New Location"
 msgstr "Neuer Standort"
 
@@ -2411,15 +2534,15 @@ msgstr ""
 msgid "This SalesOrder has not been fully allocated"
 msgstr "Dieser Auftrag ist nicht vollständig zugeordnet"
 
-#: order/templates/order/sales_order_base.html:58
+#: order/templates/order/sales_order_base.html:61
 msgid "Packing List"
 msgstr "Packliste"
 
-#: order/templates/order/sales_order_base.html:66
+#: order/templates/order/sales_order_base.html:69
 msgid "Sales Order Details"
 msgstr "Auftragsdetails"
 
-#: order/templates/order/sales_order_base.html:87 templates/js/order.js:251
+#: order/templates/order/sales_order_base.html:95 templates/js/order.js:257
 msgid "Customer Reference"
 msgstr "Kundenreferenz"
 
@@ -2435,8 +2558,8 @@ msgid "Sales Order Items"
 msgstr "Auftragspositionen"
 
 #: order/templates/order/sales_order_detail.html:72
-#: order/templates/order/sales_order_detail.html:154 stock/models.py:378
-#: stock/templates/stock/item_base.html:191 templates/js/build.js:418
+#: order/templates/order/sales_order_detail.html:154 stock/models.py:385
+#: stock/templates/stock/item_base.html:208 templates/js/build.js:418
 msgid "Serial Number"
 msgstr "Seriennummer"
 
@@ -2518,151 +2641,151 @@ msgstr "Sind Sie sicher, dass Sie diese Position löschen möchten?"
 msgid "Order Items"
 msgstr "Bestellungspositionen"
 
-#: order/views.py:99
+#: order/views.py:101
 msgid "Add Purchase Order Attachment"
 msgstr "Bestellanhang hinzufügen"
 
-#: order/views.py:150
+#: order/views.py:152
 msgid "Add Sales Order Attachment"
 msgstr "Auftragsanhang hinzufügen"
 
-#: order/views.py:310
+#: order/views.py:312
 msgid "Create Purchase Order"
 msgstr "Bestellung anlegen"
 
-#: order/views.py:346
+#: order/views.py:348
 msgid "Create Sales Order"
 msgstr "Auftrag anlegen"
 
-#: order/views.py:382
+#: order/views.py:384
 msgid "Edit Purchase Order"
 msgstr "Bestellung bearbeiten"
 
-#: order/views.py:403
+#: order/views.py:405
 msgid "Edit Sales Order"
 msgstr "Auftrag bearbeiten"
 
-#: order/views.py:420
+#: order/views.py:422
 msgid "Cancel Order"
 msgstr "Bestellung stornieren"
 
-#: order/views.py:430 order/views.py:457
+#: order/views.py:432 order/views.py:459
 msgid "Confirm order cancellation"
 msgstr "Bestellstornierung bestätigen"
 
-#: order/views.py:433
+#: order/views.py:435
 msgid "Order cannot be cancelled as either pending or placed"
 msgstr ""
 
-#: order/views.py:447
+#: order/views.py:449
 msgid "Cancel sales order"
 msgstr "Auftrag stornieren"
 
-#: order/views.py:460
+#: order/views.py:462
 msgid "Order cannot be cancelled"
 msgstr ""
 
-#: order/views.py:474
+#: order/views.py:476
 msgid "Issue Order"
 msgstr "Bestellung aufgeben"
 
-#: order/views.py:484
+#: order/views.py:486
 msgid "Confirm order placement"
 msgstr "Bestellungstätigung bestätigen"
 
-#: order/views.py:494
+#: order/views.py:496
 #, fuzzy
 #| msgid "Purchase Order Details"
 msgid "Purchase order issued"
 msgstr "Bestelldetails"
 
-#: order/views.py:505
+#: order/views.py:507
 msgid "Complete Order"
 msgstr "Auftrag fertigstellen"
 
-#: order/views.py:522
+#: order/views.py:524
 #, fuzzy
 #| msgid "Confirm build completion"
 msgid "Confirm order completion"
 msgstr "Bau-Fertigstellung bestätigen"
 
-#: order/views.py:533
+#: order/views.py:535
 #, fuzzy
 #| msgid "Mark order as complete"
 msgid "Purchase order completed"
 msgstr "Bestellung als vollständig markieren"
 
-#: order/views.py:543
+#: order/views.py:545
 msgid "Ship Order"
 msgstr "Versenden"
 
-#: order/views.py:560
+#: order/views.py:562
 msgid "Confirm order shipment"
 msgstr "Versand bestätigen"
 
-#: order/views.py:566
+#: order/views.py:568
 msgid "Could not ship order"
 msgstr "Versand fehlgeschlagen"
 
-#: order/views.py:618
+#: order/views.py:620
 msgid "Receive Parts"
 msgstr "Teile empfangen"
 
-#: order/views.py:686
+#: order/views.py:688
 msgid "Items received"
 msgstr "Anzahl empfangener Positionen"
 
-#: order/views.py:700
+#: order/views.py:702
 msgid "No destination set"
 msgstr "Kein Ziel gesetzt"
 
-#: order/views.py:745
+#: order/views.py:747
 msgid "Error converting quantity to number"
 msgstr "Fehler beim Konvertieren zu Zahl"
 
-#: order/views.py:751
+#: order/views.py:753
 msgid "Receive quantity less than zero"
 msgstr "Anzahl kleiner null empfangen"
 
-#: order/views.py:757
+#: order/views.py:759
 msgid "No lines specified"
 msgstr "Keine Zeilen angegeben"
 
-#: order/views.py:1127
+#: order/views.py:1129
 #, fuzzy
 #| msgid "Supplier part description"
 msgid "Supplier part must be specified"
 msgstr "Zuliefererbeschreibung des Teils"
 
-#: order/views.py:1133
+#: order/views.py:1135
 msgid "Supplier must match for Part and Order"
 msgstr "Zulieferer muss zum Teil und zur Bestellung passen"
 
-#: order/views.py:1253 order/views.py:1272
+#: order/views.py:1255 order/views.py:1274
 msgid "Edit Line Item"
 msgstr "Position bearbeiten"
 
-#: order/views.py:1289 order/views.py:1302
+#: order/views.py:1291 order/views.py:1304
 msgid "Delete Line Item"
 msgstr "Position löschen"
 
-#: order/views.py:1295 order/views.py:1308
+#: order/views.py:1297 order/views.py:1310
 msgid "Deleted line item"
 msgstr "Position gelöscht"
 
-#: order/views.py:1317
+#: order/views.py:1319
 msgid "Allocate Stock to Order"
 msgstr "Lagerbestand dem Auftrag zuweisen"
 
-#: order/views.py:1387
+#: order/views.py:1394
 msgid "Edit Allocation Quantity"
 msgstr "Zuordnung bearbeiten"
 
-#: order/views.py:1403
+#: order/views.py:1410
 msgid "Remove allocation"
 msgstr "Zuordnung entfernen"
 
-#: part/bom.py:138 part/templates/part/category.html:61
+#: part/bom.py:138 part/models.py:722 part/templates/part/category.html:61
 #: part/templates/part/detail.html:87
 msgid "Default Location"
 msgstr "Standard-Lagerort"
@@ -2684,11 +2807,11 @@ msgstr "Fehler beim Lesen der Stückliste (ungültige Daten)"
 msgid "Error reading BOM file (incorrect row size)"
 msgstr "Fehler beim Lesen der Stückliste (ungültige Zeilengröße)"
 
-#: part/forms.py:61 stock/forms.py:255
+#: part/forms.py:61 stock/forms.py:261
 msgid "File Format"
 msgstr "Dateiformat"
 
-#: part/forms.py:61 stock/forms.py:255
+#: part/forms.py:61 stock/forms.py:261
 msgid "Select output file format"
 msgstr "Ausgabe-Dateiformat auswählen"
 
@@ -2740,7 +2863,7 @@ msgstr "Neues Zulieferer-Teil"
 msgid "Include part supplier data in exported BOM"
 msgstr ""
 
-#: part/forms.py:92 part/models.py:1717
+#: part/forms.py:92 part/models.py:1781
 msgid "Parent Part"
 msgstr "Ausgangsteil"
 
@@ -2780,7 +2903,7 @@ msgstr "Teile löschen"
 msgid "Select part category"
 msgstr "Teilekategorie wählen"
 
-#: part/forms.py:188
+#: part/forms.py:189
 #, fuzzy
 #| msgid "Perform 'deep copy' which will duplicate all BOM data for this part"
 msgid "Duplicate all BOM data for this part"
@@ -2788,49 +2911,49 @@ msgstr ""
 "Tiefe Kopie ausführen. Dies wird alle Daten der Stückliste für dieses Teil "
 "duplizieren"
 
-#: part/forms.py:189
+#: part/forms.py:190
 msgid "Copy BOM"
 msgstr ""
 
-#: part/forms.py:194
+#: part/forms.py:195
 msgid "Duplicate all parameter data for this part"
 msgstr ""
 
-#: part/forms.py:195
+#: part/forms.py:196
 #, fuzzy
 #| msgid "Parameters"
 msgid "Copy Parameters"
 msgstr "Parameter"
 
-#: part/forms.py:200
+#: part/forms.py:201
 msgid "Confirm part creation"
 msgstr "Erstellen des Teils bestätigen"
 
-#: part/forms.py:205
+#: part/forms.py:206
 #, fuzzy
 #| msgid "No part parameter templates found"
 msgid "Include category parameter templates"
 msgstr "Keine Teilparametervorlagen gefunden"
 
-#: part/forms.py:210
+#: part/forms.py:211
 #, fuzzy
 #| msgid "No part parameter templates found"
 msgid "Include parent categories parameter templates"
 msgstr "Keine Teilparametervorlagen gefunden"
 
-#: part/forms.py:285
+#: part/forms.py:291
 #, fuzzy
 #| msgid "Parameter template name must be unique"
 msgid "Add parameter template to same level categories"
 msgstr "Vorlagen-Name des Parameters muss eindeutig sein"
 
-#: part/forms.py:289
+#: part/forms.py:295
 #, fuzzy
 #| msgid "Parameter template name must be unique"
 msgid "Add parameter template to all categories"
 msgstr "Vorlagen-Name des Parameters muss eindeutig sein"
 
-#: part/forms.py:331
+#: part/forms.py:339
 msgid "Input quantity for price calculation"
 msgstr "Eintragsmenge zur Preisberechnung"
 
@@ -2842,7 +2965,7 @@ msgstr "Standard-Standort für Teile dieser Kategorie"
 msgid "Default keywords for parts in this category"
 msgstr "Standard-Stichworte für Teile dieser Kategorie"
 
-#: part/models.py:77 part/models.py:1762
+#: part/models.py:77 part/models.py:1826
 #: part/templates/part/part_app_base.html:9
 msgid "Part Category"
 msgstr "Teilkategorie"
@@ -2852,142 +2975,185 @@ msgstr "Teilkategorie"
 msgid "Part Categories"
 msgstr "Teile-Kategorien"
 
-#: part/models.py:408 part/models.py:418
+#: part/models.py:409 part/models.py:419
 #, python-brace-format
 msgid "Part '{p1}' is  used in BOM for '{p2}' (recursive)"
 msgstr "Teil '{p1}' wird in Stückliste für Teil '{p2}' benutzt (rekursiv)"
 
-#: part/models.py:515
+#: part/models.py:516
 #, fuzzy
 #| msgid "No serial numbers found"
 msgid "Next available serial numbers are"
 msgstr "Keine Seriennummern gefunden"
 
-#: part/models.py:519
+#: part/models.py:520
 msgid "Next available serial number is"
 msgstr ""
 
-#: part/models.py:524
+#: part/models.py:525
 #, fuzzy
 #| msgid "Empty serial number string"
 msgid "Most recent serial number is"
 msgstr "Keine Seriennummer angegeben"
 
-#: part/models.py:603
+#: part/models.py:604
 msgid "Duplicate IPN not allowed in part settings"
 msgstr ""
 
-#: part/models.py:614
+#: part/models.py:615
 msgid "Part must be unique for name, IPN and revision"
 msgstr "Namen, Teile- und Revisionsnummern müssen eindeutig sein"
 
-#: part/models.py:644 part/templates/part/detail.html:19
+#: part/models.py:646 part/templates/part/detail.html:19
 msgid "Part name"
 msgstr "Name des Teils"
 
-#: part/models.py:648
+#: part/models.py:653
+#, fuzzy
+#| msgid "Template"
+msgid "Is Template"
+msgstr "Vorlage"
+
+#: part/models.py:654
 msgid "Is this part a template part?"
 msgstr "Ist dieses Teil eine Vorlage?"
 
-#: part/models.py:657
+#: part/models.py:665
 msgid "Is this part a variant of another part?"
 msgstr "Ist dieses Teil eine Variante eines anderen Teils?"
 
-#: part/models.py:659
+#: part/models.py:666 part/templates/part/detail.html:57
+msgid "Variant Of"
+msgstr "Variante von"
+
+#: part/models.py:672
 msgid "Part description"
 msgstr "Beschreibung des Teils"
 
-#: part/models.py:661
+#: part/models.py:677 part/templates/part/category.html:68
+#: part/templates/part/detail.html:64
+msgid "Keywords"
+msgstr "Schlüsselwörter"
+
+#: part/models.py:678
 msgid "Part keywords to improve visibility in search results"
 msgstr "Schlüsselworte um die Sichtbarkeit in Suchergebnissen zu verbessern"
 
-#: part/models.py:666
+#: part/models.py:685 part/templates/part/detail.html:70
+#: part/templates/part/set_category.html:15 templates/js/part.js:405
+msgid "Category"
+msgstr "Kategorie"
+
+#: part/models.py:686
 msgid "Part category"
 msgstr "Teile-Kategorie"
 
-#: part/models.py:668
+#: part/models.py:691 part/templates/part/detail.html:25
+#: part/templates/part/part_base.html:95 templates/js/part.js:180
+msgid "IPN"
+msgstr "IPN (Interne Produktnummer)"
+
+#: part/models.py:692
 msgid "Internal Part Number"
 msgstr "Interne Teilenummer"
 
-#: part/models.py:670
+#: part/models.py:698
 msgid "Part revision or version number"
 msgstr "Revisions- oder Versionsnummer"
 
-#: part/models.py:684
+#: part/models.py:699 part/templates/part/detail.html:32
+#: templates/js/part.js:184
+msgid "Revision"
+msgstr "Revision"
+
+#: part/models.py:720
 msgid "Where is this item normally stored?"
 msgstr "Wo wird dieses Teil normalerweise gelagert?"
 
-#: part/models.py:728
+#: part/models.py:767 part/templates/part/detail.html:94
+msgid "Default Supplier"
+msgstr "Standard-Zulieferer"
+
+#: part/models.py:768
 msgid "Default supplier part"
 msgstr "Standard-Zulieferer?"
 
-#: part/models.py:731
+#: part/models.py:775
+#, fuzzy
+#| msgid "Default Supplier"
+msgid "Default Expiry"
+msgstr "Standard-Zulieferer"
+
+#: part/models.py:776
+msgid "Expiry time (in days) for stock items of this part"
+msgstr ""
+
+#: part/models.py:781 part/templates/part/detail.html:108
+msgid "Minimum Stock"
+msgstr "Minimaler Lagerbestand"
+
+#: part/models.py:782
 msgid "Minimum allowed stock level"
 msgstr "Minimal zulässiger Lagerbestand"
 
-#: part/models.py:733
+#: part/models.py:788 part/templates/part/detail.html:102
+#: part/templates/part/params.html:26
+msgid "Units"
+msgstr "Einheiten"
+
+#: part/models.py:789
 msgid "Stock keeping units for this part"
 msgstr "Stock Keeping Units (SKU) für dieses Teil"
 
-#: part/models.py:737 part/templates/part/detail.html:158
-#: templates/js/table_filters.js:264
-msgid "Assembly"
-msgstr "Baugruppe"
-
-#: part/models.py:738
+#: part/models.py:795
 msgid "Can this part be built from other parts?"
 msgstr "Kann dieses Teil aus anderen Teilen angefertigt werden?"
 
-#: part/models.py:744
+#: part/models.py:801
 msgid "Can this part be used to build other parts?"
 msgstr "Kann dieses Teil zum Bau von anderen genutzt werden?"
 
-#: part/models.py:750
+#: part/models.py:807
 msgid "Does this part have tracking for unique items?"
 msgstr "Hat dieses Teil Tracking für einzelne Objekte?"
 
-#: part/models.py:755
+#: part/models.py:812
 msgid "Can this part be purchased from external suppliers?"
 msgstr "Kann dieses Teil von externen Zulieferern gekauft werden?"
 
-#: part/models.py:760
+#: part/models.py:817
 msgid "Can this part be sold to customers?"
 msgstr "Kann dieses Teil an Kunden verkauft werden?"
 
-#: part/models.py:764 part/templates/part/detail.html:215
+#: part/models.py:821 part/templates/part/detail.html:222
 #: templates/js/table_filters.js:19 templates/js/table_filters.js:55
-#: templates/js/table_filters.js:186 templates/js/table_filters.js:247
+#: templates/js/table_filters.js:196 templates/js/table_filters.js:261
 msgid "Active"
 msgstr "Aktiv"
 
-#: part/models.py:765
+#: part/models.py:822
 msgid "Is this part active?"
 msgstr "Ist dieses Teil aktiv?"
 
-#: part/models.py:769 part/templates/part/detail.html:138
-#: templates/js/table_filters.js:27
-msgid "Virtual"
-msgstr "Virtuell"
-
-#: part/models.py:770
+#: part/models.py:827
 msgid "Is this a virtual part, such as a software product or license?"
 msgstr "Ist dieses Teil virtuell, wie zum Beispiel eine Software oder Lizenz?"
 
-#: part/models.py:772
+#: part/models.py:832
 msgid "Part notes - supports Markdown formatting"
 msgstr "Bemerkungen - unterstüzt Markdown-Formatierung"
 
-#: part/models.py:774
+#: part/models.py:835
 msgid "Stored BOM checksum"
 msgstr "Prüfsumme der Stückliste gespeichert"
 
-#: part/models.py:1590
+#: part/models.py:1654
 #, fuzzy
 #| msgid "Stock item cannot be created for a template Part"
 msgid "Test templates can only be created for trackable parts"
 msgstr "Lagerobjekt kann nicht für Vorlagen-Teile angelegt werden"
 
-#: part/models.py:1607
+#: part/models.py:1671
 #, fuzzy
 #| msgid ""
 #| "A stock item with this serial number already exists for template part "
@@ -2997,146 +3163,146 @@ msgstr ""
 "Ein Teil mit dieser Seriennummer existiert bereits für die Teilevorlage "
 "{part}"
 
-#: part/models.py:1626 templates/js/part.js:567 templates/js/stock.js:92
+#: part/models.py:1690 templates/js/part.js:567 templates/js/stock.js:93
 #, fuzzy
 #| msgid "Instance Name"
 msgid "Test Name"
 msgstr "Instanzname"
 
-#: part/models.py:1627
+#: part/models.py:1691
 #, fuzzy
 #| msgid "Serial number for this item"
 msgid "Enter a name for the test"
 msgstr "Seriennummer für dieses Teil"
 
-#: part/models.py:1632
+#: part/models.py:1696
 #, fuzzy
 #| msgid "Description"
 msgid "Test Description"
 msgstr "Beschreibung"
 
-#: part/models.py:1633
+#: part/models.py:1697
 #, fuzzy
 #| msgid "Brief description of the build"
 msgid "Enter description for this test"
 msgstr "Kurze Beschreibung des Baus"
 
-#: part/models.py:1638 templates/js/part.js:576
-#: templates/js/table_filters.js:172
+#: part/models.py:1702 templates/js/part.js:576
+#: templates/js/table_filters.js:182
 msgid "Required"
 msgstr "benötigt"
 
-#: part/models.py:1639
+#: part/models.py:1703
 msgid "Is this test required to pass?"
 msgstr ""
 
-#: part/models.py:1644 templates/js/part.js:584
+#: part/models.py:1708 templates/js/part.js:584
 #, fuzzy
 #| msgid "Required Parts"
 msgid "Requires Value"
 msgstr "benötigte Teile"
 
-#: part/models.py:1645
+#: part/models.py:1709
 msgid "Does this test require a value when adding a test result?"
 msgstr ""
 
-#: part/models.py:1650 templates/js/part.js:591
+#: part/models.py:1714 templates/js/part.js:591
 #, fuzzy
 #| msgid "Delete Attachment"
 msgid "Requires Attachment"
 msgstr "Anhang löschen"
 
-#: part/models.py:1651
+#: part/models.py:1715
 msgid "Does this test require a file attachment when adding a test result?"
 msgstr ""
 
-#: part/models.py:1684
+#: part/models.py:1748
 msgid "Parameter template name must be unique"
 msgstr "Vorlagen-Name des Parameters muss eindeutig sein"
 
-#: part/models.py:1689
+#: part/models.py:1753
 msgid "Parameter Name"
 msgstr "Name des Parameters"
 
-#: part/models.py:1691
+#: part/models.py:1755
 msgid "Parameter Units"
 msgstr "Parameter Einheit"
 
-#: part/models.py:1719 part/models.py:1767
+#: part/models.py:1783 part/models.py:1831
 #: templates/InvenTree/settings/category.html:62
 msgid "Parameter Template"
 msgstr "Parameter Vorlage"
 
-#: part/models.py:1721
+#: part/models.py:1785
 msgid "Parameter Value"
 msgstr "Parameter Wert"
 
-#: part/models.py:1771
+#: part/models.py:1835
 #, fuzzy
 #| msgid "Parameter Value"
 msgid "Default Parameter Value"
 msgstr "Parameter Wert"
 
-#: part/models.py:1801
+#: part/models.py:1865
 msgid "Select parent part"
 msgstr "Ausgangsteil auswählen"
 
-#: part/models.py:1809
+#: part/models.py:1873
 msgid "Select part to be used in BOM"
 msgstr "Teil für die Nutzung in der Stückliste auswählen"
 
-#: part/models.py:1815
+#: part/models.py:1879
 msgid "BOM quantity for this BOM item"
 msgstr "Stücklisten-Anzahl für dieses Stücklisten-Teil"
 
-#: part/models.py:1817
+#: part/models.py:1881
 #, fuzzy
 #| msgid "Confim BOM item deletion"
 msgid "This BOM item is optional"
 msgstr "Löschung von BOM-Position bestätigen"
 
-#: part/models.py:1820
+#: part/models.py:1884
 msgid "Estimated build wastage quantity (absolute or percentage)"
 msgstr "Geschätzter Ausschuss (absolut oder prozentual)"
 
-#: part/models.py:1823
+#: part/models.py:1887
 msgid "BOM item reference"
 msgstr "Referenz des Objekts auf der Stückliste"
 
-#: part/models.py:1826
+#: part/models.py:1890
 msgid "BOM item notes"
 msgstr "Notizen zum Stücklisten-Objekt"
 
-#: part/models.py:1828
+#: part/models.py:1892
 msgid "BOM line checksum"
 msgstr "Prüfsumme der Stückliste"
 
-#: part/models.py:1899 part/views.py:1500 part/views.py:1552
-#: stock/models.py:234
+#: part/models.py:1963 part/views.py:1510 part/views.py:1562
+#: stock/models.py:241
 #, fuzzy
 #| msgid "Overage must be an integer value or a percentage"
 msgid "Quantity must be integer value for trackable parts"
 msgstr "Überschuss muss eine Ganzzahl oder ein Prozentwert sein"
 
-#: part/models.py:1908 part/models.py:1910
+#: part/models.py:1972 part/models.py:1974
 #, fuzzy
 #| msgid "Supplier part description"
 msgid "Sub part must be specified"
 msgstr "Zuliefererbeschreibung des Teils"
 
-#: part/models.py:1913
+#: part/models.py:1977
 #, fuzzy
 #| msgid "New BOM Item"
 msgid "BOM Item"
 msgstr "Neue Stücklistenposition"
 
-#: part/models.py:2028
+#: part/models.py:2092
 #, fuzzy
 #| msgid "Select a part"
 msgid "Select Related Part"
 msgstr "Teil auswählen"
 
-#: part/models.py:2060
+#: part/models.py:2124
 msgid ""
 "Error creating relationship: check that the part is not related to itself "
 "and that the relationship is unique"
@@ -3157,9 +3323,9 @@ msgstr "Bestellung"
 #: part/templates/part/allocation.html:45
 #: stock/templates/stock/item_base.html:8
 #: stock/templates/stock/item_base.html:72
-#: stock/templates/stock/item_base.html:274
+#: stock/templates/stock/item_base.html:291
 #: stock/templates/stock/stock_adjust.html:16 templates/js/build.js:751
-#: templates/js/stock.js:699 templates/js/stock.js:948
+#: templates/js/stock.js:720 templates/js/stock.js:980
 msgid "Stock Item"
 msgstr "Lagerobjekt"
 
@@ -3232,7 +3398,7 @@ msgstr "Stückliste validieren"
 msgid "Validate"
 msgstr "BOM validieren"
 
-#: part/templates/part/bom.html:62 part/views.py:1791
+#: part/templates/part/bom.html:62 part/views.py:1801
 msgid "Export Bill of Materials"
 msgstr "Stückliste exportieren"
 
@@ -3360,7 +3526,7 @@ msgstr "Neuen Bau beginnen"
 msgid "All parts"
 msgstr "Alle Teile"
 
-#: part/templates/part/category.html:24 part/views.py:2182
+#: part/templates/part/category.html:24 part/views.py:2192
 msgid "Create new part category"
 msgstr "Teilkategorie anlegen"
 
@@ -3388,10 +3554,6 @@ msgstr "Pfad zur Kategorie"
 msgid "Category Description"
 msgstr "Kategorie-Beschreibung"
 
-#: part/templates/part/category.html:68 part/templates/part/detail.html:64
-msgid "Keywords"
-msgstr "Schlüsselwörter"
-
 #: part/templates/part/category.html:74
 msgid "Subcategories"
 msgstr "Unter-Kategorien"
@@ -3426,7 +3588,7 @@ msgstr "Teilkategorie auswählen"
 msgid "Export Data"
 msgstr "Exportieren"
 
-#: part/templates/part/category.html:174
+#: part/templates/part/category.html:174 templates/js/stock.js:628
 #, fuzzy
 #| msgid "Create New Location"
 msgid "Create new location"
@@ -3450,7 +3612,7 @@ msgstr "Teilkategorie anlegen"
 msgid "Create new Part Category"
 msgstr "Teilkategorie anlegen"
 
-#: part/templates/part/category.html:216 stock/views.py:1358
+#: part/templates/part/category.html:216 stock/views.py:1363
 msgid "Create new Stock Location"
 msgstr "Neuen Lager-Standort erstellen"
 
@@ -3480,15 +3642,6 @@ msgstr "Los"
 msgid "Part Details"
 msgstr "Teile-Details"
 
-#: part/templates/part/detail.html:25 part/templates/part/part_base.html:95
-#: templates/js/part.js:180
-msgid "IPN"
-msgstr "IPN (Interne Produktnummer)"
-
-#: part/templates/part/detail.html:32 templates/js/part.js:184
-msgid "Revision"
-msgstr "Revision"
-
 #: part/templates/part/detail.html:39
 #, fuzzy
 #| msgid "Serial Number"
@@ -3501,107 +3654,87 @@ msgstr "Seriennummer"
 msgid "No serial numbers recorded"
 msgstr "Keine Seriennummern gefunden"
 
-#: part/templates/part/detail.html:57
-msgid "Variant Of"
-msgstr "Variante von"
+#: part/templates/part/detail.html:115
+#, fuzzy
+#| msgid "Stock Export Options"
+msgid "Stock Expiry Time"
+msgstr "Lagerbestandsexportoptionen"
 
-#: part/templates/part/detail.html:70 part/templates/part/set_category.html:15
-#: templates/js/part.js:405
-msgid "Category"
-msgstr "Kategorie"
-
-#: part/templates/part/detail.html:94
-msgid "Default Supplier"
-msgstr "Standard-Zulieferer"
-
-#: part/templates/part/detail.html:102 part/templates/part/params.html:26
-msgid "Units"
-msgstr "Einheiten"
-
-#: part/templates/part/detail.html:108
-msgid "Minimum Stock"
-msgstr "Minimaler Lagerbestand"
-
-#: part/templates/part/detail.html:114 templates/js/order.js:270
+#: part/templates/part/detail.html:121 templates/js/order.js:276
 msgid "Creation Date"
 msgstr "Erstelldatum"
 
-#: part/templates/part/detail.html:120
+#: part/templates/part/detail.html:127
 msgid "Created By"
 msgstr "Erstellt von"
 
-#: part/templates/part/detail.html:127
+#: part/templates/part/detail.html:134
 msgid "Responsible User"
 msgstr "Verantwortlicher Benutzer"
 
-#: part/templates/part/detail.html:141
+#: part/templates/part/detail.html:148
 msgid "Part is virtual (not a physical part)"
 msgstr "Teil ist virtuell (kein physisches Teil)"
 
-#: part/templates/part/detail.html:143
+#: part/templates/part/detail.html:150
 msgid "Part is not a virtual part"
 msgstr "Teil ist nicht virtuell"
 
-#: part/templates/part/detail.html:148 stock/forms.py:249
-#: templates/js/table_filters.js:23 templates/js/table_filters.js:252
-msgid "Template"
-msgstr "Vorlage"
-
-#: part/templates/part/detail.html:151
+#: part/templates/part/detail.html:158
 #, fuzzy
 #| msgid "Part cannot be a template part if it is a variant of another part"
 msgid "Part is a template part (variants can be made from this part)"
 msgstr "Teil kann keine Vorlage sein wenn es Variante eines anderen Teils ist"
 
-#: part/templates/part/detail.html:153
+#: part/templates/part/detail.html:160
 #, fuzzy
 #| msgid "Part is not a virtual part"
 msgid "Part is not a template part"
 msgstr "Teil ist nicht virtuell"
 
-#: part/templates/part/detail.html:161
+#: part/templates/part/detail.html:168
 msgid "Part can be assembled from other parts"
 msgstr "Teil kann aus anderen Teilen angefertigt werden"
 
-#: part/templates/part/detail.html:163
+#: part/templates/part/detail.html:170
 msgid "Part cannot be assembled from other parts"
 msgstr "Teil kann nicht aus anderen Teilen angefertigt werden"
 
-#: part/templates/part/detail.html:171
+#: part/templates/part/detail.html:178
 msgid "Part can be used in assemblies"
 msgstr "Teil kann in Baugruppen benutzt werden"
 
-#: part/templates/part/detail.html:173
+#: part/templates/part/detail.html:180
 msgid "Part cannot be used in assemblies"
 msgstr "Teil kann nicht in Baugruppen benutzt werden"
 
-#: part/templates/part/detail.html:181
+#: part/templates/part/detail.html:188
 msgid "Part stock is tracked by serial number"
 msgstr "Teilebestand in der Seriennummer hinterlegt"
 
-#: part/templates/part/detail.html:183
+#: part/templates/part/detail.html:190
 msgid "Part stock is not tracked by serial number"
 msgstr "Teilebestand ist nicht in der Seriennummer hinterlegt"
 
-#: part/templates/part/detail.html:191 part/templates/part/detail.html:193
+#: part/templates/part/detail.html:198 part/templates/part/detail.html:200
 msgid "Part can be purchased from external suppliers"
 msgstr "Teil kann von externen Zulieferern gekauft werden"
 
-#: part/templates/part/detail.html:201
+#: part/templates/part/detail.html:208
 msgid "Part can be sold to customers"
 msgstr "Teil kann an Kunden verkauft werden"
 
-#: part/templates/part/detail.html:203
+#: part/templates/part/detail.html:210
 msgid "Part cannot be sold to customers"
 msgstr "Teil kann nicht an Kunden verkauft werden"
 
-#: part/templates/part/detail.html:218
+#: part/templates/part/detail.html:225
 #, fuzzy
 #| msgid "This part is not active"
 msgid "Part is active"
 msgstr "Dieses Teil ist nicht aktiv"
 
-#: part/templates/part/detail.html:220
+#: part/templates/part/detail.html:227
 #, fuzzy
 #| msgid "This part is not active"
 msgid "Part is not active"
@@ -3621,12 +3754,12 @@ msgstr "Parameter hinzufügen"
 
 #: part/templates/part/params.html:15
 #: templates/InvenTree/settings/category.html:29
-#: templates/InvenTree/settings/part.html:38
+#: templates/InvenTree/settings/part.html:41
 msgid "New Parameter"
 msgstr "Neuer Parameter"
 
-#: part/templates/part/params.html:25 stock/models.py:1420
-#: templates/js/stock.js:112
+#: part/templates/part/params.html:25 stock/models.py:1499
+#: templates/InvenTree/settings/header.html:8 templates/js/stock.js:113
 msgid "Value"
 msgstr "Wert"
 
@@ -3663,7 +3796,7 @@ msgid "Star this part"
 msgstr "Teil favorisieren"
 
 #: part/templates/part/part_base.html:49
-#: stock/templates/stock/item_base.html:101
+#: stock/templates/stock/item_base.html:108
 #: stock/templates/stock/location.html:29
 #, fuzzy
 #| msgid "Source Location"
@@ -3671,7 +3804,7 @@ msgid "Barcode actions"
 msgstr "Quell-Standort"
 
 #: part/templates/part/part_base.html:51
-#: stock/templates/stock/item_base.html:103
+#: stock/templates/stock/item_base.html:110
 #: stock/templates/stock/location.html:31
 #, fuzzy
 #| msgid "Part QR Code"
@@ -3679,7 +3812,7 @@ msgid "Show QR Code"
 msgstr "Teil-QR-Code"
 
 #: part/templates/part/part_base.html:52
-#: stock/templates/stock/item_base.html:104
+#: stock/templates/stock/item_base.html:126
 #: stock/templates/stock/location.html:32
 msgid "Print Label"
 msgstr ""
@@ -3718,7 +3851,7 @@ msgstr "Vorlage bearbeiten"
 msgid "Delete part"
 msgstr "Teile löschen"
 
-#: part/templates/part/part_base.html:124 templates/js/table_filters.js:111
+#: part/templates/part/part_base.html:124 templates/js/table_filters.js:121
 msgid "In Stock"
 msgstr "Auf Lager"
 
@@ -3845,7 +3978,7 @@ msgstr "Stückliste"
 msgid "Used In"
 msgstr "Benutzt in"
 
-#: part/templates/part/tabs.html:61 stock/templates/stock/item_base.html:318
+#: part/templates/part/tabs.html:61 stock/templates/stock/item_base.html:349
 msgid "Tests"
 msgstr ""
 
@@ -3881,248 +4014,248 @@ msgstr "Neues Teil hinzufügen"
 msgid "New Variant"
 msgstr "Varianten"
 
-#: part/views.py:84
+#: part/views.py:86
 #, fuzzy
 #| msgid "Allocated Parts"
 msgid "Add Related Part"
 msgstr "Zugeordnete Teile"
 
-#: part/views.py:140
+#: part/views.py:142
 #, fuzzy
 #| msgid "Delete Supplier Part"
 msgid "Delete Related Part"
 msgstr "Zuliefererteil entfernen"
 
-#: part/views.py:152
+#: part/views.py:154
 msgid "Add part attachment"
 msgstr "Teilanhang hinzufügen"
 
-#: part/views.py:207 templates/attachment_table.html:34
+#: part/views.py:209 templates/attachment_table.html:34
 msgid "Edit attachment"
 msgstr "Anhang bearbeiten"
 
-#: part/views.py:213
+#: part/views.py:215
 msgid "Part attachment updated"
 msgstr "Teilanhang aktualisiert"
 
-#: part/views.py:228
+#: part/views.py:230
 msgid "Delete Part Attachment"
 msgstr "Teilanhang löschen"
 
-#: part/views.py:236
+#: part/views.py:238
 msgid "Deleted part attachment"
 msgstr "Teilanhang gelöscht"
 
-#: part/views.py:245
+#: part/views.py:247
 #, fuzzy
 #| msgid "Create Part Parameter Template"
 msgid "Create Test Template"
 msgstr "Teilparametervorlage anlegen"
 
-#: part/views.py:274
+#: part/views.py:276
 #, fuzzy
 #| msgid "Edit Template"
 msgid "Edit Test Template"
 msgstr "Vorlage bearbeiten"
 
-#: part/views.py:290
+#: part/views.py:292
 #, fuzzy
 #| msgid "Delete Template"
 msgid "Delete Test Template"
 msgstr "Vorlage löschen"
 
-#: part/views.py:299
+#: part/views.py:301
 msgid "Set Part Category"
 msgstr "Teilkategorie auswählen"
 
-#: part/views.py:349
+#: part/views.py:351
 #, python-brace-format
 msgid "Set category for {n} parts"
 msgstr "Kategorie für {n} Teile setzen"
 
-#: part/views.py:384
+#: part/views.py:386
 msgid "Create Variant"
 msgstr "Variante anlegen"
 
-#: part/views.py:466
+#: part/views.py:468
 msgid "Duplicate Part"
 msgstr "Teil duplizieren"
 
-#: part/views.py:473
+#: part/views.py:475
 msgid "Copied part"
 msgstr "Teil kopiert"
 
-#: part/views.py:527 part/views.py:661
+#: part/views.py:529 part/views.py:667
 msgid "Possible matches exist - confirm creation of new part"
 msgstr ""
 
-#: part/views.py:592 templates/js/stock.js:844
+#: part/views.py:594 templates/js/stock.js:876
 msgid "Create New Part"
 msgstr "Neues Teil anlegen"
 
-#: part/views.py:599
+#: part/views.py:601
 msgid "Created new part"
 msgstr "Neues Teil angelegt"
 
-#: part/views.py:830
+#: part/views.py:836
 msgid "Part QR Code"
 msgstr "Teil-QR-Code"
 
-#: part/views.py:849
+#: part/views.py:855
 msgid "Upload Part Image"
 msgstr "Teilbild hochladen"
 
-#: part/views.py:857 part/views.py:894
+#: part/views.py:863 part/views.py:900
 msgid "Updated part image"
 msgstr "Teilbild aktualisiert"
 
-#: part/views.py:866
+#: part/views.py:872
 msgid "Select Part Image"
 msgstr "Teilbild auswählen"
 
-#: part/views.py:897
+#: part/views.py:903
 msgid "Part image not found"
 msgstr "Teilbild nicht gefunden"
 
-#: part/views.py:908
+#: part/views.py:914
 msgid "Edit Part Properties"
 msgstr "Teileigenschaften bearbeiten"
 
-#: part/views.py:935
+#: part/views.py:945
 #, fuzzy
 #| msgid "Duplicate Part"
 msgid "Duplicate BOM"
 msgstr "Teil duplizieren"
 
-#: part/views.py:966
+#: part/views.py:976
 #, fuzzy
 #| msgid "Confirm unallocation of build stock"
 msgid "Confirm duplication of BOM from parent"
 msgstr "Zuweisungsaufhebung bestätigen"
 
-#: part/views.py:987
+#: part/views.py:997
 msgid "Validate BOM"
 msgstr "BOM validieren"
 
-#: part/views.py:1010
+#: part/views.py:1020
 #, fuzzy
 #| msgid "Confirm that the BOM is correct"
 msgid "Confirm that the BOM is valid"
 msgstr "Bestätigen, dass die Stückliste korrekt ist"
 
-#: part/views.py:1021
+#: part/views.py:1031
 #, fuzzy
 #| msgid "Validate Bill of Materials"
 msgid "Validated Bill of Materials"
 msgstr "Stückliste validieren"
 
-#: part/views.py:1155
+#: part/views.py:1165
 msgid "No BOM file provided"
 msgstr "Keine Stückliste angegeben"
 
-#: part/views.py:1503
+#: part/views.py:1513
 msgid "Enter a valid quantity"
 msgstr "Bitte eine gültige Anzahl eingeben"
 
-#: part/views.py:1528 part/views.py:1531
+#: part/views.py:1538 part/views.py:1541
 msgid "Select valid part"
 msgstr "Bitte ein gültiges Teil auswählen"
 
-#: part/views.py:1537
+#: part/views.py:1547
 msgid "Duplicate part selected"
 msgstr "Teil doppelt ausgewählt"
 
-#: part/views.py:1575
+#: part/views.py:1585
 msgid "Select a part"
 msgstr "Teil auswählen"
 
-#: part/views.py:1581
+#: part/views.py:1591
 #, fuzzy
 #| msgid "Select part to be used in BOM"
 msgid "Selected part creates a circular BOM"
 msgstr "Teil für die Nutzung in der Stückliste auswählen"
 
-#: part/views.py:1585
+#: part/views.py:1595
 msgid "Specify quantity"
 msgstr "Anzahl angeben"
 
-#: part/views.py:1841
+#: part/views.py:1851
 msgid "Confirm Part Deletion"
 msgstr "Löschen des Teils bestätigen"
 
-#: part/views.py:1850
+#: part/views.py:1860
 msgid "Part was deleted"
 msgstr "Teil wurde gelöscht"
 
-#: part/views.py:1859
+#: part/views.py:1869
 msgid "Part Pricing"
 msgstr "Teilbepreisung"
 
-#: part/views.py:1973
+#: part/views.py:1983
 msgid "Create Part Parameter Template"
 msgstr "Teilparametervorlage anlegen"
 
-#: part/views.py:1983
+#: part/views.py:1993
 msgid "Edit Part Parameter Template"
 msgstr "Teilparametervorlage bearbeiten"
 
-#: part/views.py:1992
+#: part/views.py:2002
 msgid "Delete Part Parameter Template"
 msgstr "Teilparametervorlage löschen"
 
-#: part/views.py:2002
+#: part/views.py:2012
 msgid "Create Part Parameter"
 msgstr "Teilparameter anlegen"
 
-#: part/views.py:2054
+#: part/views.py:2064
 msgid "Edit Part Parameter"
 msgstr "Teilparameter bearbeiten"
 
-#: part/views.py:2070
+#: part/views.py:2080
 msgid "Delete Part Parameter"
 msgstr "Teilparameter löschen"
 
-#: part/views.py:2129
+#: part/views.py:2139
 msgid "Edit Part Category"
 msgstr "Teilkategorie bearbeiten"
 
-#: part/views.py:2166
+#: part/views.py:2176
 msgid "Delete Part Category"
 msgstr "Teilkategorie löschen"
 
-#: part/views.py:2174
+#: part/views.py:2184
 msgid "Part category was deleted"
 msgstr "Teilekategorie wurde gelöscht"
 
-#: part/views.py:2230
+#: part/views.py:2240
 #, fuzzy
 #| msgid "Create Part Parameter Template"
 msgid "Create Category Parameter Template"
 msgstr "Teilparametervorlage anlegen"
 
-#: part/views.py:2333
+#: part/views.py:2343
 #, fuzzy
 #| msgid "Edit Part Parameter Template"
 msgid "Edit Category Parameter Template"
 msgstr "Teilparametervorlage bearbeiten"
 
-#: part/views.py:2391
+#: part/views.py:2401
 #, fuzzy
 #| msgid "Delete Part Parameter Template"
 msgid "Delete Category Parameter Template"
 msgstr "Teilparametervorlage löschen"
 
-#: part/views.py:2416
+#: part/views.py:2426
 #, fuzzy
 #| msgid "Create BOM item"
 msgid "Create BOM Item"
 msgstr "BOM-Position anlegen"
 
-#: part/views.py:2488
+#: part/views.py:2498
 msgid "Edit BOM item"
 msgstr "BOM-Position beaarbeiten"
 
-#: part/views.py:2545
+#: part/views.py:2555
 msgid "Confim BOM item deletion"
 msgstr "Löschung von BOM-Position bestätigen"
 
@@ -4162,346 +4295,358 @@ msgstr ""
 msgid "Asset file description"
 msgstr "Einstellungs-Beschreibung"
 
-#: stock/forms.py:111
+#: stock/forms.py:116
 msgid "Enter unique serial numbers (or leave blank)"
 msgstr "Eindeutige Seriennummern eingeben (oder leer lassen)"
 
-#: stock/forms.py:192
+#: stock/forms.py:198
 msgid "Label"
 msgstr ""
 
-#: stock/forms.py:193 stock/forms.py:249
+#: stock/forms.py:199 stock/forms.py:255
 #, fuzzy
 #| msgid "Select stock item to allocate"
 msgid "Select test report template"
 msgstr "Lagerobjekt für Zuordnung auswählen"
 
-#: stock/forms.py:257
+#: stock/forms.py:263
 msgid "Include stock items in sub locations"
 msgstr "Lagerobjekte in untergeordneten Lagerorten einschließen"
 
-#: stock/forms.py:292
+#: stock/forms.py:298
 #, fuzzy
 #| msgid "No stock items matching query"
 msgid "Stock item to install"
 msgstr "Keine zur Anfrage passenden Lagerobjekte"
 
-#: stock/forms.py:299
+#: stock/forms.py:305
 #, fuzzy
 #| msgid "Stock Quantity"
 msgid "Stock quantity to assign"
 msgstr "Bestand"
 
-#: stock/forms.py:327
+#: stock/forms.py:333
 #, fuzzy
 #| msgid "Quantity must not exceed available stock quantity ({n})"
 msgid "Must not exceed available quantity"
 msgstr "Anzahl darf nicht die verfügbare Anzahl überschreiten ({n})"
 
-#: stock/forms.py:337
+#: stock/forms.py:343
 #, fuzzy
 #| msgid "Does this part have tracking for unique items?"
 msgid "Destination location for uninstalled items"
 msgstr "Hat dieses Teil Tracking für einzelne Objekte?"
 
-#: stock/forms.py:339
+#: stock/forms.py:345
 #, fuzzy
 #| msgid "Description of the company"
 msgid "Add transaction note (optional)"
 msgstr "Firmenbeschreibung"
 
-#: stock/forms.py:341
+#: stock/forms.py:347
 #, fuzzy
 #| msgid "Confirm stock allocation"
 msgid "Confirm uninstall"
 msgstr "Lagerbestandszuordnung bestätigen"
 
-#: stock/forms.py:341
+#: stock/forms.py:347
 #, fuzzy
 #| msgid "Confirm movement of stock items"
 msgid "Confirm removal of installed stock items"
 msgstr "Bewegung der Lagerobjekte bestätigen"
 
-#: stock/forms.py:365
+#: stock/forms.py:371
 msgid "Destination stock location"
 msgstr "Ziel-Lagerbestand"
 
-#: stock/forms.py:367
+#: stock/forms.py:373
 msgid "Add note (required)"
 msgstr ""
 
-#: stock/forms.py:371 stock/views.py:935 stock/views.py:1133
+#: stock/forms.py:377 stock/views.py:935 stock/views.py:1133
 msgid "Confirm stock adjustment"
 msgstr "Bestands-Anpassung bestätigen"
 
-#: stock/forms.py:371
+#: stock/forms.py:377
 msgid "Confirm movement of stock items"
 msgstr "Bewegung der Lagerobjekte bestätigen"
 
-#: stock/forms.py:373
+#: stock/forms.py:379
 #, fuzzy
 #| msgid "Default Location"
 msgid "Set Default Location"
 msgstr "Standard-Lagerort"
 
-#: stock/forms.py:373
+#: stock/forms.py:379
 msgid "Set the destination as the default location for selected parts"
 msgstr "Setze das Ziel als Standard-Ziel für ausgewählte Teile"
 
-#: stock/models.py:179
+#: stock/models.py:186
 #, fuzzy
 #| msgid "Created new stock item"
 msgid "Created stock item"
 msgstr "Neues Lagerobjekt erstellt"
 
-#: stock/models.py:215
+#: stock/models.py:222
 #, fuzzy
 #| msgid "A stock item with this serial number already exists"
 msgid "StockItem with this serial number already exists"
 msgstr "Ein Teil mit dieser Seriennummer existiert bereits"
 
-#: stock/models.py:251
+#: stock/models.py:258
 #, python-brace-format
 msgid "Part type ('{pf}') must be {pe}"
 msgstr "Teile-Typ ('{pf}') muss {pe} sein"
 
-#: stock/models.py:261 stock/models.py:270
+#: stock/models.py:268 stock/models.py:277
 msgid "Quantity must be 1 for item with a serial number"
 msgstr "Anzahl muss für Objekte mit Seriennummer \"1\" sein"
 
-#: stock/models.py:262
+#: stock/models.py:269
 msgid "Serial number cannot be set if quantity greater than 1"
 msgstr ""
 "Seriennummer kann nicht gesetzt werden wenn die Anzahl größer als \"1\" ist"
 
-#: stock/models.py:284
+#: stock/models.py:291
 msgid "Item cannot belong to itself"
 msgstr "Teil kann nicht zu sich selbst gehören"
 
-#: stock/models.py:290
+#: stock/models.py:297
 msgid "Item must have a build reference if is_building=True"
 msgstr ""
 
-#: stock/models.py:297
+#: stock/models.py:304
 msgid "Build reference does not point to the same part object"
 msgstr ""
 
-#: stock/models.py:330
+#: stock/models.py:337
 msgid "Parent Stock Item"
 msgstr "Eltern-Lagerobjekt"
 
-#: stock/models.py:339
+#: stock/models.py:346
 msgid "Base part"
 msgstr "Basis-Teil"
 
-#: stock/models.py:348
+#: stock/models.py:355
 msgid "Select a matching supplier part for this stock item"
 msgstr "Passenden Zulieferer für dieses Lagerobjekt auswählen"
 
-#: stock/models.py:353 stock/templates/stock/stock_app_base.html:7
+#: stock/models.py:360 stock/templates/stock/stock_app_base.html:7
 msgid "Stock Location"
 msgstr "Lagerort"
 
-#: stock/models.py:356
+#: stock/models.py:363
 msgid "Where is this stock item located?"
 msgstr "Wo wird dieses Teil normalerweise gelagert?"
 
-#: stock/models.py:361 stock/templates/stock/item_base.html:212
+#: stock/models.py:368 stock/templates/stock/item_base.html:229
 msgid "Installed In"
 msgstr "Installiert in"
 
-#: stock/models.py:364
+#: stock/models.py:371
 msgid "Is this item installed in another item?"
 msgstr "Ist dieses Teil in einem anderen verbaut?"
 
-#: stock/models.py:380
+#: stock/models.py:387
 msgid "Serial number for this item"
 msgstr "Seriennummer für dieses Teil"
 
-#: stock/models.py:392
+#: stock/models.py:399
 msgid "Batch code for this stock item"
 msgstr "Losnummer für dieses Lagerobjekt"
 
-#: stock/models.py:396
+#: stock/models.py:403
 msgid "Stock Quantity"
 msgstr "Bestand"
 
-#: stock/models.py:405
+#: stock/models.py:412
 msgid "Source Build"
 msgstr "Quellbau"
 
-#: stock/models.py:407
+#: stock/models.py:414
 msgid "Build for this stock item"
 msgstr "Bau für dieses Lagerobjekt"
 
-#: stock/models.py:418
+#: stock/models.py:425
 msgid "Source Purchase Order"
 msgstr "Quellbestellung"
 
-#: stock/models.py:421
+#: stock/models.py:428
 msgid "Purchase order for this stock item"
 msgstr "Bestellung für dieses Teil"
 
-#: stock/models.py:427
+#: stock/models.py:434
 msgid "Destination Sales Order"
 msgstr "Zielauftrag"
 
-#: stock/models.py:439
+#: stock/models.py:440 stock/templates/stock/item_base.html:316
+#: templates/js/stock.js:597
+#, fuzzy
+#| msgid "Export"
+msgid "Expiry Date"
+msgstr "Exportieren"
+
+#: stock/models.py:441
+msgid ""
+"Expiry date for stock item. Stock will be considered expired after this date"
+msgstr ""
+
+#: stock/models.py:454
 msgid "Delete this Stock Item when stock is depleted"
 msgstr "Objekt löschen wenn Lagerbestand aufgebraucht"
 
-#: stock/models.py:449 stock/templates/stock/item_notes.html:14
+#: stock/models.py:464 stock/templates/stock/item_notes.html:14
 #: stock/templates/stock/item_notes.html:30
 msgid "Stock Item Notes"
 msgstr "Lagerobjekt-Notizen"
 
-#: stock/models.py:459
+#: stock/models.py:474
 msgid "Single unit purchase price at time of purchase"
 msgstr ""
 
-#: stock/models.py:510
+#: stock/models.py:574
 #, fuzzy
 #| msgid "Item assigned to customer?"
 msgid "Assigned to Customer"
 msgstr "Ist dieses Objekt einem Kunden zugeteilt?"
 
-#: stock/models.py:512
+#: stock/models.py:576
 #, fuzzy
 #| msgid "Item assigned to customer?"
 msgid "Manually assigned to customer"
 msgstr "Ist dieses Objekt einem Kunden zugeteilt?"
 
-#: stock/models.py:525
+#: stock/models.py:589
 #, fuzzy
 #| msgid "Item assigned to customer?"
 msgid "Returned from customer"
 msgstr "Ist dieses Objekt einem Kunden zugeteilt?"
 
-#: stock/models.py:527
+#: stock/models.py:591
 #, fuzzy
 #| msgid "Create new stock location"
 msgid "Returned to location"
 msgstr "Neuen Lagerort anlegen"
 
-#: stock/models.py:652
+#: stock/models.py:716
 #, fuzzy
 #| msgid "Installed in Stock Item"
 msgid "Installed into stock item"
 msgstr "In Lagerobjekt installiert"
 
-#: stock/models.py:660
+#: stock/models.py:724
 #, fuzzy
 #| msgid "Installed in Stock Item"
 msgid "Installed stock item"
 msgstr "In Lagerobjekt installiert"
 
-#: stock/models.py:684
+#: stock/models.py:748
 #, fuzzy
 #| msgid "Installed in Stock Item"
 msgid "Uninstalled stock item"
 msgstr "In Lagerobjekt installiert"
 
-#: stock/models.py:703
+#: stock/models.py:767
 #, fuzzy
 #| msgid "Include sublocations"
 msgid "Uninstalled into location"
 msgstr "Unterlagerorte einschließen"
 
-#: stock/models.py:803
+#: stock/models.py:847
 #, fuzzy
 #| msgid "Part is not a virtual part"
 msgid "Part is not set as trackable"
 msgstr "Teil ist nicht virtuell"
 
-#: stock/models.py:809
+#: stock/models.py:853
 msgid "Quantity must be integer"
 msgstr "Anzahl muss eine Ganzzahl sein"
 
-#: stock/models.py:815
+#: stock/models.py:859
 #, python-brace-format
 msgid "Quantity must not exceed available stock quantity ({n})"
 msgstr "Anzahl darf nicht die verfügbare Anzahl überschreiten ({n})"
 
-#: stock/models.py:818
+#: stock/models.py:862
 msgid "Serial numbers must be a list of integers"
 msgstr "Seriennummern muss eine Liste von Ganzzahlen sein"
 
-#: stock/models.py:821
+#: stock/models.py:865
 msgid "Quantity does not match serial numbers"
 msgstr "Anzahl stimmt nicht mit den Seriennummern überein"
 
-#: stock/models.py:853
+#: stock/models.py:897
 msgid "Add serial number"
 msgstr "Seriennummer hinzufügen"
 
-#: stock/models.py:856
+#: stock/models.py:900
 #, python-brace-format
 msgid "Serialized {n} items"
 msgstr "{n} Teile serialisiert"
 
-#: stock/models.py:967
+#: stock/models.py:1011
 msgid "StockItem cannot be moved as it is not in stock"
 msgstr "Lagerobjekt kann nicht bewegt werden, da kein Bestand vorhanden ist"
 
-#: stock/models.py:1321
+#: stock/models.py:1400
 msgid "Tracking entry title"
 msgstr "Name des Eintrags-Trackings"
 
-#: stock/models.py:1323
+#: stock/models.py:1402
 msgid "Entry notes"
 msgstr "Eintrags-Notizen"
 
-#: stock/models.py:1325
+#: stock/models.py:1404
 msgid "Link to external page for further information"
 msgstr "Link auf externe Seite für weitere Informationen"
 
-#: stock/models.py:1385
+#: stock/models.py:1464
 #, fuzzy
 #| msgid "Serial number for this item"
 msgid "Value must be provided for this test"
 msgstr "Seriennummer für dieses Teil"
 
-#: stock/models.py:1391
+#: stock/models.py:1470
 msgid "Attachment must be uploaded for this test"
 msgstr ""
 
-#: stock/models.py:1408
+#: stock/models.py:1487
 msgid "Test"
 msgstr ""
 
-#: stock/models.py:1409
+#: stock/models.py:1488
 #, fuzzy
 #| msgid "Part name"
 msgid "Test name"
 msgstr "Name des Teils"
 
-#: stock/models.py:1414
+#: stock/models.py:1493
 #, fuzzy
 #| msgid "Search Results"
 msgid "Result"
 msgstr "Suchergebnisse"
 
-#: stock/models.py:1415 templates/js/table_filters.js:162
+#: stock/models.py:1494 templates/js/table_filters.js:172
 msgid "Test result"
 msgstr ""
 
-#: stock/models.py:1421
+#: stock/models.py:1500
 msgid "Test output value"
 msgstr ""
 
-#: stock/models.py:1427
+#: stock/models.py:1506
 #, fuzzy
 #| msgid "Attachments"
 msgid "Attachment"
 msgstr "Anhänge"
 
-#: stock/models.py:1428
+#: stock/models.py:1507
 #, fuzzy
 #| msgid "Delete attachment"
 msgid "Test result attachment"
 msgstr "Anhang löschen"
 
-#: stock/models.py:1434
+#: stock/models.py:1513
 #, fuzzy
 #| msgid "Edit notes"
 msgid "Test notes"
@@ -4566,137 +4711,166 @@ msgstr ""
 "Dieses Lagerobjekt wird automatisch gelöscht wenn der Lagerbestand "
 "aufgebraucht ist."
 
-#: stock/templates/stock/item_base.html:107 templates/js/barcode.js:283
+#: stock/templates/stock/item_base.html:74
+#: stock/templates/stock/item_base.html:320 templates/js/table_filters.js:111
+msgid "Expired"
+msgstr ""
+
+#: stock/templates/stock/item_base.html:78
+#: stock/templates/stock/item_base.html:322 templates/js/table_filters.js:116
+msgid "Stale"
+msgstr ""
+
+#: stock/templates/stock/item_base.html:113 templates/js/barcode.js:283
 #: templates/js/barcode.js:288
 msgid "Unlink Barcode"
 msgstr ""
 
-#: stock/templates/stock/item_base.html:109
+#: stock/templates/stock/item_base.html:115
 msgid "Link Barcode"
 msgstr ""
 
-#: stock/templates/stock/item_base.html:117
+#: stock/templates/stock/item_base.html:123
+#, fuzzy
+#| msgid "Confirm stock adjustment"
+msgid "Document actions"
+msgstr "Bestands-Anpassung bestätigen"
+
+#: stock/templates/stock/item_base.html:129
+#: stock/templates/stock/item_tests.html:25
+msgid "Test Report"
+msgstr ""
+
+#: stock/templates/stock/item_base.html:137
 #, fuzzy
 #| msgid "Confirm stock adjustment"
 msgid "Stock adjustment actions"
 msgstr "Bestands-Anpassung bestätigen"
 
-#: stock/templates/stock/item_base.html:121
+#: stock/templates/stock/item_base.html:141
 #: stock/templates/stock/location.html:41 templates/stock_table.html:23
 msgid "Count stock"
 msgstr "Bestand zählen"
 
-#: stock/templates/stock/item_base.html:122 templates/stock_table.html:21
+#: stock/templates/stock/item_base.html:142 templates/stock_table.html:21
 msgid "Add stock"
 msgstr "Bestand hinzufügen"
 
-#: stock/templates/stock/item_base.html:123 templates/stock_table.html:22
+#: stock/templates/stock/item_base.html:143 templates/stock_table.html:22
 msgid "Remove stock"
 msgstr "Bestand entfernen"
 
-#: stock/templates/stock/item_base.html:125
+#: stock/templates/stock/item_base.html:145
 #, fuzzy
 #| msgid "Order stock"
 msgid "Transfer stock"
 msgstr "Bestand bestellen"
 
-#: stock/templates/stock/item_base.html:127
+#: stock/templates/stock/item_base.html:147
 #, fuzzy
 #| msgid "Serialize Stock"
 msgid "Serialize stock"
 msgstr "Lagerbestand erfassen"
 
-#: stock/templates/stock/item_base.html:131
+#: stock/templates/stock/item_base.html:151
 #, fuzzy
 #| msgid "Item assigned to customer?"
 msgid "Assign to customer"
 msgstr "Ist dieses Objekt einem Kunden zugeteilt?"
 
-#: stock/templates/stock/item_base.html:134
+#: stock/templates/stock/item_base.html:154
 #, fuzzy
 #| msgid "Count stock"
 msgid "Return to stock"
 msgstr "Bestand zählen"
 
-#: stock/templates/stock/item_base.html:138 templates/js/stock.js:985
+#: stock/templates/stock/item_base.html:158 templates/js/stock.js:1017
 #, fuzzy
 #| msgid "Installed in Stock Item"
 msgid "Uninstall stock item"
 msgstr "In Lagerobjekt installiert"
 
-#: stock/templates/stock/item_base.html:138
+#: stock/templates/stock/item_base.html:158
 msgid "Uninstall"
 msgstr ""
 
-#: stock/templates/stock/item_base.html:147
+#: stock/templates/stock/item_base.html:167
 #: stock/templates/stock/location.html:38
 #, fuzzy
 #| msgid "Stock Locations"
 msgid "Stock actions"
 msgstr "Lagerobjekt-Standorte"
 
-#: stock/templates/stock/item_base.html:150
+#: stock/templates/stock/item_base.html:170
 #, fuzzy
 #| msgid "Count stock items"
 msgid "Convert to variant"
 msgstr "Lagerobjekte zählen"
 
-#: stock/templates/stock/item_base.html:153
+#: stock/templates/stock/item_base.html:173
 #, fuzzy
 #| msgid "Count stock items"
 msgid "Duplicate stock item"
 msgstr "Lagerobjekte zählen"
 
-#: stock/templates/stock/item_base.html:155
+#: stock/templates/stock/item_base.html:175
 #, fuzzy
 #| msgid "Edit Stock Item"
 msgid "Edit stock item"
 msgstr "Lagerobjekt bearbeiten"
 
-#: stock/templates/stock/item_base.html:158
+#: stock/templates/stock/item_base.html:178
 #, fuzzy
 #| msgid "Delete Stock Item"
 msgid "Delete stock item"
 msgstr "Lagerobjekt löschen"
 
-#: stock/templates/stock/item_base.html:164
-msgid "Generate test report"
-msgstr ""
-
-#: stock/templates/stock/item_base.html:172
+#: stock/templates/stock/item_base.html:189
 msgid "Stock Item Details"
 msgstr "Lagerbestands-Details"
 
-#: stock/templates/stock/item_base.html:231 templates/js/build.js:442
+#: stock/templates/stock/item_base.html:248 templates/js/build.js:442
 #, fuzzy
 #| msgid "No stock location set"
 msgid "No location set"
 msgstr "Kein Lagerort gesetzt"
 
-#: stock/templates/stock/item_base.html:238
+#: stock/templates/stock/item_base.html:255
 #, fuzzy
 #| msgid "Unique Identifier"
 msgid "Barcode Identifier"
 msgstr "Eindeutiger Bezeichner"
 
-#: stock/templates/stock/item_base.html:252 templates/js/build.js:642
+#: stock/templates/stock/item_base.html:269 templates/js/build.js:642
 #: templates/navbar.html:25
 msgid "Build"
 msgstr "Bau"
 
-#: stock/templates/stock/item_base.html:273
+#: stock/templates/stock/item_base.html:290
 msgid "Parent Item"
 msgstr "Elternposition"
 
-#: stock/templates/stock/item_base.html:298
+#: stock/templates/stock/item_base.html:320
+#, fuzzy
+#| msgid "This stock item is allocated to Build"
+msgid "This StockItem expired on"
+msgstr "Dieses Lagerobjekt ist dem Bau zugewiesen"
+
+#: stock/templates/stock/item_base.html:322
+#, fuzzy
+#| msgid "Child Stock Items"
+msgid "This StockItem expires on"
+msgstr "Kind-Lagerobjekte"
+
+#: stock/templates/stock/item_base.html:329
 msgid "Last Updated"
 msgstr "Zuletzt aktualisiert"
 
-#: stock/templates/stock/item_base.html:303
+#: stock/templates/stock/item_base.html:334
 msgid "Last Stocktake"
 msgstr "Letzte Inventur"
 
-#: stock/templates/stock/item_base.html:307
+#: stock/templates/stock/item_base.html:338
 msgid "No stocktake performed"
 msgstr "Keine Inventur ausgeführt"
 
@@ -4764,10 +4938,6 @@ msgstr "Vorlage löschen"
 msgid "Add Test Data"
 msgstr ""
 
-#: stock/templates/stock/item_tests.html:25
-msgid "Test Report"
-msgstr ""
-
 #: stock/templates/stock/location.html:18
 msgid "All stock items"
 msgstr "Alle Lagerobjekte"
@@ -4840,7 +5010,7 @@ msgstr "Sind Sie sicher, dass Sie diesen Anhang löschen wollen?"
 msgid "The following stock items will be uninstalled"
 msgstr "Die folgenden Objekte werden erstellt"
 
-#: stock/templates/stock/stockitem_convert.html:7 stock/views.py:1330
+#: stock/templates/stock/stockitem_convert.html:7 stock/views.py:1335
 #, fuzzy
 #| msgid "Count Stock Items"
 msgid "Convert Stock Item"
@@ -5078,43 +5248,43 @@ msgstr "{n} Teile im Lager gelöscht"
 msgid "Edit Stock Item"
 msgstr "Lagerobjekt bearbeiten"
 
-#: stock/views.py:1380
+#: stock/views.py:1385
 msgid "Serialize Stock"
 msgstr "Lagerbestand erfassen"
 
-#: stock/views.py:1474 templates/js/build.js:210
+#: stock/views.py:1479 templates/js/build.js:210
 msgid "Create new Stock Item"
 msgstr "Neues Lagerobjekt hinzufügen"
 
-#: stock/views.py:1578
+#: stock/views.py:1587
 #, fuzzy
 #| msgid "Count stock items"
 msgid "Duplicate Stock Item"
 msgstr "Lagerobjekte zählen"
 
-#: stock/views.py:1650
+#: stock/views.py:1664
 #, fuzzy
 #| msgid "Quantity must be greater than zero"
 msgid "Quantity cannot be negative"
 msgstr "Anzahl muss größer Null sein"
 
-#: stock/views.py:1736
+#: stock/views.py:1750
 msgid "Delete Stock Location"
 msgstr "Standort löschen"
 
-#: stock/views.py:1750
+#: stock/views.py:1764
 msgid "Delete Stock Item"
 msgstr "Lagerobjekt löschen"
 
-#: stock/views.py:1762
+#: stock/views.py:1776
 msgid "Delete Stock Tracking Entry"
 msgstr "Lagerbestands-Tracking-Eintrag löschen"
 
-#: stock/views.py:1781
+#: stock/views.py:1795
 msgid "Edit Stock Tracking Entry"
 msgstr "Lagerbestands-Tracking-Eintrag bearbeiten"
 
-#: stock/views.py:1791
+#: stock/views.py:1805
 msgid "Add Stock Tracking Entry"
 msgstr "Lagerbestands-Tracking-Eintrag hinzufügen"
 
@@ -5154,7 +5324,13 @@ msgstr "Eltern-Bau"
 msgid "Pending Builds"
 msgstr "Eltern-Bau"
 
-#: templates/InvenTree/index.html:4
+#: templates/InvenTree/expired_stock.html:7
+#, fuzzy
+#| msgid "Assigned"
+msgid "Expired Stock"
+msgstr "Zugewiesen"
+
+#: templates/InvenTree/index.html:5
 msgid "Index"
 msgstr ""
 
@@ -5192,13 +5368,13 @@ msgstr "Keine Ergebnisse gefunden"
 msgid "Enter a search query"
 msgstr "Auftrag stornieren"
 
-#: templates/InvenTree/search.html:191 templates/js/stock.js:289
+#: templates/InvenTree/search.html:191 templates/js/stock.js:290
 #, fuzzy
 #| msgid "Item assigned to customer?"
 msgid "Shipped to customer"
 msgstr "Ist dieses Objekt einem Kunden zugeteilt?"
 
-#: templates/InvenTree/search.html:194 templates/js/stock.js:299
+#: templates/InvenTree/search.html:194 templates/js/stock.js:300
 msgid "No stock location set"
 msgstr "Kein Lagerort gesetzt"
 
@@ -5239,12 +5415,12 @@ msgid "Default Value"
 msgstr "Standard-Lagerort"
 
 #: templates/InvenTree/settings/category.html:70
-#: templates/InvenTree/settings/part.html:75
+#: templates/InvenTree/settings/part.html:78
 msgid "Edit Template"
 msgstr "Vorlage bearbeiten"
 
 #: templates/InvenTree/settings/category.html:71
-#: templates/InvenTree/settings/part.html:76
+#: templates/InvenTree/settings/part.html:79
 msgid "Delete Template"
 msgstr "Vorlage löschen"
 
@@ -5254,6 +5430,12 @@ msgstr "Vorlage löschen"
 msgid "Global InvenTree Settings"
 msgstr "InvenTree-Version"
 
+#: templates/InvenTree/settings/header.html:7
+#, fuzzy
+#| msgid "Settings"
+msgid "Setting"
+msgstr "Einstellungen"
+
 #: templates/InvenTree/settings/part.html:9
 #, fuzzy
 #| msgid "Settings"
@@ -5266,13 +5448,13 @@ msgstr "Einstellungen"
 msgid "Part Options"
 msgstr "Quell-Standort"
 
-#: templates/InvenTree/settings/part.html:34
+#: templates/InvenTree/settings/part.html:37
 #, fuzzy
 #| msgid "Edit Part Parameter Template"
 msgid "Part Parameter Templates"
 msgstr "Teilparametervorlage bearbeiten"
 
-#: templates/InvenTree/settings/part.html:55
+#: templates/InvenTree/settings/part.html:58
 msgid "No part parameter templates found"
 msgstr "Keine Teilparametervorlagen gefunden"
 
@@ -5282,11 +5464,11 @@ msgstr "Keine Teilparametervorlagen gefunden"
 msgid "Purchase Order Settings"
 msgstr "Bestelldetails"
 
-#: templates/InvenTree/settings/setting.html:16
+#: templates/InvenTree/settings/setting.html:23
 msgid "No value set"
 msgstr ""
 
-#: templates/InvenTree/settings/setting.html:24
+#: templates/InvenTree/settings/setting.html:31
 #, fuzzy
 #| msgid "Settings"
 msgid "Edit setting"
@@ -5309,6 +5491,12 @@ msgstr "Auftragsdetails"
 msgid "Stock Settings"
 msgstr "Lagerobjekt-Standorte"
 
+#: templates/InvenTree/settings/stock.html:13
+#, fuzzy
+#| msgid "Stock Locations"
+msgid "Stock Options"
+msgstr "Lagerobjekt-Standorte"
+
 #: templates/InvenTree/settings/tabs.html:3
 #: templates/InvenTree/settings/user.html:10
 #, fuzzy
@@ -5402,6 +5590,18 @@ msgstr "Adresse"
 msgid "Outstanding Sales Orders"
 msgstr "Zielauftrag"
 
+#: templates/InvenTree/so_overdue.html:7
+#, fuzzy
+#| msgid "Sales Orders"
+msgid "Overdue Sales Orders"
+msgstr "Bestellungen"
+
+#: templates/InvenTree/stale_stock.html:7
+#, fuzzy
+#| msgid "Serialize Stock"
+msgid "Stale Stock"
+msgstr "Lagerbestand erfassen"
+
 #: templates/InvenTree/starred_parts.html:7
 msgid "Starred Parts"
 msgstr "Teilfavoriten"
@@ -5711,15 +5911,11 @@ msgstr "Vorlagenteil"
 msgid "Assembled part"
 msgstr "Baugruppe"
 
-#: templates/js/company.js:208
-msgid "Link"
-msgstr "Link"
-
 #: templates/js/order.js:135
 msgid "No purchase orders found"
 msgstr "Keine Bestellungen gefunden"
 
-#: templates/js/order.js:188 templates/js/stock.js:681
+#: templates/js/order.js:188 templates/js/stock.js:702
 msgid "Date"
 msgstr "Datum"
 
@@ -5727,7 +5923,13 @@ msgstr "Datum"
 msgid "No sales orders found"
 msgstr "Keine Aufträge gefunden"
 
-#: templates/js/order.js:275
+#: templates/js/order.js:241
+#, fuzzy
+#| msgid "Build order allocation is complete"
+msgid "Order is overdue"
+msgstr "Bau-Zuweisung ist vollständig"
+
+#: templates/js/order.js:286
 msgid "Shipment Date"
 msgstr "Versanddatum"
 
@@ -5761,8 +5963,8 @@ msgstr "Keine Teile gefunden"
 msgid "No parts found"
 msgstr "Keine Teile gefunden"
 
-#: templates/js/part.js:343 templates/js/stock.js:456
-#: templates/js/stock.js:1017
+#: templates/js/part.js:343 templates/js/stock.js:463
+#: templates/js/stock.js:1049
 msgid "Select"
 msgstr "Auswählen"
 
@@ -5770,7 +5972,7 @@ msgstr "Auswählen"
 msgid "No category"
 msgstr "Keine Kategorie"
 
-#: templates/js/part.js:429 templates/js/table_filters.js:260
+#: templates/js/part.js:429 templates/js/table_filters.js:274
 msgid "Low stock"
 msgstr "Bestand niedrig"
 
@@ -5792,13 +5994,13 @@ msgstr ""
 msgid "No test templates matching query"
 msgstr "Keine zur Anfrage passenden Lagerobjekte"
 
-#: templates/js/part.js:604 templates/js/stock.js:63
+#: templates/js/part.js:604 templates/js/stock.js:64
 #, fuzzy
 #| msgid "Edit Sales Order"
 msgid "Edit test result"
 msgstr "Auftrag bearbeiten"
 
-#: templates/js/part.js:605 templates/js/stock.js:64
+#: templates/js/part.js:605 templates/js/stock.js:65
 #, fuzzy
 #| msgid "Delete attachment"
 msgid "Delete test result"
@@ -5808,137 +6010,149 @@ msgstr "Anhang löschen"
 msgid "This test is defined for a parent part"
 msgstr ""
 
-#: templates/js/stock.js:26
+#: templates/js/stock.js:27
 msgid "PASS"
 msgstr ""
 
-#: templates/js/stock.js:28
+#: templates/js/stock.js:29
 msgid "FAIL"
 msgstr ""
 
-#: templates/js/stock.js:33
+#: templates/js/stock.js:34
 msgid "NO RESULT"
 msgstr ""
 
-#: templates/js/stock.js:59
+#: templates/js/stock.js:60
 #, fuzzy
 #| msgid "Edit Sales Order"
 msgid "Add test result"
 msgstr "Auftrag bearbeiten"
 
-#: templates/js/stock.js:78
+#: templates/js/stock.js:79
 #, fuzzy
 #| msgid "No results found"
 msgid "No test results found"
 msgstr "Keine Ergebnisse gefunden"
 
-#: templates/js/stock.js:120
+#: templates/js/stock.js:121
 #, fuzzy
 #| msgid "Shipment Date"
 msgid "Test Date"
 msgstr "Versanddatum"
 
-#: templates/js/stock.js:281
+#: templates/js/stock.js:282
 msgid "In production"
 msgstr ""
 
-#: templates/js/stock.js:285
+#: templates/js/stock.js:286
 #, fuzzy
 #| msgid "Installed in Stock Item"
 msgid "Installed in Stock Item"
 msgstr "In Lagerobjekt installiert"
 
-#: templates/js/stock.js:293
+#: templates/js/stock.js:294
 #, fuzzy
 #| msgid "Item assigned to customer?"
 msgid "Assigned to Sales Order"
 msgstr "Ist dieses Objekt einem Kunden zugeteilt?"
 
-#: templates/js/stock.js:313
+#: templates/js/stock.js:314
 msgid "No stock items matching query"
 msgstr "Keine zur Anfrage passenden Lagerobjekte"
 
-#: templates/js/stock.js:424
+#: templates/js/stock.js:431
 #, fuzzy
 #| msgid "Include sublocations"
 msgid "Undefined location"
 msgstr "Unterlagerorte einschließen"
 
-#: templates/js/stock.js:518
+#: templates/js/stock.js:525
 #, fuzzy
 #| msgid "StockItem is lost"
 msgid "Stock item is in production"
 msgstr "Lagerobjekt verloren"
 
-#: templates/js/stock.js:523
+#: templates/js/stock.js:530
 #, fuzzy
 #| msgid "This stock item is allocated to Sales Order"
 msgid "Stock item assigned to sales order"
 msgstr "Dieses Lagerobjekt ist dem Auftrag zugewiesen"
 
-#: templates/js/stock.js:526
+#: templates/js/stock.js:533
 #, fuzzy
 #| msgid "StockItem has been allocated"
 msgid "Stock item assigned to customer"
 msgstr "Lagerobjekt wurde zugewiesen"
 
-#: templates/js/stock.js:530
+#: templates/js/stock.js:537
+#, fuzzy
+#| msgid "StockItem has been allocated"
+msgid "Stock item has expired"
+msgstr "Lagerobjekt wurde zugewiesen"
+
+#: templates/js/stock.js:539
+#, fuzzy
+#| msgid "StockItem is lost"
+msgid "Stock item will expire soon"
+msgstr "Lagerobjekt verloren"
+
+#: templates/js/stock.js:543
 #, fuzzy
 #| msgid "StockItem has been allocated"
 msgid "Stock item has been allocated"
 msgstr "Lagerobjekt wurde zugewiesen"
 
-#: templates/js/stock.js:534
+#: templates/js/stock.js:547
 #, fuzzy
 #| msgid "Is this item installed in another item?"
 msgid "Stock item has been installed in another item"
 msgstr "Ist dieses Teil in einem anderen verbaut?"
 
-#: templates/js/stock.js:542
+#: templates/js/stock.js:555
 #, fuzzy
 #| msgid "StockItem has been allocated"
 msgid "Stock item has been rejected"
 msgstr "Lagerobjekt wurde zugewiesen"
 
-#: templates/js/stock.js:546
+#: templates/js/stock.js:559
 #, fuzzy
 #| msgid "StockItem is lost"
 msgid "Stock item is lost"
 msgstr "Lagerobjekt verloren"
 
-#: templates/js/stock.js:549
+#: templates/js/stock.js:562
 #, fuzzy
 #| msgid "StockItem is lost"
 msgid "Stock item is destroyed"
 msgstr "Lagerobjekt verloren"
 
-#: templates/js/stock.js:553 templates/js/table_filters.js:106
+#: templates/js/stock.js:566 templates/js/table_filters.js:106
 #, fuzzy
 #| msgid "Delete"
 msgid "Depleted"
 msgstr "Löschen"
 
-#: templates/js/stock.js:747
+#: templates/js/stock.js:768
 msgid "No user information"
 msgstr "Keine Benutzerinformation"
 
-#: templates/js/stock.js:856
+#: templates/js/stock.js:888
 msgid "Create New Location"
 msgstr "Neuen Standort anlegen"
 
-#: templates/js/stock.js:955
+#: templates/js/stock.js:987
 #, fuzzy
 #| msgid "Serial Number"
 msgid "Serial"
 msgstr "Seriennummer"
 
-#: templates/js/stock.js:1048 templates/js/table_filters.js:121
+#: templates/js/stock.js:1080 templates/js/table_filters.js:131
 #, fuzzy
 #| msgid "Installed In"
 msgid "Installed"
 msgstr "Installiert in"
 
-#: templates/js/stock.js:1073
+#: templates/js/stock.js:1105
 #, fuzzy
 #| msgid "Installed In"
 msgid "Install item"
@@ -5956,50 +6170,50 @@ msgstr "nachverfolgbar"
 msgid "Validated"
 msgstr "BOM validieren"
 
-#: templates/js/table_filters.js:65 templates/js/table_filters.js:131
+#: templates/js/table_filters.js:65 templates/js/table_filters.js:141
 #, fuzzy
 #| msgid "Serialize Stock"
 msgid "Is Serialized"
 msgstr "Lagerbestand erfassen"
 
-#: templates/js/table_filters.js:68 templates/js/table_filters.js:138
+#: templates/js/table_filters.js:68 templates/js/table_filters.js:148
 #, fuzzy
 #| msgid "Serial Number"
 msgid "Serial number GTE"
 msgstr "Seriennummer"
 
-#: templates/js/table_filters.js:69 templates/js/table_filters.js:139
+#: templates/js/table_filters.js:69 templates/js/table_filters.js:149
 #, fuzzy
 #| msgid "Serial number for this item"
 msgid "Serial number greater than or equal to"
 msgstr "Seriennummer für dieses Teil"
 
-#: templates/js/table_filters.js:72 templates/js/table_filters.js:142
+#: templates/js/table_filters.js:72 templates/js/table_filters.js:152
 #, fuzzy
 #| msgid "Serial Number"
 msgid "Serial number LTE"
 msgstr "Seriennummer"
 
-#: templates/js/table_filters.js:73 templates/js/table_filters.js:143
+#: templates/js/table_filters.js:73 templates/js/table_filters.js:153
 #, fuzzy
 #| msgid "Serial numbers already exist: "
 msgid "Serial number less than or equal to"
 msgstr "Seriennummern existieren bereits:"
 
 #: templates/js/table_filters.js:76 templates/js/table_filters.js:77
-#: templates/js/table_filters.js:134 templates/js/table_filters.js:135
+#: templates/js/table_filters.js:144 templates/js/table_filters.js:145
 #, fuzzy
 #| msgid "Serial Number"
 msgid "Serial number"
 msgstr "Seriennummer"
 
-#: templates/js/table_filters.js:81 templates/js/table_filters.js:152
+#: templates/js/table_filters.js:81 templates/js/table_filters.js:162
 #, fuzzy
 #| msgid "Batch Code"
 msgid "Batch code"
 msgstr "Losnummer"
 
-#: templates/js/table_filters.js:91 templates/js/table_filters.js:227
+#: templates/js/table_filters.js:91 templates/js/table_filters.js:241
 msgid "Active parts"
 msgstr "Aktive Teile"
 
@@ -6030,84 +6244,96 @@ msgid "Show stock items which are depleted"
 msgstr "Objekt löschen wenn Lagerbestand aufgebraucht"
 
 #: templates/js/table_filters.js:112
+#, fuzzy
+#| msgid "Delete this Stock Item when stock is depleted"
+msgid "Show stock items which have expired"
+msgstr "Objekt löschen wenn Lagerbestand aufgebraucht"
+
+#: templates/js/table_filters.js:117
+#, fuzzy
+#| msgid "Delete this Stock Item when stock is depleted"
+msgid "Show stock which is close to expiring"
+msgstr "Objekt löschen wenn Lagerbestand aufgebraucht"
+
+#: templates/js/table_filters.js:122
 msgid "Show items which are in stock"
 msgstr ""
 
-#: templates/js/table_filters.js:116
+#: templates/js/table_filters.js:126
 msgid "In Production"
 msgstr ""
 
-#: templates/js/table_filters.js:117
+#: templates/js/table_filters.js:127
 #, fuzzy
 #| msgid "Delete this Stock Item when stock is depleted"
 msgid "Show items which are in production"
 msgstr "Objekt löschen wenn Lagerbestand aufgebraucht"
 
-#: templates/js/table_filters.js:122
+#: templates/js/table_filters.js:132
 #, fuzzy
 #| msgid "Is this item installed in another item?"
 msgid "Show stock items which are installed in another item"
 msgstr "Ist dieses Teil in einem anderen verbaut?"
 
-#: templates/js/table_filters.js:126
+#: templates/js/table_filters.js:136
 #, fuzzy
 #| msgid "Item assigned to customer?"
 msgid "Sent to customer"
 msgstr "Ist dieses Objekt einem Kunden zugeteilt?"
 
-#: templates/js/table_filters.js:127
+#: templates/js/table_filters.js:137
 msgid "Show items which have been assigned to a customer"
 msgstr ""
 
-#: templates/js/table_filters.js:147 templates/js/table_filters.js:148
+#: templates/js/table_filters.js:157 templates/js/table_filters.js:158
 msgid "Stock status"
 msgstr "Bestandsstatus"
 
-#: templates/js/table_filters.js:181
+#: templates/js/table_filters.js:191
 msgid "Build status"
 msgstr "Bau-Status"
 
-#: templates/js/table_filters.js:200 templates/js/table_filters.js:213
+#: templates/js/table_filters.js:210 templates/js/table_filters.js:223
 msgid "Order status"
 msgstr "Bestellstatus"
 
-#: templates/js/table_filters.js:205 templates/js/table_filters.js:218
+#: templates/js/table_filters.js:215 templates/js/table_filters.js:228
 #, fuzzy
 #| msgid "Cascading"
 msgid "Outstanding"
 msgstr "Kaskadierend"
 
-#: templates/js/table_filters.js:237
+#: templates/js/table_filters.js:251
 msgid "Include subcategories"
 msgstr "Unterkategorien einschließen"
 
-#: templates/js/table_filters.js:238
+#: templates/js/table_filters.js:252
 msgid "Include parts in subcategories"
 msgstr "Teile in Unterkategorien einschließen"
 
-#: templates/js/table_filters.js:242
+#: templates/js/table_filters.js:256
 msgid "Has IPN"
 msgstr ""
 
-#: templates/js/table_filters.js:243
+#: templates/js/table_filters.js:257
 #, fuzzy
 #| msgid "Internal Part Number"
 msgid "Part has internal part number"
 msgstr "Interne Teilenummer"
 
-#: templates/js/table_filters.js:248
+#: templates/js/table_filters.js:262
 msgid "Show active parts"
 msgstr "Aktive Teile anzeigen"
 
-#: templates/js/table_filters.js:256
+#: templates/js/table_filters.js:270
 msgid "Stock available"
 msgstr "Bestand verfügbar"
 
-#: templates/js/table_filters.js:272
+#: templates/js/table_filters.js:286
 msgid "Starred"
 msgstr "Favorit"
 
-#: templates/js/table_filters.js:284
+#: templates/js/table_filters.js:298
 msgid "Purchasable"
 msgstr "Käuflich"
 
@@ -6149,7 +6375,7 @@ msgstr "Admin"
 msgid "Logout"
 msgstr "Ausloggen"
 
-#: templates/navbar.html:69
+#: templates/navbar.html:69 templates/registration/login.html:43
 msgid "Login"
 msgstr "Einloggen"
 
diff --git a/InvenTree/locale/en/LC_MESSAGES/django.po b/InvenTree/locale/en/LC_MESSAGES/django.po
index 6f60f8ed93..18f86b6972 100644
--- a/InvenTree/locale/en/LC_MESSAGES/django.po
+++ b/InvenTree/locale/en/LC_MESSAGES/django.po
@@ -8,7 +8,7 @@ msgid ""
 msgstr ""
 "Project-Id-Version: PACKAGE VERSION\n"
 "Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2020-12-16 19:08+1100\n"
+"POT-Creation-Date: 2021-01-07 23:48+1100\n"
 "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
 "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
 "Language-Team: LANGUAGE <LL@li.org>\n"
@@ -26,7 +26,11 @@ msgstr ""
 msgid "No matching action found"
 msgstr ""
 
-#: InvenTree/forms.py:110 build/forms.py:91 build/forms.py:179
+#: InvenTree/fields.py:44
+msgid "Enter date"
+msgstr ""
+
+#: InvenTree/forms.py:110 build/forms.py:90 build/forms.py:178
 msgid "Confirm"
 msgstr ""
 
@@ -50,8 +54,8 @@ msgstr ""
 msgid "Select Category"
 msgstr ""
 
-#: InvenTree/helpers.py:361 order/models.py:189 order/models.py:271
-#: stock/views.py:1646
+#: InvenTree/helpers.py:361 order/models.py:216 order/models.py:298
+#: stock/views.py:1660
 msgid "Invalid quantity provided"
 msgstr ""
 
@@ -91,12 +95,12 @@ msgstr ""
 msgid "File comment"
 msgstr ""
 
-#: InvenTree/models.py:68 templates/js/stock.js:738
+#: InvenTree/models.py:68 templates/js/stock.js:759
 msgid "User"
 msgstr ""
 
-#: InvenTree/models.py:106 part/templates/part/params.html:24
-#: templates/js/part.js:129
+#: InvenTree/models.py:106 part/models.py:647
+#: part/templates/part/params.html:24 templates/js/part.js:129
 msgid "Name"
 msgstr ""
 
@@ -129,7 +133,7 @@ msgid "InvenTree system health checks failed"
 msgstr ""
 
 #: InvenTree/status_codes.py:94 InvenTree/status_codes.py:135
-#: InvenTree/status_codes.py:223
+#: InvenTree/status_codes.py:228
 msgid "Pending"
 msgstr ""
 
@@ -137,51 +141,51 @@ msgstr ""
 msgid "Placed"
 msgstr ""
 
-#: InvenTree/status_codes.py:96 InvenTree/status_codes.py:226
+#: InvenTree/status_codes.py:96 InvenTree/status_codes.py:231
 msgid "Complete"
 msgstr ""
 
 #: InvenTree/status_codes.py:97 InvenTree/status_codes.py:137
-#: InvenTree/status_codes.py:225
+#: InvenTree/status_codes.py:230
 msgid "Cancelled"
 msgstr ""
 
 #: InvenTree/status_codes.py:98 InvenTree/status_codes.py:138
-#: InvenTree/status_codes.py:175
+#: InvenTree/status_codes.py:180
 msgid "Lost"
 msgstr ""
 
 #: InvenTree/status_codes.py:99 InvenTree/status_codes.py:139
-#: InvenTree/status_codes.py:177
+#: InvenTree/status_codes.py:182
 msgid "Returned"
 msgstr ""
 
 #: InvenTree/status_codes.py:136
-#: order/templates/order/sales_order_base.html:106
+#: order/templates/order/sales_order_base.html:121
 msgid "Shipped"
 msgstr ""
 
-#: InvenTree/status_codes.py:171
+#: InvenTree/status_codes.py:176
 msgid "OK"
 msgstr ""
 
-#: InvenTree/status_codes.py:172
+#: InvenTree/status_codes.py:177
 msgid "Attention needed"
 msgstr ""
 
-#: InvenTree/status_codes.py:173
+#: InvenTree/status_codes.py:178
 msgid "Damaged"
 msgstr ""
 
-#: InvenTree/status_codes.py:174
+#: InvenTree/status_codes.py:179
 msgid "Destroyed"
 msgstr ""
 
-#: InvenTree/status_codes.py:176
+#: InvenTree/status_codes.py:181
 msgid "Rejected"
 msgstr ""
 
-#: InvenTree/status_codes.py:224
+#: InvenTree/status_codes.py:229
 msgid "Production"
 msgstr ""
 
@@ -283,13 +287,22 @@ msgstr ""
 msgid "Barcode associated with StockItem"
 msgstr ""
 
-#: build/forms.py:32
+#: build/forms.py:34
 msgid "Build Order reference"
 msgstr ""
 
-#: build/forms.py:79 build/templates/build/auto_allocate.html:17
+#: build/forms.py:35
+msgid "Order target date"
+msgstr ""
+
+#: build/forms.py:39 build/models.py:206
+msgid ""
+"Target date for build completion. Build will be overdue after this date."
+msgstr ""
+
+#: build/forms.py:78 build/templates/build/auto_allocate.html:17
 #: build/templates/build/build_base.html:83
-#: build/templates/build/detail.html:29 common/models.py:494
+#: build/templates/build/detail.html:29 common/models.py:589
 #: company/forms.py:112 company/templates/company/supplier_part_pricing.html:75
 #: order/templates/order/order_wizard/select_parts.html:32
 #: order/templates/order/purchase_order_detail.html:179
@@ -297,289 +310,285 @@ msgstr ""
 #: order/templates/order/sales_order_detail.html:156
 #: part/templates/part/allocation.html:16
 #: part/templates/part/allocation.html:49
-#: part/templates/part/sale_prices.html:82 stock/forms.py:298
+#: part/templates/part/sale_prices.html:82 stock/forms.py:304
 #: stock/templates/stock/item_base.html:40
 #: stock/templates/stock/item_base.html:46
-#: stock/templates/stock/item_base.html:197
+#: stock/templates/stock/item_base.html:214
 #: stock/templates/stock/stock_adjust.html:18 templates/js/barcode.js:338
-#: templates/js/bom.js:195 templates/js/build.js:420 templates/js/stock.js:729
-#: templates/js/stock.js:957
+#: templates/js/bom.js:195 templates/js/build.js:420 templates/js/stock.js:750
+#: templates/js/stock.js:989
 msgid "Quantity"
 msgstr ""
 
-#: build/forms.py:80
+#: build/forms.py:79
 msgid "Enter quantity for build output"
 msgstr ""
 
-#: build/forms.py:84 stock/forms.py:111
+#: build/forms.py:83 stock/forms.py:116
 msgid "Serial numbers"
 msgstr ""
 
-#: build/forms.py:86
+#: build/forms.py:85
 msgid "Enter serial numbers for build outputs"
 msgstr ""
 
-#: build/forms.py:92
+#: build/forms.py:91
 msgid "Confirm creation of build outut"
 msgstr ""
 
-#: build/forms.py:112
+#: build/forms.py:111
 msgid "Confirm deletion of build output"
 msgstr ""
 
-#: build/forms.py:133
+#: build/forms.py:132
 msgid "Confirm unallocation of stock"
 msgstr ""
 
-#: build/forms.py:157
+#: build/forms.py:156
 msgid "Confirm stock allocation"
 msgstr ""
 
-#: build/forms.py:180
+#: build/forms.py:179
 msgid "Mark build as complete"
 msgstr ""
 
-#: build/forms.py:204
+#: build/forms.py:203
 msgid "Location of completed parts"
 msgstr ""
 
-#: build/forms.py:209
+#: build/forms.py:208
 msgid "Confirm completion with incomplete stock allocation"
 msgstr ""
 
-#: build/forms.py:212
+#: build/forms.py:211
 msgid "Confirm build completion"
 msgstr ""
 
-#: build/forms.py:232 build/views.py:68
+#: build/forms.py:231 build/views.py:68
 msgid "Confirm build cancellation"
 msgstr ""
 
-#: build/forms.py:246
+#: build/forms.py:245
 msgid "Select quantity of stock to allocate"
 msgstr ""
 
-#: build/models.py:59 build/templates/build/build_base.html:8
+#: build/models.py:61 build/templates/build/build_base.html:8
 #: build/templates/build/build_base.html:35
 #: part/templates/part/allocation.html:20
 msgid "Build Order"
 msgstr ""
 
-#: build/models.py:60 build/templates/build/index.html:6
-#: build/templates/build/index.html:14 order/templates/order/so_builds.html:11
+#: build/models.py:62 build/templates/build/index.html:8
+#: build/templates/build/index.html:15 order/templates/order/so_builds.html:11
 #: order/templates/order/so_tabs.html:9 part/templates/part/tabs.html:31
 #: templates/InvenTree/settings/tabs.html:28 users/models.py:30
 msgid "Build Orders"
 msgstr ""
 
-#: build/models.py:75
+#: build/models.py:108
 msgid "Build Order Reference"
 msgstr ""
 
-#: build/models.py:76 order/templates/order/purchase_order_detail.html:174
+#: build/models.py:109 order/templates/order/purchase_order_detail.html:174
 #: templates/js/bom.js:187 templates/js/build.js:509
 msgid "Reference"
 msgstr ""
 
-#: build/models.py:83 build/templates/build/detail.html:19
-#: company/templates/company/detail.html:23
+#: build/models.py:116 build/templates/build/detail.html:19
+#: company/models.py:359 company/templates/company/detail.html:23
 #: company/templates/company/supplier_part_base.html:61
 #: company/templates/company/supplier_part_detail.html:27
-#: order/templates/order/purchase_order_detail.html:161
+#: order/templates/order/purchase_order_detail.html:161 part/models.py:671
 #: part/templates/part/detail.html:51 part/templates/part/set_category.html:14
-#: templates/InvenTree/search.html:147 templates/js/bom.js:180
+#: templates/InvenTree/search.html:147
+#: templates/InvenTree/settings/header.html:9 templates/js/bom.js:180
 #: templates/js/bom.js:517 templates/js/build.js:664 templates/js/company.js:56
-#: templates/js/order.js:175 templates/js/order.js:257 templates/js/part.js:188
+#: templates/js/order.js:175 templates/js/order.js:263 templates/js/part.js:188
 #: templates/js/part.js:271 templates/js/part.js:391 templates/js/part.js:572
-#: templates/js/stock.js:494 templates/js/stock.js:710
+#: templates/js/stock.js:501 templates/js/stock.js:731
 msgid "Description"
 msgstr ""
 
-#: build/models.py:86
+#: build/models.py:119
 msgid "Brief description of the build"
 msgstr ""
 
-#: build/models.py:95 build/templates/build/build_base.html:104
+#: build/models.py:128 build/templates/build/build_base.html:113
 #: build/templates/build/detail.html:75
 msgid "Parent Build"
 msgstr ""
 
-#: build/models.py:96
+#: build/models.py:129
 msgid "BuildOrder to which this build is allocated"
 msgstr ""
 
-#: build/models.py:101 build/templates/build/auto_allocate.html:16
+#: build/models.py:134 build/templates/build/auto_allocate.html:16
 #: build/templates/build/build_base.html:78
-#: build/templates/build/detail.html:24 order/models.py:530
+#: build/templates/build/detail.html:24 order/models.py:623
 #: order/templates/order/order_wizard/select_parts.html:30
 #: order/templates/order/purchase_order_detail.html:148
-#: order/templates/order/receive_parts.html:19 part/models.py:315
+#: order/templates/order/receive_parts.html:19 part/models.py:316
 #: part/templates/part/part_app_base.html:7 part/templates/part/related.html:26
 #: part/templates/part/set_category.html:13 templates/InvenTree/search.html:133
 #: templates/js/barcode.js:336 templates/js/bom.js:153 templates/js/bom.js:502
 #: templates/js/build.js:669 templates/js/company.js:138
-#: templates/js/part.js:252 templates/js/part.js:357 templates/js/stock.js:468
-#: templates/js/stock.js:1029
+#: templates/js/part.js:252 templates/js/part.js:357 templates/js/stock.js:475
+#: templates/js/stock.js:1061
 msgid "Part"
 msgstr ""
 
-#: build/models.py:109
+#: build/models.py:142
 msgid "Select part to build"
 msgstr ""
 
-#: build/models.py:114
+#: build/models.py:147
 msgid "Sales Order Reference"
 msgstr ""
 
-#: build/models.py:118
+#: build/models.py:151
 msgid "SalesOrder to which this build is allocated"
 msgstr ""
 
-#: build/models.py:123
+#: build/models.py:156
 msgid "Source Location"
 msgstr ""
 
-#: build/models.py:127
+#: build/models.py:160
 msgid ""
 "Select location to take stock from for this build (leave blank to take from "
 "any stock location)"
 msgstr ""
 
-#: build/models.py:132
+#: build/models.py:165
 msgid "Destination Location"
 msgstr ""
 
-#: build/models.py:136
+#: build/models.py:169
 msgid "Select location where the completed items will be stored"
 msgstr ""
 
-#: build/models.py:140
+#: build/models.py:173
 msgid "Build Quantity"
 msgstr ""
 
-#: build/models.py:143
+#: build/models.py:176
 msgid "Number of stock items to build"
 msgstr ""
 
-#: build/models.py:147
+#: build/models.py:180
 msgid "Completed items"
 msgstr ""
 
-#: build/models.py:149
+#: build/models.py:182
 msgid "Number of stock items which have been completed"
 msgstr ""
 
-#: build/models.py:153 part/templates/part/part_base.html:155
+#: build/models.py:186 part/templates/part/part_base.html:155
 msgid "Build Status"
 msgstr ""
 
-#: build/models.py:157
+#: build/models.py:190
 msgid "Build status code"
 msgstr ""
 
-#: build/models.py:161 stock/models.py:390
+#: build/models.py:194 stock/models.py:397
 msgid "Batch Code"
 msgstr ""
 
-#: build/models.py:165
+#: build/models.py:198
 msgid "Batch code for this build output"
 msgstr ""
 
-#: build/models.py:172
+#: build/models.py:205 order/models.py:404
 msgid "Target completion date"
 msgstr ""
 
-#: build/models.py:173
-msgid ""
-"Target date for build completion. Build will be overdue after this date."
-msgstr ""
-
-#: build/models.py:186 build/templates/build/detail.html:89
+#: build/models.py:219 build/templates/build/detail.html:89
 #: company/templates/company/supplier_part_base.html:68
 #: company/templates/company/supplier_part_detail.html:24
 #: part/templates/part/detail.html:80 part/templates/part/part_base.html:102
-#: stock/models.py:384 stock/templates/stock/item_base.html:280
+#: stock/models.py:391 stock/templates/stock/item_base.html:297
 msgid "External Link"
 msgstr ""
 
-#: build/models.py:187 part/models.py:672 stock/models.py:386
+#: build/models.py:220 part/models.py:705 stock/models.py:393
 msgid "Link to external URL"
 msgstr ""
 
-#: build/models.py:191 build/templates/build/tabs.html:23 company/models.py:344
+#: build/models.py:224 build/templates/build/tabs.html:23 company/models.py:366
 #: company/templates/company/tabs.html:33 order/templates/order/po_tabs.html:18
 #: order/templates/order/purchase_order_detail.html:213
-#: order/templates/order/so_tabs.html:23 part/templates/part/tabs.html:73
-#: stock/forms.py:307 stock/forms.py:339 stock/forms.py:367 stock/models.py:448
-#: stock/models.py:1433 stock/templates/stock/tabs.html:26
-#: templates/js/barcode.js:391 templates/js/bom.js:263
-#: templates/js/stock.js:116 templates/js/stock.js:582
+#: order/templates/order/so_tabs.html:23 part/models.py:831
+#: part/templates/part/tabs.html:73 stock/forms.py:313 stock/forms.py:345
+#: stock/forms.py:373 stock/models.py:463 stock/models.py:1512
+#: stock/templates/stock/tabs.html:26 templates/js/barcode.js:391
+#: templates/js/bom.js:263 templates/js/stock.js:117 templates/js/stock.js:603
 msgid "Notes"
 msgstr ""
 
-#: build/models.py:192
+#: build/models.py:225
 msgid "Extra build notes"
 msgstr ""
 
-#: build/models.py:577
+#: build/models.py:610
 msgid "No build output specified"
 msgstr ""
 
-#: build/models.py:580
+#: build/models.py:613
 msgid "Build output is already completed"
 msgstr ""
 
-#: build/models.py:583
+#: build/models.py:616
 msgid "Build output does not match Build Order"
 msgstr ""
 
-#: build/models.py:658
+#: build/models.py:691
 msgid "Completed build output"
 msgstr ""
 
-#: build/models.py:896
+#: build/models.py:933
 msgid "BuildItem must be unique for build, stock_item and install_into"
 msgstr ""
 
-#: build/models.py:918
+#: build/models.py:955
 msgid "Build item must specify a build output"
 msgstr ""
 
-#: build/models.py:923
+#: build/models.py:960
 #, python-brace-format
 msgid "Selected stock item not found in BOM for part '{p}'"
 msgstr ""
 
-#: build/models.py:927
+#: build/models.py:964
 #, python-brace-format
 msgid "Allocated quantity ({n}) must not exceed available quantity ({q})"
 msgstr ""
 
-#: build/models.py:934 order/models.py:614
+#: build/models.py:971 order/models.py:707
 msgid "StockItem is over-allocated"
 msgstr ""
 
-#: build/models.py:938 order/models.py:617
+#: build/models.py:975 order/models.py:710
 msgid "Allocation quantity must be greater than zero"
 msgstr ""
 
-#: build/models.py:942
+#: build/models.py:979
 msgid "Quantity must be 1 for serialized stock"
 msgstr ""
 
-#: build/models.py:982
+#: build/models.py:1019
 msgid "Build to allocate parts"
 msgstr ""
 
-#: build/models.py:989
+#: build/models.py:1026
 msgid "Source stock item"
 msgstr ""
 
-#: build/models.py:1001
+#: build/models.py:1038
 msgid "Stock quantity to allocate to build"
 msgstr ""
 
-#: build/models.py:1009
+#: build/models.py:1046
 msgid "Destination stock item"
 msgstr ""
 
@@ -604,7 +613,7 @@ msgid "Order required parts"
 msgstr ""
 
 #: build/templates/build/allocate.html:30
-#: company/templates/company/detail_part.html:28 order/views.py:803
+#: company/templates/company/detail_part.html:28 order/views.py:805
 #: part/templates/part/category.html:125
 msgid "Order Parts"
 msgstr ""
@@ -644,11 +653,11 @@ msgid ""
 "The following stock items will be allocated to the specified build output"
 msgstr ""
 
-#: build/templates/build/auto_allocate.html:18 stock/forms.py:337
-#: stock/templates/stock/item_base.html:227
+#: build/templates/build/auto_allocate.html:18 stock/forms.py:343
+#: stock/templates/stock/item_base.html:244
 #: stock/templates/stock/stock_adjust.html:17
 #: templates/InvenTree/search.html:183 templates/js/barcode.js:337
-#: templates/js/build.js:434 templates/js/stock.js:574
+#: templates/js/build.js:434 templates/js/stock.js:587
 msgid "Location"
 msgstr ""
 
@@ -673,13 +682,16 @@ msgstr ""
 #: order/templates/order/order_base.html:26
 #: order/templates/order/sales_order_base.html:35
 #: part/templates/part/category.html:13 part/templates/part/part_base.html:32
-#: stock/templates/stock/item_base.html:90
+#: stock/templates/stock/item_base.html:97
 #: stock/templates/stock/location.html:12
 msgid "Admin view"
 msgstr ""
 
 #: build/templates/build/build_base.html:43
-#: build/templates/build/build_base.html:92 templates/js/table_filters.js:190
+#: build/templates/build/build_base.html:100
+#: order/templates/order/sales_order_base.html:41
+#: order/templates/order/sales_order_base.html:83
+#: templates/js/table_filters.js:200 templates/js/table_filters.js:232
 msgid "Overdue"
 msgstr ""
 
@@ -706,30 +718,37 @@ msgstr ""
 #: build/templates/build/build_base.html:88
 #: build/templates/build/detail.html:57
 #: order/templates/order/receive_parts.html:24
-#: stock/templates/stock/item_base.html:312 templates/InvenTree/search.html:175
+#: stock/templates/stock/item_base.html:343 templates/InvenTree/search.html:175
 #: templates/js/barcode.js:42 templates/js/build.js:697
-#: templates/js/order.js:180 templates/js/order.js:262
-#: templates/js/stock.js:561 templates/js/stock.js:965
+#: templates/js/order.js:180 templates/js/order.js:268
+#: templates/js/stock.js:574 templates/js/stock.js:997
 msgid "Status"
 msgstr ""
 
-#: build/templates/build/build_base.html:92
+#: build/templates/build/build_base.html:96
+#: build/templates/build/detail.html:100
+#: order/templates/order/sales_order_base.html:114 templates/js/build.js:710
+#: templates/js/order.js:281
+msgid "Target Date"
+msgstr ""
+
+#: build/templates/build/build_base.html:100
 msgid "This build was due on"
 msgstr ""
 
-#: build/templates/build/build_base.html:98
+#: build/templates/build/build_base.html:107
 #: build/templates/build/detail.html:62
 msgid "Progress"
 msgstr ""
 
-#: build/templates/build/build_base.html:111
-#: build/templates/build/detail.html:82 order/models.py:528
+#: build/templates/build/build_base.html:120
+#: build/templates/build/detail.html:82 order/models.py:621
 #: order/templates/order/sales_order_base.html:9
 #: order/templates/order/sales_order_base.html:33
 #: order/templates/order/sales_order_notes.html:10
 #: order/templates/order/sales_order_ship.html:25
 #: part/templates/part/allocation.html:27
-#: stock/templates/stock/item_base.html:221 templates/js/order.js:229
+#: stock/templates/stock/item_base.html:238 templates/js/order.js:229
 msgid "Sales Order"
 msgstr ""
 
@@ -821,7 +840,7 @@ msgstr ""
 msgid "Stock can be taken from any available location."
 msgstr ""
 
-#: build/templates/build/detail.html:44 stock/forms.py:365
+#: build/templates/build/detail.html:44 stock/forms.py:371
 msgid "Destination"
 msgstr ""
 
@@ -830,22 +849,18 @@ msgid "Destination location not specified"
 msgstr ""
 
 #: build/templates/build/detail.html:68
-#: stock/templates/stock/item_base.html:245 templates/js/stock.js:569
-#: templates/js/stock.js:972 templates/js/table_filters.js:80
-#: templates/js/table_filters.js:151
+#: stock/templates/stock/item_base.html:262 templates/js/stock.js:582
+#: templates/js/stock.js:1004 templates/js/table_filters.js:80
+#: templates/js/table_filters.js:161
 msgid "Batch"
 msgstr ""
 
 #: build/templates/build/detail.html:95
 #: order/templates/order/order_base.html:98
-#: order/templates/order/sales_order_base.html:100 templates/js/build.js:705
+#: order/templates/order/sales_order_base.html:108 templates/js/build.js:705
 msgid "Created"
 msgstr ""
 
-#: build/templates/build/detail.html:100 templates/js/build.js:710
-msgid "Target Date"
-msgstr ""
-
 #: build/templates/build/detail.html:106
 msgid "No target date set"
 msgstr ""
@@ -863,10 +878,22 @@ msgstr ""
 msgid "Alter the quantity of stock allocated to the build output"
 msgstr ""
 
-#: build/templates/build/index.html:25 build/views.py:658
+#: build/templates/build/index.html:27 build/views.py:658
 msgid "New Build Order"
 msgstr ""
 
+#: build/templates/build/index.html:30
+#: order/templates/order/purchase_orders.html:22
+#: order/templates/order/sales_orders.html:22
+msgid "Display calendar view"
+msgstr ""
+
+#: build/templates/build/index.html:33
+#: order/templates/order/purchase_orders.html:25
+#: order/templates/order/sales_orders.html:25
+msgid "Display list view"
+msgstr ""
+
 #: build/templates/build/notes.html:13 build/templates/build/notes.html:30
 msgid "Build Notes"
 msgstr ""
@@ -922,7 +949,7 @@ msgstr ""
 msgid "Create Build Output"
 msgstr ""
 
-#: build/views.py:207 stock/models.py:828 stock/views.py:1667
+#: build/views.py:207 stock/models.py:872 stock/views.py:1681
 msgid "Serial numbers already exist"
 msgstr ""
 
@@ -1019,36 +1046,36 @@ msgstr ""
 msgid "Stock item must be selected"
 msgstr ""
 
-#: build/views.py:1011
+#: build/views.py:1012
 msgid "Edit Stock Allocation"
 msgstr ""
 
-#: build/views.py:1016
+#: build/views.py:1017
 msgid "Updated Build Item"
 msgstr ""
 
-#: build/views.py:1045
+#: build/views.py:1046
 msgid "Add Build Order Attachment"
 msgstr ""
 
-#: build/views.py:1059 order/views.py:111 order/views.py:164 part/views.py:168
+#: build/views.py:1060 order/views.py:113 order/views.py:166 part/views.py:170
 #: stock/views.py:180
 msgid "Added attachment"
 msgstr ""
 
-#: build/views.py:1095 order/views.py:191 order/views.py:213
+#: build/views.py:1096 order/views.py:193 order/views.py:215
 msgid "Edit Attachment"
 msgstr ""
 
-#: build/views.py:1106 order/views.py:196 order/views.py:218
+#: build/views.py:1107 order/views.py:198 order/views.py:220
 msgid "Attachment updated"
 msgstr ""
 
-#: build/views.py:1116 order/views.py:233 order/views.py:248
+#: build/views.py:1117 order/views.py:235 order/views.py:250
 msgid "Delete Attachment"
 msgstr ""
 
-#: build/views.py:1122 order/views.py:240 order/views.py:255 stock/views.py:238
+#: build/views.py:1123 order/views.py:242 order/views.py:257 stock/views.py:238
 msgid "Deleted attachment"
 msgstr ""
 
@@ -1124,103 +1151,170 @@ msgstr ""
 msgid "Copy category parameter templates when creating a part"
 msgstr ""
 
-#: common/models.py:115 part/models.py:743 part/templates/part/detail.html:168
-#: templates/js/table_filters.js:268
-msgid "Component"
+#: common/models.py:115 part/templates/part/detail.html:155 stock/forms.py:255
+#: templates/js/table_filters.js:23 templates/js/table_filters.js:266
+msgid "Template"
 msgstr ""
 
 #: common/models.py:116
-msgid "Parts can be used as sub-components by default"
+msgid "Parts are templates by default"
 msgstr ""
 
-#: common/models.py:122 part/models.py:754 part/templates/part/detail.html:188
-msgid "Purchaseable"
+#: common/models.py:122 part/models.py:794 part/templates/part/detail.html:165
+#: templates/js/table_filters.js:278
+msgid "Assembly"
 msgstr ""
 
 #: common/models.py:123
-msgid "Parts are purchaseable by default"
+msgid "Parts can be assembled from other components by default"
 msgstr ""
 
-#: common/models.py:129 part/models.py:759 part/templates/part/detail.html:198
-#: templates/js/table_filters.js:276
-msgid "Salable"
+#: common/models.py:129 part/models.py:800 part/templates/part/detail.html:175
+#: templates/js/table_filters.js:282
+msgid "Component"
 msgstr ""
 
 #: common/models.py:130
-msgid "Parts are salable by default"
+msgid "Parts can be used as sub-components by default"
 msgstr ""
 
-#: common/models.py:136 part/models.py:749 part/templates/part/detail.html:178
-#: templates/js/table_filters.js:31 templates/js/table_filters.js:280
-msgid "Trackable"
+#: common/models.py:136 part/models.py:811 part/templates/part/detail.html:195
+msgid "Purchaseable"
 msgstr ""
 
 #: common/models.py:137
-msgid "Parts are trackable by default"
+msgid "Parts are purchaseable by default"
 msgstr ""
 
-#: common/models.py:143
-msgid "Build Order Reference Prefix"
+#: common/models.py:143 part/models.py:816 part/templates/part/detail.html:205
+#: templates/js/table_filters.js:290
+msgid "Salable"
 msgstr ""
 
 #: common/models.py:144
+msgid "Parts are salable by default"
+msgstr ""
+
+#: common/models.py:150 part/models.py:806 part/templates/part/detail.html:185
+#: templates/js/table_filters.js:31 templates/js/table_filters.js:294
+msgid "Trackable"
+msgstr ""
+
+#: common/models.py:151
+msgid "Parts are trackable by default"
+msgstr ""
+
+#: common/models.py:157 part/models.py:826 part/templates/part/detail.html:145
+#: templates/js/table_filters.js:27
+msgid "Virtual"
+msgstr ""
+
+#: common/models.py:158
+msgid "Parts are virtual by default"
+msgstr ""
+
+#: common/models.py:164
+msgid "Stock Expiry"
+msgstr ""
+
+#: common/models.py:165
+msgid "Enable stock expiry functionality"
+msgstr ""
+
+#: common/models.py:171
+msgid "Sell Expired Stock"
+msgstr ""
+
+#: common/models.py:172
+msgid "Allow sale of expired stock"
+msgstr ""
+
+#: common/models.py:178
+msgid "Stock Stale Time"
+msgstr ""
+
+#: common/models.py:179
+msgid "Number of days stock items are considered stale before expiring"
+msgstr ""
+
+#: common/models.py:181 part/templates/part/detail.html:116
+msgid "days"
+msgstr ""
+
+#: common/models.py:186
+msgid "Build Expired Stock"
+msgstr ""
+
+#: common/models.py:187
+msgid "Allow building with expired stock"
+msgstr ""
+
+#: common/models.py:193
+msgid "Build Order Reference Prefix"
+msgstr ""
+
+#: common/models.py:194
 msgid "Prefix value for build order reference"
 msgstr ""
 
-#: common/models.py:149
+#: common/models.py:199
 msgid "Build Order Reference Regex"
 msgstr ""
 
-#: common/models.py:150
+#: common/models.py:200
 msgid "Regular expression pattern for matching build order reference"
 msgstr ""
 
-#: common/models.py:154
+#: common/models.py:204
 msgid "Sales Order Reference Prefix"
 msgstr ""
 
-#: common/models.py:155
+#: common/models.py:205
 msgid "Prefix value for sales order reference"
 msgstr ""
 
-#: common/models.py:159
+#: common/models.py:210
 msgid "Purchase Order Reference Prefix"
 msgstr ""
 
-#: common/models.py:160
+#: common/models.py:211
 msgid "Prefix value for purchase order reference"
 msgstr ""
 
-#: common/models.py:376
+#: common/models.py:434
 msgid "Settings key (must be unique - case insensitive"
 msgstr ""
 
-#: common/models.py:378
+#: common/models.py:436
 msgid "Settings value"
 msgstr ""
 
-#: common/models.py:437
+#: common/models.py:493
 msgid "Value must be a boolean value"
 msgstr ""
 
-#: common/models.py:451
+#: common/models.py:503
+msgid "Value must be an integer value"
+msgstr ""
+
+#: common/models.py:517
 msgid "Key string must be unique"
 msgstr ""
 
-#: common/models.py:495 company/forms.py:113
+#: common/models.py:590 company/forms.py:113
 msgid "Price break quantity"
 msgstr ""
 
-#: common/models.py:503 company/templates/company/supplier_part_pricing.html:80
+#: common/models.py:598 company/templates/company/supplier_part_pricing.html:80
 #: part/templates/part/sale_prices.html:87 templates/js/bom.js:246
 msgid "Price"
 msgstr ""
 
-#: common/models.py:504
+#: common/models.py:599
 msgid "Unit price at specified quantity"
 msgstr ""
 
-#: common/models.py:527
+#: common/models.py:622
 msgid "Default"
 msgstr ""
 
@@ -1321,44 +1415,81 @@ msgstr ""
 msgid "Currency"
 msgstr ""
 
-#: company/models.py:313 stock/models.py:338
-#: stock/templates/stock/item_base.html:177
+#: company/models.py:313 stock/models.py:345
+#: stock/templates/stock/item_base.html:194
 msgid "Base Part"
 msgstr ""
 
-#: company/models.py:318
+#: company/models.py:317
 msgid "Select part"
 msgstr ""
 
+#: company/models.py:323 company/templates/company/detail.html:57
+#: company/templates/company/supplier_part_base.html:74
+#: company/templates/company/supplier_part_detail.html:21
+#: order/templates/order/order_base.html:79
+#: order/templates/order/order_wizard/select_pos.html:30 part/bom.py:170
+#: stock/templates/stock/item_base.html:304 templates/js/company.js:48
+#: templates/js/company.js:164 templates/js/order.js:162
+msgid "Supplier"
+msgstr ""
+
 #: company/models.py:324
 msgid "Select supplier"
 msgstr ""
 
-#: company/models.py:327
+#: company/models.py:329 company/templates/company/supplier_part_base.html:78
+#: company/templates/company/supplier_part_detail.html:22 part/bom.py:171
+msgid "SKU"
+msgstr ""
+
+#: company/models.py:330
 msgid "Supplier stock keeping unit"
 msgstr ""
 
-#: company/models.py:334
+#: company/models.py:340 company/templates/company/detail.html:52
+#: company/templates/company/supplier_part_base.html:84
+#: company/templates/company/supplier_part_detail.html:30 part/bom.py:172
+#: templates/js/company.js:44 templates/js/company.js:188
+msgid "Manufacturer"
+msgstr ""
+
+#: company/models.py:341
 msgid "Select manufacturer"
 msgstr ""
 
-#: company/models.py:338
-msgid "Manufacturer part number"
-msgstr ""
-
-#: company/models.py:340
-msgid "URL for external supplier part link"
-msgstr ""
-
-#: company/models.py:342
-msgid "Supplier part description"
-msgstr ""
-
-#: company/models.py:346
-msgid "Minimum charge (e.g. stocking fee)"
+#: company/models.py:347 company/templates/company/supplier_part_base.html:88
+#: company/templates/company/supplier_part_detail.html:31 part/bom.py:173
+#: templates/js/company.js:204
+msgid "MPN"
 msgstr ""
 
 #: company/models.py:348
+msgid "Manufacturer part number"
+msgstr ""
+
+#: company/models.py:353 part/models.py:704 templates/js/company.js:208
+msgid "Link"
+msgstr ""
+
+#: company/models.py:354
+msgid "URL for external supplier part link"
+msgstr ""
+
+#: company/models.py:360
+msgid "Supplier part description"
+msgstr ""
+
+#: company/models.py:365 company/templates/company/supplier_part_base.html:95
+#: company/templates/company/supplier_part_detail.html:34
+msgid "Note"
+msgstr ""
+
+#: company/models.py:369
+msgid "Minimum charge (e.g. stocking fee)"
+msgstr ""
+
+#: company/models.py:371
 msgid "Part packaging"
 msgstr ""
 
@@ -1393,27 +1524,10 @@ msgstr ""
 msgid "Uses default currency"
 msgstr ""
 
-#: company/templates/company/detail.html:52
-#: company/templates/company/supplier_part_base.html:84
-#: company/templates/company/supplier_part_detail.html:30 part/bom.py:172
-#: templates/js/company.js:44 templates/js/company.js:188
-msgid "Manufacturer"
-msgstr ""
-
-#: company/templates/company/detail.html:57
-#: company/templates/company/supplier_part_base.html:74
-#: company/templates/company/supplier_part_detail.html:21
-#: order/templates/order/order_base.html:79
-#: order/templates/order/order_wizard/select_pos.html:30 part/bom.py:170
-#: stock/templates/stock/item_base.html:287 templates/js/company.js:48
-#: templates/js/company.js:164 templates/js/order.js:162
-msgid "Supplier"
-msgstr ""
-
 #: company/templates/company/detail.html:62
-#: order/templates/order/sales_order_base.html:81 stock/models.py:373
-#: stock/models.py:374 stock/templates/stock/item_base.html:204
-#: templates/js/company.js:40 templates/js/order.js:244
+#: order/templates/order/sales_order_base.html:89 stock/models.py:380
+#: stock/models.py:381 stock/templates/stock/item_base.html:221
+#: templates/js/company.js:40 templates/js/order.js:250
 msgid "Customer"
 msgstr ""
 
@@ -1428,7 +1542,7 @@ msgstr ""
 
 #: company/templates/company/detail_part.html:18
 #: order/templates/order/purchase_order_detail.html:68
-#: part/templates/part/supplier.html:14 templates/js/stock.js:849
+#: part/templates/part/supplier.html:14 templates/js/stock.js:881
 msgid "New Supplier Part"
 msgstr ""
 
@@ -1452,7 +1566,7 @@ msgid "Delete Parts"
 msgstr ""
 
 #: company/templates/company/detail_part.html:63
-#: part/templates/part/category.html:116 templates/js/stock.js:843
+#: part/templates/part/category.html:116 templates/js/stock.js:875
 msgid "New Part"
 msgstr ""
 
@@ -1505,8 +1619,8 @@ msgstr ""
 
 #: company/templates/company/purchase_orders.html:9
 #: company/templates/company/tabs.html:17
-#: order/templates/order/purchase_orders.html:7
-#: order/templates/order/purchase_orders.html:12
+#: order/templates/order/purchase_orders.html:8
+#: order/templates/order/purchase_orders.html:13
 #: part/templates/part/orders.html:9 part/templates/part/tabs.html:48
 #: templates/InvenTree/settings/tabs.html:31 templates/navbar.html:33
 #: users/models.py:31
@@ -1514,19 +1628,19 @@ msgid "Purchase Orders"
 msgstr ""
 
 #: company/templates/company/purchase_orders.html:15
-#: order/templates/order/purchase_orders.html:18
+#: order/templates/order/purchase_orders.html:19
 msgid "Create new purchase order"
 msgstr ""
 
 #: company/templates/company/purchase_orders.html:16
-#: order/templates/order/purchase_orders.html:19
+#: order/templates/order/purchase_orders.html:20
 msgid "New Purchase Order"
 msgstr ""
 
 #: company/templates/company/sales_orders.html:9
 #: company/templates/company/tabs.html:22
-#: order/templates/order/sales_orders.html:7
-#: order/templates/order/sales_orders.html:12
+#: order/templates/order/sales_orders.html:8
+#: order/templates/order/sales_orders.html:13
 #: part/templates/part/sales_orders.html:9 part/templates/part/tabs.html:56
 #: templates/InvenTree/settings/tabs.html:34 templates/navbar.html:42
 #: users/models.py:32
@@ -1534,18 +1648,18 @@ msgid "Sales Orders"
 msgstr ""
 
 #: company/templates/company/sales_orders.html:15
-#: order/templates/order/sales_orders.html:18
+#: order/templates/order/sales_orders.html:19
 msgid "Create new sales order"
 msgstr ""
 
 #: company/templates/company/sales_orders.html:16
-#: order/templates/order/sales_orders.html:19
+#: order/templates/order/sales_orders.html:20
 msgid "New Sales Order"
 msgstr ""
 
 #: company/templates/company/supplier_part_base.html:6
-#: company/templates/company/supplier_part_base.html:19 stock/models.py:347
-#: stock/templates/stock/item_base.html:292 templates/js/company.js:180
+#: company/templates/company/supplier_part_base.html:19 stock/models.py:354
+#: stock/templates/stock/item_base.html:309 templates/js/company.js:180
 msgid "Supplier Part"
 msgstr ""
 
@@ -1572,22 +1686,6 @@ msgstr ""
 msgid "Internal Part"
 msgstr ""
 
-#: company/templates/company/supplier_part_base.html:78
-#: company/templates/company/supplier_part_detail.html:22 part/bom.py:171
-msgid "SKU"
-msgstr ""
-
-#: company/templates/company/supplier_part_base.html:88
-#: company/templates/company/supplier_part_detail.html:31 part/bom.py:173
-#: templates/js/company.js:204
-msgid "MPN"
-msgstr ""
-
-#: company/templates/company/supplier_part_base.html:95
-#: company/templates/company/supplier_part_detail.html:34
-msgid "Note"
-msgstr ""
-
 #: company/templates/company/supplier_part_orders.html:9
 msgid "Supplier Part Orders"
 msgstr ""
@@ -1602,7 +1700,7 @@ msgid "Pricing Information"
 msgstr ""
 
 #: company/templates/company/supplier_part_pricing.html:17 company/views.py:486
-#: part/templates/part/sale_prices.html:14 part/views.py:2555
+#: part/templates/part/sale_prices.html:14 part/views.py:2565
 msgid "Add Price Break"
 msgstr ""
 
@@ -1633,7 +1731,7 @@ msgstr ""
 #: company/templates/company/tabs.html:12 part/templates/part/tabs.html:18
 #: stock/templates/stock/location.html:17 templates/InvenTree/search.html:155
 #: templates/InvenTree/settings/tabs.html:25 templates/js/part.js:192
-#: templates/js/part.js:418 templates/js/stock.js:502 templates/navbar.html:22
+#: templates/js/part.js:418 templates/js/stock.js:509 templates/navbar.html:22
 #: users/models.py:29
 msgid "Stock"
 msgstr ""
@@ -1643,7 +1741,7 @@ msgid "Orders"
 msgstr ""
 
 #: company/templates/company/tabs.html:9
-#: order/templates/order/receive_parts.html:14 part/models.py:316
+#: order/templates/order/receive_parts.html:14 part/models.py:317
 #: part/templates/part/cat_link.html:7 part/templates/part/category.html:94
 #: part/templates/part/category_tabs.html:6
 #: templates/InvenTree/settings/tabs.html:22 templates/navbar.html:19
@@ -1716,7 +1814,7 @@ msgstr ""
 msgid "Edit Supplier Part"
 msgstr ""
 
-#: company/views.py:295 templates/js/stock.js:850
+#: company/views.py:295 templates/js/stock.js:882
 msgid "Create new Supplier Part"
 msgstr ""
 
@@ -1724,15 +1822,15 @@ msgstr ""
 msgid "Delete Supplier Part"
 msgstr ""
 
-#: company/views.py:492 part/views.py:2561
+#: company/views.py:492 part/views.py:2571
 msgid "Added new price break"
 msgstr ""
 
-#: company/views.py:548 part/views.py:2605
+#: company/views.py:548 part/views.py:2615
 msgid "Edit Price Break"
 msgstr ""
 
-#: company/views.py:564 part/views.py:2621
+#: company/views.py:564 part/views.py:2631
 msgid "Delete Price Break"
 msgstr ""
 
@@ -1760,152 +1858,157 @@ msgstr ""
 msgid "Enabled"
 msgstr ""
 
-#: order/forms.py:24 order/templates/order/order_base.html:39
+#: order/forms.py:25 order/templates/order/order_base.html:39
 msgid "Place order"
 msgstr ""
 
-#: order/forms.py:35 order/templates/order/order_base.html:46
+#: order/forms.py:36 order/templates/order/order_base.html:46
 msgid "Mark order as complete"
 msgstr ""
 
-#: order/forms.py:46 order/forms.py:57 order/templates/order/order_base.html:51
-#: order/templates/order/sales_order_base.html:53
+#: order/forms.py:47 order/forms.py:58 order/templates/order/order_base.html:51
+#: order/templates/order/sales_order_base.html:56
 msgid "Cancel order"
 msgstr ""
 
-#: order/forms.py:68 order/templates/order/sales_order_base.html:50
+#: order/forms.py:69 order/templates/order/sales_order_base.html:53
 msgid "Ship order"
 msgstr ""
 
-#: order/forms.py:79
+#: order/forms.py:80
 msgid "Receive parts to this location"
 msgstr ""
 
-#: order/forms.py:99
+#: order/forms.py:100
 msgid "Purchase Order reference"
 msgstr ""
 
-#: order/forms.py:126
+#: order/forms.py:128
 msgid "Enter sales order number"
 msgstr ""
 
-#: order/models.py:110
+#: order/forms.py:134 order/models.py:405
+msgid ""
+"Target date for order completion. Order will be overdue after this date."
+msgstr ""
+
+#: order/models.py:98
 msgid "Order reference"
 msgstr ""
 
-#: order/models.py:112
+#: order/models.py:100
 msgid "Order description"
 msgstr ""
 
-#: order/models.py:114
+#: order/models.py:102
 msgid "Link to external page"
 msgstr ""
 
-#: order/models.py:124
+#: order/models.py:112
 msgid "Order notes"
 msgstr ""
 
-#: order/models.py:142 order/models.py:328
+#: order/models.py:169 order/models.py:398
 msgid "Purchase order status"
 msgstr ""
 
-#: order/models.py:150
+#: order/models.py:177
 msgid "Company from which the items are being ordered"
 msgstr ""
 
-#: order/models.py:153
+#: order/models.py:180
 msgid "Supplier order reference code"
 msgstr ""
 
-#: order/models.py:162
+#: order/models.py:189
 msgid "Date order was issued"
 msgstr ""
 
-#: order/models.py:164
+#: order/models.py:191
 msgid "Date order was completed"
 msgstr ""
 
-#: order/models.py:187 order/models.py:269 part/views.py:1494
-#: stock/models.py:244 stock/models.py:812
+#: order/models.py:214 order/models.py:296 part/views.py:1504
+#: stock/models.py:251 stock/models.py:856
 msgid "Quantity must be greater than zero"
 msgstr ""
 
-#: order/models.py:192
+#: order/models.py:219
 msgid "Part supplier must match PO supplier"
 msgstr ""
 
-#: order/models.py:264
+#: order/models.py:291
 msgid "Lines can only be received against an order marked as 'Placed'"
 msgstr ""
 
-#: order/models.py:324
+#: order/models.py:394
 msgid "Company to which the items are being sold"
 msgstr ""
 
-#: order/models.py:330
+#: order/models.py:400
 msgid "Customer order reference code"
 msgstr ""
 
-#: order/models.py:369
+#: order/models.py:462
 msgid "SalesOrder cannot be shipped as it is not currently pending"
 msgstr ""
 
-#: order/models.py:456
+#: order/models.py:549
 msgid "Item quantity"
 msgstr ""
 
-#: order/models.py:458
+#: order/models.py:551
 msgid "Line item reference"
 msgstr ""
 
-#: order/models.py:460
+#: order/models.py:553
 msgid "Line item notes"
 msgstr ""
 
-#: order/models.py:486 order/templates/order/order_base.html:9
+#: order/models.py:579 order/templates/order/order_base.html:9
 #: order/templates/order/order_base.html:24
-#: stock/templates/stock/item_base.html:259 templates/js/order.js:146
+#: stock/templates/stock/item_base.html:276 templates/js/order.js:146
 msgid "Purchase Order"
 msgstr ""
 
-#: order/models.py:499
+#: order/models.py:592
 msgid "Supplier part"
 msgstr ""
 
-#: order/models.py:502
+#: order/models.py:595
 msgid "Number of items received"
 msgstr ""
 
-#: order/models.py:509 stock/models.py:458
-#: stock/templates/stock/item_base.html:266
+#: order/models.py:602 stock/models.py:473
+#: stock/templates/stock/item_base.html:283
 msgid "Purchase Price"
 msgstr ""
 
-#: order/models.py:510
+#: order/models.py:603
 msgid "Unit purchase price"
 msgstr ""
 
-#: order/models.py:605
+#: order/models.py:698
 msgid "Cannot allocate stock item to a line with a different part"
 msgstr ""
 
-#: order/models.py:607
+#: order/models.py:700
 msgid "Cannot allocate stock to a line without a part"
 msgstr ""
 
-#: order/models.py:610
+#: order/models.py:703
 msgid "Allocation quantity cannot exceed stock quantity"
 msgstr ""
 
-#: order/models.py:620
+#: order/models.py:713
 msgid "Quantity must be 1 for serialized stock item"
 msgstr ""
 
-#: order/models.py:636
+#: order/models.py:729
 msgid "Select stock item to allocate"
 msgstr ""
 
-#: order/models.py:639
+#: order/models.py:732
 msgid "Enter stock allocation quantity"
 msgstr ""
 
@@ -1932,12 +2035,12 @@ msgid "Purchase Order Details"
 msgstr ""
 
 #: order/templates/order/order_base.html:69
-#: order/templates/order/sales_order_base.html:71
+#: order/templates/order/sales_order_base.html:74
 msgid "Order Reference"
 msgstr ""
 
 #: order/templates/order/order_base.html:74
-#: order/templates/order/sales_order_base.html:76
+#: order/templates/order/sales_order_base.html:79
 msgid "Order Status"
 msgstr ""
 
@@ -1952,7 +2055,7 @@ msgstr ""
 #: order/templates/order/order_base.html:111
 #: order/templates/order/purchase_order_detail.html:193
 #: order/templates/order/receive_parts.html:22
-#: order/templates/order/sales_order_base.html:113
+#: order/templates/order/sales_order_base.html:128
 msgid "Received"
 msgstr ""
 
@@ -1997,7 +2100,7 @@ msgid "Select existing purchase orders, or create new orders."
 msgstr ""
 
 #: order/templates/order/order_wizard/select_pos.html:31
-#: templates/js/order.js:193 templates/js/order.js:280
+#: templates/js/order.js:193 templates/js/order.js:291
 msgid "Items"
 msgstr ""
 
@@ -2023,8 +2126,8 @@ msgid "Line Items"
 msgstr ""
 
 #: order/templates/order/purchase_order_detail.html:17
-#: order/templates/order/sales_order_detail.html:19 order/views.py:1117
-#: order/views.py:1201
+#: order/templates/order/sales_order_detail.html:19 order/views.py:1119
+#: order/views.py:1203
 msgid "Add Line Item"
 msgstr ""
 
@@ -2035,7 +2138,7 @@ msgstr ""
 #: order/templates/order/purchase_order_detail.html:39
 #: order/templates/order/purchase_order_detail.html:119
 #: part/templates/part/category.html:173 part/templates/part/category.html:215
-#: templates/js/stock.js:855
+#: templates/js/stock.js:627 templates/js/stock.js:887
 msgid "New Location"
 msgstr ""
 
@@ -2096,15 +2199,15 @@ msgstr ""
 msgid "This SalesOrder has not been fully allocated"
 msgstr ""
 
-#: order/templates/order/sales_order_base.html:58
+#: order/templates/order/sales_order_base.html:61
 msgid "Packing List"
 msgstr ""
 
-#: order/templates/order/sales_order_base.html:66
+#: order/templates/order/sales_order_base.html:69
 msgid "Sales Order Details"
 msgstr ""
 
-#: order/templates/order/sales_order_base.html:87 templates/js/order.js:251
+#: order/templates/order/sales_order_base.html:95 templates/js/order.js:257
 msgid "Customer Reference"
 msgstr ""
 
@@ -2120,8 +2223,8 @@ msgid "Sales Order Items"
 msgstr ""
 
 #: order/templates/order/sales_order_detail.html:72
-#: order/templates/order/sales_order_detail.html:154 stock/models.py:378
-#: stock/templates/stock/item_base.html:191 templates/js/build.js:418
+#: order/templates/order/sales_order_detail.html:154 stock/models.py:385
+#: stock/templates/stock/item_base.html:208 templates/js/build.js:418
 msgid "Serial Number"
 msgstr ""
 
@@ -2199,143 +2302,143 @@ msgstr ""
 msgid "Order Items"
 msgstr ""
 
-#: order/views.py:99
+#: order/views.py:101
 msgid "Add Purchase Order Attachment"
 msgstr ""
 
-#: order/views.py:150
+#: order/views.py:152
 msgid "Add Sales Order Attachment"
 msgstr ""
 
-#: order/views.py:310
+#: order/views.py:312
 msgid "Create Purchase Order"
 msgstr ""
 
-#: order/views.py:346
+#: order/views.py:348
 msgid "Create Sales Order"
 msgstr ""
 
-#: order/views.py:382
+#: order/views.py:384
 msgid "Edit Purchase Order"
 msgstr ""
 
-#: order/views.py:403
+#: order/views.py:405
 msgid "Edit Sales Order"
 msgstr ""
 
-#: order/views.py:420
+#: order/views.py:422
 msgid "Cancel Order"
 msgstr ""
 
-#: order/views.py:430 order/views.py:457
+#: order/views.py:432 order/views.py:459
 msgid "Confirm order cancellation"
 msgstr ""
 
-#: order/views.py:433
+#: order/views.py:435
 msgid "Order cannot be cancelled as either pending or placed"
 msgstr ""
 
-#: order/views.py:447
+#: order/views.py:449
 msgid "Cancel sales order"
 msgstr ""
 
-#: order/views.py:460
+#: order/views.py:462
 msgid "Order cannot be cancelled"
 msgstr ""
 
-#: order/views.py:474
+#: order/views.py:476
 msgid "Issue Order"
 msgstr ""
 
-#: order/views.py:484
+#: order/views.py:486
 msgid "Confirm order placement"
 msgstr ""
 
-#: order/views.py:494
+#: order/views.py:496
 msgid "Purchase order issued"
 msgstr ""
 
-#: order/views.py:505
+#: order/views.py:507
 msgid "Complete Order"
 msgstr ""
 
-#: order/views.py:522
+#: order/views.py:524
 msgid "Confirm order completion"
 msgstr ""
 
-#: order/views.py:533
+#: order/views.py:535
 msgid "Purchase order completed"
 msgstr ""
 
-#: order/views.py:543
+#: order/views.py:545
 msgid "Ship Order"
 msgstr ""
 
-#: order/views.py:560
+#: order/views.py:562
 msgid "Confirm order shipment"
 msgstr ""
 
-#: order/views.py:566
+#: order/views.py:568
 msgid "Could not ship order"
 msgstr ""
 
-#: order/views.py:618
+#: order/views.py:620
 msgid "Receive Parts"
 msgstr ""
 
-#: order/views.py:686
+#: order/views.py:688
 msgid "Items received"
 msgstr ""
 
-#: order/views.py:700
+#: order/views.py:702
 msgid "No destination set"
 msgstr ""
 
-#: order/views.py:745
+#: order/views.py:747
 msgid "Error converting quantity to number"
 msgstr ""
 
-#: order/views.py:751
+#: order/views.py:753
 msgid "Receive quantity less than zero"
 msgstr ""
 
-#: order/views.py:757
+#: order/views.py:759
 msgid "No lines specified"
 msgstr ""
 
-#: order/views.py:1127
+#: order/views.py:1129
 msgid "Supplier part must be specified"
 msgstr ""
 
-#: order/views.py:1133
+#: order/views.py:1135
 msgid "Supplier must match for Part and Order"
 msgstr ""
 
-#: order/views.py:1253 order/views.py:1272
+#: order/views.py:1255 order/views.py:1274
 msgid "Edit Line Item"
 msgstr ""
 
-#: order/views.py:1289 order/views.py:1302
+#: order/views.py:1291 order/views.py:1304
 msgid "Delete Line Item"
 msgstr ""
 
-#: order/views.py:1295 order/views.py:1308
+#: order/views.py:1297 order/views.py:1310
 msgid "Deleted line item"
 msgstr ""
 
-#: order/views.py:1317
+#: order/views.py:1319
 msgid "Allocate Stock to Order"
 msgstr ""
 
-#: order/views.py:1387
+#: order/views.py:1394
 msgid "Edit Allocation Quantity"
 msgstr ""
 
-#: order/views.py:1403
+#: order/views.py:1410
 msgid "Remove allocation"
 msgstr ""
 
-#: part/bom.py:138 part/templates/part/category.html:61
+#: part/bom.py:138 part/models.py:722 part/templates/part/category.html:61
 #: part/templates/part/detail.html:87
 msgid "Default Location"
 msgstr ""
@@ -2357,11 +2460,11 @@ msgstr ""
 msgid "Error reading BOM file (incorrect row size)"
 msgstr ""
 
-#: part/forms.py:61 stock/forms.py:255
+#: part/forms.py:61 stock/forms.py:261
 msgid "File Format"
 msgstr ""
 
-#: part/forms.py:61 stock/forms.py:255
+#: part/forms.py:61 stock/forms.py:261
 msgid "Select output file format"
 msgstr ""
 
@@ -2405,7 +2508,7 @@ msgstr ""
 msgid "Include part supplier data in exported BOM"
 msgstr ""
 
-#: part/forms.py:92 part/models.py:1717
+#: part/forms.py:92 part/models.py:1781
 msgid "Parent Part"
 msgstr ""
 
@@ -2437,43 +2540,43 @@ msgstr ""
 msgid "Select part category"
 msgstr ""
 
-#: part/forms.py:188
+#: part/forms.py:189
 msgid "Duplicate all BOM data for this part"
 msgstr ""
 
-#: part/forms.py:189
+#: part/forms.py:190
 msgid "Copy BOM"
 msgstr ""
 
-#: part/forms.py:194
+#: part/forms.py:195
 msgid "Duplicate all parameter data for this part"
 msgstr ""
 
-#: part/forms.py:195
+#: part/forms.py:196
 msgid "Copy Parameters"
 msgstr ""
 
-#: part/forms.py:200
+#: part/forms.py:201
 msgid "Confirm part creation"
 msgstr ""
 
-#: part/forms.py:205
+#: part/forms.py:206
 msgid "Include category parameter templates"
 msgstr ""
 
-#: part/forms.py:210
+#: part/forms.py:211
 msgid "Include parent categories parameter templates"
 msgstr ""
 
-#: part/forms.py:285
+#: part/forms.py:291
 msgid "Add parameter template to same level categories"
 msgstr ""
 
-#: part/forms.py:289
+#: part/forms.py:295
 msgid "Add parameter template to all categories"
 msgstr ""
 
-#: part/forms.py:331
+#: part/forms.py:339
 msgid "Input quantity for price calculation"
 msgstr ""
 
@@ -2485,7 +2588,7 @@ msgstr ""
 msgid "Default keywords for parts in this category"
 msgstr ""
 
-#: part/models.py:77 part/models.py:1762
+#: part/models.py:77 part/models.py:1826
 #: part/templates/part/part_app_base.html:9
 msgid "Part Category"
 msgstr ""
@@ -2495,255 +2598,294 @@ msgstr ""
 msgid "Part Categories"
 msgstr ""
 
-#: part/models.py:408 part/models.py:418
+#: part/models.py:409 part/models.py:419
 #, python-brace-format
 msgid "Part '{p1}' is  used in BOM for '{p2}' (recursive)"
 msgstr ""
 
-#: part/models.py:515
+#: part/models.py:516
 msgid "Next available serial numbers are"
 msgstr ""
 
-#: part/models.py:519
+#: part/models.py:520
 msgid "Next available serial number is"
 msgstr ""
 
-#: part/models.py:524
+#: part/models.py:525
 msgid "Most recent serial number is"
 msgstr ""
 
-#: part/models.py:603
+#: part/models.py:604
 msgid "Duplicate IPN not allowed in part settings"
 msgstr ""
 
-#: part/models.py:614
+#: part/models.py:615
 msgid "Part must be unique for name, IPN and revision"
 msgstr ""
 
-#: part/models.py:644 part/templates/part/detail.html:19
+#: part/models.py:646 part/templates/part/detail.html:19
 msgid "Part name"
 msgstr ""
 
-#: part/models.py:648
+#: part/models.py:653
+msgid "Is Template"
+msgstr ""
+
+#: part/models.py:654
 msgid "Is this part a template part?"
 msgstr ""
 
-#: part/models.py:657
+#: part/models.py:665
 msgid "Is this part a variant of another part?"
 msgstr ""
 
-#: part/models.py:659
+#: part/models.py:666 part/templates/part/detail.html:57
+msgid "Variant Of"
+msgstr ""
+
+#: part/models.py:672
 msgid "Part description"
 msgstr ""
 
-#: part/models.py:661
+#: part/models.py:677 part/templates/part/category.html:68
+#: part/templates/part/detail.html:64
+msgid "Keywords"
+msgstr ""
+
+#: part/models.py:678
 msgid "Part keywords to improve visibility in search results"
 msgstr ""
 
-#: part/models.py:666
+#: part/models.py:685 part/templates/part/detail.html:70
+#: part/templates/part/set_category.html:15 templates/js/part.js:405
+msgid "Category"
+msgstr ""
+
+#: part/models.py:686
 msgid "Part category"
 msgstr ""
 
-#: part/models.py:668
+#: part/models.py:691 part/templates/part/detail.html:25
+#: part/templates/part/part_base.html:95 templates/js/part.js:180
+msgid "IPN"
+msgstr ""
+
+#: part/models.py:692
 msgid "Internal Part Number"
 msgstr ""
 
-#: part/models.py:670
+#: part/models.py:698
 msgid "Part revision or version number"
 msgstr ""
 
-#: part/models.py:684
+#: part/models.py:699 part/templates/part/detail.html:32
+#: templates/js/part.js:184
+msgid "Revision"
+msgstr ""
+
+#: part/models.py:720
 msgid "Where is this item normally stored?"
 msgstr ""
 
-#: part/models.py:728
+#: part/models.py:767 part/templates/part/detail.html:94
+msgid "Default Supplier"
+msgstr ""
+
+#: part/models.py:768
 msgid "Default supplier part"
 msgstr ""
 
-#: part/models.py:731
+#: part/models.py:775
+msgid "Default Expiry"
+msgstr ""
+
+#: part/models.py:776
+msgid "Expiry time (in days) for stock items of this part"
+msgstr ""
+
+#: part/models.py:781 part/templates/part/detail.html:108
+msgid "Minimum Stock"
+msgstr ""
+
+#: part/models.py:782
 msgid "Minimum allowed stock level"
 msgstr ""
 
-#: part/models.py:733
+#: part/models.py:788 part/templates/part/detail.html:102
+#: part/templates/part/params.html:26
+msgid "Units"
+msgstr ""
+
+#: part/models.py:789
 msgid "Stock keeping units for this part"
 msgstr ""
 
-#: part/models.py:737 part/templates/part/detail.html:158
-#: templates/js/table_filters.js:264
-msgid "Assembly"
-msgstr ""
-
-#: part/models.py:738
+#: part/models.py:795
 msgid "Can this part be built from other parts?"
 msgstr ""
 
-#: part/models.py:744
+#: part/models.py:801
 msgid "Can this part be used to build other parts?"
 msgstr ""
 
-#: part/models.py:750
+#: part/models.py:807
 msgid "Does this part have tracking for unique items?"
 msgstr ""
 
-#: part/models.py:755
+#: part/models.py:812
 msgid "Can this part be purchased from external suppliers?"
 msgstr ""
 
-#: part/models.py:760
+#: part/models.py:817
 msgid "Can this part be sold to customers?"
 msgstr ""
 
-#: part/models.py:764 part/templates/part/detail.html:215
+#: part/models.py:821 part/templates/part/detail.html:222
 #: templates/js/table_filters.js:19 templates/js/table_filters.js:55
-#: templates/js/table_filters.js:186 templates/js/table_filters.js:247
+#: templates/js/table_filters.js:196 templates/js/table_filters.js:261
 msgid "Active"
 msgstr ""
 
-#: part/models.py:765
+#: part/models.py:822
 msgid "Is this part active?"
 msgstr ""
 
-#: part/models.py:769 part/templates/part/detail.html:138
-#: templates/js/table_filters.js:27
-msgid "Virtual"
-msgstr ""
-
-#: part/models.py:770
+#: part/models.py:827
 msgid "Is this a virtual part, such as a software product or license?"
 msgstr ""
 
-#: part/models.py:772
+#: part/models.py:832
 msgid "Part notes - supports Markdown formatting"
 msgstr ""
 
-#: part/models.py:774
+#: part/models.py:835
 msgid "Stored BOM checksum"
 msgstr ""
 
-#: part/models.py:1590
+#: part/models.py:1654
 msgid "Test templates can only be created for trackable parts"
 msgstr ""
 
-#: part/models.py:1607
+#: part/models.py:1671
 msgid "Test with this name already exists for this part"
 msgstr ""
 
-#: part/models.py:1626 templates/js/part.js:567 templates/js/stock.js:92
+#: part/models.py:1690 templates/js/part.js:567 templates/js/stock.js:93
 msgid "Test Name"
 msgstr ""
 
-#: part/models.py:1627
+#: part/models.py:1691
 msgid "Enter a name for the test"
 msgstr ""
 
-#: part/models.py:1632
+#: part/models.py:1696
 msgid "Test Description"
 msgstr ""
 
-#: part/models.py:1633
+#: part/models.py:1697
 msgid "Enter description for this test"
 msgstr ""
 
-#: part/models.py:1638 templates/js/part.js:576
-#: templates/js/table_filters.js:172
+#: part/models.py:1702 templates/js/part.js:576
+#: templates/js/table_filters.js:182
 msgid "Required"
 msgstr ""
 
-#: part/models.py:1639
+#: part/models.py:1703
 msgid "Is this test required to pass?"
 msgstr ""
 
-#: part/models.py:1644 templates/js/part.js:584
+#: part/models.py:1708 templates/js/part.js:584
 msgid "Requires Value"
 msgstr ""
 
-#: part/models.py:1645
+#: part/models.py:1709
 msgid "Does this test require a value when adding a test result?"
 msgstr ""
 
-#: part/models.py:1650 templates/js/part.js:591
+#: part/models.py:1714 templates/js/part.js:591
 msgid "Requires Attachment"
 msgstr ""
 
-#: part/models.py:1651
+#: part/models.py:1715
 msgid "Does this test require a file attachment when adding a test result?"
 msgstr ""
 
-#: part/models.py:1684
+#: part/models.py:1748
 msgid "Parameter template name must be unique"
 msgstr ""
 
-#: part/models.py:1689
+#: part/models.py:1753
 msgid "Parameter Name"
 msgstr ""
 
-#: part/models.py:1691
+#: part/models.py:1755
 msgid "Parameter Units"
 msgstr ""
 
-#: part/models.py:1719 part/models.py:1767
+#: part/models.py:1783 part/models.py:1831
 #: templates/InvenTree/settings/category.html:62
 msgid "Parameter Template"
 msgstr ""
 
-#: part/models.py:1721
+#: part/models.py:1785
 msgid "Parameter Value"
 msgstr ""
 
-#: part/models.py:1771
+#: part/models.py:1835
 msgid "Default Parameter Value"
 msgstr ""
 
-#: part/models.py:1801
+#: part/models.py:1865
 msgid "Select parent part"
 msgstr ""
 
-#: part/models.py:1809
+#: part/models.py:1873
 msgid "Select part to be used in BOM"
 msgstr ""
 
-#: part/models.py:1815
+#: part/models.py:1879
 msgid "BOM quantity for this BOM item"
 msgstr ""
 
-#: part/models.py:1817
+#: part/models.py:1881
 msgid "This BOM item is optional"
 msgstr ""
 
-#: part/models.py:1820
+#: part/models.py:1884
 msgid "Estimated build wastage quantity (absolute or percentage)"
 msgstr ""
 
-#: part/models.py:1823
+#: part/models.py:1887
 msgid "BOM item reference"
 msgstr ""
 
-#: part/models.py:1826
+#: part/models.py:1890
 msgid "BOM item notes"
 msgstr ""
 
-#: part/models.py:1828
+#: part/models.py:1892
 msgid "BOM line checksum"
 msgstr ""
 
-#: part/models.py:1899 part/views.py:1500 part/views.py:1552
-#: stock/models.py:234
+#: part/models.py:1963 part/views.py:1510 part/views.py:1562
+#: stock/models.py:241
 msgid "Quantity must be integer value for trackable parts"
 msgstr ""
 
-#: part/models.py:1908 part/models.py:1910
+#: part/models.py:1972 part/models.py:1974
 msgid "Sub part must be specified"
 msgstr ""
 
-#: part/models.py:1913
+#: part/models.py:1977
 msgid "BOM Item"
 msgstr ""
 
-#: part/models.py:2028
+#: part/models.py:2092
 msgid "Select Related Part"
 msgstr ""
 
-#: part/models.py:2060
+#: part/models.py:2124
 msgid ""
 "Error creating relationship: check that the part is not related to itself "
 "and that the relationship is unique"
@@ -2764,9 +2906,9 @@ msgstr ""
 #: part/templates/part/allocation.html:45
 #: stock/templates/stock/item_base.html:8
 #: stock/templates/stock/item_base.html:72
-#: stock/templates/stock/item_base.html:274
+#: stock/templates/stock/item_base.html:291
 #: stock/templates/stock/stock_adjust.html:16 templates/js/build.js:751
-#: templates/js/stock.js:699 templates/js/stock.js:948
+#: templates/js/stock.js:720 templates/js/stock.js:980
 msgid "Stock Item"
 msgstr ""
 
@@ -2831,7 +2973,7 @@ msgstr ""
 msgid "Validate"
 msgstr ""
 
-#: part/templates/part/bom.html:62 part/views.py:1791
+#: part/templates/part/bom.html:62 part/views.py:1801
 msgid "Export Bill of Materials"
 msgstr ""
 
@@ -2931,7 +3073,7 @@ msgstr ""
 msgid "All parts"
 msgstr ""
 
-#: part/templates/part/category.html:24 part/views.py:2182
+#: part/templates/part/category.html:24 part/views.py:2192
 msgid "Create new part category"
 msgstr ""
 
@@ -2955,10 +3097,6 @@ msgstr ""
 msgid "Category Description"
 msgstr ""
 
-#: part/templates/part/category.html:68 part/templates/part/detail.html:64
-msgid "Keywords"
-msgstr ""
-
 #: part/templates/part/category.html:74
 msgid "Subcategories"
 msgstr ""
@@ -2987,7 +3125,7 @@ msgstr ""
 msgid "Export Data"
 msgstr ""
 
-#: part/templates/part/category.html:174
+#: part/templates/part/category.html:174 templates/js/stock.js:628
 msgid "Create new location"
 msgstr ""
 
@@ -3003,7 +3141,7 @@ msgstr ""
 msgid "Create new Part Category"
 msgstr ""
 
-#: part/templates/part/category.html:216 stock/views.py:1358
+#: part/templates/part/category.html:216 stock/views.py:1363
 msgid "Create new Stock Location"
 msgstr ""
 
@@ -3027,15 +3165,6 @@ msgstr ""
 msgid "Part Details"
 msgstr ""
 
-#: part/templates/part/detail.html:25 part/templates/part/part_base.html:95
-#: templates/js/part.js:180
-msgid "IPN"
-msgstr ""
-
-#: part/templates/part/detail.html:32 templates/js/part.js:184
-msgid "Revision"
-msgstr ""
-
 #: part/templates/part/detail.html:39
 msgid "Latest Serial Number"
 msgstr ""
@@ -3044,101 +3173,79 @@ msgstr ""
 msgid "No serial numbers recorded"
 msgstr ""
 
-#: part/templates/part/detail.html:57
-msgid "Variant Of"
+#: part/templates/part/detail.html:115
+msgid "Stock Expiry Time"
 msgstr ""
 
-#: part/templates/part/detail.html:70 part/templates/part/set_category.html:15
-#: templates/js/part.js:405
-msgid "Category"
-msgstr ""
-
-#: part/templates/part/detail.html:94
-msgid "Default Supplier"
-msgstr ""
-
-#: part/templates/part/detail.html:102 part/templates/part/params.html:26
-msgid "Units"
-msgstr ""
-
-#: part/templates/part/detail.html:108
-msgid "Minimum Stock"
-msgstr ""
-
-#: part/templates/part/detail.html:114 templates/js/order.js:270
+#: part/templates/part/detail.html:121 templates/js/order.js:276
 msgid "Creation Date"
 msgstr ""
 
-#: part/templates/part/detail.html:120
+#: part/templates/part/detail.html:127
 msgid "Created By"
 msgstr ""
 
-#: part/templates/part/detail.html:127
+#: part/templates/part/detail.html:134
 msgid "Responsible User"
 msgstr ""
 
-#: part/templates/part/detail.html:141
+#: part/templates/part/detail.html:148
 msgid "Part is virtual (not a physical part)"
 msgstr ""
 
-#: part/templates/part/detail.html:143
+#: part/templates/part/detail.html:150
 msgid "Part is not a virtual part"
 msgstr ""
 
-#: part/templates/part/detail.html:148 stock/forms.py:249
-#: templates/js/table_filters.js:23 templates/js/table_filters.js:252
-msgid "Template"
-msgstr ""
-
-#: part/templates/part/detail.html:151
+#: part/templates/part/detail.html:158
 msgid "Part is a template part (variants can be made from this part)"
 msgstr ""
 
-#: part/templates/part/detail.html:153
+#: part/templates/part/detail.html:160
 msgid "Part is not a template part"
 msgstr ""
 
-#: part/templates/part/detail.html:161
+#: part/templates/part/detail.html:168
 msgid "Part can be assembled from other parts"
 msgstr ""
 
-#: part/templates/part/detail.html:163
+#: part/templates/part/detail.html:170
 msgid "Part cannot be assembled from other parts"
 msgstr ""
 
-#: part/templates/part/detail.html:171
+#: part/templates/part/detail.html:178
 msgid "Part can be used in assemblies"
 msgstr ""
 
-#: part/templates/part/detail.html:173
+#: part/templates/part/detail.html:180
 msgid "Part cannot be used in assemblies"
 msgstr ""
 
-#: part/templates/part/detail.html:181
+#: part/templates/part/detail.html:188
 msgid "Part stock is tracked by serial number"
 msgstr ""
 
-#: part/templates/part/detail.html:183
+#: part/templates/part/detail.html:190
 msgid "Part stock is not tracked by serial number"
 msgstr ""
 
-#: part/templates/part/detail.html:191 part/templates/part/detail.html:193
+#: part/templates/part/detail.html:198 part/templates/part/detail.html:200
 msgid "Part can be purchased from external suppliers"
 msgstr ""
 
-#: part/templates/part/detail.html:201
+#: part/templates/part/detail.html:208
 msgid "Part can be sold to customers"
 msgstr ""
 
-#: part/templates/part/detail.html:203
+#: part/templates/part/detail.html:210
 msgid "Part cannot be sold to customers"
 msgstr ""
 
-#: part/templates/part/detail.html:218
+#: part/templates/part/detail.html:225
 msgid "Part is active"
 msgstr ""
 
-#: part/templates/part/detail.html:220
+#: part/templates/part/detail.html:227
 msgid "Part is not active"
 msgstr ""
 
@@ -3156,12 +3263,12 @@ msgstr ""
 
 #: part/templates/part/params.html:15
 #: templates/InvenTree/settings/category.html:29
-#: templates/InvenTree/settings/part.html:38
+#: templates/InvenTree/settings/part.html:41
 msgid "New Parameter"
 msgstr ""
 
-#: part/templates/part/params.html:25 stock/models.py:1420
-#: templates/js/stock.js:112
+#: part/templates/part/params.html:25 stock/models.py:1499
+#: templates/InvenTree/settings/header.html:8 templates/js/stock.js:113
 msgid "Value"
 msgstr ""
 
@@ -3196,19 +3303,19 @@ msgid "Star this part"
 msgstr ""
 
 #: part/templates/part/part_base.html:49
-#: stock/templates/stock/item_base.html:101
+#: stock/templates/stock/item_base.html:108
 #: stock/templates/stock/location.html:29
 msgid "Barcode actions"
 msgstr ""
 
 #: part/templates/part/part_base.html:51
-#: stock/templates/stock/item_base.html:103
+#: stock/templates/stock/item_base.html:110
 #: stock/templates/stock/location.html:31
 msgid "Show QR Code"
 msgstr ""
 
 #: part/templates/part/part_base.html:52
-#: stock/templates/stock/item_base.html:104
+#: stock/templates/stock/item_base.html:126
 #: stock/templates/stock/location.html:32
 msgid "Print Label"
 msgstr ""
@@ -3237,7 +3344,7 @@ msgstr ""
 msgid "Delete part"
 msgstr ""
 
-#: part/templates/part/part_base.html:124 templates/js/table_filters.js:111
+#: part/templates/part/part_base.html:124 templates/js/table_filters.js:121
 msgid "In Stock"
 msgstr ""
 
@@ -3346,7 +3453,7 @@ msgstr ""
 msgid "Used In"
 msgstr ""
 
-#: part/templates/part/tabs.html:61 stock/templates/stock/item_base.html:318
+#: part/templates/part/tabs.html:61 stock/templates/stock/item_base.html:349
 msgid "Tests"
 msgstr ""
 
@@ -3374,220 +3481,220 @@ msgstr ""
 msgid "New Variant"
 msgstr ""
 
-#: part/views.py:84
+#: part/views.py:86
 msgid "Add Related Part"
 msgstr ""
 
-#: part/views.py:140
+#: part/views.py:142
 msgid "Delete Related Part"
 msgstr ""
 
-#: part/views.py:152
+#: part/views.py:154
 msgid "Add part attachment"
 msgstr ""
 
-#: part/views.py:207 templates/attachment_table.html:34
+#: part/views.py:209 templates/attachment_table.html:34
 msgid "Edit attachment"
 msgstr ""
 
-#: part/views.py:213
+#: part/views.py:215
 msgid "Part attachment updated"
 msgstr ""
 
-#: part/views.py:228
+#: part/views.py:230
 msgid "Delete Part Attachment"
 msgstr ""
 
-#: part/views.py:236
+#: part/views.py:238
 msgid "Deleted part attachment"
 msgstr ""
 
-#: part/views.py:245
+#: part/views.py:247
 msgid "Create Test Template"
 msgstr ""
 
-#: part/views.py:274
+#: part/views.py:276
 msgid "Edit Test Template"
 msgstr ""
 
-#: part/views.py:290
+#: part/views.py:292
 msgid "Delete Test Template"
 msgstr ""
 
-#: part/views.py:299
+#: part/views.py:301
 msgid "Set Part Category"
 msgstr ""
 
-#: part/views.py:349
+#: part/views.py:351
 #, python-brace-format
 msgid "Set category for {n} parts"
 msgstr ""
 
-#: part/views.py:384
+#: part/views.py:386
 msgid "Create Variant"
 msgstr ""
 
-#: part/views.py:466
+#: part/views.py:468
 msgid "Duplicate Part"
 msgstr ""
 
-#: part/views.py:473
+#: part/views.py:475
 msgid "Copied part"
 msgstr ""
 
-#: part/views.py:527 part/views.py:661
+#: part/views.py:529 part/views.py:667
 msgid "Possible matches exist - confirm creation of new part"
 msgstr ""
 
-#: part/views.py:592 templates/js/stock.js:844
+#: part/views.py:594 templates/js/stock.js:876
 msgid "Create New Part"
 msgstr ""
 
-#: part/views.py:599
+#: part/views.py:601
 msgid "Created new part"
 msgstr ""
 
-#: part/views.py:830
+#: part/views.py:836
 msgid "Part QR Code"
 msgstr ""
 
-#: part/views.py:849
+#: part/views.py:855
 msgid "Upload Part Image"
 msgstr ""
 
-#: part/views.py:857 part/views.py:894
+#: part/views.py:863 part/views.py:900
 msgid "Updated part image"
 msgstr ""
 
-#: part/views.py:866
+#: part/views.py:872
 msgid "Select Part Image"
 msgstr ""
 
-#: part/views.py:897
+#: part/views.py:903
 msgid "Part image not found"
 msgstr ""
 
-#: part/views.py:908
+#: part/views.py:914
 msgid "Edit Part Properties"
 msgstr ""
 
-#: part/views.py:935
+#: part/views.py:945
 msgid "Duplicate BOM"
 msgstr ""
 
-#: part/views.py:966
+#: part/views.py:976
 msgid "Confirm duplication of BOM from parent"
 msgstr ""
 
-#: part/views.py:987
+#: part/views.py:997
 msgid "Validate BOM"
 msgstr ""
 
-#: part/views.py:1010
+#: part/views.py:1020
 msgid "Confirm that the BOM is valid"
 msgstr ""
 
-#: part/views.py:1021
+#: part/views.py:1031
 msgid "Validated Bill of Materials"
 msgstr ""
 
-#: part/views.py:1155
+#: part/views.py:1165
 msgid "No BOM file provided"
 msgstr ""
 
-#: part/views.py:1503
+#: part/views.py:1513
 msgid "Enter a valid quantity"
 msgstr ""
 
-#: part/views.py:1528 part/views.py:1531
+#: part/views.py:1538 part/views.py:1541
 msgid "Select valid part"
 msgstr ""
 
-#: part/views.py:1537
+#: part/views.py:1547
 msgid "Duplicate part selected"
 msgstr ""
 
-#: part/views.py:1575
+#: part/views.py:1585
 msgid "Select a part"
 msgstr ""
 
-#: part/views.py:1581
+#: part/views.py:1591
 msgid "Selected part creates a circular BOM"
 msgstr ""
 
-#: part/views.py:1585
+#: part/views.py:1595
 msgid "Specify quantity"
 msgstr ""
 
-#: part/views.py:1841
+#: part/views.py:1851
 msgid "Confirm Part Deletion"
 msgstr ""
 
-#: part/views.py:1850
+#: part/views.py:1860
 msgid "Part was deleted"
 msgstr ""
 
-#: part/views.py:1859
+#: part/views.py:1869
 msgid "Part Pricing"
 msgstr ""
 
-#: part/views.py:1973
+#: part/views.py:1983
 msgid "Create Part Parameter Template"
 msgstr ""
 
-#: part/views.py:1983
+#: part/views.py:1993
 msgid "Edit Part Parameter Template"
 msgstr ""
 
-#: part/views.py:1992
+#: part/views.py:2002
 msgid "Delete Part Parameter Template"
 msgstr ""
 
-#: part/views.py:2002
+#: part/views.py:2012
 msgid "Create Part Parameter"
 msgstr ""
 
-#: part/views.py:2054
+#: part/views.py:2064
 msgid "Edit Part Parameter"
 msgstr ""
 
-#: part/views.py:2070
+#: part/views.py:2080
 msgid "Delete Part Parameter"
 msgstr ""
 
-#: part/views.py:2129
+#: part/views.py:2139
 msgid "Edit Part Category"
 msgstr ""
 
-#: part/views.py:2166
+#: part/views.py:2176
 msgid "Delete Part Category"
 msgstr ""
 
-#: part/views.py:2174
+#: part/views.py:2184
 msgid "Part category was deleted"
 msgstr ""
 
-#: part/views.py:2230
+#: part/views.py:2240
 msgid "Create Category Parameter Template"
 msgstr ""
 
-#: part/views.py:2333
+#: part/views.py:2343
 msgid "Edit Category Parameter Template"
 msgstr ""
 
-#: part/views.py:2391
+#: part/views.py:2401
 msgid "Delete Category Parameter Template"
 msgstr ""
 
-#: part/views.py:2416
+#: part/views.py:2426
 msgid "Create BOM Item"
 msgstr ""
 
-#: part/views.py:2488
+#: part/views.py:2498
 msgid "Edit BOM item"
 msgstr ""
 
-#: part/views.py:2545
+#: part/views.py:2555
 msgid "Confim BOM item deletion"
 msgstr ""
 
@@ -3619,295 +3726,305 @@ msgstr ""
 msgid "Asset file description"
 msgstr ""
 
-#: stock/forms.py:111
+#: stock/forms.py:116
 msgid "Enter unique serial numbers (or leave blank)"
 msgstr ""
 
-#: stock/forms.py:192
+#: stock/forms.py:198
 msgid "Label"
 msgstr ""
 
-#: stock/forms.py:193 stock/forms.py:249
+#: stock/forms.py:199 stock/forms.py:255
 msgid "Select test report template"
 msgstr ""
 
-#: stock/forms.py:257
+#: stock/forms.py:263
 msgid "Include stock items in sub locations"
 msgstr ""
 
-#: stock/forms.py:292
+#: stock/forms.py:298
 msgid "Stock item to install"
 msgstr ""
 
-#: stock/forms.py:299
+#: stock/forms.py:305
 msgid "Stock quantity to assign"
 msgstr ""
 
-#: stock/forms.py:327
+#: stock/forms.py:333
 msgid "Must not exceed available quantity"
 msgstr ""
 
-#: stock/forms.py:337
+#: stock/forms.py:343
 msgid "Destination location for uninstalled items"
 msgstr ""
 
-#: stock/forms.py:339
+#: stock/forms.py:345
 msgid "Add transaction note (optional)"
 msgstr ""
 
-#: stock/forms.py:341
+#: stock/forms.py:347
 msgid "Confirm uninstall"
 msgstr ""
 
-#: stock/forms.py:341
+#: stock/forms.py:347
 msgid "Confirm removal of installed stock items"
 msgstr ""
 
-#: stock/forms.py:365
+#: stock/forms.py:371
 msgid "Destination stock location"
 msgstr ""
 
-#: stock/forms.py:367
+#: stock/forms.py:373
 msgid "Add note (required)"
 msgstr ""
 
-#: stock/forms.py:371 stock/views.py:935 stock/views.py:1133
+#: stock/forms.py:377 stock/views.py:935 stock/views.py:1133
 msgid "Confirm stock adjustment"
 msgstr ""
 
-#: stock/forms.py:371
+#: stock/forms.py:377
 msgid "Confirm movement of stock items"
 msgstr ""
 
-#: stock/forms.py:373
+#: stock/forms.py:379
 msgid "Set Default Location"
 msgstr ""
 
-#: stock/forms.py:373
+#: stock/forms.py:379
 msgid "Set the destination as the default location for selected parts"
 msgstr ""
 
-#: stock/models.py:179
+#: stock/models.py:186
 msgid "Created stock item"
 msgstr ""
 
-#: stock/models.py:215
+#: stock/models.py:222
 msgid "StockItem with this serial number already exists"
 msgstr ""
 
-#: stock/models.py:251
+#: stock/models.py:258
 #, python-brace-format
 msgid "Part type ('{pf}') must be {pe}"
 msgstr ""
 
-#: stock/models.py:261 stock/models.py:270
+#: stock/models.py:268 stock/models.py:277
 msgid "Quantity must be 1 for item with a serial number"
 msgstr ""
 
-#: stock/models.py:262
+#: stock/models.py:269
 msgid "Serial number cannot be set if quantity greater than 1"
 msgstr ""
 
-#: stock/models.py:284
+#: stock/models.py:291
 msgid "Item cannot belong to itself"
 msgstr ""
 
-#: stock/models.py:290
+#: stock/models.py:297
 msgid "Item must have a build reference if is_building=True"
 msgstr ""
 
-#: stock/models.py:297
+#: stock/models.py:304
 msgid "Build reference does not point to the same part object"
 msgstr ""
 
-#: stock/models.py:330
+#: stock/models.py:337
 msgid "Parent Stock Item"
 msgstr ""
 
-#: stock/models.py:339
+#: stock/models.py:346
 msgid "Base part"
 msgstr ""
 
-#: stock/models.py:348
+#: stock/models.py:355
 msgid "Select a matching supplier part for this stock item"
 msgstr ""
 
-#: stock/models.py:353 stock/templates/stock/stock_app_base.html:7
+#: stock/models.py:360 stock/templates/stock/stock_app_base.html:7
 msgid "Stock Location"
 msgstr ""
 
-#: stock/models.py:356
+#: stock/models.py:363
 msgid "Where is this stock item located?"
 msgstr ""
 
-#: stock/models.py:361 stock/templates/stock/item_base.html:212
+#: stock/models.py:368 stock/templates/stock/item_base.html:229
 msgid "Installed In"
 msgstr ""
 
-#: stock/models.py:364
+#: stock/models.py:371
 msgid "Is this item installed in another item?"
 msgstr ""
 
-#: stock/models.py:380
+#: stock/models.py:387
 msgid "Serial number for this item"
 msgstr ""
 
-#: stock/models.py:392
+#: stock/models.py:399
 msgid "Batch code for this stock item"
 msgstr ""
 
-#: stock/models.py:396
+#: stock/models.py:403
 msgid "Stock Quantity"
 msgstr ""
 
-#: stock/models.py:405
+#: stock/models.py:412
 msgid "Source Build"
 msgstr ""
 
-#: stock/models.py:407
+#: stock/models.py:414
 msgid "Build for this stock item"
 msgstr ""
 
-#: stock/models.py:418
+#: stock/models.py:425
 msgid "Source Purchase Order"
 msgstr ""
 
-#: stock/models.py:421
+#: stock/models.py:428
 msgid "Purchase order for this stock item"
 msgstr ""
 
-#: stock/models.py:427
+#: stock/models.py:434
 msgid "Destination Sales Order"
 msgstr ""
 
-#: stock/models.py:439
+#: stock/models.py:440 stock/templates/stock/item_base.html:316
+#: templates/js/stock.js:597
+msgid "Expiry Date"
+msgstr ""
+
+#: stock/models.py:441
+msgid ""
+"Expiry date for stock item. Stock will be considered expired after this date"
+msgstr ""
+
+#: stock/models.py:454
 msgid "Delete this Stock Item when stock is depleted"
 msgstr ""
 
-#: stock/models.py:449 stock/templates/stock/item_notes.html:14
+#: stock/models.py:464 stock/templates/stock/item_notes.html:14
 #: stock/templates/stock/item_notes.html:30
 msgid "Stock Item Notes"
 msgstr ""
 
-#: stock/models.py:459
+#: stock/models.py:474
 msgid "Single unit purchase price at time of purchase"
 msgstr ""
 
-#: stock/models.py:510
+#: stock/models.py:574
 msgid "Assigned to Customer"
 msgstr ""
 
-#: stock/models.py:512
+#: stock/models.py:576
 msgid "Manually assigned to customer"
 msgstr ""
 
-#: stock/models.py:525
+#: stock/models.py:589
 msgid "Returned from customer"
 msgstr ""
 
-#: stock/models.py:527
+#: stock/models.py:591
 msgid "Returned to location"
 msgstr ""
 
-#: stock/models.py:652
+#: stock/models.py:716
 msgid "Installed into stock item"
 msgstr ""
 
-#: stock/models.py:660
+#: stock/models.py:724
 msgid "Installed stock item"
 msgstr ""
 
-#: stock/models.py:684
+#: stock/models.py:748
 msgid "Uninstalled stock item"
 msgstr ""
 
-#: stock/models.py:703
+#: stock/models.py:767
 msgid "Uninstalled into location"
 msgstr ""
 
-#: stock/models.py:803
+#: stock/models.py:847
 msgid "Part is not set as trackable"
 msgstr ""
 
-#: stock/models.py:809
+#: stock/models.py:853
 msgid "Quantity must be integer"
 msgstr ""
 
-#: stock/models.py:815
+#: stock/models.py:859
 #, python-brace-format
 msgid "Quantity must not exceed available stock quantity ({n})"
 msgstr ""
 
-#: stock/models.py:818
+#: stock/models.py:862
 msgid "Serial numbers must be a list of integers"
 msgstr ""
 
-#: stock/models.py:821
+#: stock/models.py:865
 msgid "Quantity does not match serial numbers"
 msgstr ""
 
-#: stock/models.py:853
+#: stock/models.py:897
 msgid "Add serial number"
 msgstr ""
 
-#: stock/models.py:856
+#: stock/models.py:900
 #, python-brace-format
 msgid "Serialized {n} items"
 msgstr ""
 
-#: stock/models.py:967
+#: stock/models.py:1011
 msgid "StockItem cannot be moved as it is not in stock"
 msgstr ""
 
-#: stock/models.py:1321
+#: stock/models.py:1400
 msgid "Tracking entry title"
 msgstr ""
 
-#: stock/models.py:1323
+#: stock/models.py:1402
 msgid "Entry notes"
 msgstr ""
 
-#: stock/models.py:1325
+#: stock/models.py:1404
 msgid "Link to external page for further information"
 msgstr ""
 
-#: stock/models.py:1385
+#: stock/models.py:1464
 msgid "Value must be provided for this test"
 msgstr ""
 
-#: stock/models.py:1391
+#: stock/models.py:1470
 msgid "Attachment must be uploaded for this test"
 msgstr ""
 
-#: stock/models.py:1408
+#: stock/models.py:1487
 msgid "Test"
 msgstr ""
 
-#: stock/models.py:1409
+#: stock/models.py:1488
 msgid "Test name"
 msgstr ""
 
-#: stock/models.py:1414
+#: stock/models.py:1493
 msgid "Result"
 msgstr ""
 
-#: stock/models.py:1415 templates/js/table_filters.js:162
+#: stock/models.py:1494 templates/js/table_filters.js:172
 msgid "Test result"
 msgstr ""
 
-#: stock/models.py:1421
+#: stock/models.py:1500
 msgid "Test output value"
 msgstr ""
 
-#: stock/models.py:1427
+#: stock/models.py:1506
 msgid "Attachment"
 msgstr ""
 
-#: stock/models.py:1428
+#: stock/models.py:1507
 msgid "Test result attachment"
 msgstr ""
 
-#: stock/models.py:1434
+#: stock/models.py:1513
 msgid "Test notes"
 msgstr ""
 
@@ -3958,111 +4075,134 @@ msgid ""
 "This stock item will be automatically deleted when all stock is depleted."
 msgstr ""
 
-#: stock/templates/stock/item_base.html:107 templates/js/barcode.js:283
+#: stock/templates/stock/item_base.html:74
+#: stock/templates/stock/item_base.html:320 templates/js/table_filters.js:111
+msgid "Expired"
+msgstr ""
+
+#: stock/templates/stock/item_base.html:78
+#: stock/templates/stock/item_base.html:322 templates/js/table_filters.js:116
+msgid "Stale"
+msgstr ""
+
+#: stock/templates/stock/item_base.html:113 templates/js/barcode.js:283
 #: templates/js/barcode.js:288
 msgid "Unlink Barcode"
 msgstr ""
 
-#: stock/templates/stock/item_base.html:109
+#: stock/templates/stock/item_base.html:115
 msgid "Link Barcode"
 msgstr ""
 
-#: stock/templates/stock/item_base.html:117
+#: stock/templates/stock/item_base.html:123
+msgid "Document actions"
+msgstr ""
+
+#: stock/templates/stock/item_base.html:129
+#: stock/templates/stock/item_tests.html:25
+msgid "Test Report"
+msgstr ""
+
+#: stock/templates/stock/item_base.html:137
 msgid "Stock adjustment actions"
 msgstr ""
 
-#: stock/templates/stock/item_base.html:121
+#: stock/templates/stock/item_base.html:141
 #: stock/templates/stock/location.html:41 templates/stock_table.html:23
 msgid "Count stock"
 msgstr ""
 
-#: stock/templates/stock/item_base.html:122 templates/stock_table.html:21
+#: stock/templates/stock/item_base.html:142 templates/stock_table.html:21
 msgid "Add stock"
 msgstr ""
 
-#: stock/templates/stock/item_base.html:123 templates/stock_table.html:22
+#: stock/templates/stock/item_base.html:143 templates/stock_table.html:22
 msgid "Remove stock"
 msgstr ""
 
-#: stock/templates/stock/item_base.html:125
+#: stock/templates/stock/item_base.html:145
 msgid "Transfer stock"
 msgstr ""
 
-#: stock/templates/stock/item_base.html:127
+#: stock/templates/stock/item_base.html:147
 msgid "Serialize stock"
 msgstr ""
 
-#: stock/templates/stock/item_base.html:131
+#: stock/templates/stock/item_base.html:151
 msgid "Assign to customer"
 msgstr ""
 
-#: stock/templates/stock/item_base.html:134
+#: stock/templates/stock/item_base.html:154
 msgid "Return to stock"
 msgstr ""
 
-#: stock/templates/stock/item_base.html:138 templates/js/stock.js:985
+#: stock/templates/stock/item_base.html:158 templates/js/stock.js:1017
 msgid "Uninstall stock item"
 msgstr ""
 
-#: stock/templates/stock/item_base.html:138
+#: stock/templates/stock/item_base.html:158
 msgid "Uninstall"
 msgstr ""
 
-#: stock/templates/stock/item_base.html:147
+#: stock/templates/stock/item_base.html:167
 #: stock/templates/stock/location.html:38
 msgid "Stock actions"
 msgstr ""
 
-#: stock/templates/stock/item_base.html:150
+#: stock/templates/stock/item_base.html:170
 msgid "Convert to variant"
 msgstr ""
 
-#: stock/templates/stock/item_base.html:153
+#: stock/templates/stock/item_base.html:173
 msgid "Duplicate stock item"
 msgstr ""
 
-#: stock/templates/stock/item_base.html:155
+#: stock/templates/stock/item_base.html:175
 msgid "Edit stock item"
 msgstr ""
 
-#: stock/templates/stock/item_base.html:158
+#: stock/templates/stock/item_base.html:178
 msgid "Delete stock item"
 msgstr ""
 
-#: stock/templates/stock/item_base.html:164
-msgid "Generate test report"
-msgstr ""
-
-#: stock/templates/stock/item_base.html:172
+#: stock/templates/stock/item_base.html:189
 msgid "Stock Item Details"
 msgstr ""
 
-#: stock/templates/stock/item_base.html:231 templates/js/build.js:442
+#: stock/templates/stock/item_base.html:248 templates/js/build.js:442
 msgid "No location set"
 msgstr ""
 
-#: stock/templates/stock/item_base.html:238
+#: stock/templates/stock/item_base.html:255
 msgid "Barcode Identifier"
 msgstr ""
 
-#: stock/templates/stock/item_base.html:252 templates/js/build.js:642
+#: stock/templates/stock/item_base.html:269 templates/js/build.js:642
 #: templates/navbar.html:25
 msgid "Build"
 msgstr ""
 
-#: stock/templates/stock/item_base.html:273
+#: stock/templates/stock/item_base.html:290
 msgid "Parent Item"
 msgstr ""
 
-#: stock/templates/stock/item_base.html:298
+#: stock/templates/stock/item_base.html:320
+msgid "This StockItem expired on"
+msgstr ""
+
+#: stock/templates/stock/item_base.html:322
+msgid "This StockItem expires on"
+msgstr ""
+
+#: stock/templates/stock/item_base.html:329
 msgid "Last Updated"
 msgstr ""
 
-#: stock/templates/stock/item_base.html:303
+#: stock/templates/stock/item_base.html:334
 msgid "Last Stocktake"
 msgstr ""
 
-#: stock/templates/stock/item_base.html:307
+#: stock/templates/stock/item_base.html:338
 msgid "No stocktake performed"
 msgstr ""
 
@@ -4118,10 +4258,6 @@ msgstr ""
 msgid "Add Test Data"
 msgstr ""
 
-#: stock/templates/stock/item_tests.html:25
-msgid "Test Report"
-msgstr ""
-
 #: stock/templates/stock/location.html:18
 msgid "All stock items"
 msgstr ""
@@ -4182,7 +4318,7 @@ msgstr ""
 msgid "The following stock items will be uninstalled"
 msgstr ""
 
-#: stock/templates/stock/stockitem_convert.html:7 stock/views.py:1330
+#: stock/templates/stock/stockitem_convert.html:7 stock/views.py:1335
 msgid "Convert Stock Item"
 msgstr ""
 
@@ -4380,39 +4516,39 @@ msgstr ""
 msgid "Edit Stock Item"
 msgstr ""
 
-#: stock/views.py:1380
+#: stock/views.py:1385
 msgid "Serialize Stock"
 msgstr ""
 
-#: stock/views.py:1474 templates/js/build.js:210
+#: stock/views.py:1479 templates/js/build.js:210
 msgid "Create new Stock Item"
 msgstr ""
 
-#: stock/views.py:1578
+#: stock/views.py:1587
 msgid "Duplicate Stock Item"
 msgstr ""
 
-#: stock/views.py:1650
+#: stock/views.py:1664
 msgid "Quantity cannot be negative"
 msgstr ""
 
-#: stock/views.py:1736
+#: stock/views.py:1750
 msgid "Delete Stock Location"
 msgstr ""
 
-#: stock/views.py:1750
+#: stock/views.py:1764
 msgid "Delete Stock Item"
 msgstr ""
 
-#: stock/views.py:1762
+#: stock/views.py:1776
 msgid "Delete Stock Tracking Entry"
 msgstr ""
 
-#: stock/views.py:1781
+#: stock/views.py:1795
 msgid "Edit Stock Tracking Entry"
 msgstr ""
 
-#: stock/views.py:1791
+#: stock/views.py:1805
 msgid "Add Stock Tracking Entry"
 msgstr ""
 
@@ -4444,7 +4580,11 @@ msgstr ""
 msgid "Pending Builds"
 msgstr ""
 
-#: templates/InvenTree/index.html:4
+#: templates/InvenTree/expired_stock.html:7
+msgid "Expired Stock"
+msgstr ""
+
+#: templates/InvenTree/index.html:5
 msgid "Index"
 msgstr ""
 
@@ -4472,11 +4612,11 @@ msgstr ""
 msgid "Enter a search query"
 msgstr ""
 
-#: templates/InvenTree/search.html:191 templates/js/stock.js:289
+#: templates/InvenTree/search.html:191 templates/js/stock.js:290
 msgid "Shipped to customer"
 msgstr ""
 
-#: templates/InvenTree/search.html:194 templates/js/stock.js:299
+#: templates/InvenTree/search.html:194 templates/js/stock.js:300
 msgid "No stock location set"
 msgstr ""
 
@@ -4505,12 +4645,12 @@ msgid "Default Value"
 msgstr ""
 
 #: templates/InvenTree/settings/category.html:70
-#: templates/InvenTree/settings/part.html:75
+#: templates/InvenTree/settings/part.html:78
 msgid "Edit Template"
 msgstr ""
 
 #: templates/InvenTree/settings/category.html:71
-#: templates/InvenTree/settings/part.html:76
+#: templates/InvenTree/settings/part.html:79
 msgid "Delete Template"
 msgstr ""
 
@@ -4518,6 +4658,10 @@ msgstr ""
 msgid "Global InvenTree Settings"
 msgstr ""
 
+#: templates/InvenTree/settings/header.html:7
+msgid "Setting"
+msgstr ""
+
 #: templates/InvenTree/settings/part.html:9
 msgid "Part Settings"
 msgstr ""
@@ -4526,11 +4670,11 @@ msgstr ""
 msgid "Part Options"
 msgstr ""
 
-#: templates/InvenTree/settings/part.html:34
+#: templates/InvenTree/settings/part.html:37
 msgid "Part Parameter Templates"
 msgstr ""
 
-#: templates/InvenTree/settings/part.html:55
+#: templates/InvenTree/settings/part.html:58
 msgid "No part parameter templates found"
 msgstr ""
 
@@ -4538,11 +4682,11 @@ msgstr ""
 msgid "Purchase Order Settings"
 msgstr ""
 
-#: templates/InvenTree/settings/setting.html:16
+#: templates/InvenTree/settings/setting.html:23
 msgid "No value set"
 msgstr ""
 
-#: templates/InvenTree/settings/setting.html:24
+#: templates/InvenTree/settings/setting.html:31
 msgid "Edit setting"
 msgstr ""
 
@@ -4559,6 +4703,10 @@ msgstr ""
 msgid "Stock Settings"
 msgstr ""
 
+#: templates/InvenTree/settings/stock.html:13
+msgid "Stock Options"
+msgstr ""
+
 #: templates/InvenTree/settings/tabs.html:3
 #: templates/InvenTree/settings/user.html:10
 msgid "User Settings"
@@ -4630,6 +4778,14 @@ msgstr ""
 msgid "Outstanding Sales Orders"
 msgstr ""
 
+#: templates/InvenTree/so_overdue.html:7
+msgid "Overdue Sales Orders"
+msgstr ""
+
+#: templates/InvenTree/stale_stock.html:7
+msgid "Stale Stock"
+msgstr ""
+
 #: templates/InvenTree/starred_parts.html:7
 msgid "Starred Parts"
 msgstr ""
@@ -4887,15 +5043,11 @@ msgstr ""
 msgid "Assembled part"
 msgstr ""
 
-#: templates/js/company.js:208
-msgid "Link"
-msgstr ""
-
 #: templates/js/order.js:135
 msgid "No purchase orders found"
 msgstr ""
 
-#: templates/js/order.js:188 templates/js/stock.js:681
+#: templates/js/order.js:188 templates/js/stock.js:702
 msgid "Date"
 msgstr ""
 
@@ -4903,7 +5055,11 @@ msgstr ""
 msgid "No sales orders found"
 msgstr ""
 
-#: templates/js/order.js:275
+#: templates/js/order.js:241
+msgid "Order is overdue"
+msgstr ""
+
+#: templates/js/order.js:286
 msgid "Shipment Date"
 msgstr ""
 
@@ -4931,8 +5087,8 @@ msgstr ""
 msgid "No parts found"
 msgstr ""
 
-#: templates/js/part.js:343 templates/js/stock.js:456
-#: templates/js/stock.js:1017
+#: templates/js/part.js:343 templates/js/stock.js:463
+#: templates/js/stock.js:1049
 msgid "Select"
 msgstr ""
 
@@ -4940,7 +5096,7 @@ msgstr ""
 msgid "No category"
 msgstr ""
 
-#: templates/js/part.js:429 templates/js/table_filters.js:260
+#: templates/js/part.js:429 templates/js/table_filters.js:274
 msgid "Low stock"
 msgstr ""
 
@@ -4960,11 +5116,11 @@ msgstr ""
 msgid "No test templates matching query"
 msgstr ""
 
-#: templates/js/part.js:604 templates/js/stock.js:63
+#: templates/js/part.js:604 templates/js/stock.js:64
 msgid "Edit test result"
 msgstr ""
 
-#: templates/js/part.js:605 templates/js/stock.js:64
+#: templates/js/part.js:605 templates/js/stock.js:65
 msgid "Delete test result"
 msgstr ""
 
@@ -4972,103 +5128,111 @@ msgstr ""
 msgid "This test is defined for a parent part"
 msgstr ""
 
-#: templates/js/stock.js:26
+#: templates/js/stock.js:27
 msgid "PASS"
 msgstr ""
 
-#: templates/js/stock.js:28
+#: templates/js/stock.js:29
 msgid "FAIL"
 msgstr ""
 
-#: templates/js/stock.js:33
+#: templates/js/stock.js:34
 msgid "NO RESULT"
 msgstr ""
 
-#: templates/js/stock.js:59
+#: templates/js/stock.js:60
 msgid "Add test result"
 msgstr ""
 
-#: templates/js/stock.js:78
+#: templates/js/stock.js:79
 msgid "No test results found"
 msgstr ""
 
-#: templates/js/stock.js:120
+#: templates/js/stock.js:121
 msgid "Test Date"
 msgstr ""
 
-#: templates/js/stock.js:281
+#: templates/js/stock.js:282
 msgid "In production"
 msgstr ""
 
-#: templates/js/stock.js:285
+#: templates/js/stock.js:286
 msgid "Installed in Stock Item"
 msgstr ""
 
-#: templates/js/stock.js:293
+#: templates/js/stock.js:294
 msgid "Assigned to Sales Order"
 msgstr ""
 
-#: templates/js/stock.js:313
+#: templates/js/stock.js:314
 msgid "No stock items matching query"
 msgstr ""
 
-#: templates/js/stock.js:424
+#: templates/js/stock.js:431
 msgid "Undefined location"
 msgstr ""
 
-#: templates/js/stock.js:518
+#: templates/js/stock.js:525
 msgid "Stock item is in production"
 msgstr ""
 
-#: templates/js/stock.js:523
+#: templates/js/stock.js:530
 msgid "Stock item assigned to sales order"
 msgstr ""
 
-#: templates/js/stock.js:526
+#: templates/js/stock.js:533
 msgid "Stock item assigned to customer"
 msgstr ""
 
-#: templates/js/stock.js:530
+#: templates/js/stock.js:537
+msgid "Stock item has expired"
+msgstr ""
+
+#: templates/js/stock.js:539
+msgid "Stock item will expire soon"
+msgstr ""
+
+#: templates/js/stock.js:543
 msgid "Stock item has been allocated"
 msgstr ""
 
-#: templates/js/stock.js:534
+#: templates/js/stock.js:547
 msgid "Stock item has been installed in another item"
 msgstr ""
 
-#: templates/js/stock.js:542
+#: templates/js/stock.js:555
 msgid "Stock item has been rejected"
 msgstr ""
 
-#: templates/js/stock.js:546
+#: templates/js/stock.js:559
 msgid "Stock item is lost"
 msgstr ""
 
-#: templates/js/stock.js:549
+#: templates/js/stock.js:562
 msgid "Stock item is destroyed"
 msgstr ""
 
-#: templates/js/stock.js:553 templates/js/table_filters.js:106
+#: templates/js/stock.js:566 templates/js/table_filters.js:106
 msgid "Depleted"
 msgstr ""
 
-#: templates/js/stock.js:747
+#: templates/js/stock.js:768
 msgid "No user information"
 msgstr ""
 
-#: templates/js/stock.js:856
+#: templates/js/stock.js:888
 msgid "Create New Location"
 msgstr ""
 
-#: templates/js/stock.js:955
+#: templates/js/stock.js:987
 msgid "Serial"
 msgstr ""
 
-#: templates/js/stock.js:1048 templates/js/table_filters.js:121
+#: templates/js/stock.js:1080 templates/js/table_filters.js:131
 msgid "Installed"
 msgstr ""
 
-#: templates/js/stock.js:1073
+#: templates/js/stock.js:1105
 msgid "Install item"
 msgstr ""
 
@@ -5080,36 +5244,36 @@ msgstr ""
 msgid "Validated"
 msgstr ""
 
-#: templates/js/table_filters.js:65 templates/js/table_filters.js:131
+#: templates/js/table_filters.js:65 templates/js/table_filters.js:141
 msgid "Is Serialized"
 msgstr ""
 
-#: templates/js/table_filters.js:68 templates/js/table_filters.js:138
+#: templates/js/table_filters.js:68 templates/js/table_filters.js:148
 msgid "Serial number GTE"
 msgstr ""
 
-#: templates/js/table_filters.js:69 templates/js/table_filters.js:139
+#: templates/js/table_filters.js:69 templates/js/table_filters.js:149
 msgid "Serial number greater than or equal to"
 msgstr ""
 
-#: templates/js/table_filters.js:72 templates/js/table_filters.js:142
+#: templates/js/table_filters.js:72 templates/js/table_filters.js:152
 msgid "Serial number LTE"
 msgstr ""
 
-#: templates/js/table_filters.js:73 templates/js/table_filters.js:143
+#: templates/js/table_filters.js:73 templates/js/table_filters.js:153
 msgid "Serial number less than or equal to"
 msgstr ""
 
 #: templates/js/table_filters.js:76 templates/js/table_filters.js:77
-#: templates/js/table_filters.js:134 templates/js/table_filters.js:135
+#: templates/js/table_filters.js:144 templates/js/table_filters.js:145
 msgid "Serial number"
 msgstr ""
 
-#: templates/js/table_filters.js:81 templates/js/table_filters.js:152
+#: templates/js/table_filters.js:81 templates/js/table_filters.js:162
 msgid "Batch code"
 msgstr ""
 
-#: templates/js/table_filters.js:91 templates/js/table_filters.js:227
+#: templates/js/table_filters.js:91 templates/js/table_filters.js:241
 msgid "Active parts"
 msgstr ""
 
@@ -5138,74 +5302,82 @@ msgid "Show stock items which are depleted"
 msgstr ""
 
 #: templates/js/table_filters.js:112
-msgid "Show items which are in stock"
-msgstr ""
-
-#: templates/js/table_filters.js:116
-msgid "In Production"
+msgid "Show stock items which have expired"
 msgstr ""
 
 #: templates/js/table_filters.js:117
-msgid "Show items which are in production"
+msgid "Show stock which is close to expiring"
 msgstr ""
 
 #: templates/js/table_filters.js:122
-msgid "Show stock items which are installed in another item"
+msgid "Show items which are in stock"
 msgstr ""
 
 #: templates/js/table_filters.js:126
-msgid "Sent to customer"
+msgid "In Production"
 msgstr ""
 
 #: templates/js/table_filters.js:127
+msgid "Show items which are in production"
+msgstr ""
+
+#: templates/js/table_filters.js:132
+msgid "Show stock items which are installed in another item"
+msgstr ""
+
+#: templates/js/table_filters.js:136
+msgid "Sent to customer"
+msgstr ""
+
+#: templates/js/table_filters.js:137
 msgid "Show items which have been assigned to a customer"
 msgstr ""
 
-#: templates/js/table_filters.js:147 templates/js/table_filters.js:148
+#: templates/js/table_filters.js:157 templates/js/table_filters.js:158
 msgid "Stock status"
 msgstr ""
 
-#: templates/js/table_filters.js:181
+#: templates/js/table_filters.js:191
 msgid "Build status"
 msgstr ""
 
-#: templates/js/table_filters.js:200 templates/js/table_filters.js:213
+#: templates/js/table_filters.js:210 templates/js/table_filters.js:223
 msgid "Order status"
 msgstr ""
 
-#: templates/js/table_filters.js:205 templates/js/table_filters.js:218
+#: templates/js/table_filters.js:215 templates/js/table_filters.js:228
 msgid "Outstanding"
 msgstr ""
 
-#: templates/js/table_filters.js:237
+#: templates/js/table_filters.js:251
 msgid "Include subcategories"
 msgstr ""
 
-#: templates/js/table_filters.js:238
+#: templates/js/table_filters.js:252
 msgid "Include parts in subcategories"
 msgstr ""
 
-#: templates/js/table_filters.js:242
+#: templates/js/table_filters.js:256
 msgid "Has IPN"
 msgstr ""
 
-#: templates/js/table_filters.js:243
+#: templates/js/table_filters.js:257
 msgid "Part has internal part number"
 msgstr ""
 
-#: templates/js/table_filters.js:248
+#: templates/js/table_filters.js:262
 msgid "Show active parts"
 msgstr ""
 
-#: templates/js/table_filters.js:256
+#: templates/js/table_filters.js:270
 msgid "Stock available"
 msgstr ""
 
-#: templates/js/table_filters.js:272
+#: templates/js/table_filters.js:286
 msgid "Starred"
 msgstr ""
 
-#: templates/js/table_filters.js:284
+#: templates/js/table_filters.js:298
 msgid "Purchasable"
 msgstr ""
 
@@ -5245,7 +5417,7 @@ msgstr ""
 msgid "Logout"
 msgstr ""
 
-#: templates/navbar.html:69
+#: templates/navbar.html:69 templates/registration/login.html:43
 msgid "Login"
 msgstr ""
 
diff --git a/InvenTree/locale/es/LC_MESSAGES/django.po b/InvenTree/locale/es/LC_MESSAGES/django.po
index 6f60f8ed93..18f86b6972 100644
--- a/InvenTree/locale/es/LC_MESSAGES/django.po
+++ b/InvenTree/locale/es/LC_MESSAGES/django.po
@@ -8,7 +8,7 @@ msgid ""
 msgstr ""
 "Project-Id-Version: PACKAGE VERSION\n"
 "Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2020-12-16 19:08+1100\n"
+"POT-Creation-Date: 2021-01-07 23:48+1100\n"
 "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
 "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
 "Language-Team: LANGUAGE <LL@li.org>\n"
@@ -26,7 +26,11 @@ msgstr ""
 msgid "No matching action found"
 msgstr ""
 
-#: InvenTree/forms.py:110 build/forms.py:91 build/forms.py:179
+#: InvenTree/fields.py:44
+msgid "Enter date"
+msgstr ""
+
+#: InvenTree/forms.py:110 build/forms.py:90 build/forms.py:178
 msgid "Confirm"
 msgstr ""
 
@@ -50,8 +54,8 @@ msgstr ""
 msgid "Select Category"
 msgstr ""
 
-#: InvenTree/helpers.py:361 order/models.py:189 order/models.py:271
-#: stock/views.py:1646
+#: InvenTree/helpers.py:361 order/models.py:216 order/models.py:298
+#: stock/views.py:1660
 msgid "Invalid quantity provided"
 msgstr ""
 
@@ -91,12 +95,12 @@ msgstr ""
 msgid "File comment"
 msgstr ""
 
-#: InvenTree/models.py:68 templates/js/stock.js:738
+#: InvenTree/models.py:68 templates/js/stock.js:759
 msgid "User"
 msgstr ""
 
-#: InvenTree/models.py:106 part/templates/part/params.html:24
-#: templates/js/part.js:129
+#: InvenTree/models.py:106 part/models.py:647
+#: part/templates/part/params.html:24 templates/js/part.js:129
 msgid "Name"
 msgstr ""
 
@@ -129,7 +133,7 @@ msgid "InvenTree system health checks failed"
 msgstr ""
 
 #: InvenTree/status_codes.py:94 InvenTree/status_codes.py:135
-#: InvenTree/status_codes.py:223
+#: InvenTree/status_codes.py:228
 msgid "Pending"
 msgstr ""
 
@@ -137,51 +141,51 @@ msgstr ""
 msgid "Placed"
 msgstr ""
 
-#: InvenTree/status_codes.py:96 InvenTree/status_codes.py:226
+#: InvenTree/status_codes.py:96 InvenTree/status_codes.py:231
 msgid "Complete"
 msgstr ""
 
 #: InvenTree/status_codes.py:97 InvenTree/status_codes.py:137
-#: InvenTree/status_codes.py:225
+#: InvenTree/status_codes.py:230
 msgid "Cancelled"
 msgstr ""
 
 #: InvenTree/status_codes.py:98 InvenTree/status_codes.py:138
-#: InvenTree/status_codes.py:175
+#: InvenTree/status_codes.py:180
 msgid "Lost"
 msgstr ""
 
 #: InvenTree/status_codes.py:99 InvenTree/status_codes.py:139
-#: InvenTree/status_codes.py:177
+#: InvenTree/status_codes.py:182
 msgid "Returned"
 msgstr ""
 
 #: InvenTree/status_codes.py:136
-#: order/templates/order/sales_order_base.html:106
+#: order/templates/order/sales_order_base.html:121
 msgid "Shipped"
 msgstr ""
 
-#: InvenTree/status_codes.py:171
+#: InvenTree/status_codes.py:176
 msgid "OK"
 msgstr ""
 
-#: InvenTree/status_codes.py:172
+#: InvenTree/status_codes.py:177
 msgid "Attention needed"
 msgstr ""
 
-#: InvenTree/status_codes.py:173
+#: InvenTree/status_codes.py:178
 msgid "Damaged"
 msgstr ""
 
-#: InvenTree/status_codes.py:174
+#: InvenTree/status_codes.py:179
 msgid "Destroyed"
 msgstr ""
 
-#: InvenTree/status_codes.py:176
+#: InvenTree/status_codes.py:181
 msgid "Rejected"
 msgstr ""
 
-#: InvenTree/status_codes.py:224
+#: InvenTree/status_codes.py:229
 msgid "Production"
 msgstr ""
 
@@ -283,13 +287,22 @@ msgstr ""
 msgid "Barcode associated with StockItem"
 msgstr ""
 
-#: build/forms.py:32
+#: build/forms.py:34
 msgid "Build Order reference"
 msgstr ""
 
-#: build/forms.py:79 build/templates/build/auto_allocate.html:17
+#: build/forms.py:35
+msgid "Order target date"
+msgstr ""
+
+#: build/forms.py:39 build/models.py:206
+msgid ""
+"Target date for build completion. Build will be overdue after this date."
+msgstr ""
+
+#: build/forms.py:78 build/templates/build/auto_allocate.html:17
 #: build/templates/build/build_base.html:83
-#: build/templates/build/detail.html:29 common/models.py:494
+#: build/templates/build/detail.html:29 common/models.py:589
 #: company/forms.py:112 company/templates/company/supplier_part_pricing.html:75
 #: order/templates/order/order_wizard/select_parts.html:32
 #: order/templates/order/purchase_order_detail.html:179
@@ -297,289 +310,285 @@ msgstr ""
 #: order/templates/order/sales_order_detail.html:156
 #: part/templates/part/allocation.html:16
 #: part/templates/part/allocation.html:49
-#: part/templates/part/sale_prices.html:82 stock/forms.py:298
+#: part/templates/part/sale_prices.html:82 stock/forms.py:304
 #: stock/templates/stock/item_base.html:40
 #: stock/templates/stock/item_base.html:46
-#: stock/templates/stock/item_base.html:197
+#: stock/templates/stock/item_base.html:214
 #: stock/templates/stock/stock_adjust.html:18 templates/js/barcode.js:338
-#: templates/js/bom.js:195 templates/js/build.js:420 templates/js/stock.js:729
-#: templates/js/stock.js:957
+#: templates/js/bom.js:195 templates/js/build.js:420 templates/js/stock.js:750
+#: templates/js/stock.js:989
 msgid "Quantity"
 msgstr ""
 
-#: build/forms.py:80
+#: build/forms.py:79
 msgid "Enter quantity for build output"
 msgstr ""
 
-#: build/forms.py:84 stock/forms.py:111
+#: build/forms.py:83 stock/forms.py:116
 msgid "Serial numbers"
 msgstr ""
 
-#: build/forms.py:86
+#: build/forms.py:85
 msgid "Enter serial numbers for build outputs"
 msgstr ""
 
-#: build/forms.py:92
+#: build/forms.py:91
 msgid "Confirm creation of build outut"
 msgstr ""
 
-#: build/forms.py:112
+#: build/forms.py:111
 msgid "Confirm deletion of build output"
 msgstr ""
 
-#: build/forms.py:133
+#: build/forms.py:132
 msgid "Confirm unallocation of stock"
 msgstr ""
 
-#: build/forms.py:157
+#: build/forms.py:156
 msgid "Confirm stock allocation"
 msgstr ""
 
-#: build/forms.py:180
+#: build/forms.py:179
 msgid "Mark build as complete"
 msgstr ""
 
-#: build/forms.py:204
+#: build/forms.py:203
 msgid "Location of completed parts"
 msgstr ""
 
-#: build/forms.py:209
+#: build/forms.py:208
 msgid "Confirm completion with incomplete stock allocation"
 msgstr ""
 
-#: build/forms.py:212
+#: build/forms.py:211
 msgid "Confirm build completion"
 msgstr ""
 
-#: build/forms.py:232 build/views.py:68
+#: build/forms.py:231 build/views.py:68
 msgid "Confirm build cancellation"
 msgstr ""
 
-#: build/forms.py:246
+#: build/forms.py:245
 msgid "Select quantity of stock to allocate"
 msgstr ""
 
-#: build/models.py:59 build/templates/build/build_base.html:8
+#: build/models.py:61 build/templates/build/build_base.html:8
 #: build/templates/build/build_base.html:35
 #: part/templates/part/allocation.html:20
 msgid "Build Order"
 msgstr ""
 
-#: build/models.py:60 build/templates/build/index.html:6
-#: build/templates/build/index.html:14 order/templates/order/so_builds.html:11
+#: build/models.py:62 build/templates/build/index.html:8
+#: build/templates/build/index.html:15 order/templates/order/so_builds.html:11
 #: order/templates/order/so_tabs.html:9 part/templates/part/tabs.html:31
 #: templates/InvenTree/settings/tabs.html:28 users/models.py:30
 msgid "Build Orders"
 msgstr ""
 
-#: build/models.py:75
+#: build/models.py:108
 msgid "Build Order Reference"
 msgstr ""
 
-#: build/models.py:76 order/templates/order/purchase_order_detail.html:174
+#: build/models.py:109 order/templates/order/purchase_order_detail.html:174
 #: templates/js/bom.js:187 templates/js/build.js:509
 msgid "Reference"
 msgstr ""
 
-#: build/models.py:83 build/templates/build/detail.html:19
-#: company/templates/company/detail.html:23
+#: build/models.py:116 build/templates/build/detail.html:19
+#: company/models.py:359 company/templates/company/detail.html:23
 #: company/templates/company/supplier_part_base.html:61
 #: company/templates/company/supplier_part_detail.html:27
-#: order/templates/order/purchase_order_detail.html:161
+#: order/templates/order/purchase_order_detail.html:161 part/models.py:671
 #: part/templates/part/detail.html:51 part/templates/part/set_category.html:14
-#: templates/InvenTree/search.html:147 templates/js/bom.js:180
+#: templates/InvenTree/search.html:147
+#: templates/InvenTree/settings/header.html:9 templates/js/bom.js:180
 #: templates/js/bom.js:517 templates/js/build.js:664 templates/js/company.js:56
-#: templates/js/order.js:175 templates/js/order.js:257 templates/js/part.js:188
+#: templates/js/order.js:175 templates/js/order.js:263 templates/js/part.js:188
 #: templates/js/part.js:271 templates/js/part.js:391 templates/js/part.js:572
-#: templates/js/stock.js:494 templates/js/stock.js:710
+#: templates/js/stock.js:501 templates/js/stock.js:731
 msgid "Description"
 msgstr ""
 
-#: build/models.py:86
+#: build/models.py:119
 msgid "Brief description of the build"
 msgstr ""
 
-#: build/models.py:95 build/templates/build/build_base.html:104
+#: build/models.py:128 build/templates/build/build_base.html:113
 #: build/templates/build/detail.html:75
 msgid "Parent Build"
 msgstr ""
 
-#: build/models.py:96
+#: build/models.py:129
 msgid "BuildOrder to which this build is allocated"
 msgstr ""
 
-#: build/models.py:101 build/templates/build/auto_allocate.html:16
+#: build/models.py:134 build/templates/build/auto_allocate.html:16
 #: build/templates/build/build_base.html:78
-#: build/templates/build/detail.html:24 order/models.py:530
+#: build/templates/build/detail.html:24 order/models.py:623
 #: order/templates/order/order_wizard/select_parts.html:30
 #: order/templates/order/purchase_order_detail.html:148
-#: order/templates/order/receive_parts.html:19 part/models.py:315
+#: order/templates/order/receive_parts.html:19 part/models.py:316
 #: part/templates/part/part_app_base.html:7 part/templates/part/related.html:26
 #: part/templates/part/set_category.html:13 templates/InvenTree/search.html:133
 #: templates/js/barcode.js:336 templates/js/bom.js:153 templates/js/bom.js:502
 #: templates/js/build.js:669 templates/js/company.js:138
-#: templates/js/part.js:252 templates/js/part.js:357 templates/js/stock.js:468
-#: templates/js/stock.js:1029
+#: templates/js/part.js:252 templates/js/part.js:357 templates/js/stock.js:475
+#: templates/js/stock.js:1061
 msgid "Part"
 msgstr ""
 
-#: build/models.py:109
+#: build/models.py:142
 msgid "Select part to build"
 msgstr ""
 
-#: build/models.py:114
+#: build/models.py:147
 msgid "Sales Order Reference"
 msgstr ""
 
-#: build/models.py:118
+#: build/models.py:151
 msgid "SalesOrder to which this build is allocated"
 msgstr ""
 
-#: build/models.py:123
+#: build/models.py:156
 msgid "Source Location"
 msgstr ""
 
-#: build/models.py:127
+#: build/models.py:160
 msgid ""
 "Select location to take stock from for this build (leave blank to take from "
 "any stock location)"
 msgstr ""
 
-#: build/models.py:132
+#: build/models.py:165
 msgid "Destination Location"
 msgstr ""
 
-#: build/models.py:136
+#: build/models.py:169
 msgid "Select location where the completed items will be stored"
 msgstr ""
 
-#: build/models.py:140
+#: build/models.py:173
 msgid "Build Quantity"
 msgstr ""
 
-#: build/models.py:143
+#: build/models.py:176
 msgid "Number of stock items to build"
 msgstr ""
 
-#: build/models.py:147
+#: build/models.py:180
 msgid "Completed items"
 msgstr ""
 
-#: build/models.py:149
+#: build/models.py:182
 msgid "Number of stock items which have been completed"
 msgstr ""
 
-#: build/models.py:153 part/templates/part/part_base.html:155
+#: build/models.py:186 part/templates/part/part_base.html:155
 msgid "Build Status"
 msgstr ""
 
-#: build/models.py:157
+#: build/models.py:190
 msgid "Build status code"
 msgstr ""
 
-#: build/models.py:161 stock/models.py:390
+#: build/models.py:194 stock/models.py:397
 msgid "Batch Code"
 msgstr ""
 
-#: build/models.py:165
+#: build/models.py:198
 msgid "Batch code for this build output"
 msgstr ""
 
-#: build/models.py:172
+#: build/models.py:205 order/models.py:404
 msgid "Target completion date"
 msgstr ""
 
-#: build/models.py:173
-msgid ""
-"Target date for build completion. Build will be overdue after this date."
-msgstr ""
-
-#: build/models.py:186 build/templates/build/detail.html:89
+#: build/models.py:219 build/templates/build/detail.html:89
 #: company/templates/company/supplier_part_base.html:68
 #: company/templates/company/supplier_part_detail.html:24
 #: part/templates/part/detail.html:80 part/templates/part/part_base.html:102
-#: stock/models.py:384 stock/templates/stock/item_base.html:280
+#: stock/models.py:391 stock/templates/stock/item_base.html:297
 msgid "External Link"
 msgstr ""
 
-#: build/models.py:187 part/models.py:672 stock/models.py:386
+#: build/models.py:220 part/models.py:705 stock/models.py:393
 msgid "Link to external URL"
 msgstr ""
 
-#: build/models.py:191 build/templates/build/tabs.html:23 company/models.py:344
+#: build/models.py:224 build/templates/build/tabs.html:23 company/models.py:366
 #: company/templates/company/tabs.html:33 order/templates/order/po_tabs.html:18
 #: order/templates/order/purchase_order_detail.html:213
-#: order/templates/order/so_tabs.html:23 part/templates/part/tabs.html:73
-#: stock/forms.py:307 stock/forms.py:339 stock/forms.py:367 stock/models.py:448
-#: stock/models.py:1433 stock/templates/stock/tabs.html:26
-#: templates/js/barcode.js:391 templates/js/bom.js:263
-#: templates/js/stock.js:116 templates/js/stock.js:582
+#: order/templates/order/so_tabs.html:23 part/models.py:831
+#: part/templates/part/tabs.html:73 stock/forms.py:313 stock/forms.py:345
+#: stock/forms.py:373 stock/models.py:463 stock/models.py:1512
+#: stock/templates/stock/tabs.html:26 templates/js/barcode.js:391
+#: templates/js/bom.js:263 templates/js/stock.js:117 templates/js/stock.js:603
 msgid "Notes"
 msgstr ""
 
-#: build/models.py:192
+#: build/models.py:225
 msgid "Extra build notes"
 msgstr ""
 
-#: build/models.py:577
+#: build/models.py:610
 msgid "No build output specified"
 msgstr ""
 
-#: build/models.py:580
+#: build/models.py:613
 msgid "Build output is already completed"
 msgstr ""
 
-#: build/models.py:583
+#: build/models.py:616
 msgid "Build output does not match Build Order"
 msgstr ""
 
-#: build/models.py:658
+#: build/models.py:691
 msgid "Completed build output"
 msgstr ""
 
-#: build/models.py:896
+#: build/models.py:933
 msgid "BuildItem must be unique for build, stock_item and install_into"
 msgstr ""
 
-#: build/models.py:918
+#: build/models.py:955
 msgid "Build item must specify a build output"
 msgstr ""
 
-#: build/models.py:923
+#: build/models.py:960
 #, python-brace-format
 msgid "Selected stock item not found in BOM for part '{p}'"
 msgstr ""
 
-#: build/models.py:927
+#: build/models.py:964
 #, python-brace-format
 msgid "Allocated quantity ({n}) must not exceed available quantity ({q})"
 msgstr ""
 
-#: build/models.py:934 order/models.py:614
+#: build/models.py:971 order/models.py:707
 msgid "StockItem is over-allocated"
 msgstr ""
 
-#: build/models.py:938 order/models.py:617
+#: build/models.py:975 order/models.py:710
 msgid "Allocation quantity must be greater than zero"
 msgstr ""
 
-#: build/models.py:942
+#: build/models.py:979
 msgid "Quantity must be 1 for serialized stock"
 msgstr ""
 
-#: build/models.py:982
+#: build/models.py:1019
 msgid "Build to allocate parts"
 msgstr ""
 
-#: build/models.py:989
+#: build/models.py:1026
 msgid "Source stock item"
 msgstr ""
 
-#: build/models.py:1001
+#: build/models.py:1038
 msgid "Stock quantity to allocate to build"
 msgstr ""
 
-#: build/models.py:1009
+#: build/models.py:1046
 msgid "Destination stock item"
 msgstr ""
 
@@ -604,7 +613,7 @@ msgid "Order required parts"
 msgstr ""
 
 #: build/templates/build/allocate.html:30
-#: company/templates/company/detail_part.html:28 order/views.py:803
+#: company/templates/company/detail_part.html:28 order/views.py:805
 #: part/templates/part/category.html:125
 msgid "Order Parts"
 msgstr ""
@@ -644,11 +653,11 @@ msgid ""
 "The following stock items will be allocated to the specified build output"
 msgstr ""
 
-#: build/templates/build/auto_allocate.html:18 stock/forms.py:337
-#: stock/templates/stock/item_base.html:227
+#: build/templates/build/auto_allocate.html:18 stock/forms.py:343
+#: stock/templates/stock/item_base.html:244
 #: stock/templates/stock/stock_adjust.html:17
 #: templates/InvenTree/search.html:183 templates/js/barcode.js:337
-#: templates/js/build.js:434 templates/js/stock.js:574
+#: templates/js/build.js:434 templates/js/stock.js:587
 msgid "Location"
 msgstr ""
 
@@ -673,13 +682,16 @@ msgstr ""
 #: order/templates/order/order_base.html:26
 #: order/templates/order/sales_order_base.html:35
 #: part/templates/part/category.html:13 part/templates/part/part_base.html:32
-#: stock/templates/stock/item_base.html:90
+#: stock/templates/stock/item_base.html:97
 #: stock/templates/stock/location.html:12
 msgid "Admin view"
 msgstr ""
 
 #: build/templates/build/build_base.html:43
-#: build/templates/build/build_base.html:92 templates/js/table_filters.js:190
+#: build/templates/build/build_base.html:100
+#: order/templates/order/sales_order_base.html:41
+#: order/templates/order/sales_order_base.html:83
+#: templates/js/table_filters.js:200 templates/js/table_filters.js:232
 msgid "Overdue"
 msgstr ""
 
@@ -706,30 +718,37 @@ msgstr ""
 #: build/templates/build/build_base.html:88
 #: build/templates/build/detail.html:57
 #: order/templates/order/receive_parts.html:24
-#: stock/templates/stock/item_base.html:312 templates/InvenTree/search.html:175
+#: stock/templates/stock/item_base.html:343 templates/InvenTree/search.html:175
 #: templates/js/barcode.js:42 templates/js/build.js:697
-#: templates/js/order.js:180 templates/js/order.js:262
-#: templates/js/stock.js:561 templates/js/stock.js:965
+#: templates/js/order.js:180 templates/js/order.js:268
+#: templates/js/stock.js:574 templates/js/stock.js:997
 msgid "Status"
 msgstr ""
 
-#: build/templates/build/build_base.html:92
+#: build/templates/build/build_base.html:96
+#: build/templates/build/detail.html:100
+#: order/templates/order/sales_order_base.html:114 templates/js/build.js:710
+#: templates/js/order.js:281
+msgid "Target Date"
+msgstr ""
+
+#: build/templates/build/build_base.html:100
 msgid "This build was due on"
 msgstr ""
 
-#: build/templates/build/build_base.html:98
+#: build/templates/build/build_base.html:107
 #: build/templates/build/detail.html:62
 msgid "Progress"
 msgstr ""
 
-#: build/templates/build/build_base.html:111
-#: build/templates/build/detail.html:82 order/models.py:528
+#: build/templates/build/build_base.html:120
+#: build/templates/build/detail.html:82 order/models.py:621
 #: order/templates/order/sales_order_base.html:9
 #: order/templates/order/sales_order_base.html:33
 #: order/templates/order/sales_order_notes.html:10
 #: order/templates/order/sales_order_ship.html:25
 #: part/templates/part/allocation.html:27
-#: stock/templates/stock/item_base.html:221 templates/js/order.js:229
+#: stock/templates/stock/item_base.html:238 templates/js/order.js:229
 msgid "Sales Order"
 msgstr ""
 
@@ -821,7 +840,7 @@ msgstr ""
 msgid "Stock can be taken from any available location."
 msgstr ""
 
-#: build/templates/build/detail.html:44 stock/forms.py:365
+#: build/templates/build/detail.html:44 stock/forms.py:371
 msgid "Destination"
 msgstr ""
 
@@ -830,22 +849,18 @@ msgid "Destination location not specified"
 msgstr ""
 
 #: build/templates/build/detail.html:68
-#: stock/templates/stock/item_base.html:245 templates/js/stock.js:569
-#: templates/js/stock.js:972 templates/js/table_filters.js:80
-#: templates/js/table_filters.js:151
+#: stock/templates/stock/item_base.html:262 templates/js/stock.js:582
+#: templates/js/stock.js:1004 templates/js/table_filters.js:80
+#: templates/js/table_filters.js:161
 msgid "Batch"
 msgstr ""
 
 #: build/templates/build/detail.html:95
 #: order/templates/order/order_base.html:98
-#: order/templates/order/sales_order_base.html:100 templates/js/build.js:705
+#: order/templates/order/sales_order_base.html:108 templates/js/build.js:705
 msgid "Created"
 msgstr ""
 
-#: build/templates/build/detail.html:100 templates/js/build.js:710
-msgid "Target Date"
-msgstr ""
-
 #: build/templates/build/detail.html:106
 msgid "No target date set"
 msgstr ""
@@ -863,10 +878,22 @@ msgstr ""
 msgid "Alter the quantity of stock allocated to the build output"
 msgstr ""
 
-#: build/templates/build/index.html:25 build/views.py:658
+#: build/templates/build/index.html:27 build/views.py:658
 msgid "New Build Order"
 msgstr ""
 
+#: build/templates/build/index.html:30
+#: order/templates/order/purchase_orders.html:22
+#: order/templates/order/sales_orders.html:22
+msgid "Display calendar view"
+msgstr ""
+
+#: build/templates/build/index.html:33
+#: order/templates/order/purchase_orders.html:25
+#: order/templates/order/sales_orders.html:25
+msgid "Display list view"
+msgstr ""
+
 #: build/templates/build/notes.html:13 build/templates/build/notes.html:30
 msgid "Build Notes"
 msgstr ""
@@ -922,7 +949,7 @@ msgstr ""
 msgid "Create Build Output"
 msgstr ""
 
-#: build/views.py:207 stock/models.py:828 stock/views.py:1667
+#: build/views.py:207 stock/models.py:872 stock/views.py:1681
 msgid "Serial numbers already exist"
 msgstr ""
 
@@ -1019,36 +1046,36 @@ msgstr ""
 msgid "Stock item must be selected"
 msgstr ""
 
-#: build/views.py:1011
+#: build/views.py:1012
 msgid "Edit Stock Allocation"
 msgstr ""
 
-#: build/views.py:1016
+#: build/views.py:1017
 msgid "Updated Build Item"
 msgstr ""
 
-#: build/views.py:1045
+#: build/views.py:1046
 msgid "Add Build Order Attachment"
 msgstr ""
 
-#: build/views.py:1059 order/views.py:111 order/views.py:164 part/views.py:168
+#: build/views.py:1060 order/views.py:113 order/views.py:166 part/views.py:170
 #: stock/views.py:180
 msgid "Added attachment"
 msgstr ""
 
-#: build/views.py:1095 order/views.py:191 order/views.py:213
+#: build/views.py:1096 order/views.py:193 order/views.py:215
 msgid "Edit Attachment"
 msgstr ""
 
-#: build/views.py:1106 order/views.py:196 order/views.py:218
+#: build/views.py:1107 order/views.py:198 order/views.py:220
 msgid "Attachment updated"
 msgstr ""
 
-#: build/views.py:1116 order/views.py:233 order/views.py:248
+#: build/views.py:1117 order/views.py:235 order/views.py:250
 msgid "Delete Attachment"
 msgstr ""
 
-#: build/views.py:1122 order/views.py:240 order/views.py:255 stock/views.py:238
+#: build/views.py:1123 order/views.py:242 order/views.py:257 stock/views.py:238
 msgid "Deleted attachment"
 msgstr ""
 
@@ -1124,103 +1151,170 @@ msgstr ""
 msgid "Copy category parameter templates when creating a part"
 msgstr ""
 
-#: common/models.py:115 part/models.py:743 part/templates/part/detail.html:168
-#: templates/js/table_filters.js:268
-msgid "Component"
+#: common/models.py:115 part/templates/part/detail.html:155 stock/forms.py:255
+#: templates/js/table_filters.js:23 templates/js/table_filters.js:266
+msgid "Template"
 msgstr ""
 
 #: common/models.py:116
-msgid "Parts can be used as sub-components by default"
+msgid "Parts are templates by default"
 msgstr ""
 
-#: common/models.py:122 part/models.py:754 part/templates/part/detail.html:188
-msgid "Purchaseable"
+#: common/models.py:122 part/models.py:794 part/templates/part/detail.html:165
+#: templates/js/table_filters.js:278
+msgid "Assembly"
 msgstr ""
 
 #: common/models.py:123
-msgid "Parts are purchaseable by default"
+msgid "Parts can be assembled from other components by default"
 msgstr ""
 
-#: common/models.py:129 part/models.py:759 part/templates/part/detail.html:198
-#: templates/js/table_filters.js:276
-msgid "Salable"
+#: common/models.py:129 part/models.py:800 part/templates/part/detail.html:175
+#: templates/js/table_filters.js:282
+msgid "Component"
 msgstr ""
 
 #: common/models.py:130
-msgid "Parts are salable by default"
+msgid "Parts can be used as sub-components by default"
 msgstr ""
 
-#: common/models.py:136 part/models.py:749 part/templates/part/detail.html:178
-#: templates/js/table_filters.js:31 templates/js/table_filters.js:280
-msgid "Trackable"
+#: common/models.py:136 part/models.py:811 part/templates/part/detail.html:195
+msgid "Purchaseable"
 msgstr ""
 
 #: common/models.py:137
-msgid "Parts are trackable by default"
+msgid "Parts are purchaseable by default"
 msgstr ""
 
-#: common/models.py:143
-msgid "Build Order Reference Prefix"
+#: common/models.py:143 part/models.py:816 part/templates/part/detail.html:205
+#: templates/js/table_filters.js:290
+msgid "Salable"
 msgstr ""
 
 #: common/models.py:144
+msgid "Parts are salable by default"
+msgstr ""
+
+#: common/models.py:150 part/models.py:806 part/templates/part/detail.html:185
+#: templates/js/table_filters.js:31 templates/js/table_filters.js:294
+msgid "Trackable"
+msgstr ""
+
+#: common/models.py:151
+msgid "Parts are trackable by default"
+msgstr ""
+
+#: common/models.py:157 part/models.py:826 part/templates/part/detail.html:145
+#: templates/js/table_filters.js:27
+msgid "Virtual"
+msgstr ""
+
+#: common/models.py:158
+msgid "Parts are virtual by default"
+msgstr ""
+
+#: common/models.py:164
+msgid "Stock Expiry"
+msgstr ""
+
+#: common/models.py:165
+msgid "Enable stock expiry functionality"
+msgstr ""
+
+#: common/models.py:171
+msgid "Sell Expired Stock"
+msgstr ""
+
+#: common/models.py:172
+msgid "Allow sale of expired stock"
+msgstr ""
+
+#: common/models.py:178
+msgid "Stock Stale Time"
+msgstr ""
+
+#: common/models.py:179
+msgid "Number of days stock items are considered stale before expiring"
+msgstr ""
+
+#: common/models.py:181 part/templates/part/detail.html:116
+msgid "days"
+msgstr ""
+
+#: common/models.py:186
+msgid "Build Expired Stock"
+msgstr ""
+
+#: common/models.py:187
+msgid "Allow building with expired stock"
+msgstr ""
+
+#: common/models.py:193
+msgid "Build Order Reference Prefix"
+msgstr ""
+
+#: common/models.py:194
 msgid "Prefix value for build order reference"
 msgstr ""
 
-#: common/models.py:149
+#: common/models.py:199
 msgid "Build Order Reference Regex"
 msgstr ""
 
-#: common/models.py:150
+#: common/models.py:200
 msgid "Regular expression pattern for matching build order reference"
 msgstr ""
 
-#: common/models.py:154
+#: common/models.py:204
 msgid "Sales Order Reference Prefix"
 msgstr ""
 
-#: common/models.py:155
+#: common/models.py:205
 msgid "Prefix value for sales order reference"
 msgstr ""
 
-#: common/models.py:159
+#: common/models.py:210
 msgid "Purchase Order Reference Prefix"
 msgstr ""
 
-#: common/models.py:160
+#: common/models.py:211
 msgid "Prefix value for purchase order reference"
 msgstr ""
 
-#: common/models.py:376
+#: common/models.py:434
 msgid "Settings key (must be unique - case insensitive"
 msgstr ""
 
-#: common/models.py:378
+#: common/models.py:436
 msgid "Settings value"
 msgstr ""
 
-#: common/models.py:437
+#: common/models.py:493
 msgid "Value must be a boolean value"
 msgstr ""
 
-#: common/models.py:451
+#: common/models.py:503
+msgid "Value must be an integer value"
+msgstr ""
+
+#: common/models.py:517
 msgid "Key string must be unique"
 msgstr ""
 
-#: common/models.py:495 company/forms.py:113
+#: common/models.py:590 company/forms.py:113
 msgid "Price break quantity"
 msgstr ""
 
-#: common/models.py:503 company/templates/company/supplier_part_pricing.html:80
+#: common/models.py:598 company/templates/company/supplier_part_pricing.html:80
 #: part/templates/part/sale_prices.html:87 templates/js/bom.js:246
 msgid "Price"
 msgstr ""
 
-#: common/models.py:504
+#: common/models.py:599
 msgid "Unit price at specified quantity"
 msgstr ""
 
-#: common/models.py:527
+#: common/models.py:622
 msgid "Default"
 msgstr ""
 
@@ -1321,44 +1415,81 @@ msgstr ""
 msgid "Currency"
 msgstr ""
 
-#: company/models.py:313 stock/models.py:338
-#: stock/templates/stock/item_base.html:177
+#: company/models.py:313 stock/models.py:345
+#: stock/templates/stock/item_base.html:194
 msgid "Base Part"
 msgstr ""
 
-#: company/models.py:318
+#: company/models.py:317
 msgid "Select part"
 msgstr ""
 
+#: company/models.py:323 company/templates/company/detail.html:57
+#: company/templates/company/supplier_part_base.html:74
+#: company/templates/company/supplier_part_detail.html:21
+#: order/templates/order/order_base.html:79
+#: order/templates/order/order_wizard/select_pos.html:30 part/bom.py:170
+#: stock/templates/stock/item_base.html:304 templates/js/company.js:48
+#: templates/js/company.js:164 templates/js/order.js:162
+msgid "Supplier"
+msgstr ""
+
 #: company/models.py:324
 msgid "Select supplier"
 msgstr ""
 
-#: company/models.py:327
+#: company/models.py:329 company/templates/company/supplier_part_base.html:78
+#: company/templates/company/supplier_part_detail.html:22 part/bom.py:171
+msgid "SKU"
+msgstr ""
+
+#: company/models.py:330
 msgid "Supplier stock keeping unit"
 msgstr ""
 
-#: company/models.py:334
+#: company/models.py:340 company/templates/company/detail.html:52
+#: company/templates/company/supplier_part_base.html:84
+#: company/templates/company/supplier_part_detail.html:30 part/bom.py:172
+#: templates/js/company.js:44 templates/js/company.js:188
+msgid "Manufacturer"
+msgstr ""
+
+#: company/models.py:341
 msgid "Select manufacturer"
 msgstr ""
 
-#: company/models.py:338
-msgid "Manufacturer part number"
-msgstr ""
-
-#: company/models.py:340
-msgid "URL for external supplier part link"
-msgstr ""
-
-#: company/models.py:342
-msgid "Supplier part description"
-msgstr ""
-
-#: company/models.py:346
-msgid "Minimum charge (e.g. stocking fee)"
+#: company/models.py:347 company/templates/company/supplier_part_base.html:88
+#: company/templates/company/supplier_part_detail.html:31 part/bom.py:173
+#: templates/js/company.js:204
+msgid "MPN"
 msgstr ""
 
 #: company/models.py:348
+msgid "Manufacturer part number"
+msgstr ""
+
+#: company/models.py:353 part/models.py:704 templates/js/company.js:208
+msgid "Link"
+msgstr ""
+
+#: company/models.py:354
+msgid "URL for external supplier part link"
+msgstr ""
+
+#: company/models.py:360
+msgid "Supplier part description"
+msgstr ""
+
+#: company/models.py:365 company/templates/company/supplier_part_base.html:95
+#: company/templates/company/supplier_part_detail.html:34
+msgid "Note"
+msgstr ""
+
+#: company/models.py:369
+msgid "Minimum charge (e.g. stocking fee)"
+msgstr ""
+
+#: company/models.py:371
 msgid "Part packaging"
 msgstr ""
 
@@ -1393,27 +1524,10 @@ msgstr ""
 msgid "Uses default currency"
 msgstr ""
 
-#: company/templates/company/detail.html:52
-#: company/templates/company/supplier_part_base.html:84
-#: company/templates/company/supplier_part_detail.html:30 part/bom.py:172
-#: templates/js/company.js:44 templates/js/company.js:188
-msgid "Manufacturer"
-msgstr ""
-
-#: company/templates/company/detail.html:57
-#: company/templates/company/supplier_part_base.html:74
-#: company/templates/company/supplier_part_detail.html:21
-#: order/templates/order/order_base.html:79
-#: order/templates/order/order_wizard/select_pos.html:30 part/bom.py:170
-#: stock/templates/stock/item_base.html:287 templates/js/company.js:48
-#: templates/js/company.js:164 templates/js/order.js:162
-msgid "Supplier"
-msgstr ""
-
 #: company/templates/company/detail.html:62
-#: order/templates/order/sales_order_base.html:81 stock/models.py:373
-#: stock/models.py:374 stock/templates/stock/item_base.html:204
-#: templates/js/company.js:40 templates/js/order.js:244
+#: order/templates/order/sales_order_base.html:89 stock/models.py:380
+#: stock/models.py:381 stock/templates/stock/item_base.html:221
+#: templates/js/company.js:40 templates/js/order.js:250
 msgid "Customer"
 msgstr ""
 
@@ -1428,7 +1542,7 @@ msgstr ""
 
 #: company/templates/company/detail_part.html:18
 #: order/templates/order/purchase_order_detail.html:68
-#: part/templates/part/supplier.html:14 templates/js/stock.js:849
+#: part/templates/part/supplier.html:14 templates/js/stock.js:881
 msgid "New Supplier Part"
 msgstr ""
 
@@ -1452,7 +1566,7 @@ msgid "Delete Parts"
 msgstr ""
 
 #: company/templates/company/detail_part.html:63
-#: part/templates/part/category.html:116 templates/js/stock.js:843
+#: part/templates/part/category.html:116 templates/js/stock.js:875
 msgid "New Part"
 msgstr ""
 
@@ -1505,8 +1619,8 @@ msgstr ""
 
 #: company/templates/company/purchase_orders.html:9
 #: company/templates/company/tabs.html:17
-#: order/templates/order/purchase_orders.html:7
-#: order/templates/order/purchase_orders.html:12
+#: order/templates/order/purchase_orders.html:8
+#: order/templates/order/purchase_orders.html:13
 #: part/templates/part/orders.html:9 part/templates/part/tabs.html:48
 #: templates/InvenTree/settings/tabs.html:31 templates/navbar.html:33
 #: users/models.py:31
@@ -1514,19 +1628,19 @@ msgid "Purchase Orders"
 msgstr ""
 
 #: company/templates/company/purchase_orders.html:15
-#: order/templates/order/purchase_orders.html:18
+#: order/templates/order/purchase_orders.html:19
 msgid "Create new purchase order"
 msgstr ""
 
 #: company/templates/company/purchase_orders.html:16
-#: order/templates/order/purchase_orders.html:19
+#: order/templates/order/purchase_orders.html:20
 msgid "New Purchase Order"
 msgstr ""
 
 #: company/templates/company/sales_orders.html:9
 #: company/templates/company/tabs.html:22
-#: order/templates/order/sales_orders.html:7
-#: order/templates/order/sales_orders.html:12
+#: order/templates/order/sales_orders.html:8
+#: order/templates/order/sales_orders.html:13
 #: part/templates/part/sales_orders.html:9 part/templates/part/tabs.html:56
 #: templates/InvenTree/settings/tabs.html:34 templates/navbar.html:42
 #: users/models.py:32
@@ -1534,18 +1648,18 @@ msgid "Sales Orders"
 msgstr ""
 
 #: company/templates/company/sales_orders.html:15
-#: order/templates/order/sales_orders.html:18
+#: order/templates/order/sales_orders.html:19
 msgid "Create new sales order"
 msgstr ""
 
 #: company/templates/company/sales_orders.html:16
-#: order/templates/order/sales_orders.html:19
+#: order/templates/order/sales_orders.html:20
 msgid "New Sales Order"
 msgstr ""
 
 #: company/templates/company/supplier_part_base.html:6
-#: company/templates/company/supplier_part_base.html:19 stock/models.py:347
-#: stock/templates/stock/item_base.html:292 templates/js/company.js:180
+#: company/templates/company/supplier_part_base.html:19 stock/models.py:354
+#: stock/templates/stock/item_base.html:309 templates/js/company.js:180
 msgid "Supplier Part"
 msgstr ""
 
@@ -1572,22 +1686,6 @@ msgstr ""
 msgid "Internal Part"
 msgstr ""
 
-#: company/templates/company/supplier_part_base.html:78
-#: company/templates/company/supplier_part_detail.html:22 part/bom.py:171
-msgid "SKU"
-msgstr ""
-
-#: company/templates/company/supplier_part_base.html:88
-#: company/templates/company/supplier_part_detail.html:31 part/bom.py:173
-#: templates/js/company.js:204
-msgid "MPN"
-msgstr ""
-
-#: company/templates/company/supplier_part_base.html:95
-#: company/templates/company/supplier_part_detail.html:34
-msgid "Note"
-msgstr ""
-
 #: company/templates/company/supplier_part_orders.html:9
 msgid "Supplier Part Orders"
 msgstr ""
@@ -1602,7 +1700,7 @@ msgid "Pricing Information"
 msgstr ""
 
 #: company/templates/company/supplier_part_pricing.html:17 company/views.py:486
-#: part/templates/part/sale_prices.html:14 part/views.py:2555
+#: part/templates/part/sale_prices.html:14 part/views.py:2565
 msgid "Add Price Break"
 msgstr ""
 
@@ -1633,7 +1731,7 @@ msgstr ""
 #: company/templates/company/tabs.html:12 part/templates/part/tabs.html:18
 #: stock/templates/stock/location.html:17 templates/InvenTree/search.html:155
 #: templates/InvenTree/settings/tabs.html:25 templates/js/part.js:192
-#: templates/js/part.js:418 templates/js/stock.js:502 templates/navbar.html:22
+#: templates/js/part.js:418 templates/js/stock.js:509 templates/navbar.html:22
 #: users/models.py:29
 msgid "Stock"
 msgstr ""
@@ -1643,7 +1741,7 @@ msgid "Orders"
 msgstr ""
 
 #: company/templates/company/tabs.html:9
-#: order/templates/order/receive_parts.html:14 part/models.py:316
+#: order/templates/order/receive_parts.html:14 part/models.py:317
 #: part/templates/part/cat_link.html:7 part/templates/part/category.html:94
 #: part/templates/part/category_tabs.html:6
 #: templates/InvenTree/settings/tabs.html:22 templates/navbar.html:19
@@ -1716,7 +1814,7 @@ msgstr ""
 msgid "Edit Supplier Part"
 msgstr ""
 
-#: company/views.py:295 templates/js/stock.js:850
+#: company/views.py:295 templates/js/stock.js:882
 msgid "Create new Supplier Part"
 msgstr ""
 
@@ -1724,15 +1822,15 @@ msgstr ""
 msgid "Delete Supplier Part"
 msgstr ""
 
-#: company/views.py:492 part/views.py:2561
+#: company/views.py:492 part/views.py:2571
 msgid "Added new price break"
 msgstr ""
 
-#: company/views.py:548 part/views.py:2605
+#: company/views.py:548 part/views.py:2615
 msgid "Edit Price Break"
 msgstr ""
 
-#: company/views.py:564 part/views.py:2621
+#: company/views.py:564 part/views.py:2631
 msgid "Delete Price Break"
 msgstr ""
 
@@ -1760,152 +1858,157 @@ msgstr ""
 msgid "Enabled"
 msgstr ""
 
-#: order/forms.py:24 order/templates/order/order_base.html:39
+#: order/forms.py:25 order/templates/order/order_base.html:39
 msgid "Place order"
 msgstr ""
 
-#: order/forms.py:35 order/templates/order/order_base.html:46
+#: order/forms.py:36 order/templates/order/order_base.html:46
 msgid "Mark order as complete"
 msgstr ""
 
-#: order/forms.py:46 order/forms.py:57 order/templates/order/order_base.html:51
-#: order/templates/order/sales_order_base.html:53
+#: order/forms.py:47 order/forms.py:58 order/templates/order/order_base.html:51
+#: order/templates/order/sales_order_base.html:56
 msgid "Cancel order"
 msgstr ""
 
-#: order/forms.py:68 order/templates/order/sales_order_base.html:50
+#: order/forms.py:69 order/templates/order/sales_order_base.html:53
 msgid "Ship order"
 msgstr ""
 
-#: order/forms.py:79
+#: order/forms.py:80
 msgid "Receive parts to this location"
 msgstr ""
 
-#: order/forms.py:99
+#: order/forms.py:100
 msgid "Purchase Order reference"
 msgstr ""
 
-#: order/forms.py:126
+#: order/forms.py:128
 msgid "Enter sales order number"
 msgstr ""
 
-#: order/models.py:110
+#: order/forms.py:134 order/models.py:405
+msgid ""
+"Target date for order completion. Order will be overdue after this date."
+msgstr ""
+
+#: order/models.py:98
 msgid "Order reference"
 msgstr ""
 
-#: order/models.py:112
+#: order/models.py:100
 msgid "Order description"
 msgstr ""
 
-#: order/models.py:114
+#: order/models.py:102
 msgid "Link to external page"
 msgstr ""
 
-#: order/models.py:124
+#: order/models.py:112
 msgid "Order notes"
 msgstr ""
 
-#: order/models.py:142 order/models.py:328
+#: order/models.py:169 order/models.py:398
 msgid "Purchase order status"
 msgstr ""
 
-#: order/models.py:150
+#: order/models.py:177
 msgid "Company from which the items are being ordered"
 msgstr ""
 
-#: order/models.py:153
+#: order/models.py:180
 msgid "Supplier order reference code"
 msgstr ""
 
-#: order/models.py:162
+#: order/models.py:189
 msgid "Date order was issued"
 msgstr ""
 
-#: order/models.py:164
+#: order/models.py:191
 msgid "Date order was completed"
 msgstr ""
 
-#: order/models.py:187 order/models.py:269 part/views.py:1494
-#: stock/models.py:244 stock/models.py:812
+#: order/models.py:214 order/models.py:296 part/views.py:1504
+#: stock/models.py:251 stock/models.py:856
 msgid "Quantity must be greater than zero"
 msgstr ""
 
-#: order/models.py:192
+#: order/models.py:219
 msgid "Part supplier must match PO supplier"
 msgstr ""
 
-#: order/models.py:264
+#: order/models.py:291
 msgid "Lines can only be received against an order marked as 'Placed'"
 msgstr ""
 
-#: order/models.py:324
+#: order/models.py:394
 msgid "Company to which the items are being sold"
 msgstr ""
 
-#: order/models.py:330
+#: order/models.py:400
 msgid "Customer order reference code"
 msgstr ""
 
-#: order/models.py:369
+#: order/models.py:462
 msgid "SalesOrder cannot be shipped as it is not currently pending"
 msgstr ""
 
-#: order/models.py:456
+#: order/models.py:549
 msgid "Item quantity"
 msgstr ""
 
-#: order/models.py:458
+#: order/models.py:551
 msgid "Line item reference"
 msgstr ""
 
-#: order/models.py:460
+#: order/models.py:553
 msgid "Line item notes"
 msgstr ""
 
-#: order/models.py:486 order/templates/order/order_base.html:9
+#: order/models.py:579 order/templates/order/order_base.html:9
 #: order/templates/order/order_base.html:24
-#: stock/templates/stock/item_base.html:259 templates/js/order.js:146
+#: stock/templates/stock/item_base.html:276 templates/js/order.js:146
 msgid "Purchase Order"
 msgstr ""
 
-#: order/models.py:499
+#: order/models.py:592
 msgid "Supplier part"
 msgstr ""
 
-#: order/models.py:502
+#: order/models.py:595
 msgid "Number of items received"
 msgstr ""
 
-#: order/models.py:509 stock/models.py:458
-#: stock/templates/stock/item_base.html:266
+#: order/models.py:602 stock/models.py:473
+#: stock/templates/stock/item_base.html:283
 msgid "Purchase Price"
 msgstr ""
 
-#: order/models.py:510
+#: order/models.py:603
 msgid "Unit purchase price"
 msgstr ""
 
-#: order/models.py:605
+#: order/models.py:698
 msgid "Cannot allocate stock item to a line with a different part"
 msgstr ""
 
-#: order/models.py:607
+#: order/models.py:700
 msgid "Cannot allocate stock to a line without a part"
 msgstr ""
 
-#: order/models.py:610
+#: order/models.py:703
 msgid "Allocation quantity cannot exceed stock quantity"
 msgstr ""
 
-#: order/models.py:620
+#: order/models.py:713
 msgid "Quantity must be 1 for serialized stock item"
 msgstr ""
 
-#: order/models.py:636
+#: order/models.py:729
 msgid "Select stock item to allocate"
 msgstr ""
 
-#: order/models.py:639
+#: order/models.py:732
 msgid "Enter stock allocation quantity"
 msgstr ""
 
@@ -1932,12 +2035,12 @@ msgid "Purchase Order Details"
 msgstr ""
 
 #: order/templates/order/order_base.html:69
-#: order/templates/order/sales_order_base.html:71
+#: order/templates/order/sales_order_base.html:74
 msgid "Order Reference"
 msgstr ""
 
 #: order/templates/order/order_base.html:74
-#: order/templates/order/sales_order_base.html:76
+#: order/templates/order/sales_order_base.html:79
 msgid "Order Status"
 msgstr ""
 
@@ -1952,7 +2055,7 @@ msgstr ""
 #: order/templates/order/order_base.html:111
 #: order/templates/order/purchase_order_detail.html:193
 #: order/templates/order/receive_parts.html:22
-#: order/templates/order/sales_order_base.html:113
+#: order/templates/order/sales_order_base.html:128
 msgid "Received"
 msgstr ""
 
@@ -1997,7 +2100,7 @@ msgid "Select existing purchase orders, or create new orders."
 msgstr ""
 
 #: order/templates/order/order_wizard/select_pos.html:31
-#: templates/js/order.js:193 templates/js/order.js:280
+#: templates/js/order.js:193 templates/js/order.js:291
 msgid "Items"
 msgstr ""
 
@@ -2023,8 +2126,8 @@ msgid "Line Items"
 msgstr ""
 
 #: order/templates/order/purchase_order_detail.html:17
-#: order/templates/order/sales_order_detail.html:19 order/views.py:1117
-#: order/views.py:1201
+#: order/templates/order/sales_order_detail.html:19 order/views.py:1119
+#: order/views.py:1203
 msgid "Add Line Item"
 msgstr ""
 
@@ -2035,7 +2138,7 @@ msgstr ""
 #: order/templates/order/purchase_order_detail.html:39
 #: order/templates/order/purchase_order_detail.html:119
 #: part/templates/part/category.html:173 part/templates/part/category.html:215
-#: templates/js/stock.js:855
+#: templates/js/stock.js:627 templates/js/stock.js:887
 msgid "New Location"
 msgstr ""
 
@@ -2096,15 +2199,15 @@ msgstr ""
 msgid "This SalesOrder has not been fully allocated"
 msgstr ""
 
-#: order/templates/order/sales_order_base.html:58
+#: order/templates/order/sales_order_base.html:61
 msgid "Packing List"
 msgstr ""
 
-#: order/templates/order/sales_order_base.html:66
+#: order/templates/order/sales_order_base.html:69
 msgid "Sales Order Details"
 msgstr ""
 
-#: order/templates/order/sales_order_base.html:87 templates/js/order.js:251
+#: order/templates/order/sales_order_base.html:95 templates/js/order.js:257
 msgid "Customer Reference"
 msgstr ""
 
@@ -2120,8 +2223,8 @@ msgid "Sales Order Items"
 msgstr ""
 
 #: order/templates/order/sales_order_detail.html:72
-#: order/templates/order/sales_order_detail.html:154 stock/models.py:378
-#: stock/templates/stock/item_base.html:191 templates/js/build.js:418
+#: order/templates/order/sales_order_detail.html:154 stock/models.py:385
+#: stock/templates/stock/item_base.html:208 templates/js/build.js:418
 msgid "Serial Number"
 msgstr ""
 
@@ -2199,143 +2302,143 @@ msgstr ""
 msgid "Order Items"
 msgstr ""
 
-#: order/views.py:99
+#: order/views.py:101
 msgid "Add Purchase Order Attachment"
 msgstr ""
 
-#: order/views.py:150
+#: order/views.py:152
 msgid "Add Sales Order Attachment"
 msgstr ""
 
-#: order/views.py:310
+#: order/views.py:312
 msgid "Create Purchase Order"
 msgstr ""
 
-#: order/views.py:346
+#: order/views.py:348
 msgid "Create Sales Order"
 msgstr ""
 
-#: order/views.py:382
+#: order/views.py:384
 msgid "Edit Purchase Order"
 msgstr ""
 
-#: order/views.py:403
+#: order/views.py:405
 msgid "Edit Sales Order"
 msgstr ""
 
-#: order/views.py:420
+#: order/views.py:422
 msgid "Cancel Order"
 msgstr ""
 
-#: order/views.py:430 order/views.py:457
+#: order/views.py:432 order/views.py:459
 msgid "Confirm order cancellation"
 msgstr ""
 
-#: order/views.py:433
+#: order/views.py:435
 msgid "Order cannot be cancelled as either pending or placed"
 msgstr ""
 
-#: order/views.py:447
+#: order/views.py:449
 msgid "Cancel sales order"
 msgstr ""
 
-#: order/views.py:460
+#: order/views.py:462
 msgid "Order cannot be cancelled"
 msgstr ""
 
-#: order/views.py:474
+#: order/views.py:476
 msgid "Issue Order"
 msgstr ""
 
-#: order/views.py:484
+#: order/views.py:486
 msgid "Confirm order placement"
 msgstr ""
 
-#: order/views.py:494
+#: order/views.py:496
 msgid "Purchase order issued"
 msgstr ""
 
-#: order/views.py:505
+#: order/views.py:507
 msgid "Complete Order"
 msgstr ""
 
-#: order/views.py:522
+#: order/views.py:524
 msgid "Confirm order completion"
 msgstr ""
 
-#: order/views.py:533
+#: order/views.py:535
 msgid "Purchase order completed"
 msgstr ""
 
-#: order/views.py:543
+#: order/views.py:545
 msgid "Ship Order"
 msgstr ""
 
-#: order/views.py:560
+#: order/views.py:562
 msgid "Confirm order shipment"
 msgstr ""
 
-#: order/views.py:566
+#: order/views.py:568
 msgid "Could not ship order"
 msgstr ""
 
-#: order/views.py:618
+#: order/views.py:620
 msgid "Receive Parts"
 msgstr ""
 
-#: order/views.py:686
+#: order/views.py:688
 msgid "Items received"
 msgstr ""
 
-#: order/views.py:700
+#: order/views.py:702
 msgid "No destination set"
 msgstr ""
 
-#: order/views.py:745
+#: order/views.py:747
 msgid "Error converting quantity to number"
 msgstr ""
 
-#: order/views.py:751
+#: order/views.py:753
 msgid "Receive quantity less than zero"
 msgstr ""
 
-#: order/views.py:757
+#: order/views.py:759
 msgid "No lines specified"
 msgstr ""
 
-#: order/views.py:1127
+#: order/views.py:1129
 msgid "Supplier part must be specified"
 msgstr ""
 
-#: order/views.py:1133
+#: order/views.py:1135
 msgid "Supplier must match for Part and Order"
 msgstr ""
 
-#: order/views.py:1253 order/views.py:1272
+#: order/views.py:1255 order/views.py:1274
 msgid "Edit Line Item"
 msgstr ""
 
-#: order/views.py:1289 order/views.py:1302
+#: order/views.py:1291 order/views.py:1304
 msgid "Delete Line Item"
 msgstr ""
 
-#: order/views.py:1295 order/views.py:1308
+#: order/views.py:1297 order/views.py:1310
 msgid "Deleted line item"
 msgstr ""
 
-#: order/views.py:1317
+#: order/views.py:1319
 msgid "Allocate Stock to Order"
 msgstr ""
 
-#: order/views.py:1387
+#: order/views.py:1394
 msgid "Edit Allocation Quantity"
 msgstr ""
 
-#: order/views.py:1403
+#: order/views.py:1410
 msgid "Remove allocation"
 msgstr ""
 
-#: part/bom.py:138 part/templates/part/category.html:61
+#: part/bom.py:138 part/models.py:722 part/templates/part/category.html:61
 #: part/templates/part/detail.html:87
 msgid "Default Location"
 msgstr ""
@@ -2357,11 +2460,11 @@ msgstr ""
 msgid "Error reading BOM file (incorrect row size)"
 msgstr ""
 
-#: part/forms.py:61 stock/forms.py:255
+#: part/forms.py:61 stock/forms.py:261
 msgid "File Format"
 msgstr ""
 
-#: part/forms.py:61 stock/forms.py:255
+#: part/forms.py:61 stock/forms.py:261
 msgid "Select output file format"
 msgstr ""
 
@@ -2405,7 +2508,7 @@ msgstr ""
 msgid "Include part supplier data in exported BOM"
 msgstr ""
 
-#: part/forms.py:92 part/models.py:1717
+#: part/forms.py:92 part/models.py:1781
 msgid "Parent Part"
 msgstr ""
 
@@ -2437,43 +2540,43 @@ msgstr ""
 msgid "Select part category"
 msgstr ""
 
-#: part/forms.py:188
+#: part/forms.py:189
 msgid "Duplicate all BOM data for this part"
 msgstr ""
 
-#: part/forms.py:189
+#: part/forms.py:190
 msgid "Copy BOM"
 msgstr ""
 
-#: part/forms.py:194
+#: part/forms.py:195
 msgid "Duplicate all parameter data for this part"
 msgstr ""
 
-#: part/forms.py:195
+#: part/forms.py:196
 msgid "Copy Parameters"
 msgstr ""
 
-#: part/forms.py:200
+#: part/forms.py:201
 msgid "Confirm part creation"
 msgstr ""
 
-#: part/forms.py:205
+#: part/forms.py:206
 msgid "Include category parameter templates"
 msgstr ""
 
-#: part/forms.py:210
+#: part/forms.py:211
 msgid "Include parent categories parameter templates"
 msgstr ""
 
-#: part/forms.py:285
+#: part/forms.py:291
 msgid "Add parameter template to same level categories"
 msgstr ""
 
-#: part/forms.py:289
+#: part/forms.py:295
 msgid "Add parameter template to all categories"
 msgstr ""
 
-#: part/forms.py:331
+#: part/forms.py:339
 msgid "Input quantity for price calculation"
 msgstr ""
 
@@ -2485,7 +2588,7 @@ msgstr ""
 msgid "Default keywords for parts in this category"
 msgstr ""
 
-#: part/models.py:77 part/models.py:1762
+#: part/models.py:77 part/models.py:1826
 #: part/templates/part/part_app_base.html:9
 msgid "Part Category"
 msgstr ""
@@ -2495,255 +2598,294 @@ msgstr ""
 msgid "Part Categories"
 msgstr ""
 
-#: part/models.py:408 part/models.py:418
+#: part/models.py:409 part/models.py:419
 #, python-brace-format
 msgid "Part '{p1}' is  used in BOM for '{p2}' (recursive)"
 msgstr ""
 
-#: part/models.py:515
+#: part/models.py:516
 msgid "Next available serial numbers are"
 msgstr ""
 
-#: part/models.py:519
+#: part/models.py:520
 msgid "Next available serial number is"
 msgstr ""
 
-#: part/models.py:524
+#: part/models.py:525
 msgid "Most recent serial number is"
 msgstr ""
 
-#: part/models.py:603
+#: part/models.py:604
 msgid "Duplicate IPN not allowed in part settings"
 msgstr ""
 
-#: part/models.py:614
+#: part/models.py:615
 msgid "Part must be unique for name, IPN and revision"
 msgstr ""
 
-#: part/models.py:644 part/templates/part/detail.html:19
+#: part/models.py:646 part/templates/part/detail.html:19
 msgid "Part name"
 msgstr ""
 
-#: part/models.py:648
+#: part/models.py:653
+msgid "Is Template"
+msgstr ""
+
+#: part/models.py:654
 msgid "Is this part a template part?"
 msgstr ""
 
-#: part/models.py:657
+#: part/models.py:665
 msgid "Is this part a variant of another part?"
 msgstr ""
 
-#: part/models.py:659
+#: part/models.py:666 part/templates/part/detail.html:57
+msgid "Variant Of"
+msgstr ""
+
+#: part/models.py:672
 msgid "Part description"
 msgstr ""
 
-#: part/models.py:661
+#: part/models.py:677 part/templates/part/category.html:68
+#: part/templates/part/detail.html:64
+msgid "Keywords"
+msgstr ""
+
+#: part/models.py:678
 msgid "Part keywords to improve visibility in search results"
 msgstr ""
 
-#: part/models.py:666
+#: part/models.py:685 part/templates/part/detail.html:70
+#: part/templates/part/set_category.html:15 templates/js/part.js:405
+msgid "Category"
+msgstr ""
+
+#: part/models.py:686
 msgid "Part category"
 msgstr ""
 
-#: part/models.py:668
+#: part/models.py:691 part/templates/part/detail.html:25
+#: part/templates/part/part_base.html:95 templates/js/part.js:180
+msgid "IPN"
+msgstr ""
+
+#: part/models.py:692
 msgid "Internal Part Number"
 msgstr ""
 
-#: part/models.py:670
+#: part/models.py:698
 msgid "Part revision or version number"
 msgstr ""
 
-#: part/models.py:684
+#: part/models.py:699 part/templates/part/detail.html:32
+#: templates/js/part.js:184
+msgid "Revision"
+msgstr ""
+
+#: part/models.py:720
 msgid "Where is this item normally stored?"
 msgstr ""
 
-#: part/models.py:728
+#: part/models.py:767 part/templates/part/detail.html:94
+msgid "Default Supplier"
+msgstr ""
+
+#: part/models.py:768
 msgid "Default supplier part"
 msgstr ""
 
-#: part/models.py:731
+#: part/models.py:775
+msgid "Default Expiry"
+msgstr ""
+
+#: part/models.py:776
+msgid "Expiry time (in days) for stock items of this part"
+msgstr ""
+
+#: part/models.py:781 part/templates/part/detail.html:108
+msgid "Minimum Stock"
+msgstr ""
+
+#: part/models.py:782
 msgid "Minimum allowed stock level"
 msgstr ""
 
-#: part/models.py:733
+#: part/models.py:788 part/templates/part/detail.html:102
+#: part/templates/part/params.html:26
+msgid "Units"
+msgstr ""
+
+#: part/models.py:789
 msgid "Stock keeping units for this part"
 msgstr ""
 
-#: part/models.py:737 part/templates/part/detail.html:158
-#: templates/js/table_filters.js:264
-msgid "Assembly"
-msgstr ""
-
-#: part/models.py:738
+#: part/models.py:795
 msgid "Can this part be built from other parts?"
 msgstr ""
 
-#: part/models.py:744
+#: part/models.py:801
 msgid "Can this part be used to build other parts?"
 msgstr ""
 
-#: part/models.py:750
+#: part/models.py:807
 msgid "Does this part have tracking for unique items?"
 msgstr ""
 
-#: part/models.py:755
+#: part/models.py:812
 msgid "Can this part be purchased from external suppliers?"
 msgstr ""
 
-#: part/models.py:760
+#: part/models.py:817
 msgid "Can this part be sold to customers?"
 msgstr ""
 
-#: part/models.py:764 part/templates/part/detail.html:215
+#: part/models.py:821 part/templates/part/detail.html:222
 #: templates/js/table_filters.js:19 templates/js/table_filters.js:55
-#: templates/js/table_filters.js:186 templates/js/table_filters.js:247
+#: templates/js/table_filters.js:196 templates/js/table_filters.js:261
 msgid "Active"
 msgstr ""
 
-#: part/models.py:765
+#: part/models.py:822
 msgid "Is this part active?"
 msgstr ""
 
-#: part/models.py:769 part/templates/part/detail.html:138
-#: templates/js/table_filters.js:27
-msgid "Virtual"
-msgstr ""
-
-#: part/models.py:770
+#: part/models.py:827
 msgid "Is this a virtual part, such as a software product or license?"
 msgstr ""
 
-#: part/models.py:772
+#: part/models.py:832
 msgid "Part notes - supports Markdown formatting"
 msgstr ""
 
-#: part/models.py:774
+#: part/models.py:835
 msgid "Stored BOM checksum"
 msgstr ""
 
-#: part/models.py:1590
+#: part/models.py:1654
 msgid "Test templates can only be created for trackable parts"
 msgstr ""
 
-#: part/models.py:1607
+#: part/models.py:1671
 msgid "Test with this name already exists for this part"
 msgstr ""
 
-#: part/models.py:1626 templates/js/part.js:567 templates/js/stock.js:92
+#: part/models.py:1690 templates/js/part.js:567 templates/js/stock.js:93
 msgid "Test Name"
 msgstr ""
 
-#: part/models.py:1627
+#: part/models.py:1691
 msgid "Enter a name for the test"
 msgstr ""
 
-#: part/models.py:1632
+#: part/models.py:1696
 msgid "Test Description"
 msgstr ""
 
-#: part/models.py:1633
+#: part/models.py:1697
 msgid "Enter description for this test"
 msgstr ""
 
-#: part/models.py:1638 templates/js/part.js:576
-#: templates/js/table_filters.js:172
+#: part/models.py:1702 templates/js/part.js:576
+#: templates/js/table_filters.js:182
 msgid "Required"
 msgstr ""
 
-#: part/models.py:1639
+#: part/models.py:1703
 msgid "Is this test required to pass?"
 msgstr ""
 
-#: part/models.py:1644 templates/js/part.js:584
+#: part/models.py:1708 templates/js/part.js:584
 msgid "Requires Value"
 msgstr ""
 
-#: part/models.py:1645
+#: part/models.py:1709
 msgid "Does this test require a value when adding a test result?"
 msgstr ""
 
-#: part/models.py:1650 templates/js/part.js:591
+#: part/models.py:1714 templates/js/part.js:591
 msgid "Requires Attachment"
 msgstr ""
 
-#: part/models.py:1651
+#: part/models.py:1715
 msgid "Does this test require a file attachment when adding a test result?"
 msgstr ""
 
-#: part/models.py:1684
+#: part/models.py:1748
 msgid "Parameter template name must be unique"
 msgstr ""
 
-#: part/models.py:1689
+#: part/models.py:1753
 msgid "Parameter Name"
 msgstr ""
 
-#: part/models.py:1691
+#: part/models.py:1755
 msgid "Parameter Units"
 msgstr ""
 
-#: part/models.py:1719 part/models.py:1767
+#: part/models.py:1783 part/models.py:1831
 #: templates/InvenTree/settings/category.html:62
 msgid "Parameter Template"
 msgstr ""
 
-#: part/models.py:1721
+#: part/models.py:1785
 msgid "Parameter Value"
 msgstr ""
 
-#: part/models.py:1771
+#: part/models.py:1835
 msgid "Default Parameter Value"
 msgstr ""
 
-#: part/models.py:1801
+#: part/models.py:1865
 msgid "Select parent part"
 msgstr ""
 
-#: part/models.py:1809
+#: part/models.py:1873
 msgid "Select part to be used in BOM"
 msgstr ""
 
-#: part/models.py:1815
+#: part/models.py:1879
 msgid "BOM quantity for this BOM item"
 msgstr ""
 
-#: part/models.py:1817
+#: part/models.py:1881
 msgid "This BOM item is optional"
 msgstr ""
 
-#: part/models.py:1820
+#: part/models.py:1884
 msgid "Estimated build wastage quantity (absolute or percentage)"
 msgstr ""
 
-#: part/models.py:1823
+#: part/models.py:1887
 msgid "BOM item reference"
 msgstr ""
 
-#: part/models.py:1826
+#: part/models.py:1890
 msgid "BOM item notes"
 msgstr ""
 
-#: part/models.py:1828
+#: part/models.py:1892
 msgid "BOM line checksum"
 msgstr ""
 
-#: part/models.py:1899 part/views.py:1500 part/views.py:1552
-#: stock/models.py:234
+#: part/models.py:1963 part/views.py:1510 part/views.py:1562
+#: stock/models.py:241
 msgid "Quantity must be integer value for trackable parts"
 msgstr ""
 
-#: part/models.py:1908 part/models.py:1910
+#: part/models.py:1972 part/models.py:1974
 msgid "Sub part must be specified"
 msgstr ""
 
-#: part/models.py:1913
+#: part/models.py:1977
 msgid "BOM Item"
 msgstr ""
 
-#: part/models.py:2028
+#: part/models.py:2092
 msgid "Select Related Part"
 msgstr ""
 
-#: part/models.py:2060
+#: part/models.py:2124
 msgid ""
 "Error creating relationship: check that the part is not related to itself "
 "and that the relationship is unique"
@@ -2764,9 +2906,9 @@ msgstr ""
 #: part/templates/part/allocation.html:45
 #: stock/templates/stock/item_base.html:8
 #: stock/templates/stock/item_base.html:72
-#: stock/templates/stock/item_base.html:274
+#: stock/templates/stock/item_base.html:291
 #: stock/templates/stock/stock_adjust.html:16 templates/js/build.js:751
-#: templates/js/stock.js:699 templates/js/stock.js:948
+#: templates/js/stock.js:720 templates/js/stock.js:980
 msgid "Stock Item"
 msgstr ""
 
@@ -2831,7 +2973,7 @@ msgstr ""
 msgid "Validate"
 msgstr ""
 
-#: part/templates/part/bom.html:62 part/views.py:1791
+#: part/templates/part/bom.html:62 part/views.py:1801
 msgid "Export Bill of Materials"
 msgstr ""
 
@@ -2931,7 +3073,7 @@ msgstr ""
 msgid "All parts"
 msgstr ""
 
-#: part/templates/part/category.html:24 part/views.py:2182
+#: part/templates/part/category.html:24 part/views.py:2192
 msgid "Create new part category"
 msgstr ""
 
@@ -2955,10 +3097,6 @@ msgstr ""
 msgid "Category Description"
 msgstr ""
 
-#: part/templates/part/category.html:68 part/templates/part/detail.html:64
-msgid "Keywords"
-msgstr ""
-
 #: part/templates/part/category.html:74
 msgid "Subcategories"
 msgstr ""
@@ -2987,7 +3125,7 @@ msgstr ""
 msgid "Export Data"
 msgstr ""
 
-#: part/templates/part/category.html:174
+#: part/templates/part/category.html:174 templates/js/stock.js:628
 msgid "Create new location"
 msgstr ""
 
@@ -3003,7 +3141,7 @@ msgstr ""
 msgid "Create new Part Category"
 msgstr ""
 
-#: part/templates/part/category.html:216 stock/views.py:1358
+#: part/templates/part/category.html:216 stock/views.py:1363
 msgid "Create new Stock Location"
 msgstr ""
 
@@ -3027,15 +3165,6 @@ msgstr ""
 msgid "Part Details"
 msgstr ""
 
-#: part/templates/part/detail.html:25 part/templates/part/part_base.html:95
-#: templates/js/part.js:180
-msgid "IPN"
-msgstr ""
-
-#: part/templates/part/detail.html:32 templates/js/part.js:184
-msgid "Revision"
-msgstr ""
-
 #: part/templates/part/detail.html:39
 msgid "Latest Serial Number"
 msgstr ""
@@ -3044,101 +3173,79 @@ msgstr ""
 msgid "No serial numbers recorded"
 msgstr ""
 
-#: part/templates/part/detail.html:57
-msgid "Variant Of"
+#: part/templates/part/detail.html:115
+msgid "Stock Expiry Time"
 msgstr ""
 
-#: part/templates/part/detail.html:70 part/templates/part/set_category.html:15
-#: templates/js/part.js:405
-msgid "Category"
-msgstr ""
-
-#: part/templates/part/detail.html:94
-msgid "Default Supplier"
-msgstr ""
-
-#: part/templates/part/detail.html:102 part/templates/part/params.html:26
-msgid "Units"
-msgstr ""
-
-#: part/templates/part/detail.html:108
-msgid "Minimum Stock"
-msgstr ""
-
-#: part/templates/part/detail.html:114 templates/js/order.js:270
+#: part/templates/part/detail.html:121 templates/js/order.js:276
 msgid "Creation Date"
 msgstr ""
 
-#: part/templates/part/detail.html:120
+#: part/templates/part/detail.html:127
 msgid "Created By"
 msgstr ""
 
-#: part/templates/part/detail.html:127
+#: part/templates/part/detail.html:134
 msgid "Responsible User"
 msgstr ""
 
-#: part/templates/part/detail.html:141
+#: part/templates/part/detail.html:148
 msgid "Part is virtual (not a physical part)"
 msgstr ""
 
-#: part/templates/part/detail.html:143
+#: part/templates/part/detail.html:150
 msgid "Part is not a virtual part"
 msgstr ""
 
-#: part/templates/part/detail.html:148 stock/forms.py:249
-#: templates/js/table_filters.js:23 templates/js/table_filters.js:252
-msgid "Template"
-msgstr ""
-
-#: part/templates/part/detail.html:151
+#: part/templates/part/detail.html:158
 msgid "Part is a template part (variants can be made from this part)"
 msgstr ""
 
-#: part/templates/part/detail.html:153
+#: part/templates/part/detail.html:160
 msgid "Part is not a template part"
 msgstr ""
 
-#: part/templates/part/detail.html:161
+#: part/templates/part/detail.html:168
 msgid "Part can be assembled from other parts"
 msgstr ""
 
-#: part/templates/part/detail.html:163
+#: part/templates/part/detail.html:170
 msgid "Part cannot be assembled from other parts"
 msgstr ""
 
-#: part/templates/part/detail.html:171
+#: part/templates/part/detail.html:178
 msgid "Part can be used in assemblies"
 msgstr ""
 
-#: part/templates/part/detail.html:173
+#: part/templates/part/detail.html:180
 msgid "Part cannot be used in assemblies"
 msgstr ""
 
-#: part/templates/part/detail.html:181
+#: part/templates/part/detail.html:188
 msgid "Part stock is tracked by serial number"
 msgstr ""
 
-#: part/templates/part/detail.html:183
+#: part/templates/part/detail.html:190
 msgid "Part stock is not tracked by serial number"
 msgstr ""
 
-#: part/templates/part/detail.html:191 part/templates/part/detail.html:193
+#: part/templates/part/detail.html:198 part/templates/part/detail.html:200
 msgid "Part can be purchased from external suppliers"
 msgstr ""
 
-#: part/templates/part/detail.html:201
+#: part/templates/part/detail.html:208
 msgid "Part can be sold to customers"
 msgstr ""
 
-#: part/templates/part/detail.html:203
+#: part/templates/part/detail.html:210
 msgid "Part cannot be sold to customers"
 msgstr ""
 
-#: part/templates/part/detail.html:218
+#: part/templates/part/detail.html:225
 msgid "Part is active"
 msgstr ""
 
-#: part/templates/part/detail.html:220
+#: part/templates/part/detail.html:227
 msgid "Part is not active"
 msgstr ""
 
@@ -3156,12 +3263,12 @@ msgstr ""
 
 #: part/templates/part/params.html:15
 #: templates/InvenTree/settings/category.html:29
-#: templates/InvenTree/settings/part.html:38
+#: templates/InvenTree/settings/part.html:41
 msgid "New Parameter"
 msgstr ""
 
-#: part/templates/part/params.html:25 stock/models.py:1420
-#: templates/js/stock.js:112
+#: part/templates/part/params.html:25 stock/models.py:1499
+#: templates/InvenTree/settings/header.html:8 templates/js/stock.js:113
 msgid "Value"
 msgstr ""
 
@@ -3196,19 +3303,19 @@ msgid "Star this part"
 msgstr ""
 
 #: part/templates/part/part_base.html:49
-#: stock/templates/stock/item_base.html:101
+#: stock/templates/stock/item_base.html:108
 #: stock/templates/stock/location.html:29
 msgid "Barcode actions"
 msgstr ""
 
 #: part/templates/part/part_base.html:51
-#: stock/templates/stock/item_base.html:103
+#: stock/templates/stock/item_base.html:110
 #: stock/templates/stock/location.html:31
 msgid "Show QR Code"
 msgstr ""
 
 #: part/templates/part/part_base.html:52
-#: stock/templates/stock/item_base.html:104
+#: stock/templates/stock/item_base.html:126
 #: stock/templates/stock/location.html:32
 msgid "Print Label"
 msgstr ""
@@ -3237,7 +3344,7 @@ msgstr ""
 msgid "Delete part"
 msgstr ""
 
-#: part/templates/part/part_base.html:124 templates/js/table_filters.js:111
+#: part/templates/part/part_base.html:124 templates/js/table_filters.js:121
 msgid "In Stock"
 msgstr ""
 
@@ -3346,7 +3453,7 @@ msgstr ""
 msgid "Used In"
 msgstr ""
 
-#: part/templates/part/tabs.html:61 stock/templates/stock/item_base.html:318
+#: part/templates/part/tabs.html:61 stock/templates/stock/item_base.html:349
 msgid "Tests"
 msgstr ""
 
@@ -3374,220 +3481,220 @@ msgstr ""
 msgid "New Variant"
 msgstr ""
 
-#: part/views.py:84
+#: part/views.py:86
 msgid "Add Related Part"
 msgstr ""
 
-#: part/views.py:140
+#: part/views.py:142
 msgid "Delete Related Part"
 msgstr ""
 
-#: part/views.py:152
+#: part/views.py:154
 msgid "Add part attachment"
 msgstr ""
 
-#: part/views.py:207 templates/attachment_table.html:34
+#: part/views.py:209 templates/attachment_table.html:34
 msgid "Edit attachment"
 msgstr ""
 
-#: part/views.py:213
+#: part/views.py:215
 msgid "Part attachment updated"
 msgstr ""
 
-#: part/views.py:228
+#: part/views.py:230
 msgid "Delete Part Attachment"
 msgstr ""
 
-#: part/views.py:236
+#: part/views.py:238
 msgid "Deleted part attachment"
 msgstr ""
 
-#: part/views.py:245
+#: part/views.py:247
 msgid "Create Test Template"
 msgstr ""
 
-#: part/views.py:274
+#: part/views.py:276
 msgid "Edit Test Template"
 msgstr ""
 
-#: part/views.py:290
+#: part/views.py:292
 msgid "Delete Test Template"
 msgstr ""
 
-#: part/views.py:299
+#: part/views.py:301
 msgid "Set Part Category"
 msgstr ""
 
-#: part/views.py:349
+#: part/views.py:351
 #, python-brace-format
 msgid "Set category for {n} parts"
 msgstr ""
 
-#: part/views.py:384
+#: part/views.py:386
 msgid "Create Variant"
 msgstr ""
 
-#: part/views.py:466
+#: part/views.py:468
 msgid "Duplicate Part"
 msgstr ""
 
-#: part/views.py:473
+#: part/views.py:475
 msgid "Copied part"
 msgstr ""
 
-#: part/views.py:527 part/views.py:661
+#: part/views.py:529 part/views.py:667
 msgid "Possible matches exist - confirm creation of new part"
 msgstr ""
 
-#: part/views.py:592 templates/js/stock.js:844
+#: part/views.py:594 templates/js/stock.js:876
 msgid "Create New Part"
 msgstr ""
 
-#: part/views.py:599
+#: part/views.py:601
 msgid "Created new part"
 msgstr ""
 
-#: part/views.py:830
+#: part/views.py:836
 msgid "Part QR Code"
 msgstr ""
 
-#: part/views.py:849
+#: part/views.py:855
 msgid "Upload Part Image"
 msgstr ""
 
-#: part/views.py:857 part/views.py:894
+#: part/views.py:863 part/views.py:900
 msgid "Updated part image"
 msgstr ""
 
-#: part/views.py:866
+#: part/views.py:872
 msgid "Select Part Image"
 msgstr ""
 
-#: part/views.py:897
+#: part/views.py:903
 msgid "Part image not found"
 msgstr ""
 
-#: part/views.py:908
+#: part/views.py:914
 msgid "Edit Part Properties"
 msgstr ""
 
-#: part/views.py:935
+#: part/views.py:945
 msgid "Duplicate BOM"
 msgstr ""
 
-#: part/views.py:966
+#: part/views.py:976
 msgid "Confirm duplication of BOM from parent"
 msgstr ""
 
-#: part/views.py:987
+#: part/views.py:997
 msgid "Validate BOM"
 msgstr ""
 
-#: part/views.py:1010
+#: part/views.py:1020
 msgid "Confirm that the BOM is valid"
 msgstr ""
 
-#: part/views.py:1021
+#: part/views.py:1031
 msgid "Validated Bill of Materials"
 msgstr ""
 
-#: part/views.py:1155
+#: part/views.py:1165
 msgid "No BOM file provided"
 msgstr ""
 
-#: part/views.py:1503
+#: part/views.py:1513
 msgid "Enter a valid quantity"
 msgstr ""
 
-#: part/views.py:1528 part/views.py:1531
+#: part/views.py:1538 part/views.py:1541
 msgid "Select valid part"
 msgstr ""
 
-#: part/views.py:1537
+#: part/views.py:1547
 msgid "Duplicate part selected"
 msgstr ""
 
-#: part/views.py:1575
+#: part/views.py:1585
 msgid "Select a part"
 msgstr ""
 
-#: part/views.py:1581
+#: part/views.py:1591
 msgid "Selected part creates a circular BOM"
 msgstr ""
 
-#: part/views.py:1585
+#: part/views.py:1595
 msgid "Specify quantity"
 msgstr ""
 
-#: part/views.py:1841
+#: part/views.py:1851
 msgid "Confirm Part Deletion"
 msgstr ""
 
-#: part/views.py:1850
+#: part/views.py:1860
 msgid "Part was deleted"
 msgstr ""
 
-#: part/views.py:1859
+#: part/views.py:1869
 msgid "Part Pricing"
 msgstr ""
 
-#: part/views.py:1973
+#: part/views.py:1983
 msgid "Create Part Parameter Template"
 msgstr ""
 
-#: part/views.py:1983
+#: part/views.py:1993
 msgid "Edit Part Parameter Template"
 msgstr ""
 
-#: part/views.py:1992
+#: part/views.py:2002
 msgid "Delete Part Parameter Template"
 msgstr ""
 
-#: part/views.py:2002
+#: part/views.py:2012
 msgid "Create Part Parameter"
 msgstr ""
 
-#: part/views.py:2054
+#: part/views.py:2064
 msgid "Edit Part Parameter"
 msgstr ""
 
-#: part/views.py:2070
+#: part/views.py:2080
 msgid "Delete Part Parameter"
 msgstr ""
 
-#: part/views.py:2129
+#: part/views.py:2139
 msgid "Edit Part Category"
 msgstr ""
 
-#: part/views.py:2166
+#: part/views.py:2176
 msgid "Delete Part Category"
 msgstr ""
 
-#: part/views.py:2174
+#: part/views.py:2184
 msgid "Part category was deleted"
 msgstr ""
 
-#: part/views.py:2230
+#: part/views.py:2240
 msgid "Create Category Parameter Template"
 msgstr ""
 
-#: part/views.py:2333
+#: part/views.py:2343
 msgid "Edit Category Parameter Template"
 msgstr ""
 
-#: part/views.py:2391
+#: part/views.py:2401
 msgid "Delete Category Parameter Template"
 msgstr ""
 
-#: part/views.py:2416
+#: part/views.py:2426
 msgid "Create BOM Item"
 msgstr ""
 
-#: part/views.py:2488
+#: part/views.py:2498
 msgid "Edit BOM item"
 msgstr ""
 
-#: part/views.py:2545
+#: part/views.py:2555
 msgid "Confim BOM item deletion"
 msgstr ""
 
@@ -3619,295 +3726,305 @@ msgstr ""
 msgid "Asset file description"
 msgstr ""
 
-#: stock/forms.py:111
+#: stock/forms.py:116
 msgid "Enter unique serial numbers (or leave blank)"
 msgstr ""
 
-#: stock/forms.py:192
+#: stock/forms.py:198
 msgid "Label"
 msgstr ""
 
-#: stock/forms.py:193 stock/forms.py:249
+#: stock/forms.py:199 stock/forms.py:255
 msgid "Select test report template"
 msgstr ""
 
-#: stock/forms.py:257
+#: stock/forms.py:263
 msgid "Include stock items in sub locations"
 msgstr ""
 
-#: stock/forms.py:292
+#: stock/forms.py:298
 msgid "Stock item to install"
 msgstr ""
 
-#: stock/forms.py:299
+#: stock/forms.py:305
 msgid "Stock quantity to assign"
 msgstr ""
 
-#: stock/forms.py:327
+#: stock/forms.py:333
 msgid "Must not exceed available quantity"
 msgstr ""
 
-#: stock/forms.py:337
+#: stock/forms.py:343
 msgid "Destination location for uninstalled items"
 msgstr ""
 
-#: stock/forms.py:339
+#: stock/forms.py:345
 msgid "Add transaction note (optional)"
 msgstr ""
 
-#: stock/forms.py:341
+#: stock/forms.py:347
 msgid "Confirm uninstall"
 msgstr ""
 
-#: stock/forms.py:341
+#: stock/forms.py:347
 msgid "Confirm removal of installed stock items"
 msgstr ""
 
-#: stock/forms.py:365
+#: stock/forms.py:371
 msgid "Destination stock location"
 msgstr ""
 
-#: stock/forms.py:367
+#: stock/forms.py:373
 msgid "Add note (required)"
 msgstr ""
 
-#: stock/forms.py:371 stock/views.py:935 stock/views.py:1133
+#: stock/forms.py:377 stock/views.py:935 stock/views.py:1133
 msgid "Confirm stock adjustment"
 msgstr ""
 
-#: stock/forms.py:371
+#: stock/forms.py:377
 msgid "Confirm movement of stock items"
 msgstr ""
 
-#: stock/forms.py:373
+#: stock/forms.py:379
 msgid "Set Default Location"
 msgstr ""
 
-#: stock/forms.py:373
+#: stock/forms.py:379
 msgid "Set the destination as the default location for selected parts"
 msgstr ""
 
-#: stock/models.py:179
+#: stock/models.py:186
 msgid "Created stock item"
 msgstr ""
 
-#: stock/models.py:215
+#: stock/models.py:222
 msgid "StockItem with this serial number already exists"
 msgstr ""
 
-#: stock/models.py:251
+#: stock/models.py:258
 #, python-brace-format
 msgid "Part type ('{pf}') must be {pe}"
 msgstr ""
 
-#: stock/models.py:261 stock/models.py:270
+#: stock/models.py:268 stock/models.py:277
 msgid "Quantity must be 1 for item with a serial number"
 msgstr ""
 
-#: stock/models.py:262
+#: stock/models.py:269
 msgid "Serial number cannot be set if quantity greater than 1"
 msgstr ""
 
-#: stock/models.py:284
+#: stock/models.py:291
 msgid "Item cannot belong to itself"
 msgstr ""
 
-#: stock/models.py:290
+#: stock/models.py:297
 msgid "Item must have a build reference if is_building=True"
 msgstr ""
 
-#: stock/models.py:297
+#: stock/models.py:304
 msgid "Build reference does not point to the same part object"
 msgstr ""
 
-#: stock/models.py:330
+#: stock/models.py:337
 msgid "Parent Stock Item"
 msgstr ""
 
-#: stock/models.py:339
+#: stock/models.py:346
 msgid "Base part"
 msgstr ""
 
-#: stock/models.py:348
+#: stock/models.py:355
 msgid "Select a matching supplier part for this stock item"
 msgstr ""
 
-#: stock/models.py:353 stock/templates/stock/stock_app_base.html:7
+#: stock/models.py:360 stock/templates/stock/stock_app_base.html:7
 msgid "Stock Location"
 msgstr ""
 
-#: stock/models.py:356
+#: stock/models.py:363
 msgid "Where is this stock item located?"
 msgstr ""
 
-#: stock/models.py:361 stock/templates/stock/item_base.html:212
+#: stock/models.py:368 stock/templates/stock/item_base.html:229
 msgid "Installed In"
 msgstr ""
 
-#: stock/models.py:364
+#: stock/models.py:371
 msgid "Is this item installed in another item?"
 msgstr ""
 
-#: stock/models.py:380
+#: stock/models.py:387
 msgid "Serial number for this item"
 msgstr ""
 
-#: stock/models.py:392
+#: stock/models.py:399
 msgid "Batch code for this stock item"
 msgstr ""
 
-#: stock/models.py:396
+#: stock/models.py:403
 msgid "Stock Quantity"
 msgstr ""
 
-#: stock/models.py:405
+#: stock/models.py:412
 msgid "Source Build"
 msgstr ""
 
-#: stock/models.py:407
+#: stock/models.py:414
 msgid "Build for this stock item"
 msgstr ""
 
-#: stock/models.py:418
+#: stock/models.py:425
 msgid "Source Purchase Order"
 msgstr ""
 
-#: stock/models.py:421
+#: stock/models.py:428
 msgid "Purchase order for this stock item"
 msgstr ""
 
-#: stock/models.py:427
+#: stock/models.py:434
 msgid "Destination Sales Order"
 msgstr ""
 
-#: stock/models.py:439
+#: stock/models.py:440 stock/templates/stock/item_base.html:316
+#: templates/js/stock.js:597
+msgid "Expiry Date"
+msgstr ""
+
+#: stock/models.py:441
+msgid ""
+"Expiry date for stock item. Stock will be considered expired after this date"
+msgstr ""
+
+#: stock/models.py:454
 msgid "Delete this Stock Item when stock is depleted"
 msgstr ""
 
-#: stock/models.py:449 stock/templates/stock/item_notes.html:14
+#: stock/models.py:464 stock/templates/stock/item_notes.html:14
 #: stock/templates/stock/item_notes.html:30
 msgid "Stock Item Notes"
 msgstr ""
 
-#: stock/models.py:459
+#: stock/models.py:474
 msgid "Single unit purchase price at time of purchase"
 msgstr ""
 
-#: stock/models.py:510
+#: stock/models.py:574
 msgid "Assigned to Customer"
 msgstr ""
 
-#: stock/models.py:512
+#: stock/models.py:576
 msgid "Manually assigned to customer"
 msgstr ""
 
-#: stock/models.py:525
+#: stock/models.py:589
 msgid "Returned from customer"
 msgstr ""
 
-#: stock/models.py:527
+#: stock/models.py:591
 msgid "Returned to location"
 msgstr ""
 
-#: stock/models.py:652
+#: stock/models.py:716
 msgid "Installed into stock item"
 msgstr ""
 
-#: stock/models.py:660
+#: stock/models.py:724
 msgid "Installed stock item"
 msgstr ""
 
-#: stock/models.py:684
+#: stock/models.py:748
 msgid "Uninstalled stock item"
 msgstr ""
 
-#: stock/models.py:703
+#: stock/models.py:767
 msgid "Uninstalled into location"
 msgstr ""
 
-#: stock/models.py:803
+#: stock/models.py:847
 msgid "Part is not set as trackable"
 msgstr ""
 
-#: stock/models.py:809
+#: stock/models.py:853
 msgid "Quantity must be integer"
 msgstr ""
 
-#: stock/models.py:815
+#: stock/models.py:859
 #, python-brace-format
 msgid "Quantity must not exceed available stock quantity ({n})"
 msgstr ""
 
-#: stock/models.py:818
+#: stock/models.py:862
 msgid "Serial numbers must be a list of integers"
 msgstr ""
 
-#: stock/models.py:821
+#: stock/models.py:865
 msgid "Quantity does not match serial numbers"
 msgstr ""
 
-#: stock/models.py:853
+#: stock/models.py:897
 msgid "Add serial number"
 msgstr ""
 
-#: stock/models.py:856
+#: stock/models.py:900
 #, python-brace-format
 msgid "Serialized {n} items"
 msgstr ""
 
-#: stock/models.py:967
+#: stock/models.py:1011
 msgid "StockItem cannot be moved as it is not in stock"
 msgstr ""
 
-#: stock/models.py:1321
+#: stock/models.py:1400
 msgid "Tracking entry title"
 msgstr ""
 
-#: stock/models.py:1323
+#: stock/models.py:1402
 msgid "Entry notes"
 msgstr ""
 
-#: stock/models.py:1325
+#: stock/models.py:1404
 msgid "Link to external page for further information"
 msgstr ""
 
-#: stock/models.py:1385
+#: stock/models.py:1464
 msgid "Value must be provided for this test"
 msgstr ""
 
-#: stock/models.py:1391
+#: stock/models.py:1470
 msgid "Attachment must be uploaded for this test"
 msgstr ""
 
-#: stock/models.py:1408
+#: stock/models.py:1487
 msgid "Test"
 msgstr ""
 
-#: stock/models.py:1409
+#: stock/models.py:1488
 msgid "Test name"
 msgstr ""
 
-#: stock/models.py:1414
+#: stock/models.py:1493
 msgid "Result"
 msgstr ""
 
-#: stock/models.py:1415 templates/js/table_filters.js:162
+#: stock/models.py:1494 templates/js/table_filters.js:172
 msgid "Test result"
 msgstr ""
 
-#: stock/models.py:1421
+#: stock/models.py:1500
 msgid "Test output value"
 msgstr ""
 
-#: stock/models.py:1427
+#: stock/models.py:1506
 msgid "Attachment"
 msgstr ""
 
-#: stock/models.py:1428
+#: stock/models.py:1507
 msgid "Test result attachment"
 msgstr ""
 
-#: stock/models.py:1434
+#: stock/models.py:1513
 msgid "Test notes"
 msgstr ""
 
@@ -3958,111 +4075,134 @@ msgid ""
 "This stock item will be automatically deleted when all stock is depleted."
 msgstr ""
 
-#: stock/templates/stock/item_base.html:107 templates/js/barcode.js:283
+#: stock/templates/stock/item_base.html:74
+#: stock/templates/stock/item_base.html:320 templates/js/table_filters.js:111
+msgid "Expired"
+msgstr ""
+
+#: stock/templates/stock/item_base.html:78
+#: stock/templates/stock/item_base.html:322 templates/js/table_filters.js:116
+msgid "Stale"
+msgstr ""
+
+#: stock/templates/stock/item_base.html:113 templates/js/barcode.js:283
 #: templates/js/barcode.js:288
 msgid "Unlink Barcode"
 msgstr ""
 
-#: stock/templates/stock/item_base.html:109
+#: stock/templates/stock/item_base.html:115
 msgid "Link Barcode"
 msgstr ""
 
-#: stock/templates/stock/item_base.html:117
+#: stock/templates/stock/item_base.html:123
+msgid "Document actions"
+msgstr ""
+
+#: stock/templates/stock/item_base.html:129
+#: stock/templates/stock/item_tests.html:25
+msgid "Test Report"
+msgstr ""
+
+#: stock/templates/stock/item_base.html:137
 msgid "Stock adjustment actions"
 msgstr ""
 
-#: stock/templates/stock/item_base.html:121
+#: stock/templates/stock/item_base.html:141
 #: stock/templates/stock/location.html:41 templates/stock_table.html:23
 msgid "Count stock"
 msgstr ""
 
-#: stock/templates/stock/item_base.html:122 templates/stock_table.html:21
+#: stock/templates/stock/item_base.html:142 templates/stock_table.html:21
 msgid "Add stock"
 msgstr ""
 
-#: stock/templates/stock/item_base.html:123 templates/stock_table.html:22
+#: stock/templates/stock/item_base.html:143 templates/stock_table.html:22
 msgid "Remove stock"
 msgstr ""
 
-#: stock/templates/stock/item_base.html:125
+#: stock/templates/stock/item_base.html:145
 msgid "Transfer stock"
 msgstr ""
 
-#: stock/templates/stock/item_base.html:127
+#: stock/templates/stock/item_base.html:147
 msgid "Serialize stock"
 msgstr ""
 
-#: stock/templates/stock/item_base.html:131
+#: stock/templates/stock/item_base.html:151
 msgid "Assign to customer"
 msgstr ""
 
-#: stock/templates/stock/item_base.html:134
+#: stock/templates/stock/item_base.html:154
 msgid "Return to stock"
 msgstr ""
 
-#: stock/templates/stock/item_base.html:138 templates/js/stock.js:985
+#: stock/templates/stock/item_base.html:158 templates/js/stock.js:1017
 msgid "Uninstall stock item"
 msgstr ""
 
-#: stock/templates/stock/item_base.html:138
+#: stock/templates/stock/item_base.html:158
 msgid "Uninstall"
 msgstr ""
 
-#: stock/templates/stock/item_base.html:147
+#: stock/templates/stock/item_base.html:167
 #: stock/templates/stock/location.html:38
 msgid "Stock actions"
 msgstr ""
 
-#: stock/templates/stock/item_base.html:150
+#: stock/templates/stock/item_base.html:170
 msgid "Convert to variant"
 msgstr ""
 
-#: stock/templates/stock/item_base.html:153
+#: stock/templates/stock/item_base.html:173
 msgid "Duplicate stock item"
 msgstr ""
 
-#: stock/templates/stock/item_base.html:155
+#: stock/templates/stock/item_base.html:175
 msgid "Edit stock item"
 msgstr ""
 
-#: stock/templates/stock/item_base.html:158
+#: stock/templates/stock/item_base.html:178
 msgid "Delete stock item"
 msgstr ""
 
-#: stock/templates/stock/item_base.html:164
-msgid "Generate test report"
-msgstr ""
-
-#: stock/templates/stock/item_base.html:172
+#: stock/templates/stock/item_base.html:189
 msgid "Stock Item Details"
 msgstr ""
 
-#: stock/templates/stock/item_base.html:231 templates/js/build.js:442
+#: stock/templates/stock/item_base.html:248 templates/js/build.js:442
 msgid "No location set"
 msgstr ""
 
-#: stock/templates/stock/item_base.html:238
+#: stock/templates/stock/item_base.html:255
 msgid "Barcode Identifier"
 msgstr ""
 
-#: stock/templates/stock/item_base.html:252 templates/js/build.js:642
+#: stock/templates/stock/item_base.html:269 templates/js/build.js:642
 #: templates/navbar.html:25
 msgid "Build"
 msgstr ""
 
-#: stock/templates/stock/item_base.html:273
+#: stock/templates/stock/item_base.html:290
 msgid "Parent Item"
 msgstr ""
 
-#: stock/templates/stock/item_base.html:298
+#: stock/templates/stock/item_base.html:320
+msgid "This StockItem expired on"
+msgstr ""
+
+#: stock/templates/stock/item_base.html:322
+msgid "This StockItem expires on"
+msgstr ""
+
+#: stock/templates/stock/item_base.html:329
 msgid "Last Updated"
 msgstr ""
 
-#: stock/templates/stock/item_base.html:303
+#: stock/templates/stock/item_base.html:334
 msgid "Last Stocktake"
 msgstr ""
 
-#: stock/templates/stock/item_base.html:307
+#: stock/templates/stock/item_base.html:338
 msgid "No stocktake performed"
 msgstr ""
 
@@ -4118,10 +4258,6 @@ msgstr ""
 msgid "Add Test Data"
 msgstr ""
 
-#: stock/templates/stock/item_tests.html:25
-msgid "Test Report"
-msgstr ""
-
 #: stock/templates/stock/location.html:18
 msgid "All stock items"
 msgstr ""
@@ -4182,7 +4318,7 @@ msgstr ""
 msgid "The following stock items will be uninstalled"
 msgstr ""
 
-#: stock/templates/stock/stockitem_convert.html:7 stock/views.py:1330
+#: stock/templates/stock/stockitem_convert.html:7 stock/views.py:1335
 msgid "Convert Stock Item"
 msgstr ""
 
@@ -4380,39 +4516,39 @@ msgstr ""
 msgid "Edit Stock Item"
 msgstr ""
 
-#: stock/views.py:1380
+#: stock/views.py:1385
 msgid "Serialize Stock"
 msgstr ""
 
-#: stock/views.py:1474 templates/js/build.js:210
+#: stock/views.py:1479 templates/js/build.js:210
 msgid "Create new Stock Item"
 msgstr ""
 
-#: stock/views.py:1578
+#: stock/views.py:1587
 msgid "Duplicate Stock Item"
 msgstr ""
 
-#: stock/views.py:1650
+#: stock/views.py:1664
 msgid "Quantity cannot be negative"
 msgstr ""
 
-#: stock/views.py:1736
+#: stock/views.py:1750
 msgid "Delete Stock Location"
 msgstr ""
 
-#: stock/views.py:1750
+#: stock/views.py:1764
 msgid "Delete Stock Item"
 msgstr ""
 
-#: stock/views.py:1762
+#: stock/views.py:1776
 msgid "Delete Stock Tracking Entry"
 msgstr ""
 
-#: stock/views.py:1781
+#: stock/views.py:1795
 msgid "Edit Stock Tracking Entry"
 msgstr ""
 
-#: stock/views.py:1791
+#: stock/views.py:1805
 msgid "Add Stock Tracking Entry"
 msgstr ""
 
@@ -4444,7 +4580,11 @@ msgstr ""
 msgid "Pending Builds"
 msgstr ""
 
-#: templates/InvenTree/index.html:4
+#: templates/InvenTree/expired_stock.html:7
+msgid "Expired Stock"
+msgstr ""
+
+#: templates/InvenTree/index.html:5
 msgid "Index"
 msgstr ""
 
@@ -4472,11 +4612,11 @@ msgstr ""
 msgid "Enter a search query"
 msgstr ""
 
-#: templates/InvenTree/search.html:191 templates/js/stock.js:289
+#: templates/InvenTree/search.html:191 templates/js/stock.js:290
 msgid "Shipped to customer"
 msgstr ""
 
-#: templates/InvenTree/search.html:194 templates/js/stock.js:299
+#: templates/InvenTree/search.html:194 templates/js/stock.js:300
 msgid "No stock location set"
 msgstr ""
 
@@ -4505,12 +4645,12 @@ msgid "Default Value"
 msgstr ""
 
 #: templates/InvenTree/settings/category.html:70
-#: templates/InvenTree/settings/part.html:75
+#: templates/InvenTree/settings/part.html:78
 msgid "Edit Template"
 msgstr ""
 
 #: templates/InvenTree/settings/category.html:71
-#: templates/InvenTree/settings/part.html:76
+#: templates/InvenTree/settings/part.html:79
 msgid "Delete Template"
 msgstr ""
 
@@ -4518,6 +4658,10 @@ msgstr ""
 msgid "Global InvenTree Settings"
 msgstr ""
 
+#: templates/InvenTree/settings/header.html:7
+msgid "Setting"
+msgstr ""
+
 #: templates/InvenTree/settings/part.html:9
 msgid "Part Settings"
 msgstr ""
@@ -4526,11 +4670,11 @@ msgstr ""
 msgid "Part Options"
 msgstr ""
 
-#: templates/InvenTree/settings/part.html:34
+#: templates/InvenTree/settings/part.html:37
 msgid "Part Parameter Templates"
 msgstr ""
 
-#: templates/InvenTree/settings/part.html:55
+#: templates/InvenTree/settings/part.html:58
 msgid "No part parameter templates found"
 msgstr ""
 
@@ -4538,11 +4682,11 @@ msgstr ""
 msgid "Purchase Order Settings"
 msgstr ""
 
-#: templates/InvenTree/settings/setting.html:16
+#: templates/InvenTree/settings/setting.html:23
 msgid "No value set"
 msgstr ""
 
-#: templates/InvenTree/settings/setting.html:24
+#: templates/InvenTree/settings/setting.html:31
 msgid "Edit setting"
 msgstr ""
 
@@ -4559,6 +4703,10 @@ msgstr ""
 msgid "Stock Settings"
 msgstr ""
 
+#: templates/InvenTree/settings/stock.html:13
+msgid "Stock Options"
+msgstr ""
+
 #: templates/InvenTree/settings/tabs.html:3
 #: templates/InvenTree/settings/user.html:10
 msgid "User Settings"
@@ -4630,6 +4778,14 @@ msgstr ""
 msgid "Outstanding Sales Orders"
 msgstr ""
 
+#: templates/InvenTree/so_overdue.html:7
+msgid "Overdue Sales Orders"
+msgstr ""
+
+#: templates/InvenTree/stale_stock.html:7
+msgid "Stale Stock"
+msgstr ""
+
 #: templates/InvenTree/starred_parts.html:7
 msgid "Starred Parts"
 msgstr ""
@@ -4887,15 +5043,11 @@ msgstr ""
 msgid "Assembled part"
 msgstr ""
 
-#: templates/js/company.js:208
-msgid "Link"
-msgstr ""
-
 #: templates/js/order.js:135
 msgid "No purchase orders found"
 msgstr ""
 
-#: templates/js/order.js:188 templates/js/stock.js:681
+#: templates/js/order.js:188 templates/js/stock.js:702
 msgid "Date"
 msgstr ""
 
@@ -4903,7 +5055,11 @@ msgstr ""
 msgid "No sales orders found"
 msgstr ""
 
-#: templates/js/order.js:275
+#: templates/js/order.js:241
+msgid "Order is overdue"
+msgstr ""
+
+#: templates/js/order.js:286
 msgid "Shipment Date"
 msgstr ""
 
@@ -4931,8 +5087,8 @@ msgstr ""
 msgid "No parts found"
 msgstr ""
 
-#: templates/js/part.js:343 templates/js/stock.js:456
-#: templates/js/stock.js:1017
+#: templates/js/part.js:343 templates/js/stock.js:463
+#: templates/js/stock.js:1049
 msgid "Select"
 msgstr ""
 
@@ -4940,7 +5096,7 @@ msgstr ""
 msgid "No category"
 msgstr ""
 
-#: templates/js/part.js:429 templates/js/table_filters.js:260
+#: templates/js/part.js:429 templates/js/table_filters.js:274
 msgid "Low stock"
 msgstr ""
 
@@ -4960,11 +5116,11 @@ msgstr ""
 msgid "No test templates matching query"
 msgstr ""
 
-#: templates/js/part.js:604 templates/js/stock.js:63
+#: templates/js/part.js:604 templates/js/stock.js:64
 msgid "Edit test result"
 msgstr ""
 
-#: templates/js/part.js:605 templates/js/stock.js:64
+#: templates/js/part.js:605 templates/js/stock.js:65
 msgid "Delete test result"
 msgstr ""
 
@@ -4972,103 +5128,111 @@ msgstr ""
 msgid "This test is defined for a parent part"
 msgstr ""
 
-#: templates/js/stock.js:26
+#: templates/js/stock.js:27
 msgid "PASS"
 msgstr ""
 
-#: templates/js/stock.js:28
+#: templates/js/stock.js:29
 msgid "FAIL"
 msgstr ""
 
-#: templates/js/stock.js:33
+#: templates/js/stock.js:34
 msgid "NO RESULT"
 msgstr ""
 
-#: templates/js/stock.js:59
+#: templates/js/stock.js:60
 msgid "Add test result"
 msgstr ""
 
-#: templates/js/stock.js:78
+#: templates/js/stock.js:79
 msgid "No test results found"
 msgstr ""
 
-#: templates/js/stock.js:120
+#: templates/js/stock.js:121
 msgid "Test Date"
 msgstr ""
 
-#: templates/js/stock.js:281
+#: templates/js/stock.js:282
 msgid "In production"
 msgstr ""
 
-#: templates/js/stock.js:285
+#: templates/js/stock.js:286
 msgid "Installed in Stock Item"
 msgstr ""
 
-#: templates/js/stock.js:293
+#: templates/js/stock.js:294
 msgid "Assigned to Sales Order"
 msgstr ""
 
-#: templates/js/stock.js:313
+#: templates/js/stock.js:314
 msgid "No stock items matching query"
 msgstr ""
 
-#: templates/js/stock.js:424
+#: templates/js/stock.js:431
 msgid "Undefined location"
 msgstr ""
 
-#: templates/js/stock.js:518
+#: templates/js/stock.js:525
 msgid "Stock item is in production"
 msgstr ""
 
-#: templates/js/stock.js:523
+#: templates/js/stock.js:530
 msgid "Stock item assigned to sales order"
 msgstr ""
 
-#: templates/js/stock.js:526
+#: templates/js/stock.js:533
 msgid "Stock item assigned to customer"
 msgstr ""
 
-#: templates/js/stock.js:530
+#: templates/js/stock.js:537
+msgid "Stock item has expired"
+msgstr ""
+
+#: templates/js/stock.js:539
+msgid "Stock item will expire soon"
+msgstr ""
+
+#: templates/js/stock.js:543
 msgid "Stock item has been allocated"
 msgstr ""
 
-#: templates/js/stock.js:534
+#: templates/js/stock.js:547
 msgid "Stock item has been installed in another item"
 msgstr ""
 
-#: templates/js/stock.js:542
+#: templates/js/stock.js:555
 msgid "Stock item has been rejected"
 msgstr ""
 
-#: templates/js/stock.js:546
+#: templates/js/stock.js:559
 msgid "Stock item is lost"
 msgstr ""
 
-#: templates/js/stock.js:549
+#: templates/js/stock.js:562
 msgid "Stock item is destroyed"
 msgstr ""
 
-#: templates/js/stock.js:553 templates/js/table_filters.js:106
+#: templates/js/stock.js:566 templates/js/table_filters.js:106
 msgid "Depleted"
 msgstr ""
 
-#: templates/js/stock.js:747
+#: templates/js/stock.js:768
 msgid "No user information"
 msgstr ""
 
-#: templates/js/stock.js:856
+#: templates/js/stock.js:888
 msgid "Create New Location"
 msgstr ""
 
-#: templates/js/stock.js:955
+#: templates/js/stock.js:987
 msgid "Serial"
 msgstr ""
 
-#: templates/js/stock.js:1048 templates/js/table_filters.js:121
+#: templates/js/stock.js:1080 templates/js/table_filters.js:131
 msgid "Installed"
 msgstr ""
 
-#: templates/js/stock.js:1073
+#: templates/js/stock.js:1105
 msgid "Install item"
 msgstr ""
 
@@ -5080,36 +5244,36 @@ msgstr ""
 msgid "Validated"
 msgstr ""
 
-#: templates/js/table_filters.js:65 templates/js/table_filters.js:131
+#: templates/js/table_filters.js:65 templates/js/table_filters.js:141
 msgid "Is Serialized"
 msgstr ""
 
-#: templates/js/table_filters.js:68 templates/js/table_filters.js:138
+#: templates/js/table_filters.js:68 templates/js/table_filters.js:148
 msgid "Serial number GTE"
 msgstr ""
 
-#: templates/js/table_filters.js:69 templates/js/table_filters.js:139
+#: templates/js/table_filters.js:69 templates/js/table_filters.js:149
 msgid "Serial number greater than or equal to"
 msgstr ""
 
-#: templates/js/table_filters.js:72 templates/js/table_filters.js:142
+#: templates/js/table_filters.js:72 templates/js/table_filters.js:152
 msgid "Serial number LTE"
 msgstr ""
 
-#: templates/js/table_filters.js:73 templates/js/table_filters.js:143
+#: templates/js/table_filters.js:73 templates/js/table_filters.js:153
 msgid "Serial number less than or equal to"
 msgstr ""
 
 #: templates/js/table_filters.js:76 templates/js/table_filters.js:77
-#: templates/js/table_filters.js:134 templates/js/table_filters.js:135
+#: templates/js/table_filters.js:144 templates/js/table_filters.js:145
 msgid "Serial number"
 msgstr ""
 
-#: templates/js/table_filters.js:81 templates/js/table_filters.js:152
+#: templates/js/table_filters.js:81 templates/js/table_filters.js:162
 msgid "Batch code"
 msgstr ""
 
-#: templates/js/table_filters.js:91 templates/js/table_filters.js:227
+#: templates/js/table_filters.js:91 templates/js/table_filters.js:241
 msgid "Active parts"
 msgstr ""
 
@@ -5138,74 +5302,82 @@ msgid "Show stock items which are depleted"
 msgstr ""
 
 #: templates/js/table_filters.js:112
-msgid "Show items which are in stock"
-msgstr ""
-
-#: templates/js/table_filters.js:116
-msgid "In Production"
+msgid "Show stock items which have expired"
 msgstr ""
 
 #: templates/js/table_filters.js:117
-msgid "Show items which are in production"
+msgid "Show stock which is close to expiring"
 msgstr ""
 
 #: templates/js/table_filters.js:122
-msgid "Show stock items which are installed in another item"
+msgid "Show items which are in stock"
 msgstr ""
 
 #: templates/js/table_filters.js:126
-msgid "Sent to customer"
+msgid "In Production"
 msgstr ""
 
 #: templates/js/table_filters.js:127
+msgid "Show items which are in production"
+msgstr ""
+
+#: templates/js/table_filters.js:132
+msgid "Show stock items which are installed in another item"
+msgstr ""
+
+#: templates/js/table_filters.js:136
+msgid "Sent to customer"
+msgstr ""
+
+#: templates/js/table_filters.js:137
 msgid "Show items which have been assigned to a customer"
 msgstr ""
 
-#: templates/js/table_filters.js:147 templates/js/table_filters.js:148
+#: templates/js/table_filters.js:157 templates/js/table_filters.js:158
 msgid "Stock status"
 msgstr ""
 
-#: templates/js/table_filters.js:181
+#: templates/js/table_filters.js:191
 msgid "Build status"
 msgstr ""
 
-#: templates/js/table_filters.js:200 templates/js/table_filters.js:213
+#: templates/js/table_filters.js:210 templates/js/table_filters.js:223
 msgid "Order status"
 msgstr ""
 
-#: templates/js/table_filters.js:205 templates/js/table_filters.js:218
+#: templates/js/table_filters.js:215 templates/js/table_filters.js:228
 msgid "Outstanding"
 msgstr ""
 
-#: templates/js/table_filters.js:237
+#: templates/js/table_filters.js:251
 msgid "Include subcategories"
 msgstr ""
 
-#: templates/js/table_filters.js:238
+#: templates/js/table_filters.js:252
 msgid "Include parts in subcategories"
 msgstr ""
 
-#: templates/js/table_filters.js:242
+#: templates/js/table_filters.js:256
 msgid "Has IPN"
 msgstr ""
 
-#: templates/js/table_filters.js:243
+#: templates/js/table_filters.js:257
 msgid "Part has internal part number"
 msgstr ""
 
-#: templates/js/table_filters.js:248
+#: templates/js/table_filters.js:262
 msgid "Show active parts"
 msgstr ""
 
-#: templates/js/table_filters.js:256
+#: templates/js/table_filters.js:270
 msgid "Stock available"
 msgstr ""
 
-#: templates/js/table_filters.js:272
+#: templates/js/table_filters.js:286
 msgid "Starred"
 msgstr ""
 
-#: templates/js/table_filters.js:284
+#: templates/js/table_filters.js:298
 msgid "Purchasable"
 msgstr ""
 
@@ -5245,7 +5417,7 @@ msgstr ""
 msgid "Logout"
 msgstr ""
 
-#: templates/navbar.html:69
+#: templates/navbar.html:69 templates/registration/login.html:43
 msgid "Login"
 msgstr ""
 
diff --git a/InvenTree/order/api.py b/InvenTree/order/api.py
index 6b8a3c81e0..972ff16f9c 100644
--- a/InvenTree/order/api.py
+++ b/InvenTree/order/api.py
@@ -107,6 +107,13 @@ class POList(generics.ListCreateAPIView):
             except (ValueError, SupplierPart.DoesNotExist):
                 pass
 
+        # Filter by 'date range'
+        min_date = params.get('min_date', None)
+        max_date = params.get('max_date', None)
+
+        if min_date is not None and max_date is not None:
+            queryset = PurchaseOrder.filterByDate(queryset, min_date, max_date)
+
         return queryset
 
     filter_backends = [
@@ -293,6 +300,13 @@ class SOList(generics.ListCreateAPIView):
             except (Part.DoesNotExist, ValueError):
                 pass
 
+        # Filter by 'date range'
+        min_date = params.get('min_date', None)
+        max_date = params.get('max_date', None)
+
+        if min_date is not None and max_date is not None:
+            queryset = SalesOrder.filterByDate(queryset, min_date, max_date)
+
         return queryset
 
     filter_backends = [
diff --git a/InvenTree/order/forms.py b/InvenTree/order/forms.py
index d797a4e42d..6db51b55e6 100644
--- a/InvenTree/order/forms.py
+++ b/InvenTree/order/forms.py
@@ -12,6 +12,7 @@ from mptt.fields import TreeNodeChoiceField
 
 from InvenTree.forms import HelperForm
 from InvenTree.fields import RoundingDecimalFormField
+from InvenTree.fields import DatePickerFormField
 
 from stock.models import StockLocation
 from .models import PurchaseOrder, PurchaseOrderLineItem, PurchaseOrderAttachment
@@ -120,6 +121,7 @@ class EditSalesOrderForm(HelperForm):
         self.field_prefix = {
             'reference': 'SO',
             'link': 'fa-link',
+            'target_date': 'fa-calendar-alt',
         }
 
         self.field_placeholder = {
@@ -128,11 +130,8 @@ class EditSalesOrderForm(HelperForm):
 
         super().__init__(*args, **kwargs)
 
-    # TODO: Improve this using a better date picker
-    target_date = forms.DateField(
-        widget=forms.DateInput(
-            attrs={'type': 'date'},
-        )
+    target_date = DatePickerFormField(
+        help_text=_('Target date for order completion. Order will be overdue after this date.'),
     )
 
     class Meta:
diff --git a/InvenTree/order/models.py b/InvenTree/order/models.py
index 9045ab86d7..184452f24e 100644
--- a/InvenTree/order/models.py
+++ b/InvenTree/order/models.py
@@ -121,6 +121,44 @@ class PurchaseOrder(Order):
         received_by: User that received the goods
     """
 
+    @staticmethod
+    def filterByDate(queryset, min_date, max_date):
+        """
+        Filter by 'minimum and maximum date range'
+
+        - Specified as min_date, max_date
+        - Both must be specified for filter to be applied
+        - Determine which "interesting" orders exist bewteen these dates
+
+        To be "interesting":
+        - A "received" order where the received date lies within the date range
+        - TODO: A "pending" order where the target date lies within the date range
+        - TODO: An "overdue" order where the target date is in the past
+        """
+
+        date_fmt = '%Y-%m-%d'  # ISO format date string
+
+        # Ensure that both dates are valid
+        try:
+            min_date = datetime.strptime(str(min_date), date_fmt).date()
+            max_date = datetime.strptime(str(max_date), date_fmt).date()
+        except (ValueError, TypeError):
+            # Date processing error, return queryset unchanged
+            return queryset
+
+        # Construct a queryset for "received" orders within the range
+        received = Q(status=PurchaseOrderStatus.COMPLETE) & Q(complete_date__gte=min_date) & Q(complete_date__lte=max_date)
+
+        # TODO - Construct a queryset for "pending" orders within the range
+
+        # TODO - Construct a queryset for "overdue" orders within the range
+
+        flt = received
+
+        queryset = queryset.filter(flt)
+
+        return queryset
+
     def __str__(self):
 
         prefix = getSetting('PURCHASEORDER_REFERENCE_PREFIX')
@@ -301,6 +339,43 @@ class SalesOrder(Order):
 
     OVERDUE_FILTER = Q(status__in=SalesOrderStatus.OPEN) & ~Q(target_date=None) & Q(target_date__lte=datetime.now().date())
 
+    @staticmethod
+    def filterByDate(queryset, min_date, max_date):
+        """
+        Filter by "minimum and maximum date range"
+
+        - Specified as min_date, max_date
+        - Both must be specified for filter to be applied
+        - Determine which "interesting" orders exist between these dates
+
+        To be "interesting":
+        - A "completed" order where the completion date lies within the date range
+        - A "pending" order where the target date lies within the date range
+        - TODO: An "overdue" order where the target date is in the past
+        """
+
+        date_fmt = '%Y-%m-%d'  # ISO format date string
+
+        # Ensure that both dates are valid
+        try:
+            min_date = datetime.strptime(str(min_date), date_fmt).date()
+            max_date = datetime.strptime(str(max_date), date_fmt).date()
+        except (ValueError, TypeError):
+            # Date processing error, return queryset unchanged
+            return queryset
+ 
+        # Construct a queryset for "completed" orders within the range
+        completed = Q(status__in=SalesOrderStatus.COMPLETE) & Q(shipment_date__gte=min_date) & Q(shipment_date__lte=max_date)
+
+        # Construct a queryset for "pending" orders within the range
+        pending = Q(status__in=SalesOrderStatus.OPEN) & ~Q(target_date=None) & Q(target_date__gte=min_date) & Q(target_date__lte=max_date)
+
+        # TODO: Construct a queryset for "overdue" orders within the range
+
+        queryset = queryset.filter(completed | pending)
+
+        return queryset
+
     def __str__(self):
 
         prefix = getSetting('SALESORDER_REFERENCE_PREFIX')
diff --git a/InvenTree/order/serializers.py b/InvenTree/order/serializers.py
index f0505728f6..5463feb26f 100644
--- a/InvenTree/order/serializers.py
+++ b/InvenTree/order/serializers.py
@@ -181,7 +181,7 @@ class SalesOrderSerializer(InvenTreeModelSerializer):
 
     status_text = serializers.CharField(source='get_status_display', read_only=True)
 
-    overdue = serializers.BooleanField()
+    overdue = serializers.BooleanField(required=False, read_only=True)
 
     class Meta:
         model = SalesOrder
diff --git a/InvenTree/order/templates/order/purchase_orders.html b/InvenTree/order/templates/order/purchase_orders.html
index 347033a888..d05e8fbf86 100644
--- a/InvenTree/order/templates/order/purchase_orders.html
+++ b/InvenTree/order/templates/order/purchase_orders.html
@@ -1,5 +1,6 @@
 {% extends "base.html" %}
 
+{% load inventree_extras %}
 {% load static %}
 {% load i18n %}
 
@@ -18,6 +19,12 @@ InvenTree | {% trans "Purchase Orders" %}
         <button class='btn btn-primary' type='button' id='po-create' title='{% trans "Create new purchase order" %}'>
             <span class='fas fa-plus-circle'></span> {% trans "New Purchase Order" %}</button>
         {% endif %}
+        <button class='btn btn-default' type='button' id='view-calendar' title='{% trans "Display calendar view" %}'>
+            <span class='fas fa-calendar-alt'></span>
+        </button>
+        <button class='btn btn-default' type='button' id='view-list' title='{% trans "Display list view" %}'>
+            <span class='fas fa-th-list'></span>
+        </button>
         <div class='filter-list' id='filter-list-purchaseorder'>
             <!-- An empty div in which the filter list will be constructed -->
         </div>
@@ -27,11 +34,124 @@ InvenTree | {% trans "Purchase Orders" %}
 <table class='table table-striped table-condensed po-table' data-toolbar='#table-buttons' id='purchase-order-table'>
 </table>
 
+<div id='purchase-order-calendar'></div>
+
+{% endblock %}
+
+{% block js_load %}
+{{ block.super }}
+
+<script type='text/javascript'>
+
+    function loadOrderEvents(calendar) {
+
+        var start = startDate(calendar);
+        var end = endDate(calendar);
+
+        clearEvents(calendar);
+
+        // Request purchase orders from the server within specified date range
+        inventreeGet(
+            '{% url "api-po-list" %}',
+            {
+                supplier_detail: true,
+                min_date: start,
+                max_date: end,
+            },
+            {
+                success: function(response) {
+                    var prefix = '{% settings_value "PURCHASEORDER_REFERENCE_PREFIX" %}';
+
+                    for (var idx = 0; idx < response.length; idx++) {
+
+                        var order = response[idx];
+
+                        var date = order.creation_date;
+
+                        if (order.complete_date) {
+                            date = order.complete_date;
+                        }
+
+                        var title = `${prefix}${order.reference} - ${order.supplier_detail.name}`;
+
+                        var color = '#4c68f5';
+                        
+                        if (order.complete_date) {
+                            color = '#25c235';
+                        } else if (order.overdue) {
+                            color = '#c22525';
+                        } else {
+                            color = '#4c68f5';
+                        }
+
+                        var event = {
+                            title: title,
+                            start: date,
+                            end: date,
+                            url: `/order/purchase-order/${order.pk}/`,
+                            backgroundColor: color,
+                        };
+
+                        calendar.addEvent(event);
+                    }
+                }
+            }
+        );
+    }
+
+    var calendar = null;
+
+    document.addEventListener('DOMContentLoaded', function() {
+        var el = document.getElementById('purchase-order-calendar');
+
+        calendar = new FullCalendar.Calendar(el, {
+            initialView: 'dayGridMonth',
+            nowIndicator: true,
+            aspectRatio: 2.5,
+            datesSet: function() {
+                loadOrderEvents(calendar);
+            }
+        });
+
+        calendar.render();
+    });
+
+</script>
+
 {% endblock %}
 
 {% block js_ready %}
 {{ block.super }}
 
+$('#purchase-order-calendar').hide();
+$('#view-list').hide();
+
+$('#view-calendar').click(function() {
+    // Hide the list view, show the calendar view
+    $("#purchase-order-table").hide();
+    $("#view-calendar").hide();
+    $(".fixed-table-pagination").hide();
+    $(".columns-right").hide();
+    $(".search").hide();
+    $('#filter-list-salesorder').hide();
+    
+    $("#purchase-order-calendar").show();
+    $("#view-list").show();
+});
+
+$("#view-list").click(function() {
+    // Hide the calendar view, show the list view
+    $("#purchase-order-calendar").hide();
+    $("#view-list").hide();
+    
+    $(".fixed-table-pagination").show();
+    $(".columns-right").show();
+    $(".search").show();
+    $("#purchase-order-table").show();
+    $('#filter-list-salesorder').show();
+    $("#view-calendar").show();
+});
+
 $("#po-create").click(function() {
     launchModalForm("{% url 'po-create' %}",
         {
diff --git a/InvenTree/order/templates/order/sales_orders.html b/InvenTree/order/templates/order/sales_orders.html
index acfd875078..257ee13887 100644
--- a/InvenTree/order/templates/order/sales_orders.html
+++ b/InvenTree/order/templates/order/sales_orders.html
@@ -1,5 +1,6 @@
 {% extends "base.html" %}
 
+{% load inventree_extras %}
 {% load static %}
 {% load i18n %}
 
@@ -18,6 +19,12 @@ InvenTree | {% trans "Sales Orders" %}
         <button class='btn btn-primary' type='button' id='so-create' title='{% trans "Create new sales order" %}'>
             <span class='fas fa-plus-circle'></span> {% trans "New Sales Order" %}</button>
         {% endif %}
+        <button class='btn btn-default' type='button' id='view-calendar' title='{% trans "Display calendar view" %}'>
+            <span class='fas fa-calendar-alt'></span>
+        </button>
+        <button class='btn btn-default' type='button' id='view-list' title='{% trans "Display list view" %}'>
+            <span class='fas fa-th-list'></span>
+        </button>
         <div class='filter-list' id='filter-list-salesorder'>
             <!-- An empty div in which the filter list will be constructed -->
         </div>
@@ -27,11 +34,124 @@ InvenTree | {% trans "Sales Orders" %}
 <table class='table table-striped table-condensed po-table' data-toolbar='#table-buttons' id='sales-order-table'>
 </table>
 
+<div id='sales-order-calendar'></div>
+
+{% endblock %}
+
+{% block js_load %}
+{{ block.super }}
+
+<script type='text/javascript'>
+
+    function loadOrderEvents(calendar) {
+
+        var start = startDate(calendar);
+        var end = endDate(calendar);
+
+        clearEvents(calendar);
+
+        // Request orders from the server within specified date range
+        inventreeGet(
+            '{% url "api-so-list" %}',
+            {
+                customer_detail: true,
+                min_date: start,
+                max_date: end,
+            },
+            {
+                success: function(response) {
+
+                    var prefix = '{% settings_value "SALESORDER_REFERENCE_PREFIX" %}';
+
+                    for (var idx = 0; idx < response.length; idx++) {
+                        var order = response[idx];
+
+                        var date = order.creation_date;
+
+                        if (order.shipment_date) {
+                            date = order.shipment_date;
+                        } else if (order.target_date) {
+                            date = order.target_date;
+                        }
+
+                        var title = `${prefix}${order.reference} - ${order.customer_detail.name}`;
+
+                        // Default color is blue
+                        var color = '#4c68f5';
+
+                        // Overdue orders are red
+                        if (order.overdue) {
+                            color = '#c22525';
+                        } else if (order.status == {{ SalesOrderStatus.SHIPPED }}) {
+                            color = '#25c235';
+                        }
+
+                        var event = {
+                            title: title,
+                            start: date,
+                            end: date,
+                            url: `/order/sales-order/${order.pk}/`,
+                            backgroundColor: color,
+                        };
+
+                        calendar.addEvent(event);
+                    }
+                }
+            }
+        );
+    }
+
+    var calendar = null;
+
+    document.addEventListener('DOMContentLoaded', function() {
+        var calendarEl = document.getElementById('sales-order-calendar');
+        calendar = new FullCalendar.Calendar(calendarEl, {
+            initialView: 'dayGridMonth',
+            nowIndicator: true,
+            aspectRatio: 2.5,
+            datesSet: function() {
+                loadOrderEvents(calendar);
+            },
+        });
+
+        calendar.render();
+
+    });
+</script>
 {% endblock %}
 
 {% block js_ready %}
 {{ block.super }}
 
+$("#sales-order-calendar").hide();
+$("#view-list").hide();
+
+$('#view-calendar').click(function() {
+    // Hide the list view, show the calendar view
+    $("#sales-order-table").hide();
+    $("#view-calendar").hide();
+    $(".fixed-table-pagination").hide();
+    $(".columns-right").hide();
+    $(".search").hide();
+    $('#filter-list-salesorder').hide();
+    
+    $("#sales-order-calendar").show();
+    $("#view-list").show();
+});
+
+$("#view-list").click(function() {
+    // Hide the calendar view, show the list view
+    $("#sales-order-calendar").hide();
+    $("#view-list").hide();
+    
+    $(".fixed-table-pagination").show();
+    $(".columns-right").show();
+    $(".search").show();
+    $("#sales-order-table").show();
+    $('#filter-list-salesorder').show();
+    $("#view-calendar").show();
+});
+
 loadSalesOrderTable("#sales-order-table", {
     url: "{% url 'api-so-list' %}",
 });
diff --git a/InvenTree/order/test_views.py b/InvenTree/order/test_views.py
index 2f014e751f..51981af105 100644
--- a/InvenTree/order/test_views.py
+++ b/InvenTree/order/test_views.py
@@ -11,6 +11,7 @@ from django.contrib.auth.models import Group
 from InvenTree.status_codes import PurchaseOrderStatus
 
 from .models import PurchaseOrder, PurchaseOrderLineItem
+from .models import SalesOrder
 
 import json
 
@@ -59,6 +60,88 @@ class OrderListTest(OrderViewTestCase):
         self.assertEqual(response.status_code, 200)
 
 
+class SalesOrderCreate(OrderViewTestCase):
+    """
+    Create a SalesOrder using the form view
+    """
+
+    URL = reverse('so-create')
+
+    def test_create_view(self):
+        """
+        Retrieve the view for creating a sales order'
+        """
+
+        response = self.client.get(self.URL, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
+
+        self.assertEqual(response.status_code, 200)
+
+    def post(self, data, **kwargs):
+
+        return self.client.post(self.URL, data, HTTP_X_REQUESTED_WITH='XMLHttpRequest', **kwargs)
+
+    def test_post_error(self):
+        """
+        POST with errors
+        """
+
+        n = SalesOrder.objects.count()
+
+        data = {
+            'reference': '12345678',
+        }
+
+        response = self.post(data)
+
+        data = json.loads(response.content)
+
+        self.assertIn('form_valid', data.keys())
+
+        # Customer is not specified - should return False
+        self.assertFalse(data['form_valid'])
+
+        errors = json.loads(data['form_errors'])
+
+        self.assertIn('customer', errors.keys())
+        self.assertIn('description', errors.keys())
+
+        # No new SalesOrder objects should have been created
+        self.assertEqual(SalesOrder.objects.count(), n)
+
+    def test_post_valid(self):
+        """
+        POST a valid SalesOrder
+        """
+
+        n = SalesOrder.objects.count()
+
+        data = {
+            'reference': '12345678',
+            'customer': 4,
+            'description': 'A description',
+        }
+
+        response = self.post(data)
+
+        json_data = json.loads(response.content)
+
+        self.assertTrue(json_data['form_valid'])
+
+        # Create another SalesOrder, this time with a target date
+        data = {
+            'reference': '12345679',
+            'customer': 4,
+            'description': 'Another order, this one with a target date!',
+            'target_date': '2020-12-25',
+        }
+
+        response = self.post(data)
+
+        json_data = json.loads(response.content)
+
+        self.assertEqual(SalesOrder.objects.count(), n + 2)
+
+
 class POTests(OrderViewTestCase):
     """ Tests for PurchaseOrder views """
 
diff --git a/InvenTree/order/views.py b/InvenTree/order/views.py
index d5658909bb..bd758e39fb 100644
--- a/InvenTree/order/views.py
+++ b/InvenTree/order/views.py
@@ -24,6 +24,8 @@ from company.models import Company, SupplierPart
 from stock.models import StockItem, StockLocation
 from part.models import Part
 
+from common.models import InvenTreeSetting
+
 from . import forms as order_forms
 
 from InvenTree.views import AjaxView, AjaxCreateView, AjaxUpdateView, AjaxDeleteView
@@ -1359,7 +1361,8 @@ class SalesOrderAllocationCreate(AjaxCreateView):
         try:
             line = SalesOrderLineItem.objects.get(pk=line_id)
 
-            queryset = form.fields['item'].queryset
+            # Construct a queryset for allowable stock items
+            queryset = StockItem.objects.filter(StockItem.IN_STOCK_FILTER)
 
             # Ensure the part reference matches
             queryset = queryset.filter(part=line.part)
@@ -1369,6 +1372,10 @@ class SalesOrderAllocationCreate(AjaxCreateView):
 
             queryset = queryset.exclude(pk__in=allocated)
 
+            # Exclude stock items which have expired
+            if not InvenTreeSetting.get_setting('STOCK_ALLOW_EXPIRED_SALE'):
+                queryset = queryset.exclude(StockItem.EXPIRED_FILTER)
+
             form.fields['item'].queryset = queryset
 
             # Hide the 'line' field
diff --git a/InvenTree/part/fixtures/part.yaml b/InvenTree/part/fixtures/part.yaml
index f6d9d246db..508a1577bb 100644
--- a/InvenTree/part/fixtures/part.yaml
+++ b/InvenTree/part/fixtures/part.yaml
@@ -74,6 +74,7 @@
     level: 0
     lft: 0
     rght: 0
+    default_expiry: 10
 
 - model: part.part
   pk: 50
@@ -134,6 +135,7 @@
   fields:
     name: 'Red chair'
     variant_of: 10000
+    IPN: "R.CH"
     trackable: true
     category: 7
     tree_id: 1
diff --git a/InvenTree/part/forms.py b/InvenTree/part/forms.py
index 68912edd98..1ac62161a5 100644
--- a/InvenTree/part/forms.py
+++ b/InvenTree/part/forms.py
@@ -181,6 +181,7 @@ class EditPartForm(HelperForm):
         'keywords': 'fa-key',
         'link': 'fa-link',
         'IPN': 'fa-hashtag',
+        'default_expiry': 'fa-stopwatch',
     }
 
     bom_copy = forms.BooleanField(required=False,
@@ -228,11 +229,16 @@ class EditPartForm(HelperForm):
             'link',
             'default_location',
             'default_supplier',
+            'default_expiry',
             'units',
             'minimum_stock',
+            'component',
+            'assembly',
+            'is_template',
             'trackable',
             'purchaseable',
             'salable',
+            'virtual',
         ]
 
 
@@ -319,7 +325,9 @@ class EditBomItemForm(HelperForm):
         ]
 
         # Prevent editing of the part associated with this BomItem
-        widgets = {'part': forms.HiddenInput()}
+        widgets = {
+            'part': forms.HiddenInput()
+        }
 
 
 class PartPriceForm(forms.Form):
diff --git a/InvenTree/part/migrations/0061_auto_20210103_2313.py b/InvenTree/part/migrations/0061_auto_20210103_2313.py
new file mode 100644
index 0000000000..ca0c2a277f
--- /dev/null
+++ b/InvenTree/part/migrations/0061_auto_20210103_2313.py
@@ -0,0 +1,85 @@
+# Generated by Django 3.0.7 on 2021-01-03 12:13
+
+import InvenTree.fields
+import InvenTree.validators
+from django.db import migrations, models
+import django.db.models.deletion
+import markdownx.models
+import mptt.fields
+import part.settings
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('stock', '0055_auto_20201117_1453'),
+        ('part', '0060_merge_20201112_1722'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='part',
+            name='IPN',
+            field=models.CharField(blank=True, help_text='Internal Part Number', max_length=100, null=True, validators=[InvenTree.validators.validate_part_ipn], verbose_name='IPN'),
+        ),
+        migrations.AlterField(
+            model_name='part',
+            name='assembly',
+            field=models.BooleanField(default=part.settings.part_assembly_default, help_text='Can this part be built from other parts?', verbose_name='Assembly'),
+        ),
+        migrations.AlterField(
+            model_name='part',
+            name='category',
+            field=mptt.fields.TreeForeignKey(blank=True, help_text='Part category', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='parts', to='part.PartCategory', verbose_name='Category'),
+        ),
+        migrations.AlterField(
+            model_name='part',
+            name='default_location',
+            field=mptt.fields.TreeForeignKey(blank=True, help_text='Where is this item normally stored?', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='default_parts', to='stock.StockLocation', verbose_name='Default Location'),
+        ),
+        migrations.AlterField(
+            model_name='part',
+            name='description',
+            field=models.CharField(help_text='Part description', max_length=250, verbose_name='Description'),
+        ),
+        migrations.AlterField(
+            model_name='part',
+            name='is_template',
+            field=models.BooleanField(default=part.settings.part_template_default, help_text='Is this part a template part?', verbose_name='Is Template'),
+        ),
+        migrations.AlterField(
+            model_name='part',
+            name='keywords',
+            field=models.CharField(blank=True, help_text='Part keywords to improve visibility in search results', max_length=250, null=True, verbose_name='Keywords'),
+        ),
+        migrations.AlterField(
+            model_name='part',
+            name='link',
+            field=InvenTree.fields.InvenTreeURLField(blank=True, help_text='Link to external URL', null=True, verbose_name='Link'),
+        ),
+        migrations.AlterField(
+            model_name='part',
+            name='name',
+            field=models.CharField(help_text='Part name', max_length=100, validators=[InvenTree.validators.validate_part_name], verbose_name='Name'),
+        ),
+        migrations.AlterField(
+            model_name='part',
+            name='notes',
+            field=markdownx.models.MarkdownxField(blank=True, help_text='Part notes - supports Markdown formatting', null=True, verbose_name='Notes'),
+        ),
+        migrations.AlterField(
+            model_name='part',
+            name='revision',
+            field=models.CharField(blank=True, help_text='Part revision or version number', max_length=100, null=True, verbose_name='Revision'),
+        ),
+        migrations.AlterField(
+            model_name='part',
+            name='variant_of',
+            field=models.ForeignKey(blank=True, help_text='Is this part a variant of another part?', limit_choices_to={'active': True, 'is_template': True}, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='variants', to='part.Part', verbose_name='Variant Of'),
+        ),
+        migrations.AlterField(
+            model_name='part',
+            name='virtual',
+            field=models.BooleanField(default=part.settings.part_virtual_default, help_text='Is this a virtual part, such as a software product or license?', verbose_name='Virtual'),
+        ),
+    ]
diff --git a/InvenTree/part/migrations/0061_auto_20210104_2331.py b/InvenTree/part/migrations/0061_auto_20210104_2331.py
new file mode 100644
index 0000000000..c40b611b29
--- /dev/null
+++ b/InvenTree/part/migrations/0061_auto_20210104_2331.py
@@ -0,0 +1,36 @@
+# Generated by Django 3.0.7 on 2021-01-04 12:31
+
+import django.core.validators
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('company', '0031_auto_20210103_2215'),
+        ('part', '0060_merge_20201112_1722'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='part',
+            name='default_expiry',
+            field=models.PositiveIntegerField(default=0, help_text='Expiry time (in days) for stock items of this part', validators=[django.core.validators.MinValueValidator(0)], verbose_name='Default Expiry'),
+        ),
+        migrations.AlterField(
+            model_name='part',
+            name='default_supplier',
+            field=models.ForeignKey(blank=True, help_text='Default supplier part', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='default_parts', to='company.SupplierPart', verbose_name='Default Supplier'),
+        ),
+        migrations.AlterField(
+            model_name='part',
+            name='minimum_stock',
+            field=models.PositiveIntegerField(default=0, help_text='Minimum allowed stock level', validators=[django.core.validators.MinValueValidator(0)], verbose_name='Minimum Stock'),
+        ),
+        migrations.AlterField(
+            model_name='part',
+            name='units',
+            field=models.CharField(blank=True, default='', help_text='Stock keeping units for this part', max_length=20, null=True, verbose_name='Units'),
+        ),
+    ]
diff --git a/InvenTree/part/migrations/0062_merge_20210105_0056.py b/InvenTree/part/migrations/0062_merge_20210105_0056.py
new file mode 100644
index 0000000000..4a8f4378f4
--- /dev/null
+++ b/InvenTree/part/migrations/0062_merge_20210105_0056.py
@@ -0,0 +1,14 @@
+# Generated by Django 3.0.7 on 2021-01-04 13:56
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('part', '0061_auto_20210104_2331'),
+        ('part', '0061_auto_20210103_2313'),
+    ]
+
+    operations = [
+    ]
diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py
index cf0c92899a..8c88adf747 100644
--- a/InvenTree/part/models.py
+++ b/InvenTree/part/models.py
@@ -291,11 +291,12 @@ class Part(MPTTModel):
         keywords: Optional keywords for improving part search results
         IPN: Internal part number (optional)
         revision: Part revision
-        is_template: If True, this part is a 'template' part and cannot be instantiated as a StockItem
+        is_template: If True, this part is a 'template' part
         link: Link to an external page with more information about this part (e.g. internal Wiki)
         image: Image of this part
         default_location: Where the item is normally stored (may be null)
         default_supplier: The default SupplierPart which should be used to procure and stock this part
+        default_expiry: The default expiry duration for any StockItem instances of this part
         minimum_stock: Minimum preferred quantity to keep in stock
         units: Units of measure for this part (default='pcs')
         salable: Can this part be sold to customers?
@@ -640,36 +641,69 @@ class Part(MPTTModel):
                     parent_part.clean()
                     parent_part.save()
 
-    name = models.CharField(max_length=100, blank=False,
-                            help_text=_('Part name'),
-                            validators=[validators.validate_part_name]
-                            )
+    name = models.CharField(
+        max_length=100, blank=False,
+        help_text=_('Part name'),
+        verbose_name=_('Name'),
+        validators=[validators.validate_part_name]
+    )
 
-    is_template = models.BooleanField(default=False, help_text=_('Is this part a template part?'))
+    is_template = models.BooleanField(
+        default=part_settings.part_template_default,
+        verbose_name=_('Is Template'),
+        help_text=_('Is this part a template part?')
+    )
 
-    variant_of = models.ForeignKey('part.Part', related_name='variants',
-                                   null=True, blank=True,
-                                   limit_choices_to={
-                                       'is_template': True,
-                                       'active': True,
-                                   },
-                                   on_delete=models.SET_NULL,
-                                   help_text=_('Is this part a variant of another part?'))
+    variant_of = models.ForeignKey(
+        'part.Part', related_name='variants',
+        null=True, blank=True,
+        limit_choices_to={
+            'is_template': True,
+            'active': True,
+        },
+        on_delete=models.SET_NULL,
+        help_text=_('Is this part a variant of another part?'),
+        verbose_name=_('Variant Of'),
+    )
 
-    description = models.CharField(max_length=250, blank=False, help_text=_('Part description'))
+    description = models.CharField(
+        max_length=250, blank=False,
+        verbose_name=_('Description'),
+        help_text=_('Part description')
+    )
 
-    keywords = models.CharField(max_length=250, blank=True, null=True, help_text=_('Part keywords to improve visibility in search results'))
+    keywords = models.CharField(
+        max_length=250, blank=True, null=True,
+        verbose_name=_('Keywords'),
+        help_text=_('Part keywords to improve visibility in search results')
+    )
 
-    category = TreeForeignKey(PartCategory, related_name='parts',
-                              null=True, blank=True,
-                              on_delete=models.DO_NOTHING,
-                              help_text=_('Part category'))
+    category = TreeForeignKey(
+        PartCategory, related_name='parts',
+        null=True, blank=True,
+        on_delete=models.DO_NOTHING,
+        verbose_name=_('Category'),
+        help_text=_('Part category')
+    )
 
-    IPN = models.CharField(max_length=100, blank=True, null=True, help_text=_('Internal Part Number'), validators=[validators.validate_part_ipn])
+    IPN = models.CharField(
+        max_length=100, blank=True, null=True,
+        verbose_name=_('IPN'),
+        help_text=_('Internal Part Number'),
+        validators=[validators.validate_part_ipn]
+    )
 
-    revision = models.CharField(max_length=100, blank=True, null=True, help_text=_('Part revision or version number'))
+    revision = models.CharField(
+        max_length=100, blank=True, null=True,
+        help_text=_('Part revision or version number'),
+        verbose_name=_('Revision'),
+    )
 
-    link = InvenTreeURLField(blank=True, null=True, help_text=_('Link to external URL'))
+    link = InvenTreeURLField(
+        blank=True, null=True,
+        verbose_name=_('Link'),
+        help_text=_('Link to external URL')
+    )
 
     image = StdImageField(
         upload_to=rename_part_image,
@@ -679,10 +713,14 @@ class Part(MPTTModel):
         delete_orphans=True,
     )
 
-    default_location = TreeForeignKey('stock.StockLocation', on_delete=models.SET_NULL,
-                                      blank=True, null=True,
-                                      help_text=_('Where is this item normally stored?'),
-                                      related_name='default_parts')
+    default_location = TreeForeignKey(
+        'stock.StockLocation',
+        on_delete=models.SET_NULL,
+        blank=True, null=True,
+        help_text=_('Where is this item normally stored?'),
+        related_name='default_parts',
+        verbose_name=_('Default Location'),
+    )
 
     def get_default_location(self):
         """ Get the default location for a Part (may be None).
@@ -722,18 +760,37 @@ class Part(MPTTModel):
         # Default to None if there are multiple suppliers to choose from
         return None
 
-    default_supplier = models.ForeignKey(SupplierPart,
-                                         on_delete=models.SET_NULL,
-                                         blank=True, null=True,
-                                         help_text=_('Default supplier part'),
-                                         related_name='default_parts')
+    default_supplier = models.ForeignKey(
+        SupplierPart,
+        on_delete=models.SET_NULL,
+        blank=True, null=True,
+        verbose_name=_('Default Supplier'),
+        help_text=_('Default supplier part'),
+        related_name='default_parts'
+    )
 
-    minimum_stock = models.PositiveIntegerField(default=0, validators=[MinValueValidator(0)], help_text=_('Minimum allowed stock level'))
+    default_expiry = models.PositiveIntegerField(
+        default=0,
+        validators=[MinValueValidator(0)],
+        verbose_name=_('Default Expiry'),
+        help_text=_('Expiry time (in days) for stock items of this part'),
+    )
 
-    units = models.CharField(max_length=20, default="", blank=True, null=True, help_text=_('Stock keeping units for this part'))
+    minimum_stock = models.PositiveIntegerField(
+        default=0, validators=[MinValueValidator(0)],
+        verbose_name=_('Minimum Stock'),
+        help_text=_('Minimum allowed stock level')
+    )
+
+    units = models.CharField(
+        max_length=20, default="",
+        blank=True, null=True,
+        verbose_name=_('Units'),
+        help_text=_('Stock keeping units for this part')
+    )
 
     assembly = models.BooleanField(
-        default=False,
+        default=part_settings.part_assembly_default,
         verbose_name=_('Assembly'),
         help_text=_('Can this part be built from other parts?')
     )
@@ -765,11 +822,15 @@ class Part(MPTTModel):
         help_text=_('Is this part active?'))
 
     virtual = models.BooleanField(
-        default=False,
+        default=part_settings.part_virtual_default,
         verbose_name=_('Virtual'),
         help_text=_('Is this a virtual part, such as a software product or license?'))
 
-    notes = MarkdownxField(blank=True, null=True, help_text=_('Part notes - supports Markdown formatting'))
+    notes = MarkdownxField(
+        blank=True, null=True,
+        verbose_name=_('Notes'),
+        help_text=_('Part notes - supports Markdown formatting')
+    )
 
     bom_checksum = models.CharField(max_length=128, blank=True, help_text=_('Stored BOM checksum'))
 
@@ -1074,7 +1135,7 @@ class Part(MPTTModel):
 
         self.bom_items.all().delete()
 
-    def getRequiredParts(self, recursive=False, parts=set()):
+    def getRequiredParts(self, recursive=False, parts=None):
         """
         Return a list of parts required to make this part (i.e. BOM items).
 
@@ -1083,7 +1144,10 @@ class Part(MPTTModel):
             parts: Set of parts already found (to prevent recursion issues)
         """
 
-        items = self.bom_items.all().prefetch_related('sub_part')
+        if parts is None:
+            parts = set()
+
+        items = BomItem.objects.filter(part=self.pk)
 
         for bom_item in items:
 
diff --git a/InvenTree/part/serializers.py b/InvenTree/part/serializers.py
index 0eebe6617d..05fc3091f7 100644
--- a/InvenTree/part/serializers.py
+++ b/InvenTree/part/serializers.py
@@ -289,6 +289,7 @@ class PartSerializer(InvenTreeModelSerializer):
             'component',
             'description',
             'default_location',
+            'default_expiry',
             'full_name',
             'image',
             'in_stock',
diff --git a/InvenTree/part/settings.py b/InvenTree/part/settings.py
index 8d87cdffe3..801b4dd2ec 100644
--- a/InvenTree/part/settings.py
+++ b/InvenTree/part/settings.py
@@ -8,6 +8,30 @@ from __future__ import unicode_literals
 from common.models import InvenTreeSetting
 
 
+def part_assembly_default():
+    """
+    Returns the default value for the 'assembly' field of a Part object
+    """
+
+    return InvenTreeSetting.get_setting('PART_ASSEMBLY')
+
+
+def part_template_default():
+    """
+    Returns the default value for the 'is_template' field of a Part object
+    """
+
+    return InvenTreeSetting.get_setting('PART_TEMPLATE')
+
+
+def part_virtual_default():
+    """
+    Returns the default value for the 'is_virtual' field of Part object
+    """
+
+    return InvenTreeSetting.get_setting('PART_VIRTUAL')
+
+
 def part_component_default():
     """
     Returns the default value for the 'component' field of a Part object
diff --git a/InvenTree/part/templates/part/bom.html b/InvenTree/part/templates/part/bom.html
index 38d07cb00e..0b24582220 100644
--- a/InvenTree/part/templates/part/bom.html
+++ b/InvenTree/part/templates/part/bom.html
@@ -32,7 +32,7 @@
     <div class="btn-group" role="group" aria-label="...">
         {% if editing_enabled %}
         <button class='btn btn-default' type='button' title='{% trans "Remove selected BOM items" %}' id='bom-item-delete'>
-            <span class='fas fa-trash-alt'></span>
+            <span class='fas fa-trash-alt icon-red'></span>
         </button>
         <button class='btn btn-primary' type='button' title='{% trans "Import BOM data" %}' id='bom-upload'>
             <span class='fas fa-file-upload'></span> {% trans "Import from File" %}
diff --git a/InvenTree/part/templates/part/detail.html b/InvenTree/part/templates/part/detail.html
index 9711c9fbc8..f723193abb 100644
--- a/InvenTree/part/templates/part/detail.html
+++ b/InvenTree/part/templates/part/detail.html
@@ -109,6 +109,13 @@
             <td>{{ part.minimum_stock }}</td>
         </tr>
         {% endif %}
+        {% if part.default_expiry > 0 %}
+        <tr>
+            <td><span class='fas fa-stopwatch'></span></td>
+            <td><b>{% trans "Stock Expiry Time" %}</b></td>
+            <td>{{ part.default_expiry }} {% trans "days" %}</td>
+        </tr>
+        {% endif %}
         <tr>
             <td><span class='fas fa-calendar-alt'></span></td>
             <td><b>{% trans "Creation Date" %}</b></td>
diff --git a/InvenTree/part/test_part.py b/InvenTree/part/test_part.py
index c02be211b5..4c08911122 100644
--- a/InvenTree/part/test_part.py
+++ b/InvenTree/part/test_part.py
@@ -235,6 +235,8 @@ class PartSettingsTest(TestCase):
             InvenTreeSetting.set_setting('PART_PURCHASEABLE', val, self.user)
             InvenTreeSetting.set_setting('PART_SALABLE', val, self.user)
             InvenTreeSetting.set_setting('PART_TRACKABLE', val, self.user)
+            InvenTreeSetting.set_setting('PART_ASSEMBLY', val, self.user)
+            InvenTreeSetting.set_setting('PART_TEMPLATE', val, self.user)
 
             self.assertEqual(val, InvenTreeSetting.get_setting('PART_COMPONENT'))
             self.assertEqual(val, InvenTreeSetting.get_setting('PART_PURCHASEABLE'))
@@ -247,6 +249,8 @@ class PartSettingsTest(TestCase):
             self.assertEqual(part.purchaseable, val)
             self.assertEqual(part.salable, val)
             self.assertEqual(part.trackable, val)
+            self.assertEqual(part.assembly, val)
+            self.assertEqual(part.is_template, val)
     
             Part.objects.filter(pk=part.pk).delete()
 
diff --git a/InvenTree/part/views.py b/InvenTree/part/views.py
index ac4925685f..1d76860ac1 100644
--- a/InvenTree/part/views.py
+++ b/InvenTree/part/views.py
@@ -35,6 +35,8 @@ from .models import PartSellPriceBreak
 from common.models import InvenTreeSetting
 from company.models import SupplierPart
 
+import common.settings as inventree_settings
+
 from . import forms as part_forms
 from .bom import MakeBomTemplate, BomUploadManager, ExportBom, IsValidBOMFormat
 
@@ -626,6 +628,10 @@ class PartCreate(AjaxCreateView):
         """
         form = super(AjaxCreateView, self).get_form()
 
+        # Hide the "default expiry" field if the feature is not enabled
+        if not inventree_settings.stock_expiry_enabled():
+            form.fields.pop('default_expiry')
+
         # Hide the default_supplier field (there are no matching supplier parts yet!)
         form.fields['default_supplier'].widget = HiddenInput()
 
@@ -918,6 +924,10 @@ class PartEdit(AjaxUpdateView):
 
         form = super(AjaxUpdateView, self).get_form()
 
+        # Hide the "default expiry" field if the feature is not enabled
+        if not inventree_settings.stock_expiry_enabled():
+            form.fields.pop('default_expiry')
+
         part = self.get_object()
 
         form.fields['default_supplier'].queryset = SupplierPart.objects.filter(part=part)
diff --git a/InvenTree/report/models.py b/InvenTree/report/models.py
index b3725cadc0..4dd5fbfa5c 100644
--- a/InvenTree/report/models.py
+++ b/InvenTree/report/models.py
@@ -16,7 +16,7 @@ from django.conf import settings
 from django.core.validators import FileExtensionValidator
 from django.core.exceptions import ValidationError
 
-from stock.models import StockItem
+import stock.models
 
 from InvenTree.helpers import validateFilterString
 
@@ -191,7 +191,7 @@ class TestReport(ReportTemplateBase):
 
         filters = validateFilterString(self.filters)
 
-        items = StockItem.objects.filter(**filters)
+        items = stock.models.StockItem.objects.filter(**filters)
 
         # Ensure the provided StockItem object matches the filters
         items = items.filter(pk=item.pk)
diff --git a/InvenTree/stock/api.py b/InvenTree/stock/api.py
index b74ac200f7..9f0a4278f5 100644
--- a/InvenTree/stock/api.py
+++ b/InvenTree/stock/api.py
@@ -23,6 +23,9 @@ from part.serializers import PartBriefSerializer
 from company.models import SupplierPart
 from company.serializers import SupplierPartSerializer
 
+import common.settings
+import common.models
+
 from .serializers import StockItemSerializer
 from .serializers import LocationSerializer, LocationBriefSerializer
 from .serializers import StockTrackingSerializer
@@ -35,6 +38,8 @@ from InvenTree.api import AttachmentMixin
 
 from decimal import Decimal, InvalidOperation
 
+from datetime import datetime, timedelta
+
 from rest_framework.serializers import ValidationError
 from rest_framework.views import APIView
 from rest_framework.response import Response
@@ -342,10 +347,18 @@ class StockList(generics.ListCreateAPIView):
         # A location was *not* specified - try to infer it
         if 'location' not in request.data:
             location = item.part.get_default_location()
+            
             if location is not None:
                 item.location = location
                 item.save()
 
+        # An expiry date was *not* specified - try to infer it!
+        if 'expiry_date' not in request.data:
+            
+            if item.part.default_expiry > 0:
+                item.expiry_date = datetime.now().date() + timedelta(days=item.part.default_expiry)
+                item.save()
+
         # Return a response
         headers = self.get_success_headers(serializer.data)
         return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
@@ -525,6 +538,38 @@ class StockList(generics.ListCreateAPIView):
                 # Exclude items which are instaled in another item
                 queryset = queryset.filter(belongs_to=None)
 
+        if common.settings.stock_expiry_enabled():
+
+            # Filter by 'expired' status
+            expired = params.get('expired', None)
+
+            if expired is not None:
+                expired = str2bool(expired)
+
+                if expired:
+                    queryset = queryset.filter(StockItem.EXPIRED_FILTER)
+                else:
+                    queryset = queryset.exclude(StockItem.EXPIRED_FILTER)
+
+            # Filter by 'stale' status
+            stale = params.get('stale', None)
+
+            if stale is not None:
+                stale = str2bool(stale)
+
+                # How many days to account for "staleness"?
+                stale_days = common.models.InvenTreeSetting.get_setting('STOCK_STALE_DAYS')
+
+                if stale_days > 0:
+                    stale_date = datetime.now().date() + timedelta(days=stale_days)
+                    
+                    stale_filter = StockItem.IN_STOCK_FILTER & ~Q(expiry_date=None) & Q(expiry_date__lt=stale_date)
+
+                    if stale:
+                        queryset = queryset.filter(stale_filter)
+                    else:
+                        queryset = queryset.exclude(stale_filter)
+
         # Filter by customer
         customer = params.get('customer', None)
 
diff --git a/InvenTree/stock/fixtures/stock.yaml b/InvenTree/stock/fixtures/stock.yaml
index 575e828523..00d3920205 100644
--- a/InvenTree/stock/fixtures/stock.yaml
+++ b/InvenTree/stock/fixtures/stock.yaml
@@ -81,7 +81,7 @@
     part: 25
     batch: 'ABCDE'
     location: 7
-    quantity: 3
+    quantity: 0
     level: 0
     tree_id: 0
     lft: 0
@@ -232,6 +232,7 @@
     tree_id: 0
     lft: 0
     rght: 0
+    expiry_date: "1990-10-10"
 
 - model: stock.stockitem
   pk: 521
@@ -244,6 +245,7 @@
     tree_id: 0
     lft: 0
     rght: 0
+    status: 60
 
 - model: stock.stockitem
   pk: 522
@@ -255,4 +257,6 @@
     level: 0
     tree_id: 0
     lft: 0
-    rght: 0
\ No newline at end of file
+    rght: 0
+    expiry_date: "1990-10-10"
+    status: 70
\ No newline at end of file
diff --git a/InvenTree/stock/forms.py b/InvenTree/stock/forms.py
index 9cd5435423..7659981ecd 100644
--- a/InvenTree/stock/forms.py
+++ b/InvenTree/stock/forms.py
@@ -16,6 +16,7 @@ from mptt.fields import TreeNodeChoiceField
 from InvenTree.helpers import GetExportFormats
 from InvenTree.forms import HelperForm
 from InvenTree.fields import RoundingDecimalFormField
+from InvenTree.fields import DatePickerFormField
 
 from report.models import TestReport
 
@@ -109,6 +110,10 @@ class ConvertStockItemForm(HelperForm):
 class CreateStockItemForm(HelperForm):
     """ Form for creating a new StockItem """
 
+    expiry_date = DatePickerFormField(
+        help_text=('Expiration date for this stock item'),
+    )
+
     serial_numbers = forms.CharField(label=_('Serial numbers'), required=False, help_text=_('Enter unique serial numbers (or leave blank)'))
 
     def __init__(self, *args, **kwargs):
@@ -130,6 +135,7 @@ class CreateStockItemForm(HelperForm):
             'batch',
             'serial_numbers',
             'purchase_price',
+            'expiry_date',
             'link',
             'delete_on_deplete',
             'status',
@@ -243,7 +249,7 @@ class TestReportFormatForm(HelperForm):
         templates = TestReport.objects.filter(enabled=True)
 
         for template in templates:
-            if template.matches_stock_item(self.stock_item):
+            if template.enabled and template.matches_stock_item(self.stock_item):
                 choices.append((template.pk, template))
 
         return choices
@@ -394,6 +400,10 @@ class EditStockItemForm(HelperForm):
     part - Cannot be edited after creation
     """
 
+    expiry_date = DatePickerFormField(
+        help_text=('Expiration date for this stock item'),
+    )
+
     class Meta:
         model = StockItem
 
@@ -402,6 +412,7 @@ class EditStockItemForm(HelperForm):
             'serial',
             'batch',
             'status',
+            'expiry_date',
             'purchase_price',
             'link',
             'delete_on_deplete',
diff --git a/InvenTree/stock/migrations/0056_stockitem_expiry_date.py b/InvenTree/stock/migrations/0056_stockitem_expiry_date.py
new file mode 100644
index 0000000000..f558d615a6
--- /dev/null
+++ b/InvenTree/stock/migrations/0056_stockitem_expiry_date.py
@@ -0,0 +1,18 @@
+# Generated by Django 3.0.7 on 2021-01-03 12:38
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('stock', '0055_auto_20201117_1453'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='stockitem',
+            name='expiry_date',
+            field=models.DateField(blank=True, help_text='Expiry date for stock item. Stock will be considered expired after this date', null=True, verbose_name='Expiry Date'),
+        ),
+    ]
diff --git a/InvenTree/stock/models.py b/InvenTree/stock/models.py
index 04db5a81de..acd505171c 100644
--- a/InvenTree/stock/models.py
+++ b/InvenTree/stock/models.py
@@ -27,9 +27,12 @@ from mptt.models import MPTTModel, TreeForeignKey
 from djmoney.models.fields import MoneyField
 
 from decimal import Decimal, InvalidOperation
-from datetime import datetime
+from datetime import datetime, timedelta
 from InvenTree import helpers
 
+import common.models
+import report.models
+
 from InvenTree.status_codes import StockStatus
 from InvenTree.models import InvenTreeTree, InvenTreeAttachment
 from InvenTree.fields import InvenTreeURLField
@@ -129,6 +132,7 @@ class StockItem(MPTTModel):
         serial: Unique serial number for this StockItem
         link: Optional URL to link to external resource
         updated: Date that this stock item was last updated (auto)
+        expiry_date: Expiry date of the StockItem (optional)
         stocktake_date: Date of last stocktake for this item
         stocktake_user: User that performed the most recent stocktake
         review_needed: Flag if StockItem needs review
@@ -153,6 +157,9 @@ class StockItem(MPTTModel):
         status__in=StockStatus.AVAILABLE_CODES
     )
 
+    # A query filter which can be used to filter StockItem objects which have expired
+    EXPIRED_FILTER = IN_STOCK_FILTER & ~Q(expiry_date=None) & Q(expiry_date__lt=datetime.now().date())
+
     def save(self, *args, **kwargs):
         """
         Save this StockItem to the database. Performs a number of checks:
@@ -432,11 +439,19 @@ class StockItem(MPTTModel):
         related_name='stock_items',
         null=True, blank=True)
 
-    # last time the stock was checked / counted
+    expiry_date = models.DateField(
+        blank=True, null=True,
+        verbose_name=_('Expiry Date'),
+        help_text=_('Expiry date for stock item. Stock will be considered expired after this date'),
+    )
+
     stocktake_date = models.DateField(blank=True, null=True)
 
-    stocktake_user = models.ForeignKey(User, on_delete=models.SET_NULL, blank=True, null=True,
-                                       related_name='stocktake_stock')
+    stocktake_user = models.ForeignKey(
+        User, on_delete=models.SET_NULL,
+        blank=True, null=True,
+        related_name='stocktake_stock'
+    )
 
     review_needed = models.BooleanField(default=False)
 
@@ -467,6 +482,55 @@ class StockItem(MPTTModel):
                               help_text='Owner (User)',
                               related_name='owner_stockitems')
 
+    def is_stale(self):
+        """
+        Returns True if this Stock item is "stale".
+
+        To be "stale", the following conditions must be met:
+
+        - Expiry date is not None
+        - Expiry date will "expire" within the configured stale date
+        - The StockItem is otherwise "in stock"
+        """
+
+        if self.expiry_date is None:
+            return False
+
+        if not self.in_stock:
+            return False
+
+        today = datetime.now().date()
+
+        stale_days = common.models.InvenTreeSetting.get_setting('STOCK_STALE_DAYS')
+
+        if stale_days <= 0:
+            return False
+
+        expiry_date = today + timedelta(days=stale_days)
+
+        return self.expiry_date < expiry_date
+
+    def is_expired(self):
+        """
+        Returns True if this StockItem is "expired".
+
+        To be "expired", the following conditions must be met:
+
+        - Expiry date is not None
+        - Expiry date is "in the past"
+        - The StockItem is otherwise "in stock"
+        """
+
+        if self.expiry_date is None:
+            return False
+
+        if not self.in_stock:
+            return False
+
+        today = datetime.now().date()
+
+        return self.expiry_date < today
+
     def clearAllocations(self):
         """
         Clear all order allocations for this StockItem:
@@ -729,36 +793,16 @@ class StockItem(MPTTModel):
     @property
     def in_stock(self):
         """
-        Returns True if this item is in stock
+        Returns True if this item is in stock.
 
         See also: IN_STOCK_FILTER
         """
 
-        # Quantity must be above zero (unless infinite)
-        if self.quantity <= 0 and not self.infinite:
-            return False
+        query = StockItem.objects.filter(pk=self.pk)
 
-        # Not 'in stock' if it has been installed inside another StockItem
-        if self.belongs_to is not None:
-            return False
-            
-        # Not 'in stock' if it has been sent to a customer
-        if self.sales_order is not None:
-            return False
+        query = query.filter(StockItem.IN_STOCK_FILTER)
 
-        # Not 'in stock' if it has been assigned to a customer
-        if self.customer is not None:
-            return False
-
-        # Not 'in stock' if it is building
-        if self.is_building:
-            return False
-
-        # Not 'in stock' if the status code makes it unavailable
-        if self.status in StockStatus.UNAVAILABLE_CODES:
-            return False
-
-        return True
+        return query.exists()
 
     @property
     def tracking_info_count(self):
@@ -1271,6 +1315,41 @@ class StockItem(MPTTModel):
 
         return status['passed'] >= status['total']
 
+    def available_test_reports(self):
+        """
+        Return a list of TestReport objects which match this StockItem.
+        """
+
+        reports = []
+
+        item_query = StockItem.objects.filter(pk=self.pk)
+
+        for test_report in report.models.TestReport.objects.filter(enabled=True):
+
+            filters = helpers.validateFilterString(test_report.filters)
+
+            if item_query.filter(**filters).exists():
+                reports.append(test_report)
+
+        return reports
+
+    @property
+    def has_test_reports(self):
+        """
+        Return True if there are test reports available for this stock item
+        """
+
+        return len(self.available_test_reports()) > 0
+
+    @property
+    def has_labels(self):
+        """
+        Return True if there are any label templates available for this stock item
+        """
+
+        # TODO - Implement this
+        return True
+
 
 @receiver(pre_delete, sender=StockItem, dispatch_uid='stock_item_pre_delete_log')
 def before_delete_stock_item(sender, instance, using, **kwargs):
diff --git a/InvenTree/stock/serializers.py b/InvenTree/stock/serializers.py
index e8675a8fff..70ad1abc18 100644
--- a/InvenTree/stock/serializers.py
+++ b/InvenTree/stock/serializers.py
@@ -11,10 +11,17 @@ from .models import StockItemTestResult
 
 from django.db.models.functions import Coalesce
 
+from django.db.models import Case, When, Value
+from django.db.models import BooleanField
+from django.db.models import Q
+
 from sql_util.utils import SubquerySum, SubqueryCount
 
 from decimal import Decimal
 
+from datetime import datetime, timedelta
+
+import common.models
 from company.serializers import SupplierPartSerializer
 from part.serializers import PartBriefSerializer
 from InvenTree.serializers import UserSerializerBrief, InvenTreeModelSerializer
@@ -106,6 +113,30 @@ class StockItemSerializer(InvenTreeModelSerializer):
             tracking_items=SubqueryCount('tracking_info')
         )
 
+        # Add flag to indicate if the StockItem has expired
+        queryset = queryset.annotate(
+            expired=Case(
+                When(
+                    StockItem.EXPIRED_FILTER, then=Value(True, output_field=BooleanField()),
+                ),
+                default=Value(False, output_field=BooleanField())
+            )
+        )
+
+        # Add flag to indicate if the StockItem is stale
+        stale_days = common.models.InvenTreeSetting.get_setting('STOCK_STALE_DAYS')
+        stale_date = datetime.now().date() + timedelta(days=stale_days)
+        stale_filter = StockItem.IN_STOCK_FILTER & ~Q(expiry_date=None) & Q(expiry_date__lt=stale_date)
+
+        queryset = queryset.annotate(
+            stale=Case(
+                When(
+                    stale_filter, then=Value(True, output_field=BooleanField()),
+                ),
+                default=Value(False, output_field=BooleanField()),
+            )
+        )
+
         return queryset
 
     status_text = serializers.CharField(source='get_status_display', read_only=True)
@@ -122,6 +153,10 @@ class StockItemSerializer(InvenTreeModelSerializer):
     
     allocated = serializers.FloatField(source='allocation_count', required=False)
 
+    expired = serializers.BooleanField(required=False, read_only=True)
+
+    stale = serializers.BooleanField(required=False, read_only=True)
+
     serial = serializers.CharField(required=False)
 
     required_tests = serializers.IntegerField(source='required_test_count', read_only=True, required=False)
@@ -155,6 +190,8 @@ class StockItemSerializer(InvenTreeModelSerializer):
             'belongs_to',
             'build',
             'customer',
+            'expired',
+            'expiry_date',
             'in_stock',
             'is_building',
             'link',
@@ -168,6 +205,7 @@ class StockItemSerializer(InvenTreeModelSerializer):
             'required_tests',
             'sales_order',
             'serial',
+            'stale',
             'status',
             'status_text',
             'supplier_part',
diff --git a/InvenTree/stock/templates/stock/item_base.html b/InvenTree/stock/templates/stock/item_base.html
index 8cb1e254c4..48c65d65bf 100644
--- a/InvenTree/stock/templates/stock/item_base.html
+++ b/InvenTree/stock/templates/stock/item_base.html
@@ -81,7 +81,14 @@ InvenTree | {% trans "Stock Item" %} - {{ item }}
 
 <h3>
     {% trans "Stock Item" %}
+    {% if item.is_expired %}
+    <span class='label label-large label-large-red'>{% trans "Expired" %}</span>
+    {% else %}
     {% stock_status_label item.status large=True %}
+    {% if item.is_stale %}
+    <span class='label label-large label-large-yellow'>{% trans "Stale" %}</span>
+    {% endif %}
+    {% endif %}
 </h3>
 <hr>
 <h4>
@@ -112,16 +119,29 @@ InvenTree | {% trans "Stock Item" %} - {{ item }}
         <button id='barcode-options' title='{% trans "Barcode actions" %}' class='btn btn-default dropdown-toggle' type='button' data-toggle='dropdown'><span class='fas fa-qrcode'></span> <span class='caret'></span></button>
         <ul class='dropdown-menu' role='menu'>
             <li><a href='#' id='show-qr-code'><span class='fas fa-qrcode'></span> {% trans "Show QR Code" %}</a></li>
-            <li><a href='#' id='print-label'><span class='fas fa-tag'></span> {% trans "Print Label" %}</a></li>
             {% if roles.stock.change %}
-                {% if item.uid %}
-                <li><a href='#' id='unlink-barcode'><span class='fas fa-unlink'></span> {% trans "Unlink Barcode" %}</a></li>
+            {% if item.uid %}
+            <li><a href='#' id='unlink-barcode'><span class='fas fa-unlink'></span> {% trans "Unlink Barcode" %}</a></li>
                 {% else %}
                 <li><a href='#' id='link-barcode'><span class='fas fa-link'></span> {% trans "Link Barcode" %}</a></li>
                 {% endif %}
-            {% endif %}
+                {% endif %}
+            </ul>
+        </div>
+        <!-- Document / label menu -->
+        {% if item.has_labels or item.has_test_reports %}
+        <div class='btn-group'>
+            <button id='document-options' title='{% trans "Document actions" %}' class='btn btn-default dropdown-toggle' type='button' data-toggle='dropdown'><span class='fas fa-file-alt'></span> <span class='caret'></span></button>
+            <ul class='dropdown-menu' role='menu'>
+                {% if item.has_labels %}
+                <li><a href='#' id='print-label'><span class='fas fa-tag'></span> {% trans "Print Label" %}</a></li>
+                {% endif %}
+                {% if item.has_test_reports %}
+                <li><a href='#' id='stock-test-report'><span class='fas fa-file-pdf'></span> {% trans "Test Report" %}</a></li>
+                {% endif %}
         </ul>
     </div>
+    {% endif %}
     <!-- Stock adjustment menu -->
     <!-- Check permissions and owner -->
     {% if owner_control.value == "False" or owner_control.value == "True" and item.owner == user or user.is_superuser %}
@@ -175,9 +195,6 @@ InvenTree | {% trans "Stock Item" %} - {{ item }}
         </div>
         {% endif %}
     {% endif %}
-    <button type='button' class='btn btn-default' id='stock-test-report' title='{% trans "Generate test report" %}'>
-        <span class='fas fa-file-invoice'/>
-    </button>
 </div>
 
 {% endblock %}
@@ -307,6 +324,20 @@ InvenTree | {% trans "Stock Item" %} - {{ item }}
         <td><a href="{% url 'supplier-part-detail' item.supplier_part.id %}">{{ item.supplier_part.SKU }}</a></td>
     </tr>
     {% endif %}
+    {% if item.expiry_date %}
+    <tr>
+        <td><span class='fas fa-calendar-alt{% if item.is_expired %} icon-red{% endif %}'></span></td>
+        <td>{% trans "Expiry Date" %}</td>
+        <td>
+            {{ item.expiry_date }}
+            {% if item.is_expired %}
+            <span title='{% trans "This StockItem expired on" %} {{ item.expiry_date }}' class='label label-red'>{% trans "Expired" %}</span>
+            {% elif item.is_stale %}
+            <span title='{% trans "This StockItem expires on" %} {{ item.expiry_date }}' class='label label-yellow'>{% trans "Stale" %}</span>
+            {% endif %}
+        </td>
+    </tr>
+    {% endif %}
     <tr>
         <td><span class='fas fa-calendar-alt'></span></td>
         <td>{% trans "Last Updated" %}</td>
diff --git a/InvenTree/stock/test_api.py b/InvenTree/stock/test_api.py
index a34e895ed8..9b3e926456 100644
--- a/InvenTree/stock/test_api.py
+++ b/InvenTree/stock/test_api.py
@@ -1,11 +1,23 @@
+"""
+Unit testing for the Stock API
+"""
+
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from datetime import datetime, timedelta
+
 from rest_framework.test import APITestCase
 from rest_framework import status
 from django.urls import reverse
 from django.contrib.auth import get_user_model
 
 from InvenTree.helpers import addUserPermissions
+from InvenTree.status_codes import StockStatus
 
-from .models import StockLocation
+from common.models import InvenTreeSetting
+
+from .models import StockItem, StockLocation
 
 
 class StockAPITestCase(APITestCase):
@@ -26,6 +38,9 @@ class StockAPITestCase(APITestCase):
         
         self.user = user.objects.create_user('testuser', 'test@testing.com', 'password')
 
+        self.user.is_staff = True
+        self.user.save()
+
         # Add the necessary permissions to the user
         perms = [
             'view_stockitemtestresult',
@@ -76,6 +91,177 @@ class StockLocationTest(StockAPITestCase):
         self.assertEqual(response.status_code, status.HTTP_201_CREATED)
 
 
+class StockItemListTest(StockAPITestCase):
+    """
+    Tests for the StockItem API LIST endpoint
+    """
+
+    list_url = reverse('api-stock-list')
+
+    def get_stock(self, **kwargs):
+        """
+        Filter stock and return JSON object
+        """
+
+        response = self.client.get(self.list_url, format='json', data=kwargs)
+
+        self.assertEqual(response.status_code, status.HTTP_200_OK)
+
+        # Return JSON-ified data
+        return response.data
+
+    def test_get_stock_list(self):
+        """
+        List *all* StockItem objects.
+        """
+
+        response = self.get_stock()
+
+        self.assertEqual(len(response), 19)
+
+    def test_filter_by_part(self):
+        """
+        Filter StockItem by Part reference
+        """
+
+        response = self.get_stock(part=25)
+        
+        self.assertEqual(len(response), 7)
+
+        response = self.get_stock(part=10004)
+
+        self.assertEqual(len(response), 12)
+
+    def test_filter_by_IPN(self):
+        """
+        Filter StockItem by IPN reference
+        """
+
+        response = self.get_stock(IPN="R.CH")
+        self.assertEqual(len(response), 3)
+
+    def test_filter_by_location(self):
+        """
+        Filter StockItem by StockLocation reference
+        """
+
+        response = self.get_stock(location=5)
+        self.assertEqual(len(response), 1)
+
+        response = self.get_stock(location=1, cascade=0)
+        self.assertEqual(len(response), 0)
+
+        response = self.get_stock(location=1, cascade=1)
+        self.assertEqual(len(response), 2)
+
+        response = self.get_stock(location=7)
+        self.assertEqual(len(response), 16)
+
+    def test_filter_by_depleted(self):
+        """
+        Filter StockItem by depleted status
+        """
+
+        response = self.get_stock(depleted=1)
+        self.assertEqual(len(response), 1)
+
+        response = self.get_stock(depleted=0)
+        self.assertEqual(len(response), 18)
+
+    def test_filter_by_in_stock(self):
+        """
+        Filter StockItem by 'in stock' status
+        """
+
+        response = self.get_stock(in_stock=1)
+        self.assertEqual(len(response), 16)
+
+        response = self.get_stock(in_stock=0)
+        self.assertEqual(len(response), 3)
+
+    def test_filter_by_status(self):
+        """
+        Filter StockItem by 'status' field
+        """
+
+        codes = {
+            StockStatus.OK: 17,
+            StockStatus.DESTROYED: 1,
+            StockStatus.LOST: 1,
+            StockStatus.DAMAGED: 0,
+            StockStatus.REJECTED: 0,
+        }
+
+        for code in codes.keys():
+            num = codes[code]
+
+            response = self.get_stock(status=code)
+            self.assertEqual(len(response), num)
+
+    def test_filter_by_batch(self):
+        """
+        Filter StockItem by batch code
+        """
+
+        response = self.get_stock(batch='B123')
+        self.assertEqual(len(response), 1)
+
+    def test_filter_by_serialized(self):
+        """
+        Filter StockItem by serialized status
+        """
+
+        response = self.get_stock(serialized=1)
+        self.assertEqual(len(response), 12)
+
+        for item in response:
+            self.assertIsNotNone(item['serial'])
+
+        response = self.get_stock(serialized=0)
+        self.assertEqual(len(response), 7)
+
+        for item in response:
+            self.assertIsNone(item['serial'])
+
+    def test_filter_by_expired(self):
+        """
+        Filter StockItem by expiry status
+        """
+
+        # First, we can assume that the 'stock expiry' feature is disabled
+        response = self.get_stock(expired=1)
+        self.assertEqual(len(response), 19)
+
+        # Now, ensure that the expiry date feature is enabled!
+        InvenTreeSetting.set_setting('STOCK_ENABLE_EXPIRY', True, self.user)
+
+        response = self.get_stock(expired=1)
+        self.assertEqual(len(response), 1)
+
+        for item in response:
+            self.assertTrue(item['expired'])
+
+        response = self.get_stock(expired=0)
+        self.assertEqual(len(response), 18)
+
+        for item in response:
+            self.assertFalse(item['expired'])
+
+        # Mark some other stock items as expired
+        today = datetime.now().date()
+
+        for pk in [510, 511, 512]:
+            item = StockItem.objects.get(pk=pk)
+            item.expiry_date = today - timedelta(days=pk)
+            item.save()
+
+        response = self.get_stock(expired=1)
+        self.assertEqual(len(response), 4)
+
+        response = self.get_stock(expired=0)
+        self.assertEqual(len(response), 15)
+
+
 class StockItemTest(StockAPITestCase):
     """
     Series of API tests for the StockItem API
@@ -94,10 +280,6 @@ class StockItemTest(StockAPITestCase):
         StockLocation.objects.create(name='B', description='location b', parent=top)
         StockLocation.objects.create(name='C', description='location c', parent=top)
 
-    def test_get_stock_list(self):
-        response = self.client.get(self.list_url, format='json')
-        self.assertEqual(response.status_code, status.HTTP_200_OK)
-
     def test_create_default_location(self):
         """
         Test the default location functionality,
@@ -198,6 +380,56 @@ class StockItemTest(StockAPITestCase):
 
         self.assertEqual(response.status_code, status.HTTP_201_CREATED)
 
+    def test_default_expiry(self):
+        """
+        Test that the "default_expiry" functionality works via the API.
+
+        - If an expiry_date is specified, use that
+        - Otherwise, check if the referenced part has a default_expiry defined
+            - If so, use that!
+            - Otherwise, no expiry
+        
+        Notes:
+            - Part <25> has a default_expiry of 10 days
+        
+        """
+
+        # First test - create a new StockItem without an expiry date
+        data = {
+            'part': 4,
+            'quantity': 10,
+        }
+
+        response = self.client.post(self.list_url, data)
+
+        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
+
+        self.assertIsNone(response.data['expiry_date'])
+
+        # Second test - create a new StockItem with an explicit expiry date
+        data['expiry_date'] = '2022-12-12'
+
+        response = self.client.post(self.list_url, data)
+
+        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
+
+        self.assertIsNotNone(response.data['expiry_date'])
+        self.assertEqual(response.data['expiry_date'], '2022-12-12')
+
+        # Third test - create a new StockItem for a Part which has a default expiry time
+        data = {
+            'part': 25,
+            'quantity': 10
+        }
+
+        response = self.client.post(self.list_url, data)
+        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
+
+        # Expected expiry date is 10 days in the future
+        expiry = datetime.now().date() + timedelta(10)
+
+        self.assertEqual(response.data['expiry_date'], expiry.isoformat())
+
 
 class StocktakeTest(StockAPITestCase):
     """
diff --git a/InvenTree/stock/test_views.py b/InvenTree/stock/test_views.py
index eca6d775c8..278a4ddf5d 100644
--- a/InvenTree/stock/test_views.py
+++ b/InvenTree/stock/test_views.py
@@ -5,7 +5,10 @@ from django.urls import reverse
 from django.contrib.auth import get_user_model
 from django.contrib.auth.models import Group
 
+from common.models import InvenTreeSetting
+
 import json
+from datetime import datetime, timedelta
 
 from common.models import InvenTreeSetting
 from InvenTree.status_codes import StockStatus
@@ -34,6 +37,9 @@ class StockViewTestCase(TestCase):
             password='password'
         )
 
+        self.user.is_staff = True
+        self.user.save()
+
         # Put the user into a group with the correct permissions
         group = Group.objects.create(name='mygroup')
         self.user.groups.add(group)
@@ -138,21 +144,56 @@ class StockItemTest(StockViewTestCase):
         self.assertEqual(response.status_code, 200)
 
     def test_create_item(self):
-        # Test creation of StockItem
-        response = self.client.get(reverse('stock-item-create'), {'part': 1}, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
+        """
+        Test creation of StockItem
+        """
+
+        url = reverse('stock-item-create')
+
+        response = self.client.get(url, {'part': 1}, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
         self.assertEqual(response.status_code, 200)
 
-        response = self.client.get(reverse('stock-item-create'), {'part': 999}, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
+        response = self.client.get(url, {'part': 999}, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
         self.assertEqual(response.status_code, 200)
 
         # Copy from a valid item, valid location
-        response = self.client.get(reverse('stock-item-create'), {'location': 1, 'copy': 1}, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
+        response = self.client.get(url, {'location': 1, 'copy': 1}, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
         self.assertEqual(response.status_code, 200)
 
         # Copy from an invalid item, invalid location
-        response = self.client.get(reverse('stock-item-create'), {'location': 999, 'copy': 9999}, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
+        response = self.client.get(url, {'location': 999, 'copy': 9999}, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
         self.assertEqual(response.status_code, 200)
 
+    def test_create_stock_with_expiry(self):
+        """
+        Test creation of stock item of a part with an expiry date.
+        The initial value for the "expiry_date" field should be pre-filled,
+        and should be in the future!
+        """
+
+        # First, ensure that the expiry date feature is enabled!
+        InvenTreeSetting.set_setting('STOCK_ENABLE_EXPIRY', True, self.user)
+
+        url = reverse('stock-item-create')
+
+        response = self.client.get(url, {'part': 25}, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
+
+        self.assertEqual(response.status_code, 200)
+
+        # We are expecting 10 days in the future
+        expiry = datetime.now().date() + timedelta(10)
+
+        expected = f'name=\\\\"expiry_date\\\\" value=\\\\"{expiry.isoformat()}\\\\"'
+
+        self.assertIn(expected, str(response.content))
+
+        # Now check with a part which does *not* have a default expiry period
+        response = self.client.get(url, {'part': 1}, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
+
+        expected = 'name=\\\\"expiry_date\\\\" placeholder=\\\\"\\\\"'
+
+        self.assertIn(expected, str(response.content))
+
     def test_serialize_item(self):
         # Test the serialization view
 
diff --git a/InvenTree/stock/tests.py b/InvenTree/stock/tests.py
index 3d309c0360..b0b05b6326 100644
--- a/InvenTree/stock/tests.py
+++ b/InvenTree/stock/tests.py
@@ -49,6 +49,40 @@ class StockTest(TestCase):
         Part.objects.rebuild()
         StockItem.objects.rebuild()
 
+    def test_expiry(self):
+        """
+        Test expiry date functionality for StockItem model.
+        """
+
+        today = datetime.datetime.now().date()
+
+        item = StockItem.objects.create(
+            location=self.office,
+            part=Part.objects.get(pk=1),
+            quantity=10,
+        )
+
+        # Without an expiry_date set, item should not be "expired"
+        self.assertFalse(item.is_expired())
+
+        # Set the expiry date to today
+        item.expiry_date = today
+        item.save()
+
+        self.assertFalse(item.is_expired())
+
+        # Set the expiry date in the future
+        item.expiry_date = today + datetime.timedelta(days=5)
+        item.save()
+
+        self.assertFalse(item.is_expired())
+
+        # Set the expiry date in the past
+        item.expiry_date = today - datetime.timedelta(days=5)
+        item.save()
+
+        self.assertTrue(item.is_expired())
+
     def test_is_building(self):
         """
         Test that the is_building flag does not count towards stock.
@@ -143,8 +177,10 @@ class StockTest(TestCase):
         # There should be 9000 screws in stock
         self.assertEqual(part.total_stock, 9000)
 
-        # There should be 18 widgets in stock
-        self.assertEqual(StockItem.objects.filter(part=25).aggregate(Sum('quantity'))['quantity__sum'], 19)
+        # There should be 16 widgets "in stock"
+        self.assertEqual(
+            StockItem.objects.filter(part=25).aggregate(Sum('quantity'))['quantity__sum'], 16
+        )
 
     def test_delete_location(self):
 
diff --git a/InvenTree/stock/views.py b/InvenTree/stock/views.py
index 379cbab69a..1af0585c39 100644
--- a/InvenTree/stock/views.py
+++ b/InvenTree/stock/views.py
@@ -27,7 +27,7 @@ from InvenTree.helpers import str2bool, DownloadFile, GetExportFormats
 from InvenTree.helpers import extract_serial_numbers
 
 from decimal import Decimal, InvalidOperation
-from datetime import datetime
+from datetime import datetime, timedelta
 
 from company.models import Company, SupplierPart
 from part.models import Part
@@ -1330,12 +1330,17 @@ class StockItemEdit(AjaxUpdateView):
 
         form = super(AjaxUpdateView, self).get_form()
 
+        # Hide the "expiry date" field if the feature is not enabled
+        if not common.settings.stock_expiry_enabled():
+            form.fields.pop('expiry_date')
+
         item = self.get_object()
 
         # If the part cannot be purchased, hide the supplier_part field
         if not item.part.purchaseable:
             form.fields['supplier_part'].widget = HiddenInput()
-            form.fields['purchase_price'].widget = HiddenInput()
+
+            form.fields.pop('purchase_price')
         else:
             query = form.fields['supplier_part'].queryset
             query = query.filter(part=item.part.id)
@@ -1629,6 +1634,10 @@ class StockItemCreate(AjaxCreateView):
 
         form = super().get_form()
 
+        # Hide the "expiry date" field if the feature is not enabled
+        if not common.settings.stock_expiry_enabled():
+            form.fields.pop('expiry_date')
+
         part = self.get_part(form=form)
 
         if part is not None:
@@ -1639,8 +1648,8 @@ class StockItemCreate(AjaxCreateView):
             form.rebuild_layout()
 
             if not part.purchaseable:
-                form.fields['purchase_price'].widget = HiddenInput()
-
+                form.fields.pop('purchase_price')
+            
             # Hide the 'part' field (as a valid part is selected)
             # form.fields['part'].widget = HiddenInput()
 
@@ -1734,6 +1743,11 @@ class StockItemCreate(AjaxCreateView):
             initials['location'] = part.get_default_location()
             initials['supplier_part'] = part.default_supplier
 
+            # If the part has a defined expiry period, extrapolate!
+            if part.default_expiry > 0:
+                expiry_date = datetime.now().date() + timedelta(days=part.default_expiry)
+                initials['expiry_date'] = expiry_date
+
         currency_code = common.settings.currency_code_default()
 
         # SupplierPart field has been specified
diff --git a/InvenTree/templates/InvenTree/expired_stock.html b/InvenTree/templates/InvenTree/expired_stock.html
new file mode 100644
index 0000000000..20e2591c16
--- /dev/null
+++ b/InvenTree/templates/InvenTree/expired_stock.html
@@ -0,0 +1,15 @@
+{% extends "collapse_index.html" %}
+
+{% load i18n %}
+
+{% block collapse_title %}
+<span class='fas fa-calendar-times icon-header'></span>
+{% trans "Expired Stock" %}<span class='badge' id='expired-stock-count'><span class='fas fa-spin fa-spinner'></span></span>
+{% endblock %}
+
+{% block collapse_content %}
+
+<table class='table table-striped table-condensed' id='expired-stock-table'>
+</table>
+
+{% endblock %}
\ No newline at end of file
diff --git a/InvenTree/templates/InvenTree/index.html b/InvenTree/templates/InvenTree/index.html
index f862175920..1b9a492a22 100644
--- a/InvenTree/templates/InvenTree/index.html
+++ b/InvenTree/templates/InvenTree/index.html
@@ -1,5 +1,6 @@
 {% extends "base.html" %}
 {% load i18n %}
+{% load inventree_extras %}
 {% block page_title %}
 InvenTree | {% trans "Index" %}
 {% endblock %}
@@ -8,7 +9,7 @@ InvenTree | {% trans "Index" %}
 <h3>InvenTree</h3>
 <hr>
 
-<div class='col-sm-6'>
+<div class='col-sm-4'>
     {% if roles.part.view %}
     {% include "InvenTree/latest_parts.html" with collapse_id="latest_parts" %}
     {% include "InvenTree/bom_invalid.html" with collapse_id="bom_invalid" %}
@@ -19,11 +20,18 @@ InvenTree | {% trans "Index" %}
     {% include "InvenTree/build_overdue.html" with collapse_id="build_overdue" %}
     {% endif %}
 </div>
-<div class='col-sm-6'>
+<div class='col-sm-4'>
     {% if roles.stock.view %}
     {% include "InvenTree/low_stock.html" with collapse_id="order" %}
+    {% settings_value "STOCK_ENABLE_EXPIRY" as expiry %}
+    {% if expiry %}
+    {% include "InvenTree/expired_stock.html" with collapse_id="expired" %}
+    {% include "InvenTree/stale_stock.html" with collapse_id="stale" %}
+    {% endif %}
     {% include "InvenTree/required_stock_build.html" with collapse_id="stock_to_build" %}
     {% endif %}
+</div>
+<div class='col-sm-4'>
     {% if roles.purchase_order.view %}
     {% include "InvenTree/po_outstanding.html" with collapse_id="po_outstanding" %}
     {% endif %}
@@ -83,6 +91,23 @@ loadBuildTable("#build-overdue-table", {
     disableFilters: true,
 });
 
+loadStockTable($("#expired-stock-table"), {
+    params: {
+        expired: true,
+        location_detail: true,
+        part_detail: true,
+    },
+});
+
+loadStockTable($("#stale-stock-table"), {
+    params: {
+        stale: true,
+        expired: false,
+        location_detail: true,
+        part_detail: true,
+    },
+});
+
 loadSimplePartTable("#low-stock-table", "{% url 'api-part-list' %}", {
     params: {
         low_stock: true,
@@ -121,64 +146,19 @@ loadSalesOrderTable("#so-overdue-table", {
     }
 });
 
-$("#latest-parts-table").on('load-success.bs.table', function() {
-    var count = $("#latest-parts-table").bootstrapTable('getData').length;
+{% include "InvenTree/index/on_load.html" with label="latest-parts" %}
+{% include "InvenTree/index/on_load.html" with label="starred-parts" %}
+{% include "InvenTree/index/on_load.html" with label="bom-invalid" %}
+{% include "InvenTree/index/on_load.html" with label="build-pending" %}
+{% include "InvenTree/index/on_load.html" with label="build-overdue" %}
 
-    $("#latest-parts-count").html(count);
-});
+{% include "InvenTree/index/on_load.html" with label="expired-stock" %}
+{% include "InvenTree/index/on_load.html" with label="stale-stock" %}
+{% include "InvenTree/index/on_load.html" with label="low-stock" %}
+{% include "InvenTree/index/on_load.html" with label="stock-to-build" %}
 
-$("#starred-parts-table").on('load-success.bs.table', function() {
-    var count = $("#starred-parts-table").bootstrapTable('getData').length;
-
-    $("#starred-parts-count").html(count);
-});
-
-$("#bom-invalid-table").on('load-success.bs.table', function() {
-    var count = $("#bom-invalid-table").bootstrapTable('getData').length;
-
-    $("#bom-invalid-count").html(count);
-});
-
-$("#build-pending-table").on('load-success.bs.table', function() {
-    var count = $("#build-pending-table").bootstrapTable('getData').length;
-
-    $("#build-pending-count").html(count);
-});
-
-$("#build-overdue-table").on('load-success.bs.table', function() {
-    var count = $("#build-overdue-table").bootstrapTable('getData').length;
-
-    $("#build-overdue-count").html(count);
-});
-
-$("#low-stock-table").on('load-success.bs.table', function() {
-    var count = $("#low-stock-table").bootstrapTable('getData').length;
-
-    $("#low-stock-count").html(count);
-});
-
-$("#stock-to-build-table").on('load-success.bs.table', function() {
-    var count = $("#stock-to-build-table").bootstrapTable('getData').length;
-
-    $("#stock-to-build-count").html(count);
-});
-
-$("#po-outstanding-table").on('load-success.bs.table', function() {
-    var count = $("#po-outstanding-table").bootstrapTable('getData').length;
-
-    $("#po-outstanding-count").html(count);
-});
-
-$("#so-outstanding-table").on('load-success.bs.table', function() {
-    var count = $("#so-outstanding-table").bootstrapTable('getData').length;
-
-    $("#so-outstanding-count").html(count);
-});
-
-$("#so-overdue-table").on('load-success.bs.table', function() {
-    var count = $("#so-overdue-table").bootstrapTable('getData').length;
-
-    $("#so-overdue-count").html(count);
-});
+{% include "InvenTree/index/on_load.html" with label="po-outstanding" %}
+{% include "InvenTree/index/on_load.html" with label="so-outstanding" %}
+{% include "InvenTree/index/on_load.html" with label="so-overdue" %}
 
 {% endblock %}
\ No newline at end of file
diff --git a/InvenTree/templates/InvenTree/index/on_load.html b/InvenTree/templates/InvenTree/index/on_load.html
new file mode 100644
index 0000000000..a63479e60d
--- /dev/null
+++ b/InvenTree/templates/InvenTree/index/on_load.html
@@ -0,0 +1,5 @@
+$("#{{ label }}-table").on('load-success.bs.table', function() {
+    var count = $("#{{ label }}-table").bootstrapTable('getData').length;
+
+    $("#{{ label }}-count").html(count);
+});
\ No newline at end of file
diff --git a/InvenTree/templates/InvenTree/settings/build.html b/InvenTree/templates/InvenTree/settings/build.html
index 781402795b..7d04a8f8b7 100644
--- a/InvenTree/templates/InvenTree/settings/build.html
+++ b/InvenTree/templates/InvenTree/settings/build.html
@@ -13,7 +13,7 @@
 {% block settings %}
 
 <table class='table table-striped table-condensed'>
-    <thead></thead>
+    {% include "InvenTree/settings/header.html" %}
     <tbody>
         {% include "InvenTree/settings/setting.html" with key="BUILDORDER_REFERENCE_PREFIX" %}
         {% include "InvenTree/settings/setting.html" with key="BUILDORDER_REFERENCE_REGEX" %}
diff --git a/InvenTree/templates/InvenTree/settings/global.html b/InvenTree/templates/InvenTree/settings/global.html
index 775d30b915..76af68b441 100644
--- a/InvenTree/templates/InvenTree/settings/global.html
+++ b/InvenTree/templates/InvenTree/settings/global.html
@@ -13,11 +13,11 @@
 {% block settings %}
 
 <table class='table table-striped table-condensed'>
-    <thead></thead>
+    {% include "InvenTree/settings/header.html" %}
     <tbody>
-        {% include "InvenTree/settings/setting.html" with key="INVENTREE_INSTANCE" %}
-        {% include "InvenTree/settings/setting.html" with key="INVENTREE_COMPANY_NAME" %}
-        {% include "InvenTree/settings/setting.html" with key="INVENTREE_DEFAULT_CURRENCY" %}
+        {% include "InvenTree/settings/setting.html" with key="INVENTREE_INSTANCE" icon="fa-info-circle" %}
+        {% include "InvenTree/settings/setting.html" with key="INVENTREE_COMPANY_NAME" icon="fa-building" %}
+        {% include "InvenTree/settings/setting.html" with key="INVENTREE_DEFAULT_CURRENCY" icon="fa-dollar-sign" %}
     </tbody>
 </table>
 
diff --git a/InvenTree/templates/InvenTree/settings/header.html b/InvenTree/templates/InvenTree/settings/header.html
new file mode 100644
index 0000000000..d60a4dd784
--- /dev/null
+++ b/InvenTree/templates/InvenTree/settings/header.html
@@ -0,0 +1,12 @@
+{% load i18n %}
+
+<col width='25'>
+<thead>
+    <tr>
+        <th></th>
+        <th>{% trans "Setting" %}</th>
+        <th>{% trans "Value" %}</th>
+        <th>{% trans "Description" %}</th>
+        <th></th>
+    </tr>
+</thead>
diff --git a/InvenTree/templates/InvenTree/settings/part.html b/InvenTree/templates/InvenTree/settings/part.html
index d1ad6e98e9..fe46911dae 100644
--- a/InvenTree/templates/InvenTree/settings/part.html
+++ b/InvenTree/templates/InvenTree/settings/part.html
@@ -14,16 +14,19 @@
 <h4>{% trans "Part Options" %}</h4>
 
 <table class='table table-striped table-condensed'>
-    <thead></thead>
+    {% include "InvenTree/settings/header.html" %}
     <tbody>
         {% include "InvenTree/settings/setting.html" with key="PART_IPN_REGEX" %}
         {% include "InvenTree/settings/setting.html" with key="PART_ALLOW_DUPLICATE_IPN" %}
-        <tr><td colspan='4'></td></tr>
-        {% include "InvenTree/settings/setting.html" with key="PART_COMPONENT" %}
-        {% include "InvenTree/settings/setting.html" with key="PART_PURCHASEABLE" %}
-        {% include "InvenTree/settings/setting.html" with key="PART_SALABLE" %}
-        {% include "InvenTree/settings/setting.html" with key="PART_TRACKABLE" %}
-        <tr><td colspan='4'></td></tr>
+        <tr><td colspan='5  '></td></tr>
+        {% include "InvenTree/settings/setting.html" with key="PART_TEMPLATE" icon="fa-clone" %}
+        {% include "InvenTree/settings/setting.html" with key="PART_ASSEMBLY" icon="fa-tools" %}
+        {% include "InvenTree/settings/setting.html" with key="PART_COMPONENT" icon="fa-th"%}
+        {% include "InvenTree/settings/setting.html" with key="PART_TRACKABLE" icon="fa-directions" %}
+        {% include "InvenTree/settings/setting.html" with key="PART_PURCHASEABLE" icon="fa-shopping-cart" %}
+        {% include "InvenTree/settings/setting.html" with key="PART_SALABLE" icon="fa-dollar-sign" %}
+        {% include "InvenTree/settings/setting.html" with key="PART_VIRTUAL" icon="fa-ghost" %}
+        <tr><td colspan='5'></td></tr>
         {% include "InvenTree/settings/setting.html" with key="PART_COPY_BOM" %}
         {% include "InvenTree/settings/setting.html" with key="PART_COPY_PARAMETERS" %}
         {% include "InvenTree/settings/setting.html" with key="PART_COPY_TESTS" %}
diff --git a/InvenTree/templates/InvenTree/settings/po.html b/InvenTree/templates/InvenTree/settings/po.html
index a709d40dd3..20e3b0074b 100644
--- a/InvenTree/templates/InvenTree/settings/po.html
+++ b/InvenTree/templates/InvenTree/settings/po.html
@@ -11,7 +11,7 @@
 
 {% block settings %}
 <table class='table table-striped table-condensed'>
-    <thead></thead>
+    {% include "InvenTree/settings/header.html" %}
     <tbody>
         {% include "InvenTree/settings/setting.html" with key="PURCHASEORDER_REFERENCE_PREFIX" %}
     </tbody>
diff --git a/InvenTree/templates/InvenTree/settings/setting.html b/InvenTree/templates/InvenTree/settings/setting.html
index ffbb78cbbc..b7932fc30a 100644
--- a/InvenTree/templates/InvenTree/settings/setting.html
+++ b/InvenTree/templates/InvenTree/settings/setting.html
@@ -3,6 +3,11 @@
 
 {% setting_object key as setting %}
 <tr>
+    <td>
+        {% if icon %}
+        <span class='fas {{ icon }}'></span>
+        {% endif %}
+    </td>
     <td><b>{{ setting.name }}</b></td>
     <td>
         {% if setting.is_bool %}
@@ -11,7 +16,9 @@
         </div>
         {% else %}
         {% if setting.value %}
-        <b>{{ setting.value }}</b>{{ setting.units }}</td>
+        <i><b>
+            {{ setting.value }}</b> {{ setting.units }}
+        </i>
         {% else %}
         <i>{% trans "No value set" %}</i>
         {% endif %}
diff --git a/InvenTree/templates/InvenTree/settings/so.html b/InvenTree/templates/InvenTree/settings/so.html
index 368374532f..4ef1709068 100644
--- a/InvenTree/templates/InvenTree/settings/so.html
+++ b/InvenTree/templates/InvenTree/settings/so.html
@@ -12,7 +12,7 @@
 {% block settings %}
 
 <table class='table table-striped table-condensed'>
-    <thead></thead>
+    {% include "InvenTree/settings/header.html" %}
     <tbody>
         {% include "InvenTree/settings/setting.html" with key="SALESORDER_REFERENCE_PREFIX" %}
     </tbody>
diff --git a/InvenTree/templates/InvenTree/settings/stock.html b/InvenTree/templates/InvenTree/settings/stock.html
index ae5e17bf1a..588f01e0e9 100644
--- a/InvenTree/templates/InvenTree/settings/stock.html
+++ b/InvenTree/templates/InvenTree/settings/stock.html
@@ -10,14 +10,16 @@
 {% endblock %}
 
 {% block settings %}
-
 <h4>{% trans "Stock Options" %}</h4>
 
 <table class='table table-striped table-condensed'>
-    <thead></thead>
+    {% include "InvenTree/settings/header.html" %}
     <tbody>
-        {% include "InvenTree/settings/setting.html" with key="STOCK_OWNERSHIP_CONTROL" %}
+        {% include "InvenTree/settings/setting.html" with key="STOCK_ENABLE_EXPIRY" %}
+        {% include "InvenTree/settings/setting.html" with key="STOCK_STALE_DAYS" %}
+        {% include "InvenTree/settings/setting.html" with key="STOCK_ALLOW_EXPIRED_SALE" %}
+        {% include "InvenTree/settings/setting.html" with key="STOCK_ALLOW_EXPIRED_BUILD" %}
+	{% include "InvenTree/settings/setting.html" with key="STOCK_OWNERSHIP_CONTROL" %}
     </tbody>
 </table>
-
-{% endblock %}
\ No newline at end of file
+{% endblock %}
diff --git a/InvenTree/templates/InvenTree/stale_stock.html b/InvenTree/templates/InvenTree/stale_stock.html
new file mode 100644
index 0000000000..3cbb74369c
--- /dev/null
+++ b/InvenTree/templates/InvenTree/stale_stock.html
@@ -0,0 +1,15 @@
+{% extends "collapse_index.html" %}
+
+{% load i18n %}
+
+{% block collapse_title %}
+<span class='fas fa-stopwatch icon-header'></span>
+{% trans "Stale Stock" %}<span class='badge' id='stale-stock-count'><span class='fas fa-spin fa-spinner'></span></span>
+{% endblock %}
+
+{% block collapse_content %}
+
+<table class='table table-striped table-condensed' id='stale-stock-table'>
+</table>
+
+{% endblock %}
\ No newline at end of file
diff --git a/InvenTree/templates/base.html b/InvenTree/templates/base.html
index f9a52810a1..c025919936 100644
--- a/InvenTree/templates/base.html
+++ b/InvenTree/templates/base.html
@@ -40,6 +40,7 @@
 <link rel="stylesheet" href="{% static 'css/select2-bootstrap.css' %}">
 <link rel="stylesheet" href="{% static 'css/bootstrap-toggle.css' %}">
 <link rel="stylesheet" href="{% static 'css/bootstrap-table-filter-control.css' %}">
+<link rel="stylesheet" href="{% static 'fullcalendar/main.css' %}">
 <link rel="stylesheet" href="{% static 'css/inventree.css' %}">
 <link rel="stylesheet" href="{% get_color_theme_css user.get_username %}">
 
@@ -103,6 +104,7 @@ InvenTree
 <script type='text/javascript' src="{% static 'script/bootstrap/bootstrap-table-filter-control.js' %}"></script>
 <!-- <script type='text/javascript' src="{% static 'script/bootstrap/filter-control-utils.js' %}"></script> -->
 
+<script type="text/javascript" src="{% static 'fullcalendar/main.js' %}"></script>
 <script type="text/javascript" src="{% static 'script/select2/select2.js' %}"></script>
 <script type='text/javascript' src="{% static 'script/moment.js' %}"></script>
 
@@ -121,6 +123,7 @@ InvenTree
 <script type='text/javascript' src="{% url 'stock.js' %}"></script>
 <script type='text/javascript' src="{% url 'build.js' %}"></script>
 <script type='text/javascript' src="{% url 'order.js' %}"></script>
+<script type='text/javascript' src="{% url 'calendar.js' %}"></script>
 <script type='text/javascript' src="{% url 'table_filters.js' %}"></script>
 
 <script type='text/javascript' src="{% static 'fontawesome/js/solid.js' %}"></script>
diff --git a/InvenTree/templates/collapse_index.html b/InvenTree/templates/collapse_index.html
index d87f63b244..6e918d7217 100644
--- a/InvenTree/templates/collapse_index.html
+++ b/InvenTree/templates/collapse_index.html
@@ -1,6 +1,6 @@
 {% block collapse_preamble %}
 {% endblock %}
-<div class='panel-group'>
+<div class='panel-group panel-index'>
     <div class='panel panel-default'>
         <div {% block collapse_panel_setup %}class='panel panel-heading'{% endblock %}>
             <div class='panel-title'>
diff --git a/InvenTree/templates/js/calendar.js b/InvenTree/templates/js/calendar.js
new file mode 100644
index 0000000000..861bbe1727
--- /dev/null
+++ b/InvenTree/templates/js/calendar.js
@@ -0,0 +1,25 @@
+{% load i18n %}
+
+/**
+ * Helper functions for calendar display
+ */
+
+function startDate(calendar) {
+    // Extract the first displayed date on the calendar
+    return calendar.currentData.dateProfile.activeRange.start.toISOString().split("T")[0];
+}
+
+function endDate(calendar) {
+    // Extract the last display date on the calendar
+    return calendar.currentData.dateProfile.activeRange.end.toISOString().split("T")[0];
+}
+
+function clearEvents(calendar) {
+    // Remove all events from the calendar
+
+    var events = calendar.getEvents();
+
+    events.forEach(function(event) {
+        event.remove();
+    })
+}
\ No newline at end of file
diff --git a/InvenTree/templates/js/stock.js b/InvenTree/templates/js/stock.js
index da57d4f048..e3f124d252 100644
--- a/InvenTree/templates/js/stock.js
+++ b/InvenTree/templates/js/stock.js
@@ -1,4 +1,5 @@
 {% load i18n %}
+{% load inventree_extras %}
 {% load status_codes %}
 
 /* Stock API functions
@@ -278,7 +279,7 @@ function loadStockTable(table, options) {
         
         if (row.is_building && row.build) {
             // StockItem is currently being built!
-            text = "{% trans "In production" %}";
+            text = '{% trans "In production" %}';
             url = `/build/${row.build}/`;
         } else if (row.belongs_to) {
             // StockItem is installed inside a different StockItem
@@ -286,17 +287,17 @@ function loadStockTable(table, options) {
             url = `/stock/item/${row.belongs_to}/installed/`;
         } else if (row.customer) {
             // StockItem has been assigned to a customer
-            text = "{% trans "Shipped to customer" %}";
+            text = '{% trans "Shipped to customer" %}';
             url = `/company/${row.customer}/assigned-stock/`;
         } else if (row.sales_order) {
             // StockItem has been assigned to a sales order
-            text = "{% trans "Assigned to Sales Order" %}";
+            text = '{% trans "Assigned to Sales Order" %}';
             url = `/order/sales-order/${row.sales_order}/`;
         } else if (row.location) {
             text = row.location_detail.pathstring;
             url = `/stock/location/${row.location}/`;
         } else {
-            text = "<i>{% trans "No stock location set" %}</i>";
+            text = '<i>{% trans "No stock location set" %}</i>';
             url = '';
         }
 
@@ -336,7 +337,13 @@ function loadStockTable(table, options) {
                 return html;
             }
             else if (field == 'part_detail.IPN') {
-                return row.part_detail.IPN;
+                var ipn = row.part_detail.IPN;
+
+                if (ipn) {
+                    return ipn;
+                } else {
+                    return '-';
+                }
             }
             else if (field == 'part_detail.description') {
                 return row.part_detail.description;
@@ -526,6 +533,12 @@ function loadStockTable(table, options) {
                         html += makeIconBadge('fa-user', '{% trans "Stock item assigned to customer" %}');
                     }
 
+                    if (row.expired) {
+                        html += makeIconBadge('fa-calendar-times icon-red', '{% trans "Stock item has expired" %}');
+                    } else if (row.stale) {
+                        html += makeIconBadge('fa-stopwatch', '{% trans "Stock item will expire soon" %}');
+                    }
+
                     if (row.allocated) {
                         html += makeIconBadge('fa-bookmark', '{% trans "Stock item has been allocated" %}');
                     }
@@ -577,6 +590,14 @@ function loadStockTable(table, options) {
                     return locationDetail(row);
                 }
             },
+            {% settings_value "STOCK_ENABLE_EXPIRY" as expiry %}
+            {% if expiry %}
+            {
+                field: 'expiry_date',
+                title: '{% trans "Expiry Date" %}',
+                sortable: true,
+            },
+            {% endif %}
             {
                 field: 'notes',
                 title: '{% trans "Notes" %}',
@@ -603,8 +624,8 @@ function loadStockTable(table, options) {
         if (action == 'move') {
             secondary.push({
                 field: 'destination',
-                label: 'New Location',
-                title: 'Create new location',
+                label: '{% trans "New Location" %}',
+                title: '{% trans "Create new location" %}',
                 url: "/stock/location/new/",
             });
         }
@@ -822,14 +843,25 @@ function createNewStockItem(options) {
                     }
                 );
 
-                // Disable serial number field if the part is not trackable
+                // Request part information from the server
                 inventreeGet(
                     `/api/part/${value}/`, {},
                     {
                         success: function(response) {
-
+                            
+                            // Disable serial number field if the part is not trackable
                             enableField('serial_numbers', response.trackable);
                             clearField('serial_numbers');
+
+                            // Populate the expiry date
+                            if (response.default_expiry <= 0) {
+                                // No expiry date
+                                clearField('expiry_date');
+                            } else {
+                                var expiry = moment().add(response.default_expiry, 'days');
+                                
+                                setFieldValue('expiry_date', expiry.format("YYYY-MM-DD"));
+                            }
                         }
                     }
                 );
diff --git a/InvenTree/templates/js/table_filters.js b/InvenTree/templates/js/table_filters.js
index d2ea26d8c3..84aa12c139 100644
--- a/InvenTree/templates/js/table_filters.js
+++ b/InvenTree/templates/js/table_filters.js
@@ -106,6 +106,16 @@ function getAvailableTableFilters(tableKey) {
                 title: '{% trans "Depleted" %}',
                 description: '{% trans "Show stock items which are depleted" %}',
             },
+            expired: {
+                type: 'bool',
+                title: '{% trans "Expired" %}',
+                description: '{% trans "Show stock items which have expired" %}',
+            },
+            stale: {
+                type: 'bool',
+                title: '{% trans "Stale" %}',
+                description: '{% trans "Show stock which is close to expiring" %}',
+            },
             in_stock: {
                 type: 'bool',
                 title: '{% trans "In Stock" %}',