client/src/pages/Report.vue
<!-- Copyright © 2018-2020 Stanislav Valasek <valasek@gmail.com> -->
<template>
<q-page padding>
<q-toolbar class="q-pa-md bg-primary">
<change-week />
<div class="q-gutter-x-md">
<select-consultant class="q-gutter-x-md" />
</div>
<q-toolbar-title>
<q-input v-model="filter" dense label="Search" single-line
color="secondary"
@keyup.esc="filter = ''"
>
<template v-slot:append>
<q-icon v-if="filter !== ''" name="close" class="cursor-pointer" @click="filter = ''" />
<q-icon class="text-secondary" name="search" />
</template>
</q-input>
</q-toolbar-title>
<q-checkbox v-model="weekUnlocked" color="secondary" :disable="isCurrentWeek===true">
<q-tooltip>
Edit this week
</q-tooltip>
</q-checkbox>
<q-btn class="bg-secondary text-primary" :disabled="!weekUnlocked" rounded label="new" icon="add" @click="addItem" data-cy="new"/>
</q-toolbar>
<q-toolbar class="q-pa-md bg-primary">
<span class="text-subtitle1 ">
Weekly: <span :class="textColorWeek(reportedThisWeek)">
{{ reportedThisWeek }} hrs
</span>
</span>
<q-space />
<div class="text-subtitle1">
Mon: <span :class="textColor(reportedOnMonday)">
{{ reportedOnMonday }} hrs
</span>
</div>
<div class="text-subtitle1 my-hours">
Tue: <span :class="textColor(reportedOnTuesday)">
{{ reportedOnTuesday }} hrs
</span>
</div>
<span class="text-subtitle1 my-hours">
Wed: <span :class="textColor(reportedOnWednesday)">
{{ reportedOnWednesday }} hrs
</span>
</span>
<span class="text-subtitle1 my-hours">
Thu: <span :class="textColor(reportedOnThursday)">
{{ reportedOnThursday }} hrs
</span>
</span>
<span class="text-subtitle1 my-hours">
Fri: <span :class="textColor(reportedOnFriday)">
{{ reportedOnFriday }} hrs
</span>
</span>
<span class="text-subtitle1 my-hours">
Weekend: {{ reportedOnWeekend }} hrs
</span>
</q-toolbar>
<q-table :columns="headers" row-key="name" :data="selectedReportedHours" :filter="filter" :loading="loading"
no-data-label="No hours reported this week" :pagination.sync="myPagination" :rows-per-page-options="[30,50,0]"
binary-state-sort :dense="weekUnlocked" bordered
>
<template v-slot:body="props">
<q-tr :props="props" :class="{'new-row': weekUnlocked && isActive(props.row.id)}">
<q-td key="date" :props="props">
<span v-if="!weekUnlocked">
{{ props.row.date | formatDate }}
</span>
<span v-else>
<q-input :value="props.row.date | formatDate" dense>
<template v-slot:append>
<q-icon name="event" class="cursor-pointer">
<q-popup-proxy ref="qDateProxy" transition-show="scale" transition-hide="scale"
fit anchor="bottom left" self="top left"
>
<q-date :value="props.row.date" mask="YYYY-MM-DD"
:rules="['date']" first-day-of-week="1" @input="(val) => onUpdateDate({id: props.row.id, date: val})"
/>
</q-popup-proxy>
</q-icon>
</template>
</q-input>
</span>
</q-td>
<q-td key="hours" :props="props">
<span v-if="!weekUnlocked">
{{ props.row.hours }}
</span>
<span v-else>
<q-input :value="props.row.hours" type="number" step="0.5" dense
@change="val => onUpdateHours({id: props.row.id, hours: val.target.value})"
/>
</span>
</q-td>
<q-td key="project" :props="props">
<span v-if="!weekUnlocked">
{{ props.row.project }}
</span>
<span v-else>
<q-select :value="props.row.project" :options="filteredProjects" option-name="name" option-label="name"
dense options-dense use-input hide-selected fill-input input-debounce="0"
@filter="filterProject"
@input="val => onUpdateProject({id: props.row.id, project: val.name})"
@focus="$event.target.select()"
>
<template v-slot:no-option>
<q-item>
<q-item-section class="text-grey">
No results
</q-item-section>
</q-item>
</template>
</q-select>
</span>
</q-td>
<q-td key="description" :props="props">
<span v-if="!weekUnlocked">
{{ props.row.description }}
</span>
<span v-else>
<q-input :value="props.row.description" dense
@change="val => onUpdateDescription({id: props.row.id, description: val.target.value})"
/>
</span>
</q-td>
<q-td key="rate" :props="props">
<span v-if="!weekUnlocked">
{{ props.row.rate }}
</span>
<span v-else>
<q-select :value="props.row.rate" :options="rates" option-label="name" option-name="id"
dense options-dense
@input="val => onUpdateRate({id: props.row.id, rate: val.name})"
/>
</span>
</q-td>
<q-td key="actions" :props="props">
<span v-if="!weekUnlocked" />
<span v-else>
<q-icon name="insert_drive_file" small color="light-blue-4" size="1.5em" @click="duplicateItem(props.row, 'same')">
<q-tooltip>Duplicate on the same day</q-tooltip>
</q-icon>
<q-icon name="file_copy" small color="light-blue-9" size="1.5em" @click="duplicateItem(props.row, 'next')">
<q-tooltip>Duplicate to the next day</q-tooltip>
</q-icon>
<q-icon name="delete" small color="red" size="1.5em" @click="deleteItem(props.row)">
<q-tooltip>Delete</q-tooltip>
</q-icon>
</span>
</q-td>
</q-tr>
</template>
</q-table>
<!-- Dialog to confirm delete and edit date in not current -->
<confirm ref="confirm" />
</q-page>
</template>
<script>
import { mapState } from 'vuex'
import { isWithinInterval, getISODay, parseISO, addDays } from 'date-fns'
import { format } from 'date-fns-tz'
import { workHoursMixin } from '../mixins/workHoursMixin'
export default {
components: {
/* webpackChunkName: "core" */
'confirm': () => import('components/Confirm'),
/* webpackChunkName: "core" */
'select-consultant': () => import('components/SelectConsultant'),
/* webpackChunkName: "core" */
'change-week': () => import('components/ChangeWeek')
},
filters: {
formatDate: function (date) {
if (!date) return ''
return format(parseISO(date), 'iii, M/d', Intl.DateTimeFormat().resolvedOptions().timeZone)
}
},
mixins: [ workHoursMixin ],
data () {
return {
utcTimeZone: 'UTC',
lastMaxID: 0,
filter: '',
myPagination: { 'rowsPerPage': 30, 'sortBy': 'date', 'descending': false },
headers: [
{ name: 'date', label: 'Date', align: 'left', sortable: true, field: 'date', style: 'width: 20%' },
{ name: 'hours', label: 'Hours', align: 'left', sortable: true, field: 'hours', style: 'width: 5%' },
{ name: 'project', label: 'Project', align: 'left', sortable: true, field: 'project', style: 'width: 15%' },
{ name: 'description', label: 'Description', align: 'left', sortable: false, field: 'description', style: 'width: 50%' },
{ name: 'rate', label: 'Rate', align: 'left', sortable: false, field: 'rate', width: '15%' },
{ name: 'actions', label: 'Actions', align: 'center', sortable: false, value: '', style: 'width: 5%' }
],
reported: [],
filteredProjects: this.assignedProjects
}
},
computed: {
weekUnlocked: {
get () {
return this.$store.state.context.weekUnlocked
},
set (newValue) {
this.$store.dispatch('context/setWeekUnlocked', newValue)
}
},
weeklyHolidays () {
return this.getHolidays(this.dateFrom, this.dateTo) * this.dailyWorkingHours
},
selectedReportedHours () {
const from = this.dateFrom
const to = this.dateTo
return this.reportedHours.filter(function (report) {
let d = new Date(report.date)
return (d >= from && d <= to)
})
},
reportedThisWeek () {
let rep = 0.0
for (let i = 0; i < this.selectedReportedHours.length; i++) {
rep = rep + this.selectedReportedHours[i].hours
}
return rep
},
assignedProjects () {
return this.projects.filter(v => v.disabled === false)
},
...mapState({
loading: state => state.reportedHours.loading,
isCurrentWeek: state => state.context.isCurrentWeek,
dateFrom: state => state.settings.dateFrom,
dateTo: state => state.settings.dateTo,
selectedMonth: state => state.settings.selectedMonth,
reportedHours: state => state.reportedHours.consultantMonthly,
projects: state => state.projects.all,
rates: state => state.rates.all,
selectedConsultant: state => state.consultants.selected,
selectedAllocation: state => state.consultants.selectedAllocation,
dailyWorkingHours: state => state.settings.dailyWorkingHours,
dailyWorkingHoursMax: state => state.settings.dailyWorkingHoursMax,
dailyWorkingHoursMin: state => state.settings.dailyWorkingHoursMin,
holidays: state => state.holidays.all
}),
reportedOnMonday () {
let rep = 0.0
for (let i = 0; i < this.selectedReportedHours.length; i++) {
if (getISODay(parseISO(this.selectedReportedHours[i].date)) === 1) {
rep = rep + this.selectedReportedHours[i].hours
}
}
return rep
},
reportedOnTuesday () {
let rep = 0.0
for (let i = 0; i < this.selectedReportedHours.length; i++) {
if (getISODay(parseISO(this.selectedReportedHours[i].date)) === 2) {
rep = rep + this.selectedReportedHours[i].hours
}
}
return rep
},
reportedOnWednesday () {
let rep = 0.0
for (let i = 0; i < this.selectedReportedHours.length; i++) {
if (getISODay(parseISO(this.selectedReportedHours[i].date)) === 3) {
rep = rep + this.selectedReportedHours[i].hours
}
}
return rep
},
reportedOnThursday () {
let rep = 0.0
for (let i = 0; i < this.selectedReportedHours.length; i++) {
if (getISODay(parseISO(this.selectedReportedHours[i].date)) === 4) {
rep = rep + this.selectedReportedHours[i].hours
}
}
return rep
},
reportedOnFriday () {
let rep = 0.0
for (let i = 0; i < this.selectedReportedHours.length; i++) {
if (getISODay(parseISO(this.selectedReportedHours[i].date)) === 5) {
rep = rep + this.selectedReportedHours[i].hours
}
}
return rep
},
reportedOnWeekend () {
let rep = 0.0
for (let i = 0; i < this.selectedReportedHours.length; i++) {
if (getISODay(parseISO(this.selectedReportedHours[i].date)) === 6 || getISODay(parseISO(this.selectedReportedHours[i].date)) === 7) {
rep = rep + this.selectedReportedHours[i].hours
}
}
return rep
},
maxID () {
return Math.max.apply(Math, this.reportedHours.map(function (o) { return o.id }))
}
},
watch: {
selectedConsultant (newValue, oldValue) {
this.$store.dispatch('reportedHours/getMonthlyData', { date: this.selectedMonth, consultant: this.selectedConsultant })
}
},
created () {
this.$store.commit('context/SET_PAGE', 'Reported hours')
this.$store.commit('context/SET_PAGE_ICON', 'work_outline')
if (this.reportedHours.length === 0 && this.selectedConsultant !== '') {
this.$store.dispatch('reportedHours/getMonthlyData', { date: this.selectedMonth, consultant: this.selectedConsultant })
}
},
methods: {
isActive (id) {
if (this.lastMaxID === 0 || id === this.lastMaxID + 1) {
return true
} else {
return false
}
},
// FIXME return green of this day is holiday
textColor (item) {
let colorClass = ''
if (item < this.dailyWorkingHoursMin * this.selectedAllocation) { colorClass = 'text-red-6' }
if (item >= this.dailyWorkingHoursMax) { colorClass = 'text-orange-6' }
if (item >= 24) { colorClass = 'text-red-6' }
return colorClass
},
textColorWeek (item) {
let colorClass = ''
if (item < (this.dailyWorkingHours * this.selectedAllocation * 5 - this.weeklyHolidays)) { colorClass = 'text-red-6' }
return colorClass
},
filterProject (val, update, abort) {
update(() => {
const needle = val.toLowerCase()
this.filteredProjects = this.assignedProjects.filter(v => v.name.toLowerCase().indexOf(needle) > -1)
})
},
onUpdateProject (newValue) {
let payload = {
id: newValue.id,
type: 'project',
value: newValue.project
}
this.$store.dispatch('reportedHours/updateAttributeValue', payload)
// set rate to default rate
const newRate = this.assignedProjects.find(function (element) {
if (element.name === newValue.project) { return element.rate }
})
let payloadRate = {
id: newValue.id,
type: 'rate',
value: newRate.rate
}
this.$store.dispatch('reportedHours/updateAttributeValue', payloadRate)
},
async onUpdateDate (newValue) {
this.$nextTick(function () {
this.$refs.qDateProxy.hide()
})
let payload = {
id: newValue.id,
type: 'date',
value: newValue.date
}
if (isWithinInterval(parseISO(newValue.date), { start: this.dateFrom, end: this.dateTo })) {
this.$store.dispatch('reportedHours/updateAttributeValue', payload)
} else {
if (await this.$refs.confirm.open('Please confirm', 'You selected ' + format(parseISO(newValue.date), 'iiii, MMM do', Intl.DateTimeFormat().resolvedOptions().timeZone) + '. The record will be moved to another week. Continue?', 'agree', { color: 'bg-warning' })) {
this.$store.dispatch('reportedHours/updateAttributeValue', payload)
this.$store.dispatch('settings/jumpToWeek', parseISO(newValue.date))
}
}
},
onUpdateHours (newValue) {
const hrs = parseFloat(newValue.hours)
let payload = {
id: newValue.id,
type: 'hours',
value: parseFloat(newValue.hours)
}
if (isNaN(hrs) || hrs < 0 || hrs > 24) {
payload.value = 0
}
this.$store.dispatch('reportedHours/updateAttributeValue', payload)
},
onUpdateDescription (newValue) {
let payload = {
id: newValue.id,
type: 'description',
value: newValue.description
}
this.$store.dispatch('reportedHours/updateAttributeValue', payload)
},
onUpdateRate (newValue) {
let payload = {
id: newValue.id,
type: 'rate',
value: newValue.rate
}
this.$store.dispatch('reportedHours/updateAttributeValue', payload)
},
remainingHoursDaily (date, hours) {
let totalDailyHours = this.reportedHours.filter(x => x.date === date).reduce(
function (total, current) {
return total + current.hours
}, 0)
if (typeof (totalDailyHours) === 'object') {
totalDailyHours = totalDailyHours.hours
}
const totalDailyHoursNew = totalDailyHours + hours
if (totalDailyHoursNew <= 24) {
return hours
}
if (totalDailyHoursNew < 48) {
this.$q.notify({
message: 'Over 24 hours reported on ' + format(parseISO(date), 'EEEE', Intl.DateTimeFormat().resolvedOptions().timeZone),
icon: 'warning'
})
return hours
}
if (totalDailyHours >= 48) {
this.$q.notify({
message: totalDailyHours + ' hours reported on ' + format(parseISO(date), 'EEEE', Intl.DateTimeFormat().resolvedOptions().timeZone) + ' and you want to add additional ' + hours + ' hours. Record was not created.',
icon: 'report_problem'
})
return -1
}
if (totalDailyHoursNew > 48) {
this.$q.notify({
message: 'Only ' + (48 - totalDailyHours).toString() + ' hours added on ' + format(parseISO(date), 'EEEE', Intl.DateTimeFormat().resolvedOptions().timeZone) + '. You wanted to add ' + hours + ' to already reported ' + totalDailyHours + ' hours.',
icon: 'warning'
})
return 48 - totalDailyHours
}
return hours
},
addItem (item) {
let d = new Date()
if (!isWithinInterval(d, { start: this.dateFrom, end: this.dateTo })) {
d = this.dateFrom
}
const newRecord = {
id: null,
consultant: this.selectedConsultant,
date: format(d, 'yyyy-MM-dd', { timeZone: this.utcTimeZone }),
hours: 8,
rate: '',
description: '',
project: ''
}
const newHrs = this.remainingHoursDaily(newRecord.date, newRecord.hours)
if (newHrs > 0 && newHrs <= newRecord.hours) {
// newRecord.date = format(d, "yyyy-MM-dd'T'HH:mm:ssXXX")
newRecord.date = format(d, "yyyy-MM-dd'T'00:00:00XXX", { timeZone: this.utcTimeZone })
newRecord.hours = newHrs
this.$store.dispatch('reportedHours/addRecord', newRecord)
this.lastMaxID = this.maxID
}
},
duplicateItem (item, day) {
let nextDay = ''
if (day === 'same') {
nextDay = format(parseISO(item.date), 'yyyy-MM-dd', { timeZone: this.utcTimeZone })
} else {
nextDay = format(addDays(parseISO(item.date), 1), 'yyyy-MM-dd', { timeZone: this.utcTimeZone })
}
const newHrs = this.remainingHoursDaily(nextDay, item.hours)
if (newHrs > 0 && newHrs <= item.hours) {
const newRecord = {
id: null,
date: nextDay + 'T00:00:00Z',
hours: newHrs,
project: item.project,
description: item.description,
rate: item.rate,
consultant: item.consultant
}
this.$store.dispatch('reportedHours/addRecord', newRecord)
this.lastMaxID = this.maxID
}
},
async deleteItem (item) {
if (await this.$refs.confirm.open('Please confirm', 'Are you sure you want to delete the record?', 'agree', { color: 'bg-warning' })) {
this.$store.dispatch('reportedHours/removeRecord', parseInt(item.id, 10))
this.$q.notify({
message: this.$options.filters.formatDate(item.date) + ', ' + item.hours + ' hrs - record deleted',
color: 'teal'
})
this.lastMaxID = this.maxID
} else {
// console.log('canceled record delete') /* eslint-disable-line no-console */
}
}
}
}
</script>
<style lang="stylus">
/* add space between days on reported hours weekly row */
.my-hours {
margin-left: 1em !important;
}
/* highlight new row */
@keyframes rowfadein {
from {
background: transparent;
}
to {
background: #80CBC4;
}
}
@keyframes rowfadeout {
from {
background: #80CBC4;
}
to {
background: transparent;
}
}
.new-row {
animation: rowfadein 5s;
animation: rowfadeout 5s;
}
</style>