Add support for "full" schedule and time sheet views

Temporarily removes support for viewing current user's time sheet; that
will be added back in soon.
This commit is contained in:
Lance Edgar 2016-05-10 13:08:32 -05:00
parent 181123dfaa
commit 123f5ce0c6
11 changed files with 327 additions and 165 deletions

View file

@ -48,7 +48,18 @@ class ModelValidator(fe.validators.FancyValidator):
obj = Session.query(self.model_class).get(value) obj = Session.query(self.model_class).get(value)
if obj: if obj:
return obj return obj
raise formencode.Invalid("{} not found".format(self.model_name), value, state) raise fe.Invalid("{} not found".format(self.model_name), value, state)
def _from_python(self, value, state):
obj = value
if not obj:
return ''
return obj.uuid
def validate_python(self, value, state):
obj = value
if obj is not None and not isinstance(obj, self.model_class):
raise fe.Invalid("Value must be a valid {} object".format(self.model_name), value, state)
class ValidStore(ModelValidator): class ValidStore(ModelValidator):

View file

@ -3,24 +3,50 @@
* styles for time sheets / schedules * styles for time sheets / schedules
**********************************************************************/ **********************************************************************/
/******************************
* header table
******************************/
.timesheet-header { .timesheet-header {
overflow: auto; width: 100%;
position: relative;
} }
.timesheet-header .week-picker { .timesheet-header td.filters {
bottom: 0.5em; vertical-align: bottom;
position: absolute; width: 100%;
right: 0; }
.timesheet-header td.filters .field {
width: auto;
}
.timesheet-header td.menu {
padding: 0.5em;
vertical-align: top;
white-space: nowrap;
}
.timesheet-header td.tools {
margin: 0;
padding: 0;
text-align: right;
vertical-align: bottom;
white-space: nowrap;
} }
.timesheet-header .week-picker label { .timesheet-header .week-picker label {
margin-left: 1em; margin-left: 1em;
} }
/******************************
* timesheet table
******************************/
.timesheet { .timesheet {
border-bottom: 1px solid black; border-bottom: 1px solid black;
border-right: 1px solid black; border-right: 1px solid black;
clear: both;
margin-top: 0.3em;
width: 100%; width: 100%;
} }

View file

@ -8,124 +8,148 @@
$(function() { $(function() {
$('.timesheet-header select').selectmenu(); $('.timesheet-wrapper form').submit(function() {
$('.timesheet-header').mask("Fetching data");
});
$('.timesheet-header select').selectmenu({
change: function(event, ui) {
$(ui.item.element).parents('form').submit();
}
});
$('.timesheet-header a.goto').click(function() {
$('.timesheet-header').mask("Fetching data");
});
$('.week-picker button.nav').click(function() {
$('.week-picker #date').val($(this).data('date'));
});
$('.week-picker #date').datepicker({ $('.week-picker #date').datepicker({
dateFormat: 'yy-mm-dd', dateFormat: 'mm/dd/yy',
changeYear: true, changeYear: true,
changeMonth: true, changeMonth: true,
showButtonPanel: true, showButtonPanel: true,
onSelect: function(dateText, inst) { onSelect: function(dateText, inst) {
$(this).focus().select(); $(this).parents('form').submit();
} }
}); });
$('.week-picker form').submit(function() {
location.href = '?date=' + $('.week-picker #date').val();
return false;
});
}); });
</script> </script>
</%def> </%def>
<%def name="timesheet(employees, employee_column=True)"> <%def name="context_menu()"></%def>
<%def name="timesheet(employee_column=True)">
<style type="text/css"> <style type="text/css">
.timesheet thead th { .timesheet thead th {
width: ${'{:0.2f}'.format(100.0 / float(9 if employee_column else 8))}%; width: ${'{:0.2f}'.format(100.0 / float(9 if employee_column else 8))}%;
} }
</style> </style>
<div class="timesheet-header">
## <div class="field-wrapper employee"> <div class="timesheet-wrapper">
## <label>Employee</label>
## <div class="field">
## ${employee}
## </div>
## </div>
<div class="fieldset"> ${form.begin()}
<div class="field-wrapper week"> <table class="timesheet-header">
<label>Store</label> <tbody>
<div class="field"> <tr>
${form.select('store', store_options, selected_value=store.uuid if store else None)}
</div>
</div>
<div class="field-wrapper week"> <td class="filters" rowspan="2">
<label>Department</label>
<div class="field">
${form.select('department', department_options, selected_value=department.uuid if department else None)}
</div>
</div>
<div class="field-wrapper week"> ## <div class="field-wrapper employee">
<label>Week of</label> ## <label>Employee</label>
<div class="field"> ## <div class="field">
${week_of} ## ${employee}
</div> ## </div>
</div> ## </div>
</div> ${form.field_div('store', h.select('store', store.uuid if store else None, store_options))}
<div class="week-picker"> ${form.field_div('department', h.select('department', department.uuid if department else None, department_options))}
${h.form(request.current_route_url())}
${h.link_to(u"« Previous", '?date=' + prev_sunday.strftime('%Y-%m-%d'), class_='button')}
${h.link_to(u"Next »", '?date=' + next_sunday.strftime('%Y-%m-%d'), class_='button')}
<label>Jump to week:</label>
${h.text('date', value=sunday.strftime('%Y-%m-%d'))}
${h.submit('go', "Go")}
${h.end_form()}
</div>
</div><!-- timesheet-header --> <div class="field-wrapper week">
<label>Week of</label>
<div class="field">
${week_of}
</div>
</div>
<table class="timesheet"> </td><!-- filters -->
<thead>
<tr> <td class="menu">
% if employee_column: <ul id="context-menu">
<th>Employee</th> ${self.context_menu()}
% endif </ul>
% for day in weekdays: </td><!-- menu -->
<th>${day.strftime('%A')}<br />${day.strftime('%b %d')}</th> </tr>
<tr>
<td class="tools">
<div class="grid-tools">
<div class="week-picker">
<button class="nav" data-date="${prev_sunday.strftime('%m/%d/%Y')}">&laquo; Previous</button>
<button class="nav" data-date="${next_sunday.strftime('%m/%d/%Y')}">Next &raquo;</button>
<label>Jump to week:</label>
${form.text('date', value=sunday.strftime('%m/%d/%Y'))}
</div>
</div><!-- grid-tools -->
</td><!-- tools -->
</tr>
</tbody>
</table><!-- timesheet-header -->
${form.end()}
<table class="timesheet">
<thead>
<tr>
% if employee_column:
<th>Employee</th>
% endif
% for day in weekdays:
<th>${day.strftime('%A')}<br />${day.strftime('%b %d')}</th>
% endfor
<th>Total<br />Hours</th>
</tr>
</thead>
<tbody>
% for employee in sorted(employees, key=unicode):
<tr>
% if employee_column:
<td class="employee">${employee}</td>
% endif
% for day in employee.weekdays:
<td>
% for shift in day['shifts']:
<p class="shift">${shift.get_display(request.rattail_config)}</p>
% endfor
</td>
% endfor
<td>${employee.hours_display}</td>
</tr>
% endfor % endfor
<th>Total<br />Hours</th> % if employee_column:
</tr> <tr class="total">
</thead> <td class="employee">${len(employees)} employees</td>
<tbody> % for day in weekdays:
% for employee in sorted(employees, key=unicode): <td></td>
<tr> % endfor
% if employee_column: <td></td>
<td class="employee">${employee}</td> </tr>
% endif % else:
% for day in employee.weekdays: <tr>
<td> % for day in employee.weekdays:
% for shift in day['shifts']: <td>${day['hours_display']}</td>
<p class="shift">${shift.get_display(request.rattail_config)}</p> % endfor
% endfor <td>${employee.hours_display}</td>
</td> </tr>
% endfor % endif
<td>${employee.hours_display}</td> </tbody>
</tr> </table>
% endfor </div><!-- timesheet-wrapper -->
% if employee_column:
<tr class="total">
<td class="employee">${len(employees)} employees</td>
% for day in weekdays:
<td></td>
% endfor
<td></td>
</tr>
% else:
<tr>
% for day in employee.weekdays:
<td>${day['hours_display']}</td>
% endfor
<td>${employee.hours_display}</td>
</tr>
% endif
</tbody>
</table>
</%def> </%def>

View file

@ -1,11 +1,14 @@
## -*- coding: utf-8 -*- ## -*- coding: utf-8 -*-
<%inherit file="/shifts/base.mako" /> <%inherit file="/shifts/base.mako" />
<%def name="title()">Schedule: ${sunday}</%def> <%def name="title()">Full Schedule</%def>
<ul id="context-menu"> <%def name="context_menu()">
<li>${h.link_to("Print this Schedule", '#')}</li> % if request.has_perm('timesheet.view'):
<li>${h.link_to("Edit this Schedule", '#')}</li> <li>${h.link_to("View this Time Sheet", url('schedule.goto.timesheet'), class_='goto')}</li>
</ul> % endif
## <li>${h.link_to("Print this Schedule", '#')}</li>
## <li>${h.link_to("Edit this Schedule", '#')}</li>
</%def>
${self.timesheet(employees)} ${self.timesheet()}

View file

@ -1,6 +1,12 @@
## -*- coding: utf-8 -*- ## -*- coding: utf-8 -*-
<%inherit file="/shifts/base.mako" /> <%inherit file="/shifts/base.mako" />
<%def name="title()">Time Sheet: ${sunday}</%def> <%def name="title()">Full Time Sheet</%def>
${self.timesheet(employees, employee_column=False)} <%def name="context_menu()">
% if request.has_perm('schedule.view'):
<li>${h.link_to("View this Schedule", url('timesheet.goto.schedule'), class_='goto')}</li>
% endif
</%def>
${self.timesheet()}

View file

@ -28,6 +28,8 @@ from __future__ import unicode_literals
from rattail.db import model from rattail.db import model
from pyramid import httpexceptions
from tailbone.db import Session from tailbone.db import Session
@ -56,6 +58,12 @@ class View(object):
if uuid: if uuid:
return Session.query(model.User).get(uuid) return Session.query(model.User).get(uuid)
def redirect(self, url):
"""
Convenience method to return a HTTP 302 response.
"""
return httpexceptions.HTTPFound(location=url)
def fake_error(request): def fake_error(request):
""" """

View file

@ -396,12 +396,6 @@ class MasterView(View):
""" """
return kwargs return kwargs
def redirect(self, url):
"""
Convenience method to return a HTTP 302 response.
"""
return httpexceptions.HTTPFound(location=url)
############################## ##############################
# Grid Stuff # Grid Stuff
############################## ##############################

View file

@ -44,11 +44,11 @@ class ShiftLengthField(formalchemy.Field):
super(ShiftLengthField, self).__init__(name, **kwargs) super(ShiftLengthField, self).__init__(name, **kwargs)
def shift_length(self, shift): def shift_length(self, shift):
if not shift.punch_in or not shift.punch_out: if not shift.start_time or not shift.end_time:
return return
if shift.punch_out < shift.punch_in: if shift.end_time < shift.start_time:
return "??" return "??"
return humanize.naturaldelta(shift.punch_out - shift.punch_in) return humanize.naturaldelta(shift.end_time - shift.start_time)
class ScheduledShiftsView(MasterView): class ScheduledShiftsView(MasterView):
@ -61,22 +61,26 @@ class ScheduledShiftsView(MasterView):
def configure_grid(self, g): def configure_grid(self, g):
g.default_sortkey = 'start_time' g.default_sortkey = 'start_time'
g.default_sortdir = 'desc' g.default_sortdir = 'desc'
g.append(ShiftLengthField('length'))
g.configure( g.configure(
include=[ include=[
g.employee, g.employee,
g.store, g.store,
g.start_time, g.start_time,
g.end_time, g.end_time,
g.length,
], ],
readonly=True) readonly=True)
def configure_fieldset(self, fs): def configure_fieldset(self, fs):
fs.append(ShiftLengthField('length'))
fs.configure( fs.configure(
include=[ include=[
fs.employee, fs.employee,
fs.store, fs.store,
fs.start_time, fs.start_time,
fs.end_time, fs.end_time,
fs.length,
]) ])
@ -88,15 +92,18 @@ class WorkedShiftsView(MasterView):
url_prefix = '/shifts/worked' url_prefix = '/shifts/worked'
def configure_grid(self, g): def configure_grid(self, g):
g.default_sortkey = 'punch_in' # TODO: these sorters should be automatic once we fix the schema
g.sorters['start_time'] = g.make_sorter(model.WorkedShift.punch_in)
g.sorters['end_time'] = g.make_sorter(model.WorkedShift.punch_out)
g.default_sortkey = 'start_time'
g.default_sortdir = 'desc' g.default_sortdir = 'desc'
g.append(ShiftLengthField('length')) g.append(ShiftLengthField('length'))
g.configure( g.configure(
include=[ include=[
g.employee, g.employee,
g.store, g.store,
g.punch_in, g.start_time,
g.punch_out, g.end_time,
g.length, g.length,
], ],
readonly=True) readonly=True)
@ -107,8 +114,8 @@ class WorkedShiftsView(MasterView):
include=[ include=[
fs.employee, fs.employee,
fs.store, fs.store,
fs.punch_in, fs.start_time,
fs.punch_out, fs.end_time,
fs.length, fs.length,
]) ])

View file

@ -45,38 +45,66 @@ class ShiftFilter(fe.Schema):
filter_extra_fields = True filter_extra_fields = True
store = forms.validators.ValidStore() store = forms.validators.ValidStore()
department = forms.validators.ValidDepartment() department = forms.validators.ValidDepartment()
date = fe.validators.DateConverter()
class TimeSheetView(View): class TimeSheetView(View):
""" """
Base view for time sheets. Base view for time sheets.
""" """
key = None
title = None
model_class = None model_class = None
# Set this to False to avoid the default behavior of auto-filtering by # Set this to False to avoid the default behavior of auto-filtering by
# current store. # current store.
default_filter_store = True default_filter_store = True
def __call__(self): @classmethod
date = self.get_date() def get_title(cls):
return cls.title or cls.key.capitalize()
def full(self):
date = None
store = None store = None
department = None department = None
employees = Session.query(model.Employee)\ employees = Session.query(model.Employee)\
.filter(model.Employee.status == enum.EMPLOYEE_STATUS_CURRENT) .filter(model.Employee.status == enum.EMPLOYEE_STATUS_CURRENT)
form = Form(self.request, schema=ShiftFilter) form = Form(self.request, schema=ShiftFilter)
if form.validate(): if self.request.method == 'POST':
store = form.data['store'] if form.validate():
department = form.data['department'] store = form.data['store']
self.request.session['timesheet.{}.store'.format(self.key)] = store.uuid if store else None
department = form.data['department']
self.request.session['timesheet.{}.department'.format(self.key)] = department.uuid if department else None
date = form.data['date']
self.request.session['timesheet.{}.date'.format(self.key)] = date.strftime('%m/%d/%Y') if date else None
return self.redirect(self.request.current_route_url())
elif self.request.method != 'POST' and self.default_filter_store: else:
store = self.rattail_config.get('rattail', 'store') store_key = 'timesheet.{}.store'.format(self.key)
if store: department_key = 'timesheet.{}.department'.format(self.key)
store = api.get_store(Session(), store) date_key = 'timesheet.{}.date'.format(self.key)
if store_key in self.request.session or department_key in self.request.session or date_key in self.request.session:
store_uuid = self.request.session.get(store_key)
if store_uuid:
store = Session.query(model.Store).get(store_uuid) if store_uuid else None
department_uuid = self.request.session.get(department_key)
if department_uuid:
department = Session.query(model.Department).get(department_uuid)
date_value = self.request.session.get(date_key)
if date_value:
try:
date = datetime.datetime.strptime(date_value, '%m/%d/%Y').date()
except ValueError:
pass
# TODO: else: # nothing stored in session
# store = Session.query(model.Store).filter_by(id='003').one() if self.default_filter_store:
# department = Session.query(model.Department).filter_by(number=6).one() store = self.rattail_config.get('rattail', 'store')
if store:
store = api.get_store(Session(), store)
if store: if store:
employees = employees.join(model.EmployeeStore)\ employees = employees.join(model.EmployeeStore)\
@ -86,25 +114,49 @@ class TimeSheetView(View):
employees = employees.join(model.EmployeeDepartment)\ employees = employees.join(model.EmployeeDepartment)\
.filter(model.EmployeeDepartment.department == department) .filter(model.EmployeeDepartment.department == department)
return self.render(date, employees.all(), store=store, department=department, form=form)
def get_date(self):
date = None
if 'date' in self.request.params:
try:
date = datetime.datetime.strptime(self.request.params['date'], '%Y-%m-%d').date()
except ValueError:
self.request.session.flash("The specified date is not valid: {}".format(self.request.params['date']), 'error')
if not date: if not date:
date = localtime(self.rattail_config).date() date = localtime(self.rattail_config).date()
return date
return self.render(date, employees.all(), store=store, department=department, form=form)
def crossview(self):
"""
Update session storage to so 'other' view reflects current view
filters, then redirect to other view.
"""
other_key = 'timesheet' if self.key == 'schedule' else 'schedule'
self.session_put('store', self.session_get('store'), mainkey=other_key)
self.session_put('department', self.session_get('department'), mainkey=other_key)
self.session_put('date', self.session_get('date'), mainkey=other_key)
return self.redirect(self.request.route_url(other_key))
# def session_has(self, key, mainkey=None):
# if mainkey is None:
# mainkey = self.key
# return 'timesheet.{}.{}'.format(mainkey, key) in self.request.session
# def session_has_any(self, *keys, **kwargs):
# for key in keys:
# if self.session_has(key, **kwargs):
# return True
# return False
def session_get(self, key, mainkey=None):
if mainkey is None:
mainkey = self.key
return self.request.session.get('timesheet.{}.{}'.format(mainkey, key))
def session_put(self, key, value, mainkey=None):
if mainkey is None:
mainkey = self.key
self.request.session['timesheet.{}.{}'.format(mainkey, key)] = value
def get_stores(self): def get_stores(self):
return Session.query(model.Store).order_by(model.Store.id).all() return Session.query(model.Store).order_by(model.Store.id).all()
def get_store_options(self, stores): def get_store_options(self, stores):
options = [(s.uuid, "{} - {}".format(s.id, s.name)) for s in stores] options = [(s.uuid, "{} - {}".format(s.id, s.name)) for s in stores]
options.insert(0, (None, "(all)")) options.insert(0, ('', "(all)"))
return options return options
def get_departments(self): def get_departments(self):
@ -112,7 +164,7 @@ class TimeSheetView(View):
def get_department_options(self, departments): def get_department_options(self, departments):
options = [(d.uuid, d.name) for d in departments] options = [(d.uuid, d.name) for d in departments]
options.insert(0, (None, "(all)")) options.insert(0, ('', "(all)"))
return options return options
def render(self, date, employees, store=None, department=None, form=None): def render(self, date, employees, store=None, department=None, form=None):
@ -184,8 +236,10 @@ class TimeSheetView(View):
break break
elif shift.get_date(self.rattail_config) == day: elif shift.get_date(self.rattail_config) == day:
empday['shifts'].append(shift) empday['shifts'].append(shift)
empday['hours'] += shift.length length = shift.length
employee.hours += shift.length if length is not None:
empday['hours'] += shift.length
employee.hours += shift.length
del employee_shifts[0] del employee_shifts[0]
else: else:
break break
@ -198,3 +252,38 @@ class TimeSheetView(View):
if employee.hours: if employee.hours:
minutes = (employee.hours.days * 1440) + (employee.hours.seconds / 60) minutes = (employee.hours.days * 1440) + (employee.hours.seconds / 60)
employee.hours_display = '{}:{:02d}'.format(minutes // 60, minutes % 60) employee.hours_display = '{}:{:02d}'.format(minutes // 60, minutes % 60)
@classmethod
def defaults(cls, config):
"""
Provide default configuration for a time sheet view.
"""
cls._defaults(config)
@classmethod
def _defaults(cls, config):
"""
Provide default configuration for a time sheet view.
"""
title = cls.get_title()
config.add_tailbone_permission_group(cls.key, title)
# config.add_tailbone_permission(cls.key, '{}.view'.format(cls.key), "View personal {}".format(title))
config.add_tailbone_permission(cls.key, '{}.viewall'.format(cls.key), "View full {}".format(title))
# full time sheet
config.add_route(cls.key, '/{}/'.format(cls.key))
config.add_view(cls, attr='full', route_name=cls.key,
renderer='/shifts/{}.mako'.format(cls.key),
permission='{}.viewall'.format(cls.key))
# # single employee time sheet
# config.add_route('{}.employee'.format(cls.key), '/{}/employee/'.format(cls.key))
# config.add_view(cls, attr='employee', route_name='{}.employee'.format(cls.key),
# renderer='/shifts/{}.mako'.format(cls.key),
# permission='{}.view'.format(cls.key))
# goto cross-view (view 'timesheet' as 'schedule' or vice-versa)
other_key = 'timesheet' if cls.key == 'schedule' else 'schedule'
config.add_route('{}.goto.{}'.format(cls.key, other_key), '/{}/goto-{}'.format(cls.key, other_key))
config.add_view(cls, attr='crossview', route_name='{}.goto.{}'.format(cls.key, other_key),
permission='{}.view'.format(other_key))

View file

@ -26,10 +26,8 @@ Views for employee schedules
from __future__ import unicode_literals, absolute_import from __future__ import unicode_literals, absolute_import
from rattail import enum
from rattail.db import model from rattail.db import model
from tailbone.db import Session
from tailbone.views.shifts.lib import TimeSheetView from tailbone.views.shifts.lib import TimeSheetView
@ -37,14 +35,9 @@ class ScheduleView(TimeSheetView):
""" """
Simple view for current user's schedule. Simple view for current user's schedule.
""" """
key = 'schedule'
model_class = model.ScheduledShift model_class = model.ScheduledShift
def includeme(config): def includeme(config):
ScheduleView.defaults(config)
config.add_tailbone_permission('schedule', 'schedule.view', "View Schedule")
# current user's schedule
config.add_route('schedule', '/schedule/')
config.add_view(ScheduleView, route_name='schedule',
renderer='/shifts/schedule.mako', permission='schedule.view')

View file

@ -35,20 +35,21 @@ class TimeSheetView(TimeSheetView):
""" """
Simple view for current user's time sheet. Simple view for current user's time sheet.
""" """
key = 'timesheet'
title = "Time Sheet"
model_class = model.WorkedShift model_class = model.WorkedShift
def __call__(self): # def __call__(self):
date = self.get_date() # date = self.get_date()
employee = self.request.user.employee # employee = self.request.user.employee
assert employee # assert employee
return self.render(date, [employee]) # return self.render(date, [employee])
def includeme(config): def includeme(config):
TimeSheetView.defaults(config)
config.add_tailbone_permission('timesheet', 'timesheet.view', "View Time Sheet")
# current user's time sheet # current user's time sheet
config.add_route('timesheet', '/timesheet/') # config.add_route('timesheet', '/timesheet/')
config.add_view(TimeSheetView, route_name='timesheet', # config.add_view(TimeSheetView, route_name='timesheet',
renderer='/shifts/timesheet.mako', permission='timesheet.view') # renderer='/shifts/timesheet.mako', permission='timesheet.view')