Better dashboard layout

This commit is contained in:
Raphael Michel
2017-07-27 12:27:35 +02:00
parent 3415bf5cd3
commit 23ecd43885
6 changed files with 252 additions and 43 deletions

View File

@@ -89,7 +89,7 @@
<span class="caret"></span></a> <span class="caret"></span></a>
<ul class="dropdown-menu event-dropdown" role="menu" data-event-typeahead <ul class="dropdown-menu event-dropdown" role="menu" data-event-typeahead
data-source="{% url "control:events.typeahead" %}"> data-source="{% url "control:events.typeahead" %}">
<li> <li class="query-holder">
<div class="form-box"> <div class="form-box">
<input type="text" class="form-control" <input type="text" class="form-control"
placeholder="{% trans "Search for events" %}" placeholder="{% trans "Search for events" %}"

View File

@@ -3,9 +3,18 @@
{% block title %}{% trans "Dashboard" %}{% endblock %} {% block title %}{% trans "Dashboard" %}{% endblock %}
{% block content %} {% block content %}
<h1>{% trans "Dashboard" %}</h1> <h1>{% trans "Dashboard" %}</h1>
<div class="row dashboard">
<div class="dropdown-container">
<input type="text" class="form-control" id="dashboard_query"
placeholder="{% trans "Go to event" %}"
data-typeahead-query autofocus>
<ul data-event-typeahead data-source="{% url "control:events.typeahead" %}" data-typeahead-field="#dashboard_query"
class="event-dropdown dropdown-menu">
</ul>
</div>
<div class="dashboard">
{% for w in widgets %} {% for w in widgets %}
<div class="widget-container widget-{{ w.display_size|default:"small" }}"> <div class="widget-{{ w.display_size|default:"small" }} {{ w.container_class|default:"widget-container" }}">
{% if w.url %} {% if w.url %}
<a href="{{ w.url }}" class="widget"> <a href="{{ w.url }}" class="widget">
{{ w.content|safe }} {{ w.content|safe }}

View File

@@ -1,23 +1,29 @@
from decimal import Decimal from decimal import Decimal
import pytz
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from django.db.models import Sum from django.db.models import (
Count, Exists, IntegerField, Max, Min, OuterRef, Q, Subquery, Sum,
)
from django.db.models.functions import Coalesce, Greatest
from django.dispatch import receiver from django.dispatch import receiver
from django.shortcuts import render from django.shortcuts import render
from django.template.loader import get_template from django.template.loader import get_template
from django.utils import formats from django.utils import formats
from django.utils.formats import date_format from django.utils.formats import date_format
from django.utils.html import escape from django.utils.html import escape
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _, ungettext
from pretix.base.models import ( from pretix.base.models import (
Item, Order, OrderPosition, Voucher, WaitingListEntry, Event, Item, Order, OrderPosition, RequiredAction, Voucher,
WaitingListEntry,
) )
from pretix.control.forms.event import CommentForm from pretix.control.forms.event import CommentForm
from pretix.control.signals import ( from pretix.control.signals import (
event_dashboard_widgets, user_dashboard_widgets, event_dashboard_widgets, user_dashboard_widgets,
) )
from pretix.helpers.daterange import daterange
from ..logdisplay import OVERVIEW_BLACKLIST from ..logdisplay import OVERVIEW_BLACKLIST
@@ -255,21 +261,118 @@ def user_event_widgets(**kwargs):
user = kwargs.pop('user') user = kwargs.pop('user')
widgets = [] widgets = []
events = user.get_events_with_any_permission().order_by('-date_from', 'name').select_related('organizer')[:100] tpl = """
<a href="{url}" class="event">
<div class="name">{event}</div>
<div class="daterange">{daterange}</div>
<div class="times">{times}</div>
</a>
<div class="bottomrow">
{orders}
<a href="{url}" class="status-{statusclass}">
{status}
</a>
</div>
"""
active_orders = Order.objects.filter(
event=OuterRef('pk'),
status__in=[Order.STATUS_PENDING, Order.STATUS_PAID]
).order_by().values('event').annotate(
c=Count('*')
).values(
'c'
)
required_actions = RequiredAction.objects.filter(
event=OuterRef('pk'),
done=False
)
# Get set of events where we have the permission to show the # of orders
events_with_orders = set(Event.objects.filter(
Q(organizer_id__in=user.teams.filter(all_events=True, can_view_orders=True).values_list('organizer', flat=True))
| Q(id__in=user.teams.filter(can_view_orders=True).values_list('limit_events__id', flat=True))
).values_list('id', flat=True))
events = user.get_events_with_any_permission().annotate(
order_count=Subquery(active_orders, output_field=IntegerField()),
has_ra=Exists(required_actions)
).annotate(
min_from=Min('subevents__date_from'),
max_from=Max('subevents__date_from'),
max_to=Max('subevents__date_to'),
max_fromto=Greatest(Max('subevents__date_to'), Max('subevents__date_from'))
).annotate(
order_from=Coalesce('min_from', 'date_from'),
order_to=Coalesce('max_fromto', 'max_to', 'max_from', 'date_to'),
).order_by(
'-order_from', 'name'
).prefetch_related(
'_settings_objects', 'organizer___settings_objects'
).select_related('organizer')[:100]
for event in events: for event in events:
dr = event.get_date_range_display()
if event.has_subevents:
tz = pytz.timezone(event.settings.timezone)
dr = daterange(
(event.min_from).astimezone(tz),
(event.max_fromto or event.max_to or event.max_from).astimezone(tz)
)
if event.has_ra:
status = ('danger', _('Action required'))
elif not event.live:
status = ('warning', _('Shop disabled'))
elif event.presale_has_ended:
status = ('default', _('Sale over'))
elif not event.presale_is_running:
status = ('default', _('Soon'))
else:
status = ('success', _('On sale'))
widgets.append({ widgets.append({
'content': '<div class="event">{event}<span class="from">{df}</span><span class="to">{dt}</span></div>'.format( 'content': tpl.format(
event=escape(event.name), event=escape(event.name),
df=date_format(event.date_from, 'SHORT_DATE_FORMAT') if event.date_from else '', times=_('Event series') if event.has_subevents else (
dt=date_format(event.date_to, 'SHORT_DATE_FORMAT') if event.date_to else '' ((date_format(event.date_admission, 'TIME_FORMAT') + ' / ')
if event.date_admission and event.date_admission != event.date_from else '')
+ (date_format(event.date_from, 'TIME_FORMAT') if event.date_from else '')
),
url=reverse('control:event.index', kwargs={
'event': event.slug,
'organizer': event.organizer.slug
}),
orders=(
'<a href="{orders_url}" class="orders">{orders_text}</a>'.format(
orders_url=reverse('control:event.orders', kwargs={
'event': event.slug,
'organizer': event.organizer.slug
}),
orders_text=ungettext('{num} order', '{num} orders', event.order_count or 0).format(
num=event.order_count or 0
)
) if user.is_superuser or event.pk in events_with_orders else ''
),
daterange=dr,
status=status[1],
statusclass=status[0],
), ),
'display_size': 'small', 'display_size': 'small',
'priority': 100, 'priority': 100,
'url': reverse('control:event.index', kwargs={ 'container_class': 'widget-container widget-container-event',
'event': event.slug,
'organizer': event.organizer.slug
})
}) })
"""
{% if not e.live %}
<span class="label label-danger">{% trans "Shop disabled" %}</span>
{% elif e.presale_has_ended %}
<span class="label label-warning">{% trans "Presale over" %}</span>
{% elif not e.presale_is_running %}
<span class="label label-warning">{% trans "Presale not started" %}</span>
{% else %}
<span class="label label-success">{% trans "On sale" %}</span>
{% endif %}
"""
return widgets return widgets

View File

@@ -1,8 +1,12 @@
from django.db.models import Q import pytz
from django.db.models import Max, Min, Q
from django.db.models.functions import Coalesce, Greatest
from django.http import JsonResponse from django.http import JsonResponse
from django.urls import reverse from django.urls import reverse
from django.utils.translation import ugettext as _
from pretix.control.utils.i18n import i18ncomp from pretix.control.utils.i18n import i18ncomp
from pretix.helpers.daterange import daterange
def event_list(request): def event_list(request):
@@ -10,19 +14,38 @@ def event_list(request):
qs = request.user.get_events_with_any_permission().filter( qs = request.user.get_events_with_any_permission().filter(
Q(name__icontains=i18ncomp(query)) | Q(slug__icontains=query) | Q(name__icontains=i18ncomp(query)) | Q(slug__icontains=query) |
Q(organizer__name__icontains=i18ncomp(query)) | Q(organizer__slug__icontains=query) Q(organizer__name__icontains=i18ncomp(query)) | Q(organizer__slug__icontains=query)
).order_by('-date_from') ).annotate(
min_from=Min('subevents__date_from'),
max_from=Max('subevents__date_from'),
max_to=Max('subevents__date_to'),
max_fromto=Greatest(Max('subevents__date_to'), Max('subevents__date_from'))
).annotate(
order_from=Coalesce('min_from', 'date_from'),
).order_by('-order_from')
def serialize(e):
dr = e.get_date_range_display()
if e.has_subevents:
tz = pytz.timezone(e.settings.timezone)
dr = _('Series:') + ' ' + daterange(
e.min_from.astimezone(tz),
(e.max_fromto or e.max_to or e.max_from).astimezone(tz)
)
return {
'slug': e.slug,
'organizer': str(e.organizer.name),
'name': str(e.name),
'date_range': dr,
'url': reverse('control:event.index', kwargs={
'event': e.slug,
'organizer': e.organizer.slug
})
}
doc = { doc = {
'results': [ 'results': [
{ serialize(e) for e in qs.select_related('organizer')[:10]
'slug': e.slug,
'organizer': str(e.organizer.name),
'name': str(e.name),
'date_range': e.get_date_range_display(),
'url': reverse('control:event.index', kwargs={
'event': e.slug,
'organizer': e.organizer.slug
})
} for e in qs.select_related('organizer')[:10]
] ]
} }
return JsonResponse(doc) return JsonResponse(doc)

View File

@@ -9,14 +9,14 @@ $(function () {
$("[data-event-typeahead]").each(function () { $("[data-event-typeahead]").each(function () {
var $container = $(this); var $container = $(this);
var $query = $(this).find('[data-typeahead-query]'); var $query = $(this).find('[data-typeahead-query]').length ? $(this).find('[data-typeahead-query]') : $($(this).attr("data-typeahead-field"));
$query.closest("li").nextAll().remove(); $container.find("li:not(.query-holder)").remove();
$query.on("change", function () { $query.on("change", function () {
$.getJSON( $.getJSON(
$container.attr("data-source") + "?query=" + encodeURIComponent($query.val()), $container.attr("data-source") + "?query=" + encodeURIComponent($query.val()),
function (data) { function (data) {
$query.closest("li").nextAll().remove(); $container.find("li:not(.query-holder)").remove();
$.each(data.results, function (i, res) { $.each(data.results, function (i, res) {
$container.append( $container.append(
$("<li>").append( $("<li>").append(
@@ -36,6 +36,7 @@ $(function () {
) )
); );
}); });
$container.toggleClass('focused', $query.is(":focus") && $container.children().length > 0);
} }
); );
}); });
@@ -50,9 +51,12 @@ $(function () {
event.stopPropagation(); event.stopPropagation();
} }
}); });
$query.on("blur", function (event) {
$container.removeClass('focused');
});
$query.on("keyup", function (event) { $query.on("keyup", function (event) {
var $first = $query.closest("li").next(); var $first = $container.find("li:not(.query-holder)").first();
var $last = $query.closest("li").nextAll().last(); var $last = $container.find("li:not(.query-holder)").last();
var $selected = $container.find(".active"); var $selected = $container.find(".active");
if (event.which === 13) { // enter if (event.which === 13) { // enter
@@ -60,10 +64,12 @@ $(function () {
event.stopPropagation(); event.stopPropagation();
return true; return true;
} else if (event.which === 40) { // down } else if (event.which === 40) { // down
var $next;
if ($selected.length === 0) { if ($selected.length === 0) {
$selected = $query.closest("li"); $next = $first;
} else {
$next = $selected.next();
} }
var $next = $selected.next();
if ($next.length === 0) { if ($next.length === 0) {
$next = $first; $next = $first;
} }
@@ -74,7 +80,7 @@ $(function () {
return true; return true;
} else if (event.which === 38) { // up } else if (event.which === 38) { // up
if ($selected.length === 0) { if ($selected.length === 0) {
$selected = $container.first(); $selected = $first;
} }
var $prev = $selected.prev(); var $prev = $selected.prev();
if ($prev.length === 0 || $prev.find("input").length > 0) { if ($prev.length === 0 || $prev.find("input").length > 0) {

View File

@@ -2,33 +2,43 @@
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
align-items: flex-start; align-items: flex-start;
margin-left: -5px;
margin-right: -5px;
} }
.dashboard .widget-container { .dashboard .widget-container {
flex:1 0 auto; flex: 1 0 auto;
align-self: stretch; align-self: stretch;
padding: 15px 5px; padding: 15px 5px;
border: 5px solid white; border: 5px solid white;
min-height: 160px; min-height: 160px;
background: #F8F8F8; background: #F8F8F8;
} }
.dashboard .widget-container.widget-full { .dashboard .widget-container.widget-full {
width: 100%; width: 100%;
} }
.dashboard .widget-container.widget-big { .dashboard .widget-container.widget-big {
width: 50%; width: 50%;
} }
.dashboard .widget-container.widget-small { .dashboard .widget-container.widget-small {
width: 25%; width: 25%;
} }
.dashboard-panels .panel-heading .fa { .dashboard-panels .panel-heading .fa {
opacity: 0.5; opacity: 0.5;
} }
.dashboard .widget-container:hover,.dashboard .widget-container:focus {
.dashboard .widget-container:hover, .dashboard .widget-container:focus {
background: #EEEEEE; background: #EEEEEE;
} }
.dashboard .widget:hover,.dashboard .widget:focus {
.dashboard .widget:hover, .dashboard .widget:focus, .dashboard a:hover {
text-decoration: none; text-decoration: none;
} }
.dashboard .numwidget { .dashboard .numwidget {
.num { .num {
display: block; display: block;
@@ -42,6 +52,7 @@
font-size: 20px; font-size: 20px;
} }
} }
.dashboard .shopstate { .dashboard .shopstate {
text-align: center; text-align: center;
padding: 18px 0; padding: 18px 0;
@@ -58,16 +69,61 @@
color: $brand-danger; color: $brand-danger;
} }
} }
.dashboard .event {
text-align: center;
padding: 15px 30px;
font-size: 20px;
span.from, span.to { .widget-container.widget-container-event {
padding: 0;
cursor: pointer;
.widget {
height: 100%;
display: flex;
flex-direction: column;
justify-content: space-between;
}
.event {
text-align: center;
font-size: 20px;
padding: 15px 30px;
flex-grow: 1;
display: flex;
align-items: center;
justify-content: space-around;
flex-direction: column;
}
.bottomrow {
display: flex;
flex-direction: row;
a {
flex-grow: 1;
flex-basis: 50%;
font-size: 14px;
padding: 10px;
text-align: center;
color: white;
}
a.orders {
background: $navbar-inverse-bg;
}
a.status-success {
background: $brand-success;
}
a.status-warning {
background: $brand-warning;
}
a.status-default {
background: $gray-light;
}
a.status-danger {
background: $brand-danger;
}
}
.daterange, .times {
display: block; display: block;
font-size: 25px; font-size: 17px;
} }
} }
.dashboard .newevent { .dashboard .newevent {
text-align: center; text-align: center;
padding: 30px; padding: 30px;
@@ -79,6 +135,7 @@
padding-bottom: 15px; padding-bottom: 15px;
} }
} }
.dashboard .welcome-wizard { .dashboard .welcome-wizard {
padding: 5px 15px; padding: 5px 15px;
h3 { h3 {
@@ -92,11 +149,22 @@
margin-bottom: 0; margin-bottom: 0;
} }
} }
.dropdown-container {
position: relative;
margin: 15px 0;
.focused.dropdown-menu {
display: block;
}
}
@media (max-width: $screen-sm-max) { @media (max-width: $screen-sm-max) {
.dashboard .widget-container.widget-small { .dashboard .widget-container.widget-small {
width: 50%; width: 50%;
} }
} }
@media (max-width: $screen-xs-max) { @media (max-width: $screen-xs-max) {
.dashboard .widget-container.widget-small, .dashboard .widget-container.widget-small,
.dashboard .widget-container.widget-big { .dashboard .widget-container.widget-big {