소스 검색

Labor input

Alan Colon 7 년 전
부모
커밋
6e180937b2

+ 8 - 0
app/assets/app.scss

@@ -0,0 +1,8 @@
+.hour-input {
+  width: 3em;
+}
+@media (min-width: 600px) {
+  md-sidenav[md-component-id="left"] {
+    max-width: 230px
+  }
+}

+ 5 - 1
app/assets/index.js

@@ -1,6 +1,10 @@
 const assets = require('@alancnet/material-framework/app/assets')
+require('./app.scss')
 module.exports = Object.assign(assets, {
   logo: require('./logo-simple.png'),
   staffingAgencyIcon: require('@alancnet/icomoon-svg/office.svg'),
-  staffMemberIcon: require('@alancnet/icomoon-svg/user-tie.svg')
+  staffMemberIcon: require('@alancnet/icomoon-svg/user-tie.svg'),
+  locationIcon: require('@alancnet/icomoon-svg/location.svg'),
+  laborIcon: require('@alancnet/icomoon-svg/accessibility.svg'),
+  crumbIcon: require('@alancnet/material-design-icons/image_ic_navigate_next_48px.svg')
 })

+ 16 - 0
app/components/breadcrumb.js

@@ -0,0 +1,16 @@
+const app = require('../app')
+const { crumbIcon } = require('../assets')
+
+app.component('appBreadcrumb', {
+  template: html`
+    <h2 class="md-breadcrumb">
+      <span ng-repeat="link in $ctrl.links">
+        <md-icon md-svg-src="${crumbIcon}" ng-if="$index"></md-icon>
+        <a class="md-button" ng-href="{{link.link}}">{{link.text}}</a>
+      </span>
+    </h2>
+  `,
+  bindings: {
+    links: '<'
+  }
+})

+ 14 - 0
app/components/dashboard-page.js

@@ -36,6 +36,20 @@ app.component('appDashboardPage', {
           <md-grid-tile-footer><h3>Delivery</h3></md-grid-tile-footer>
         </md-grid-tile>
 
+        <md-grid-tile
+          md-rowspan="2"
+          md-colspan="3"
+          md-colspan-sm="2"
+          md-rowspan-sm="1"
+          md-colspan-xs="1"
+          md-colspan-xs="1"
+          ng-class="tile.background">
+          <canvas id="line" class="chart chart-line" chart-data="dashboard.laborCost.data"
+            chart-labels="dashboard.laborCost.labels" chart-series="dashboard.laborCost.series" chart-options="dashboard.laborCost.options">
+          </canvas>
+          <md-grid-tile-footer><h3>Labor Cost</h3></md-grid-tile-footer>
+        </md-grid-tile>
+
       </md-grid-list>
     </app-user-area>
   `,

+ 4 - 1
app/components/index.js

@@ -1,4 +1,7 @@
 require('./user-area-nav')
 require('./dashboard-page')
 require('./staff-member-pages')
-require('./staffing-agencies-pages')
+require('./staffing-agencies-pages')
+require('./labor-page')
+require('./labor-entry-page')
+require('./breadcrumb')

+ 85 - 0
app/components/labor-entry-page.js

@@ -0,0 +1,85 @@
+const app = require('../app')
+const moment = require('moment-immutable')
+const { editIcon } = require('../assets')
+
+app.component('appLaborEntryPage', {
+  template: html`
+  <app-user-area>
+    <app-breadcrumb links="[
+      { text: 'Home', link: '/dashboard' },
+      { text: ctrl.locationKey + ' Labor', link: '/labor/' + ctrl.locationKey },
+      { text: ctrl.startDate.format('L') + ' - ' + ctrl.endDate.format('L'), link: '/labor/' + ctrl.locationKey + '/' + ctrl.startDate.format('YYYY-MM-DD') }
+    ]"></app-breadcrumb>
+    <h1>Labor Entry</h1>
+    <p>For week of {{::ctrl.startDate.format('LL')}} to {{::ctrl.endDate.format('LL')}}</p>
+    <form name="form" ng-submit="ctrl.submit()">
+      <table md-table md-progress="ctrl.promise">
+        <colgroup>
+          <col style="width: 15%" />
+          <col ng-repeat="weekday in ::ctrl.weekdays" style="width: 12%" />
+        </colgroup>
+        <thead md-head>
+          <tr md-row>
+            <th md-column>Staff Member</th>
+            <th md-column ng-repeat="weekday in ::ctrl.weekdays">
+              <span hide show-xs>{{::weekday.min}}</span>
+              <span hide show-sm>{{::weekday.short}}</span>
+              <span hide show-gt-sm>{{::weekday.name}}</span>
+            </th>
+          </tr>
+        </thead>
+        <tbody md-body>
+          <tr md-row ng-repeat="sfl in ::ctrl.staffMemberLabor track by sfl.id">
+            <td md-cell>
+              {{::ctrl.staffMembers[sfl.id].name}}
+            </td>
+            <td md-cell ng-repeat="day in ::sfl.days track by $index">
+              <input class="hour-input md-button md-raised" ng-model="day.hours" type="number">
+            </td>
+          </tr>
+        </tbody>
+      </table>
+      <div>
+        <md-button type="submit" class="md-raised md-primary">Submit</md-button>
+      </div>
+    </form>
+  </app-user-area>
+  `,
+  controllerAs: 'ctrl',
+  controller: function(api, $routeParams, weekdays, $mdToast) {
+    this.weekdays = weekdays
+    this.locationKey = $routeParams.location
+    const week = moment($routeParams.week)
+    if (!week.isSame(week.startOf('week'))) throw new Error('Date is not start of week')
+    this.startDate = week
+    this.endDate = week.endOf('week')
+    api.staffMemberDictionary().then(staffMembers => {
+      this.staffMembers = staffMembers
+    })
+    this.promise = api.get(`/api/labor/${$routeParams.location}/${$routeParams.week}`).then(workdays => {
+      this.model = workdays
+      const staffMemberIds = workdays[0].labor.map(x => x.staffMemberId)
+      this.staffMemberLabor = staffMemberIds.map((id, i) => ({
+        id,
+        days: this.model.map(wd => wd.labor[i])
+      }))
+    })
+
+    this.submit = async () => {
+      const model = this.model.map(workday => ({
+        labor: workday.labor.map(labor => ({
+          staffMemberId: labor.staffMemberId,
+          hours: labor.hours || null
+        }))
+      }))
+      try {
+        await api.patch(`/api/labor/${$routeParams.location}/${$routeParams.week}`, model)
+        $mdToast.showSimple('Labor saved.')
+      } catch (err) {
+        window.err = err
+        console.error(err)
+        $mdToast.showSimple(`Could not save Labor: ${err.message || err.statusText || err}`)
+      }
+    }
+  }
+})

+ 69 - 0
app/components/labor-page.js

@@ -0,0 +1,69 @@
+const app = require('../app')
+const { editIcon } = require('../assets')
+
+app.component('appLaborPage', {
+  template: html`
+    <app-user-area>
+      <app-breadcrumb links="[
+        { text: 'Home', link: '/dashboard' },
+        { text: ctrl.locationKey + ' Labor', link: '/labor/' + ctrl.locationKey }
+      ]"></app-breadcrumb>
+
+      <h1>Labor</h1>
+      <table md-table ng-model="ctrl.selected" md-progress="ctrl.promise">
+        <thead md-head>
+          <tr md-row>
+            <th md-column>Week Starting</th>
+            <th md-column ng-repeat="weekday in ::ctrl.weekdays">
+              <span hide show-xs>{{::weekday.min}}</span>
+              <span hide show-sm>{{::weekday.short}}</span>
+              <span hide show-gt-sm>{{::weekday.name}}</span>
+            </th>
+            <th md-column>Actions</th>
+          </tr>
+        </thead>
+        <tbody md-body>
+          <tr md-row ng-repeat="week in ::ctrl.labor track by week.workweek">
+            <td md-cell>
+              {{::week.workweek}}
+            </td>
+            <td md-cell ng-repeat="workday in ::week.workdays track by $index">
+              <div>
+                <span ng-if="::workday" style="white-space: nowrap;">
+                  {{::workday.regularHours + workday.overtimeHours}}
+                  <span hide show-xs>h</span>
+                  <span hide show-sm>hrs</span>
+                  <span hide show-gt-sm>hours</span>
+                </span>
+                <span ng-if="::!workday" md-colors="{ color: 'primary-100' }">
+                  N/A
+                </span>
+              </div>
+              <div ng-if="::workday.laborCost">
+                {{::workday.laborCost | currency}}
+              </div>
+            </td>
+            <td md-cell>
+              <md-button ng-href="labor/{{::ctrl.location.key}}/{{::week.workweek}}">
+                <md-icon md-svg-icon="${editIcon}"></md-icon>
+                Edit
+              </md-button>
+            </td>
+          </tr>
+        </tbody>
+      </table>
+
+    </app-user-area>
+  `,
+  controllerAs: 'ctrl',
+  controller: function(api, $routeParams, weekdays) {
+    this.locationKey = $routeParams.location
+    this.weekdays = weekdays
+    api.location($routeParams.location).then(location => {
+      this.location = location
+    })
+    this.promise = api.get(`/api/labor/${$routeParams.location}`).then(labor => {
+      this.labor = labor
+    })
+  }
+})

+ 20 - 2
app/components/staff-member-pages.js

@@ -5,24 +5,42 @@ pages({
   columns: [
     { camelName: 'name', row: 1 },
     { camelName: 'title', row: 2 },
+    {
+      titleName: 'Category',
+      camelName: 'laborCategoryId',
+      type: 'autocomplete',
+      apiPrefix: '/api/labor-categories'
+    },
+    {
+      titleName: 'Location',
+      camelName: 'locationId',
+      type: 'autocomplete',
+      apiPrefix: '/api/locations'
+    },
     {
       titleName: 'Staffing Agency',
       camelName: 'staffingAgencyId',
       type: 'autocomplete',
       apiPrefix: '/api/staffing-agencies'
+    },
+    {
+      inList: false,
+      camelName: 'wage',
+      type: 'currency'
     }
   ],
   layout: [
     {
       section: null,
       rows: [
-        [ 'name', 'title' ]
+        [ 'name', 'title', 'wage' ],
+        [ 'locationId' ]
       ]
     },
     {
       section: 'Staffing information',
       rows: [
-        [ 'staffingAgencyId' ]
+        [ 'laborCategoryId', 'staffingAgencyId' ]
       ]
     }
   ]

+ 17 - 2
app/components/user-area-nav.js

@@ -1,5 +1,6 @@
 const app = require('../app')
-const { dashboardIcon, staffMemberIcon, staffingAgencyIcon } = require('../assets')
+const _ = require('lodash')
+const { dashboardIcon, staffMemberIcon, staffingAgencyIcon, laborIcon } = require('../assets')
 
 app.component('appUserAreaNav', {
   template: html`
@@ -14,6 +15,14 @@ app.component('appUserAreaNav', {
       </md-button>
     </md-menu-item>
 
+    <h3>Labor</h3>
+    <md-menu-item ng-repeat="location in ctrl.locations">
+      <md-button ng-href="/labor/{{location.key}}">
+        <md-icon md-svg-icon="${laborIcon}"></md-icon>
+        {{location.name}} Labor
+      </md-button>
+    </md-menu-item>
+
     <h3>Staff</h3>
     <md-menu-item>
       <md-button ng-href="/staff-members">
@@ -28,5 +37,11 @@ app.component('appUserAreaNav', {
       </md-button>
     </md-menu-item>
     
-  `
+  `,
+  controllerAs: 'ctrl',
+  controller: function(api) {
+    window.api = api.get('/api/locations').then(locations => {
+      this.locations = _.sortBy(locations, 'key')
+    })
+  }
 })

+ 2 - 0
app/routes.js

@@ -2,4 +2,6 @@ module.exports = function($routeProvider) {
   $routeProvider.when('/dashboard', {template: '<app-dashboard-page />'})
   $routeProvider.crudRoutes({ camelName: 'staffMember' })
   $routeProvider.crudRoutes({ camelName: 'staffingAgency' })
+  $routeProvider.when('/labor/:location', {template: '<app-labor-page />'})
+  $routeProvider.when('/labor/:location/:week', {template: '<app-labor-entry-page />'})
 }

+ 19 - 0
app/services/api.js

@@ -1,5 +1,24 @@
 const app = require('../app')
+const _ = require('lodash')
 
 app.run(function(api) {
   api.statistics = () => api.get('/api/statistics')
+
+  let locations = null
+  api.locations = () => locations || (locations = api.get('/api/locations'))
+
+  let locationsDictionary = null
+  api.locationsDictionary = () => locationsDictionary || (locationsDictionary = api.locations().then(locations => 
+    _.fromPairs(locations.map(loc => [loc.key, loc]))
+  ))
+  api.location = (key) => api.locations().then(async locations =>
+    (await api.locationsDictionary())[key]
+  )
+  let staffMembers = null
+  api.staffMembers = () => staffMembers || (staffMembers = api.get('/api/staff-members'))
+
+  let staffMemberDictionary = null
+  api.staffMemberDictionary = () => staffMemberDictionary || (staffMemberDictionary = api.staffMembers().then(staffMembers =>
+    _.fromPairs(staffMembers.map(sm => [sm.id, sm]))
+  ))
 })

+ 2 - 1
app/services/index.js

@@ -1,2 +1,3 @@
 require('./statistics')
-require('./api')
+require('./api')
+require('./weekdays')

+ 24 - 4
app/services/statistics.js

@@ -6,7 +6,8 @@ app.service('statistics', function(api) {
     rows,
     seriesField,
     dataField,
-    labelsField
+    labelsField,
+    format
   }) => {
     const labels = _.chain(rows).map(x => x[labelsField]).uniq().value()
     const series = _.chain(rows).map(x => x[seriesField]).uniq().value()
@@ -27,7 +28,17 @@ app.service('statistics', function(api) {
       return labels.map(l => sdata[l])
     })
 
-    return {labels, series, data}
+    return {labels, series, data, options: {
+      tooltips: {
+        callbacks: {
+          label: (tooltipItem, data) => {
+            const label = data.datasets[tooltipItem.datasetIndex].label || ''
+            const value = tooltipItem.yLabel
+            return format ? format(label, value) : `${label}: ${value}`
+          }
+        }
+      }
+    }}
   }
 
 
@@ -38,13 +49,22 @@ app.service('statistics', function(api) {
         rows,
         seriesField: 'key',
         dataField: 'delivered',
-        labelsField: 'date'
+        labelsField: 'date',
+        format: (label, value) => `${label}: ${value.toLocaleString()} cartons`
       }),
       efficiency: chart({
         rows,
         seriesField: 'key',
         dataField: 'efficiency',
-        labelsField: 'date'
+        labelsField: 'date',
+        format: (label, value) => `${label}: $${value.toFixed(2)} per carton`
+      }),
+      laborCost: chart({
+        rows,
+        seriesField: 'key',
+        dataField: 'laborCost',
+        labelsField: 'date',
+        format: (label, value) => `${label}: $${value.toFixed(2)} labor cost`
       })
     }
   }

+ 13 - 0
app/services/weekdays.js

@@ -0,0 +1,13 @@
+const app = require('../app')
+const moment = require('moment')
+
+app.service('weekdays', function() {
+  const min = moment.weekdaysMin()
+  const short = moment.weekdaysShort()
+  return moment.weekdays().map((name, value) => ({
+    name,
+    min: min[value],
+    short: short[value],
+    value
+  }))
+})

+ 2 - 0
auto-crud/index.js

@@ -1,3 +1,5 @@
+require('./location')
+require('./labor-category')
 /* Example */
 /*
 

+ 39 - 0
auto-crud/labor-category.js

@@ -0,0 +1,39 @@
+const { register, Sequelize } = require('@alancnet/material-framework/auto-crud')
+
+register({
+  camelName: 'laborCategory',
+  showNav: false,
+  schema: {
+    id: {
+      type: Sequelize.UUID,
+      defaultValue: Sequelize.UUIDV1,
+      primaryKey: true
+    },
+    name: Sequelize.STRING,
+    key: {
+      type: Sequelize.STRING,
+      unique: true
+    },
+  },
+  options: {
+    paranoid: true,
+    indexes: [
+      {
+        unique: true,
+        fields: ['key']
+      }
+    ]
+  },
+  columns: [
+    { camelName: 'key' },
+    { camelName: 'name' },
+  ],
+  layout: [
+    {
+      section: 'Details',
+      rows: [
+        [ 'name', 'key' ],
+      ]
+    }
+  ]
+})

+ 2 - 1
auto-crud/location.js

@@ -2,7 +2,8 @@ const { register, Sequelize } = require('@alancnet/material-framework/auto-crud'
 
 register({
   camelName: 'location',
-  iconAsset: 'userIcon',
+  iconAsset: 'locationIcon',
+  showNav: false,
   schema: {
     id: {
       type: Sequelize.UUID,

+ 3 - 1
lib/controllers/index.js

@@ -1,10 +1,12 @@
 const statistics = require('./statistics')
 const staffMember = require('./staff-member')
 const staffingAgency = require('./staffing-agency')
+const labor = require('./labor')
 const { controllers: C } = require('@alancnet/material-framework/server')
 
 module.exports = Object.assign(C, {
   statistics,
   staffMember,
-  staffingAgency
+  staffingAgency,
+  labor
 })

+ 233 - 0
lib/controllers/labor.js

@@ -0,0 +1,233 @@
+const _ = require('lodash')
+const moment = require('moment-immutable')
+const { getWeeks, formatDate, parseDate } = require('../dates')
+const { StaffMember, Workday, Location, Labor, sequelize } = require('../database')
+const { Op } = require('sequelize')
+
+const list = async (req, res) => {
+  const locationKey = req.params.location
+  const location = await Location.findOne({where: {key: locationKey}})
+  if (!location) return res.status(404).end()
+  const workdays = await Workday.findAll({where: { locationId: location.id }})
+
+  let workweeks = _.groupBy(workdays, d => formatDate(moment(d.date).startOf('week')))
+  
+  // fill workweeks
+  getWeeks(10).forEach(week => {
+    const w = formatDate(week)
+    if (!workweeks[w]) workweeks[w] = []
+  })
+
+  // fill workdays
+  workweeks = _.chain(workweeks)
+    .toPairs()
+    .map(([w, days]) => {
+      days  = _.chain(days)
+        .map(d => [moment(d.date).weekday(), d])
+        .fromPairs()
+        .value()
+      
+      days = _.range(0, 7).map(d => days[d] || null)
+        .map(day => day && {
+          regularHours: day.regularHours,
+          overtimeHours: day.overtimeHours,
+          laborCost: day.laborCost
+        })
+
+      return {
+        workweek: w,
+        workdays: days
+      }
+    })
+    .sortBy(x => x.workweek)
+    .reverse()
+    .value()
+
+  res.status(200).send(workweeks)
+}
+
+const get = async (req, res) => {
+  const locationKey = req.params.location
+  const location = await Location.findOne({where: {key: locationKey}})
+  const week = parseDate(req.params.week)
+  const workdays = await Workday.findAll({
+    where: {
+      locationId: location.id,
+      date: {
+        [Op.gte]: week,
+        [Op.lt]: moment(week).endOf('week')
+      }
+    },
+    order: [ 'date' ]
+  })
+  const labor = await Labor.findAll({
+    where: {
+      workdayId: {
+        [Op.in]: workdays.map(x => x.id)
+      }
+    }
+  })
+  const laborByWorkday = _.groupBy(labor, x => x.workdayId)
+  const extraStaffMembers = _.chain(labor)
+    .map(x => x.staffMemberId)
+    .filter(x => x)
+    .uniq()
+    .value()
+  const staffMembers = await StaffMember.findAll({
+    where: {
+      [Op.or]: [
+        { locationId: location.id },
+        {
+          id: {
+            [Op.in]: extraStaffMembers
+          }
+        }
+      ]      
+    },
+    order: [ 'name' ]
+  })
+  const staffMembersById = _.chain(staffMembers).map(x => [x.id, x]).fromPairs().value()
+
+  // Fill in empty days
+  const workdaysByKey = _.chain(workdays).map(wd => [formatDate(wd.date), wd]).fromPairs().value()
+  const allWorkdays = []
+  for (let day = week, i = 0; i < 7; i++, day = day.add(1, 'day')) {
+    const wd = workdaysByKey[formatDate(day)]
+    allWorkdays[i] = wd ? wd.toJSON() : {}
+  }
+
+  const workdaysWithLabor = allWorkdays.map(wd => {
+    const labor = laborByWorkday[wd.id] || []
+    const laborByStaffMember = _.chain(labor).map(l => [l.staffMemberId, l]).fromPairs().value()
+    // Map from staffMembers to preserve sorting
+    wd.labor = staffMembers
+      .map(sm => laborByStaffMember[sm.id] || {staffMemberId: sm.id})
+      // Labor requires regularHours and overtimeHours, model requires hours
+      .map(sm => ({
+        staffMemberId: sm.staffMemberId,
+        hours: (sm.regularHours + sm.overtimeHours) || null
+      }))
+    // Restore any staffMembers that are no longer assigned this location
+    labor.forEach(l => {
+      if (!staffMembersById[l.staffMemberId]) {
+        wd.labor.push(l)
+      }
+    })
+    return wd
+  })
+
+  res.status(200).send(allWorkdays)
+}
+
+const patch = async (req, res) => {
+  const transaction = await sequelize.transaction()
+  try {
+    const locationKey = req.params.location
+    const location = await Location.findOne({where: {key: locationKey}})
+    const week = parseDate(req.params.week)
+    const workdays = await Workday.findAll({
+      where: {
+        locationId: location.id,
+        date: {
+          [Op.gte]: week,
+          [Op.lt]: moment(week).endOf('week')
+        }
+      },
+      order: [ 'date' ]
+    })
+    const labor = await Labor.findAll({
+      where: {
+        workdayId: {
+          [Op.in]: workdays.map(x => x.id)
+        }
+      }
+    })
+    const laborByWorkday = _.groupBy(labor, x => x.workdayId)
+    const extraStaffMembers = _.chain(labor)
+      .map(x => x.staffMemberId)
+      .filter(x => x)
+      .uniq()
+      .value()
+    const staffMembers = await StaffMember.findAll({
+      where: {
+        [Op.or]: [
+          { locationId: location.id },
+          {
+            id: {
+              [Op.in]: extraStaffMembers
+            }
+          }
+        ]      
+      },
+      order: [ 'name' ]
+    })
+    const staffMembersById = _.chain(staffMembers).map(x => [x.id, x]).fromPairs().value()
+
+    // Fill in empty days
+    const workdaysByKey = _.chain(workdays).map(wd => [formatDate(wd.date), wd]).fromPairs().value()
+    const allWorkdays = []
+    for (let day = week, i = 0; i < 7; i++, day = day.add(1, 'day')) {
+      const wd = workdaysByKey[formatDate(day)]
+      allWorkdays[i] = wd || Workday.build({
+        locationId: location.id,
+        date: week.add(i, 'day')
+      })
+    }
+
+    const workdaysWithLabor = allWorkdays.map(wd => {
+      const labor = laborByWorkday[wd.id] || []
+      const laborByStaffMember = _.chain(labor).map(l => [l.staffMemberId, l]).fromPairs().value()
+      // Map from staffMembers to preserve sorting
+      wd.labor = staffMembers.map(sm => laborByStaffMember[sm.id] || Labor.build({staffMemberId: sm.id, workdayId: wd.id}))
+      // Restore any staffMembers that are no longer assigned this location
+      labor.forEach(l => {
+        if (!staffMembersById[l.staffMemberId]) {
+          wd.labor.push(l)
+        }
+      })
+      return wd
+    })
+
+    // Update with model
+    const model = req.body
+    await Promise.all(model.map(async (modelWorkday, i) => {
+      const modelLaborById = _.chain(modelWorkday.labor).map(l => [l.staffMemberId, l]).fromPairs().value()
+      const workday = allWorkdays[i]
+      await Promise.all(workday.labor.map(async labor => {
+        const modelLabor = modelLaborById[labor.staffMemberId]
+        // modelLabor has hours, labor needs regularHours and overtimeHours
+        labor.regularHours = Math.min(8, modelLabor.hours) || 0
+        labor.overtimeHours = Math.max(0, modelLabor.hours - 8) || 0
+        
+        if (labor.regularHours || labor.overtimeHours || !labor.isNewRecord) {
+          await labor.save({ transaction })
+        }
+      }))
+      // TODO: Update laborCost when wages are in
+      workday.overtimeHours = workday.labor.map(l => l.overtimeHours || 0).reduce((a, b) => a + b, 0)
+      workday.regularHours = workday.labor.map(l => l.regularHours || 0).reduce((a, b) => a + b, 0)
+      workday.laborCost = workday.labor.map(l => {
+        const staffMember = staffMembersById[l.staffMemberId]
+        const wage = staffMember.wage || 0
+        return ((l.regularHours || 0) * wage) + ((l.overtimeHours || 0) * wage * 1.5)
+      }).reduce((a, b) => a + b, 0)
+      
+      if (workday.overtimeHours || workday.regularHours || !workday.isNewRecord) {
+        await workday.save({ transaction })
+      }
+
+    }))
+    await transaction.commit()
+    
+    res.status(200).end()
+
+  } catch (err) {
+    await transaction.rollback()
+    throw err
+  }
+}
+module.exports = {
+  list,
+  get,
+  patch
+}

+ 3 - 2
lib/controllers/statistics.js

@@ -5,13 +5,14 @@ const get = async (req, res) => {
     SELECT
       loc.key,
       wd.date,
+      wd.laborCost,
       SUM(svc.delivered) as delivered,
       SUM(svc.scanned) as scanned,
       SUM(svc.delivered) / (wd.regularHours + (wd.overtimeHours * 1.5)) as efficiency
     FROM workdays wd
     JOIN locations loc on wd.locationId = loc.id
-    JOIN services svc on svc.workdayId = wd.id
-    GROUP BY loc.key, wd.date      
+    LEFT JOIN services svc on svc.workdayId = wd.id
+    GROUP BY loc.key, wd.date
   `)
   res.status(200).send(results)
 

+ 4 - 1
lib/database/index.js

@@ -1,11 +1,13 @@
 // TODO: App Specific Models
 const {database} = require('@alancnet/material-framework/server')
-const Location = require('./location')
+const { Location } = database
+// const Location = require('./location')
 const Workday = require('./workday')
 const Service = require('./service')
 const Retailer = require('./retailer')
 const StaffMember = require('./staff-member')
 const StaffingAgency = require('./staffing-agency')
+const Labor = require('./labor')
 
 const LocationWorkday = Location.hasMany(Workday)
 const WorkdayLocation = Workday.belongsTo(Location)
@@ -33,4 +35,5 @@ module.exports = Object.assign(database, {
   ServiceWorkday,
   RetailerService,
   ServiceRetailer,
+  Labor
 })

+ 26 - 0
lib/database/labor.js

@@ -0,0 +1,26 @@
+const Sequelize = require('sequelize')
+const sequelize = require('./sequelize')
+
+const Labor = sequelize.define('labor', {
+  id: {
+    type: Sequelize.UUID,
+    defaultValue: Sequelize.UUIDV1,
+    primaryKey: true
+  },
+  staffMemberId: Sequelize.UUID,
+  workdayId: Sequelize.UUID,
+  laborCategoryId: Sequelize.UUID,
+  regularHours: Sequelize.DOUBLE,
+  overtimeHours: Sequelize.DOUBLE,
+  laborCost: Sequelize.DECIMAL,
+}, {
+  paranoid: true,
+  indexes: [
+    {
+      unique: true,
+      fields: ['staffMemberId', 'workdayId']
+    }
+  ]
+})
+
+module.exports = Labor

+ 24 - 24
lib/database/location.js

@@ -1,27 +1,27 @@
-const Sequelize = require('sequelize')
-const sequelize = require('./sequelize')
+// const Sequelize = require('sequelize')
+// const sequelize = require('./sequelize')
 
-const Location = sequelize.define('location', {
-  id: {
-    type: Sequelize.UUID,
-    defaultValue: Sequelize.UUIDV1,
-    primaryKey: true
-  },
-  name: Sequelize.STRING,
-  address: Sequelize.STRING,
-  key: {
-    type: Sequelize.STRING,
-    unique: true
-  },
+// const Location = sequelize.define('location', {
+//   id: {
+//     type: Sequelize.UUID,
+//     defaultValue: Sequelize.UUIDV1,
+//     primaryKey: true
+//   },
+//   name: Sequelize.STRING,
+//   address: Sequelize.STRING,
+//   key: {
+//     type: Sequelize.STRING,
+//     unique: true
+//   },
 
-}, {
-  paranoid: true,
-  indexes: [
-    {
-      unique: true,
-      fields: ['key']
-    }
-  ]
-})
+// }, {
+//   paranoid: true,
+//   indexes: [
+//     {
+//       unique: true,
+//       fields: ['key']
+//     }
+//   ]
+// })
 
-module.exports = Location
+// module.exports = Location

+ 3 - 1
lib/database/staff-member.js

@@ -8,10 +8,12 @@ const StaffMember = sequelize.define('staffMember', {
     primaryKey: true
   },
   staffingAgencyId: Sequelize.UUID,
+  laborCategoryId: Sequelize.UUID,
   name: Sequelize.STRING,
   title: Sequelize.STRING,
   identifier: Sequelize.STRING,
-  locationId: Sequelize.UUID
+  locationId: Sequelize.UUID,
+  wage: Sequelize.DECIMAL
 }, {
   paranoid: true,
   indexes: [

+ 18 - 0
lib/dates.js

@@ -0,0 +1,18 @@
+const moment = require('moment-immutable')
+
+const formatDate = (date) => moment(date).format('YYYY-MM-DD')
+const parseDate = (string) => moment(string, 'YYYY-MM-DD')
+
+const getWeeks = (n) => {
+  const weeks = []
+  for (let i = 0, week = moment(moment.now()).startOf('week'); i < n; i++, week = week.add(-1, 'week')) {
+    weeks[i] = week
+  }
+  return weeks
+}
+
+module.exports = {
+  getWeeks,
+  formatDate,
+  parseDate
+}

+ 0 - 0
lib/labor-cost.js


+ 3 - 0
lib/routes.js

@@ -5,4 +5,7 @@ module.exports = app => {
   app.get('/api/statistics', C.statistics.get)
   crudRoutes({ app, controller: C.staffMember, camelName: 'staffMember' })
   crudRoutes({ app, controller: C.staffingAgency, camelName: 'staffingAgency' })
+  app.get('/api/labor/:location', C.labor.list)
+  app.get('/api/labor/:location/:week', C.labor.get)
+  app.patch('/api/labor/:location/:week', C.labor.patch)
 }

+ 3 - 1
package.json

@@ -14,7 +14,7 @@
   "dependencies": {
     "@alancnet/icomoon-svg": "^2.0.0",
     "@alancnet/material-design-icons": "^1.0.0",
-    "@alancnet/material-framework": "^1.0.3",
+    "@alancnet/material-framework": "^1.0.4",
     "aguid": "^2.0.0",
     "angular": "^1.6.10",
     "angular-animate": "^1.7.0",
@@ -38,6 +38,8 @@
     "jquery": "^3.3.1",
     "jsonwebtoken": "^8.2.2",
     "lodash": "^4.17.10",
+    "moment": "^2.22.2",
+    "moment-immutable": "^1.0.4",
     "material-design-icons": "^3.0.1",
     "node-sass": "^4.9.0",
     "password-prompt": "^1.0.4",