Add basic ability to edit employee schedule

This commit is contained in:
Lance Edgar 2016-10-12 14:16:06 -05:00
parent 788f3ad386
commit 048951153d
6 changed files with 419 additions and 57 deletions

View file

@ -138,16 +138,14 @@
</thead> </thead>
<tbody> <tbody>
% for emp in sorted(employees, key=unicode): % for emp in sorted(employees, key=unicode):
<tr> <tr data-employee-uuid="${emp.uuid}">
<td class="employee">${emp}</td> <td class="employee">${emp}</td>
% for day in emp.weekdays: % for day in emp.weekdays:
<td> <td class="day">
% for shift in day['shifts']: ${self.render_day(day)}
<p class="shift">${render_shift(shift)}</p>
% endfor
</td> </td>
% endfor % endfor
<td>${emp.hours_display}</td> <td class="total">${emp.hours_display}</td>
</tr> </tr>
% endfor % endfor
% if employee is UNDEFINED: % if employee is UNDEFINED:
@ -171,3 +169,9 @@
</table> </table>
</div><!-- timesheet-wrapper --> </div><!-- timesheet-wrapper -->
</%def> </%def>
<%def name="render_day(day)">
% for shift in day['shifts']:
<p class="shift">${render_shift(shift)}</p>
% endfor
</%def>

View file

@ -2,11 +2,12 @@
<%inherit file="/shifts/base.mako" /> <%inherit file="/shifts/base.mako" />
<%def name="context_menu()"> <%def name="context_menu()">
% if request.has_perm('timesheet.view'): % if request.has_perm('schedule.edit'):
<li>${h.link_to("View this Time Sheet", url('schedule.goto.timesheet'), class_='goto')}</li> <li>${h.link_to("Edit Schedule", url('schedule.edit'))}</li>
% endif % endif
## <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>
% endif
</%def> </%def>
${self.timesheet()} ${self.timesheet()}

View file

@ -0,0 +1,254 @@
## -*- coding: utf-8 -*-
<%inherit file="/shifts/base.mako" />
<%def name="head_tags()">
${parent.head_tags()}
<script type="text/javascript">
var weekdays = [
% for i, day in enumerate(weekdays, 1):
'${day.strftime('%a %d %b %Y')}'${',' if i < len(weekdays) else ''}
% endfor
];
var editing_day = null;
var new_shift_id = 1;
function add_shift(focus, uuid, start_time, end_time) {
var shift = $('#snippets .shift').clone();
if (! uuid) {
uuid = 'new-' + (new_shift_id++).toString();
}
shift.attr('data-uuid', uuid);
shift.children('input').each(function() {
var name = $(this).attr('name') + '-' + uuid;
$(this).attr('name', name);
$(this).attr('id', name);
});
shift.children('input[name|="edit_start_time"]').val(start_time || '');
shift.children('input[name|="edit_end_time"]').val(end_time || '');
$('#day-editor .shifts').append(shift);
shift.children('input').timepicker({showPeriod: true});
if (focus) {
shift.children('input:first').focus();
}
}
function calc_minutes(start_time, end_time) {
var start = parseTime(start_time);
start = new Date(2000, 0, 1, start.hh, start.mm);
var end = parseTime(end_time);
end = new Date(2000, 0, 1, end.hh, end.mm);
return Math.floor((end - start) / 1000 / 60);
}
function format_minutes(minutes) {
var hours = Math.floor(minutes / 60);
if (hours) {
minutes -= hours * 60;
}
return hours.toString() + ':' + (minutes < 10 ? '0' : '') + minutes.toString();
}
// stolen from http://stackoverflow.com/a/1788084
function parseTime(s) {
var part = s.match(/(\d+):(\d+)(?: )?(am|pm)?/i);
var hh = parseInt(part[1], 10);
var mm = parseInt(part[2], 10);
var ap = part[3] ? part[3].toUpperCase() : null;
if (ap == 'AM') {
if (hh == 12) {
hh = 0;
}
} else if (ap == 'PM') {
if (hh != 12) {
hh += 12;
}
}
return { hh: hh, mm: mm };
}
function time_input(shift, type) {
var input = shift.children('input[name|="' + type + '_time"]');
if (! input.length) {
input = $('<input type="hidden" name="' + type + '_time-' + shift.data('uuid') + '" />');
shift.append(input);
}
return input;
}
function update_row_hours(row) {
var minutes = 0;
row.find('.day .shift:not(.deleted)').each(function() {
var time_range = $.trim($(this).children('span').text()).split(' - ');
minutes += calc_minutes(time_range[0], time_range[1]);
});
row.children('.total').text(minutes ? format_minutes(minutes) : '0');
}
$(function() {
$('.timesheet').on('click', '.day', function() {
editing_day = $(this);
var editor = $('#day-editor');
var employee = editing_day.siblings('.employee').text();
var date = weekdays[editing_day.get(0).cellIndex - 1];
var shifts = editor.children('.shifts');
shifts.empty();
editing_day.children('.shift:not(.deleted)').each(function() {
var uuid = $(this).data('uuid');
var time_range = $.trim($(this).children('span').text()).split(' - ');
add_shift(false, uuid, time_range[0], time_range[1]);
});
if (! shifts.children('.shift').length) {
add_shift();
}
editor.dialog({
modal: true,
title: employee + ' - ' + date,
position: {my: 'center', at: 'center', of: editing_day},
width: 'auto',
autoResize: true,
buttons: [
{
text: "Update",
click: function() {
// TODO: need to validate times here...
// create / update shifts in schedule table, as needed
editor.find('.shifts .shift').each(function() {
var uuid = $(this).data('uuid');
var start_time = $(this).children('input[name|="edit_start_time"]').val();
var end_time = $(this).children('input[name|="edit_end_time"]').val();
var shift = editing_day.children('.shift[data-uuid="' + uuid + '"]');
if (! shift.length) {
shift = $('<p class="shift" data-uuid="' + uuid + '"><span></span></p>');
shift.append($('<input type="hidden" name="employee_uuid-' + uuid + '" value="'
+ editing_day.parents('tr:first').data('employee-uuid') + '" />'));
## TODO: how to handle editing schedule w/ no store selected..?
% if store:
shift.append($('<input type="hidden" name="store_uuid-' + uuid + '" value="${store.uuid}" />'));
% endif
editing_day.append(shift);
}
shift.children('span').text(start_time + ' - ' + end_time);
time_input(shift, 'start').val(date + ' ' + start_time);
time_input(shift, 'end').val(date + ' ' + end_time);
});
// remove shifts from schedule table, as needed
editing_day.children('.shift').each(function() {
var uuid = $(this).data('uuid');
if (! editor.find('.shifts .shift[data-uuid="' + uuid + '"]').length) {
if (uuid.match(/^new-/)) {
$(this).remove();
} else {
$(this).addClass('deleted');
$(this).append($('<input type="hidden" name="delete-' + uuid + '" value="delete" />'));
}
}
});
// mark day as modified, close dialog
editing_day.addClass('modified');
$('#save-changes').button('enable');
$('#undo-changes').button('enable');
update_row_hours(editing_day.parents('tr:first'));
editor.dialog('close');
}
},
{
text: "Cancel",
click: function() {
editor.dialog('close');
}
}
]
});
});
$('#day-editor #add-shift').click(function() {
add_shift(true);
});
$('#day-editor').on('click', '.shifts button', function() {
$(this).parents('.shift:first').remove();
});
$('#save-changes').click(function() {
$(this).button('disable').button('option', 'label', "Saving Changes...");
$('#schedule-form').submit();
});
$('#undo-changes').click(function() {
$(this).button('disable').button('option', 'label', "Refreshing...");
location.href = '${url('schedule.edit')}';
});
});
</script>
<style type="text/css">
.timesheet .day {
cursor: pointer;
height: 5em;
}
.timesheet tr .day.modified {
background-color: #fcc;
}
.timesheet tr:nth-child(odd) .day.modified {
background-color: #ebb;
}
.timesheet .day .shift.deleted {
display: none;
}
#day-editor .shift {
margin-bottom: 1em;
white-space: nowrap;
}
#day-editor .shift input {
width: 6em;
}
#day-editor .shift button {
margin-left: 0.5em;
}
#snippets {
display: none;
}
</style>
</%def>
<%def name="context_menu()">
% if request.has_perm('schedule.viewall'):
<li>${h.link_to("View Schedule", url('schedule'))}</li>
% endif
</%def>
<%def name="render_day(day)">
% for shift in day['shifts']:
<p class="shift" data-uuid="${shift.uuid}">
${render_shift(shift)}
</p>
% endfor
</%def>
${h.form(url('schedule.edit'), id="schedule-form")}
${self.timesheet()}
${h.end_form()}
<div class="buttons">
<button type="button" id="save-changes" disabled="disabled">Save Changes</button>
<button type="button" id="undo-changes" disabled="disabled">Undo Changes</button>
</div>
<div id="day-editor" style="display: none;">
<div class="shifts"></div>
<button type="button" id="add-shift">Add Shift</button>
</div>
<div id="snippets">
<div class="shift" data-uuid="">
${h.text('edit_start_time')} thru ${h.text('edit_end_time')}
<button type="button"><span class="ui-icon ui-icon-trash"></span></button>
</div>
</div>

View file

@ -59,6 +59,11 @@ class ScheduledShiftsView(MasterView):
url_prefix = '/shifts/scheduled' url_prefix = '/shifts/scheduled'
def configure_grid(self, g): def configure_grid(self, g):
g.joiners['employee'] = lambda q: q.join(model.Employee).join(model.Person)
g.filters['employee'] = g.make_filter('employee', model.Person.display_name,
default_active=True, default_verb='contains',
label="Employee Name")
g.default_sortkey = 'start_time' g.default_sortkey = 'start_time'
g.default_sortdir = 'desc' g.default_sortdir = 'desc'
g.append(ShiftLengthField('length')) g.append(ShiftLengthField('length'))

View file

@ -34,6 +34,7 @@ from rattail.time import localtime, make_utc, get_sunday
import formencode as fe import formencode as fe
from pyramid_simpleform import Form from pyramid_simpleform import Form
from webhelpers.html import HTML
from tailbone import forms from tailbone import forms
from tailbone.db import Session from tailbone.db import Session
@ -71,14 +72,60 @@ class TimeSheetView(View):
def get_title(cls): def get_title(cls):
return cls.title or cls.key.capitalize() return cls.title or cls.key.capitalize()
def full(self): def get_timesheet_context(self):
"""
Determine date/store/dept context from user's session and/or defaults.
"""
date = None date = None
date_key = 'timesheet.{}.date'.format(self.key)
if date_key in self.request.session:
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
if not date:
date = localtime(self.rattail_config).date()
store = None store = None
department = None department = None
store_key = 'timesheet.{}.store'.format(self.key)
department_key = 'timesheet.{}.department'.format(self.key)
if store_key in self.request.session or department_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)
else: # no store/department in session
if self.default_filter_store:
store = self.rattail_config.get('rattail', 'store')
if store:
store = api.get_store(Session(), store)
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)
if store:
employees = employees.join(model.EmployeeStore)\
.filter(model.EmployeeStore.store == store)
if department:
employees = employees.join(model.EmployeeDepartment)\
.filter(model.EmployeeDepartment.department == department)
form = Form(self.request, schema=ShiftFilter) return {
'date': date,
'store': store,
'department': department,
'employees': employees.all(),
}
def process_filter_form(self, form):
"""
Process a "shift filter" form if one was in fact POST'ed. If it was
then we store new context in session and redirect to display as normal.
"""
if self.request.method == 'POST': if self.request.method == 'POST':
if form.validate(): if form.validate():
store = form.data['store'] store = form.data['store']
@ -87,45 +134,18 @@ class TimeSheetView(View):
self.request.session['timesheet.{}.department'.format(self.key)] = department.uuid if department else None self.request.session['timesheet.{}.department'.format(self.key)] = department.uuid if department else None
date = form.data['date'] date = form.data['date']
self.request.session['timesheet.{}.date'.format(self.key)] = date.strftime('%m/%d/%Y') if date else None 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()) raise self.redirect(self.request.current_route_url())
else: def full(self):
store_key = 'timesheet.{}.store'.format(self.key) """
department_key = 'timesheet.{}.department'.format(self.key) View a "full" timesheet/schedule, i.e. all employees but filterable by
if store_key in self.request.session or department_key in self.request.session: store and/or department.
store_uuid = self.request.session.get(store_key) """
if store_uuid: form = Form(self.request, schema=ShiftFilter)
store = Session.query(model.Store).get(store_uuid) if store_uuid else None self.process_filter_form(form)
department_uuid = self.request.session.get(department_key) context = self.get_timesheet_context()
if department_uuid: context['form'] = form
department = Session.query(model.Department).get(department_uuid) return self.render_full(**context)
else: # no store/department in session
if self.default_filter_store:
store = self.rattail_config.get('rattail', 'store')
if store:
store = api.get_store(Session(), store)
date_key = 'timesheet.{}.date'.format(self.key)
if date_key in self.request.session:
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
if store:
employees = employees.join(model.EmployeeStore)\
.filter(model.EmployeeStore.store == store)
if department:
employees = employees.join(model.EmployeeDepartment)\
.filter(model.EmployeeDepartment.department == department)
if not date:
date = localtime(self.rattail_config).date()
return self.render_full(date, employees.all(), store=store, department=department, form=form)
def employee(self): def employee(self):
""" """
@ -233,7 +253,7 @@ class TimeSheetView(View):
options.insert(0, ('', "(all)")) options.insert(0, ('', "(all)"))
return options return options
def render_full(self, date, employees, store=None, department=None, form=None): def render_full(self, date=None, employees=None, store=None, department=None, form=None, **kwargs):
""" """
Render a time sheet for one or more employees, for the week which Render a time sheet for one or more employees, for the week which
includes the specified date. includes the specified date.
@ -257,7 +277,7 @@ class TimeSheetView(View):
departments = self.get_departments() departments = self.get_departments()
department_options = self.get_department_options(departments) department_options = self.get_department_options(departments)
return { context = {
'page_title': "Full {}".format(self.get_title()), 'page_title': "Full {}".format(self.get_title()),
'form': forms.FormRenderer(form) if form else None, 'form': forms.FormRenderer(form) if form else None,
'employees': employees, 'employees': employees,
@ -275,9 +295,11 @@ class TimeSheetView(View):
'permission_prefix': self.key, 'permission_prefix': self.key,
'render_shift': self.render_shift, 'render_shift': self.render_shift,
} }
context.update(kwargs)
return context
def render_shift(self, shift): def render_shift(self, shift):
return shift.get_display(self.rattail_config) return HTML.tag('span', c=shift.get_display(self.rattail_config))
def render_single(self, date, employee, form=None): def render_single(self, date, employee, form=None):
""" """
@ -371,7 +393,7 @@ class TimeSheetView(View):
""" """
title = cls.get_title() title = cls.get_title()
config.add_tailbone_permission_group(cls.key, title) config.add_tailbone_permission_group(cls.key, title)
config.add_tailbone_permission(cls.key, '{}.view'.format(cls.key), "View employee {}".format(title)) config.add_tailbone_permission(cls.key, '{}.view'.format(cls.key), "View single employee {}".format(title))
config.add_tailbone_permission(cls.key, '{}.viewall'.format(cls.key), "View full {}".format(title)) config.add_tailbone_permission(cls.key, '{}.viewall'.format(cls.key), "View full {}".format(title))
# full time sheet # full time sheet

View file

@ -26,9 +26,15 @@ Views for employee schedules
from __future__ import unicode_literals, absolute_import from __future__ import unicode_literals, absolute_import
from rattail.db import model import datetime
from tailbone.views.shifts.lib import TimeSheetView from rattail.db import model
from rattail.time import localtime, make_utc
from pyramid_simpleform import Form
from tailbone.db import Session
from tailbone.views.shifts.lib import TimeSheetView, ShiftFilter
class ScheduleView(TimeSheetView): class ScheduleView(TimeSheetView):
@ -38,6 +44,76 @@ class ScheduleView(TimeSheetView):
key = 'schedule' key = 'schedule'
model_class = model.ScheduledShift model_class = model.ScheduledShift
def edit(self):
"""
View for editing (full) schedule.
"""
if self.request.method == 'POST':
# organize form data by uuid / field
fields = ['employee_uuid', 'store_uuid', 'start_time', 'end_time', 'delete']
data = dict([(f, {}) for f in fields])
for key in self.request.POST:
for field in fields:
if key.startswith('{}-'.format(field)):
uuid = key[len('{}-'.format(field)):]
if uuid:
data[field][uuid] = self.request.POST[key]
# apply delete operations
deleted = []
for uuid, value in data['delete'].iteritems():
assert value == 'delete'
shift = Session.query(model.ScheduledShift).get(uuid)
assert shift
Session.delete(shift)
deleted.append(uuid)
# apply create / update operations
created = {}
updated = {}
time_format = '%a %d %b %Y %I:%M %p'
for uuid, employee_uuid in data['start_time'].iteritems():
if uuid in deleted:
continue
if uuid.startswith('new-'):
shift = model.ScheduledShift()
shift.employee_uuid = data['employee_uuid'][uuid]
shift.store_uuid = data['store_uuid'][uuid]
Session.add(shift)
created[uuid] = shift
else:
shift = Session.query(model.ScheduledShift).get(uuid)
assert shift
updated[uuid] = shift
start_time = datetime.datetime.strptime(data['start_time'][uuid], time_format)
shift.start_time = make_utc(localtime(self.rattail_config, start_time))
end_time = datetime.datetime.strptime(data['end_time'][uuid], time_format)
shift.end_time = make_utc(localtime(self.rattail_config, end_time))
self.request.session.flash("Changes were applied: created {}, updated {}, "
"deleted {} Scheduled Shifts".format(
len(created), len(updated), len(deleted)))
return self.redirect(self.request.route_url('schedule.edit'))
form = Form(self.request, schema=ShiftFilter)
self.process_filter_form(form)
context = self.get_timesheet_context()
context['form'] = form
context['page_title'] = "Edit Schedule"
return self.render_full(**context)
@classmethod
def defaults(cls, config):
cls._defaults(config)
# edit schedule
config.add_route('schedule.edit', '/schedule/edit')
config.add_view(cls, attr='edit', route_name='schedule.edit',
renderer='/shifts/schedule_edit.mako',
permission='schedule.edit')
config.add_tailbone_permission('schedule', 'schedule.edit', "Edit full schedule")
def includeme(config): def includeme(config):
ScheduleView.defaults(config) ScheduleView.defaults(config)