From 7104e275c3afc03f937ac17a2ccb6319eca67e51 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sun, 29 Jan 2017 18:53:52 -0600 Subject: [PATCH] Add ability to edit employee time sheet Also disable some unwanted autocomplete logic, plus add ability to prevent autocomplete "change click" event --- tailbone/static/js/tailbone.edit-shifts.js | 186 +++++++++++++++ tailbone/static/js/tailbone.js | 18 -- tailbone/templates/autocomplete.mako | 7 +- tailbone/templates/shifts/base.mako | 54 ++++- tailbone/templates/shifts/lib.mako | 10 +- tailbone/templates/shifts/schedule_edit.mako | 223 +----------------- tailbone/templates/shifts/timesheet.mako | 3 + tailbone/templates/shifts/timesheet_edit.mako | 58 +++++ tailbone/views/shifts/lib.py | 110 +++++---- tailbone/views/shifts/schedule.py | 8 +- tailbone/views/shifts/timesheet.py | 92 +++++++- 11 files changed, 467 insertions(+), 302 deletions(-) create mode 100644 tailbone/static/js/tailbone.edit-shifts.js create mode 100644 tailbone/templates/shifts/timesheet_edit.mako diff --git a/tailbone/static/js/tailbone.edit-shifts.js b/tailbone/static/js/tailbone.edit-shifts.js new file mode 100644 index 00000000..1593acd7 --- /dev/null +++ b/tailbone/static/js/tailbone.edit-shifts.js @@ -0,0 +1,186 @@ + +/************************************************************ + * + * tailbone.edit-shifts.js + * + * Common logic for editing time sheet / schedule data. + * + ************************************************************/ + + +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 = $(''); + 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 time 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 = $('

'); + shift.append($('')); + 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 time 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($('')); + } + } + }); + + // 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'); + data_modified = true; + okay_to_leave = false; + } + }, + { + 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..."); + okay_to_leave = true; + $('#timetable-form').submit(); + }); + + $('.undo-changes').click(function() { + $(this).button('disable').button('option', 'label', "Refreshing..."); + okay_to_leave = true; + location.href = location.href; + }); + +}); diff --git a/tailbone/static/js/tailbone.js b/tailbone/static/js/tailbone.js index 7e3b952c..8169b7db 100644 --- a/tailbone/static/js/tailbone.js +++ b/tailbone/static/js/tailbone.js @@ -259,24 +259,6 @@ $(function() { }); - /* - * Whenever the "change" button is clicked within the context of an - * autocomplete field, hide the static display and show the autocomplete - * textbox. - */ - $('div.autocomplete-container button.autocomplete-change').click(function() { - var container = $(this).parents('div.autocomplete-container'); - var textbox = container.find('input.autocomplete-textbox'); - - container.find('input[type="hidden"]').val(''); - container.find('div.autocomplete-display').hide(); - - textbox.val(''); - textbox.show(); - textbox.select(); - textbox.focus(); - }); - /* * Add "check all" functionality to tables with checkboxes. */ diff --git a/tailbone/templates/autocomplete.mako b/tailbone/templates/autocomplete.mako index 341ad8bb..249f8f2e 100644 --- a/tailbone/templates/autocomplete.mako +++ b/tailbone/templates/autocomplete.mako @@ -1,6 +1,6 @@ ## -*- coding: utf-8 -*- ## TODO: This function signature is getting out of hand... -<%def name="autocomplete(field_name, service_url, field_value=None, field_display=None, width='300px', select=None, selected=None, cleared=None, options={})"> +<%def name="autocomplete(field_name, service_url, field_value=None, field_display=None, width='300px', select=None, selected=None, cleared=None, change_clicked=None, options={})">
${h.hidden(field_name, id=field_name, value=field_value)} ${h.text(field_name+'-textbox', id=field_name+'-textbox', value=field_display, @@ -37,6 +37,11 @@ % endif }); $('#${field_name}-change').click(function() { + % if change_clicked: + if (! ${change_clicked}()) { + return false; + } + % endif $('#${field_name}').val(''); $('#${field_name}-display').hide(); with ($('#${field_name}-textbox')) { diff --git a/tailbone/templates/shifts/base.mako b/tailbone/templates/shifts/base.mako index 68ed8049..f84154f4 100644 --- a/tailbone/templates/shifts/base.mako +++ b/tailbone/templates/shifts/base.mako @@ -3,9 +3,8 @@ <%def name="title()">${page_title} -<%def name="head_tags()"> - ${parent.head_tags()} - ${h.stylesheet_link(request.static_url('tailbone:static/css/timesheet.css'))} +<%def name="extra_javascript()"> + ${parent.extra_javascript()} +<%def name="extra_styles()"> + ${parent.extra_styles()} + ${h.stylesheet_link(request.static_url('tailbone:static/css/timesheet.css'))} + + +<%def name="edit_timetable_javascript()"> + ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.edit-shifts.js'))} + + + +<%def name="edit_timetable_styles()"> + + + <%def name="context_menu()"> <%def name="render_day(day)"> diff --git a/tailbone/templates/shifts/lib.mako b/tailbone/templates/shifts/lib.mako index 75ef857d..a105ac02 100644 --- a/tailbone/templates/shifts/lib.mako +++ b/tailbone/templates/shifts/lib.mako @@ -1,7 +1,7 @@ ## -*- coding: utf-8 -*- <%namespace file="/autocomplete.mako" import="autocomplete" /> -<%def name="timesheet_wrapper(edit_form=None, edit_tools=None, context_menu=None, render_day=None)"> +<%def name="timesheet_wrapper(edit_form=None, edit_tools=None, context_menu=None, render_day=None, change_employee=None)">
${form.begin(id='filter-form')} @@ -21,7 +21,8 @@ ${autocomplete('employee', url('employees.autocomplete'), field_value=employee.uuid if employee else None, field_display=unicode(employee or ''), - selected='employee_selected')} + selected='employee_selected', + change_clicked=change_employee)} % else: ${form.hidden('employee', value=employee.uuid)} ${employee} @@ -111,7 +112,10 @@ % for emp in sorted(employees, key=unicode): - ${emp} + + ## TODO: add link to single employee schedule / timesheet here... + ${emp} + % for day in emp.weekdays: % if render_day: diff --git a/tailbone/templates/shifts/schedule_edit.mako b/tailbone/templates/shifts/schedule_edit.mako index a82b961f..ec771986 100644 --- a/tailbone/templates/shifts/schedule_edit.mako +++ b/tailbone/templates/shifts/schedule_edit.mako @@ -2,195 +2,13 @@ <%inherit file="/shifts/base.mako" /> <%namespace file="/shifts/lib.mako" import="timesheet_wrapper" /> -<%def name="head_tags()"> - ${parent.head_tags()} +<%def name="extra_javascript()"> + ${parent.extra_javascript()} + ${self.edit_timetable_javascript()} - + + +<%def name="extra_styles()"> + ${parent.extra_styles()} + ${self.edit_timetable_styles()} <%def name="context_menu()"> @@ -282,7 +77,7 @@ <%def name="edit_form()"> - ${h.form(url('schedule.edit'), id='schedule-form')} + ${h.form(url('schedule.edit'), id='timetable-form')} ${h.csrf_token(request)} diff --git a/tailbone/templates/shifts/timesheet.mako b/tailbone/templates/shifts/timesheet.mako index fe543c50..971feb3a 100644 --- a/tailbone/templates/shifts/timesheet.mako +++ b/tailbone/templates/shifts/timesheet.mako @@ -3,6 +3,9 @@ <%namespace file="/shifts/lib.mako" import="timesheet_wrapper" /> <%def name="context_menu()"> + % if employee is not Undefined and request.has_perm('timesheet.edit'): +
  • ${h.link_to("Edit this Time Sheet", url('timesheet.employee.edit'))}
  • + % endif % if request.has_perm('schedule.view'):
  • ${h.link_to("View this Schedule", url('timesheet.goto.schedule'), class_='goto')}
  • % endif diff --git a/tailbone/templates/shifts/timesheet_edit.mako b/tailbone/templates/shifts/timesheet_edit.mako new file mode 100644 index 00000000..baf35634 --- /dev/null +++ b/tailbone/templates/shifts/timesheet_edit.mako @@ -0,0 +1,58 @@ +## -*- coding: utf-8 -*- +<%inherit file="/shifts/base.mako" /> +<%namespace file="/shifts/lib.mako" import="timesheet_wrapper" /> + +<%def name="extra_javascript()"> + ${parent.extra_javascript()} + ${self.edit_timetable_javascript()} + + +<%def name="extra_styles()"> + ${parent.extra_styles()} + ${self.edit_timetable_styles()} + + +<%def name="context_menu()"> + % if request.has_perm('timesheet.view'): +
  • ${h.link_to("View this Time Sheet", url('timesheet.employee'))}
  • + % endif + % if request.has_perm('schedule.view'): +
  • ${h.link_to("View this Schedule", url('schedule.employee'))}
  • + % endif + + +<%def name="render_day(day)"> + % for shift in day['shifts']: +

    + ${render_shift(shift)} +

    + % endfor + + +<%def name="edit_form()"> + ${h.form(url('timesheet.employee.edit'), id='timetable-form')} + ${h.csrf_token(request)} + + +<%def name="edit_tools()"> +
    + + +
    + + +${timesheet_wrapper(edit_form=edit_form, edit_tools=edit_tools, context_menu=context_menu, render_day=render_day, change_employee='confirm_leave')} + +${edit_tools()} + + + +
    +
    + ${h.text('edit_start_time')} thru ${h.text('edit_end_time')} + +
    +
    diff --git a/tailbone/views/shifts/lib.py b/tailbone/views/shifts/lib.py index c6e07362..b1b36508 100644 --- a/tailbone/views/shifts/lib.py +++ b/tailbone/views/shifts/lib.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2016 Lance Edgar +# Copyright © 2010-2017 Lance Edgar # # This file is part of Rattail. # @@ -121,6 +121,37 @@ class TimeSheetView(View): 'employees': employees.all(), } + def get_employee_context(self): + """ + Determine employee/date context from user's session and/or defaults + """ + date = None + date_key = 'timesheet.{}.employee.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() + + employee = None + employee_key = 'timesheet.{}.employee'.format(self.key) + if employee_key in self.request.session: + employee_uuid = self.request.session[employee_key] + employee = Session.query(model.Employee).get(employee_uuid) if employee_uuid else None + if not employee: + employee = self.request.user.employee + + # force current user if not allowed to view all data + if not self.request.has_perm('{}.viewall'.format(self.key)): + employee = self.request.user.employee + assert employee + + return {'date': date, 'employee': employee} + def process_filter_form(self, form): """ Process a "shift filter" form if one was in fact POST'ed. If it was @@ -136,6 +167,19 @@ class TimeSheetView(View): self.request.session['timesheet.{}.date'.format(self.key)] = date.strftime('%m/%d/%Y') if date else None raise self.redirect(self.request.current_route_url()) + def process_employee_filter_form(self, form): + """ + Process an "employee 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 form.validate(): + employee = form.data['employee'] + self.request.session['timesheet.{}.employee'.format(self.key)] = employee.uuid if employee else None + date = form.data['date'] + self.request.session['timesheet.{}.employee.date'.format(self.key)] = date.strftime('%m/%d/%Y') if date else None + raise self.redirect(self.request.current_route_url()) + def full(self): """ View a "full" timesheet/schedule, i.e. all employees but filterable by @@ -151,50 +195,11 @@ class TimeSheetView(View): """ View time sheet for single employee. """ - date = None - employee = None - if not self.request.has_perm('{}.viewall'.format(self.key)): - # force current user if not allowed to view all data - employee = self.request.user.employee - assert employee form = Form(self.request, schema=EmployeeShiftFilter) - if self.request.method == 'POST': - if form.validate(): - if self.request.has_perm('{}.viewall'.format(self.key)): - employee = form.data['employee'] - self.request.session['timesheet.{}.employee'.format(self.key)] = employee.uuid if employee else None - date = form.data['date'] - self.request.session['timesheet.{}.employee.date'.format(self.key)] = date.strftime('%m/%d/%Y') if date else None - return self.redirect(self.request.current_route_url()) - - else: - if self.request.has_perm('{}.viewall'.format(self.key)): - employee_key = 'timesheet.{}.employee'.format(self.key) - if employee_key in self.request.session: - employee_uuid = self.request.session.get(employee_key) - if employee_uuid: - employee = Session.query(model.Employee).get(employee_uuid) - else: # no employee in session - if self.request.user: - employee = self.request.user.employee - - date_key = 'timesheet.{}.employee.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 - - # default to current user; force unless allowed to view all data - if not employee or not self.request.has_perm('{}.viewall'.format(self.key)): - employee = self.request.user.employee - assert employee - - if not date: - date = localtime(self.rattail_config).date() - return self.render_single(date, employee, form=form) + self.process_employee_filter_form(form) + context = self.get_employee_context() + context['form'] = form + return self.render_single(**context) def crossview(self): """ @@ -216,17 +221,6 @@ class TimeSheetView(View): 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 @@ -301,7 +295,7 @@ class TimeSheetView(View): def render_shift(self, shift): return HTML.tag('span', c=shift.get_display(self.rattail_config)) - def render_single(self, date, employee, form=None): + def render_single(self, date=None, employee=None, form=None, **kwargs): """ Render a time sheet for one employee, for the week which includes the specified date. @@ -319,7 +313,7 @@ class TimeSheetView(View): self.modify_employees([employee], weekdays) - return { + context = { 'page_title': "Employee {}".format(self.get_title()), 'form': forms.FormRenderer(form) if form else None, 'employee': employee, @@ -332,6 +326,8 @@ class TimeSheetView(View): 'permission_prefix': self.key, 'render_shift': self.render_shift, } + context.update(kwargs) + return context def modify_employees(self, employees, weekdays): min_time = localtime(self.rattail_config, datetime.datetime.combine(weekdays[0], datetime.time(0))) diff --git a/tailbone/views/shifts/schedule.py b/tailbone/views/shifts/schedule.py index df8b9931..787bc02a 100644 --- a/tailbone/views/shifts/schedule.py +++ b/tailbone/views/shifts/schedule.py @@ -64,6 +64,8 @@ class ScheduleView(TimeSheetView): form = Form(self.request, schema=ShiftFilter) self.process_filter_form(form) + context = self.get_timesheet_context() + # okay then, maybe process saved shift data if self.request.method == 'POST': @@ -97,7 +99,10 @@ class ScheduleView(TimeSheetView): if uuid.startswith('new-'): shift = model.ScheduledShift() shift.employee_uuid = data['employee_uuid'][uuid] - shift.store_uuid = data['store_uuid'][uuid] + if 'store_uuid' in data and uuid in data['store_uuid']: + shift.store_uuid = data['store_uuid'][uuid] + else: + shift.store_uuid = context['store'].uuid if context['store'] else None Session.add(shift) created[uuid] = shift else: @@ -114,7 +119,6 @@ class ScheduleView(TimeSheetView): len(created), len(updated), len(deleted))) return self.redirect(self.request.route_url('schedule.edit')) - context = self.get_timesheet_context() context['form'] = form context['page_title'] = "Edit Schedule" return self.render_full(**context) diff --git a/tailbone/views/shifts/timesheet.py b/tailbone/views/shifts/timesheet.py index 54dafcf2..6c1c03ad 100644 --- a/tailbone/views/shifts/timesheet.py +++ b/tailbone/views/shifts/timesheet.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2016 Lance Edgar +# Copyright © 2010-2017 Lance Edgar # # This file is part of Rattail. # @@ -26,19 +26,103 @@ Views for employee time sheets from __future__ import unicode_literals, absolute_import -from rattail.db import model +import datetime -from tailbone.views.shifts.lib import TimeSheetView as BaseTimeSheetView +from rattail.db import model +from rattail.time import make_utc, localtime + +from pyramid_simpleform import Form + +from tailbone.db import Session +from tailbone.views.shifts.lib import TimeSheetView as BaseTimeSheetView, EmployeeShiftFilter class TimeSheetView(BaseTimeSheetView): """ - Simple view for current user's time sheet. + Views for employee time sheets, i.e. worked shift data """ key = 'timesheet' title = "Time Sheet" model_class = model.WorkedShift + def edit_employee(self): + """ + View for editing single employee's timesheet + """ + # process filters; redirect if any were received + form = Form(self.request, schema=EmployeeShiftFilter) + self.process_employee_filter_form(form) + + context = self.get_employee_context() + + # okay then, maybe process saved shift data + if self.request.method == 'POST': + + # TODO: most of this is copied from 'schedule.edit' view, should merge... + + # organize form data by uuid / field + fields = ['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] + break + + # apply delete operations + deleted = [] + for uuid, value in list(data['delete'].items()): + assert value == 'delete' + shift = Session.query(model.WorkedShift).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, time in data['start_time'].iteritems(): + if uuid in deleted: + continue + if uuid.startswith('new-'): + shift = model.WorkedShift() + shift.employee_uuid = context['employee'].uuid + # TODO: add support for setting store here... + Session.add(shift) + created[uuid] = shift + else: + shift = Session.query(model.WorkedShift).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 {} Worked Shifts".format( + len(created), len(updated), len(deleted))) + return self.redirect(self.request.route_url('timesheet.employee.edit')) + + context['form'] = form + context['page_title'] = "Edit Employee Time Sheet" + return self.render_single(**context) + + @classmethod + def defaults(cls, config): + cls._defaults(config) + + # edit employee time sheet + config.add_tailbone_permission('timesheet', 'timesheet.edit', + "Edit time sheet (for *any* employee!)") + config.add_route('timesheet.employee.edit', '/timesheeet/employee/edit') + config.add_view(cls, attr='edit_employee', route_name='timesheet.employee.edit', + renderer='/shifts/timesheet_edit.mako', + permission='timesheet.edit') + def includeme(config): TimeSheetView.defaults(config)