Skip to content

Commit

Permalink
✨ added individual task rates feature (implements #199)
Browse files Browse the repository at this point in the history
🐛 fixed archived project times showing up when searching on the details page
  • Loading branch information
faburem committed Mar 21, 2024
1 parent 6903c36 commit e3b602c
Show file tree
Hide file tree
Showing 21 changed files with 249 additions and 134 deletions.
2 changes: 1 addition & 1 deletion imports/api/dashboards/server/publications.js
Expand Up @@ -13,7 +13,7 @@ Meteor.publish('dashboardById', async function dashboardById(_id) {
{
customer: dashboard.customer,
},
{ $fields: { _id: 1 } },
{ fields: { _id: 1 } },
).fetchAsync()
projectList = projectList.map((value) => value._id)
if (dashboard.resourceId.includes('all')) {
Expand Down
10 changes: 8 additions & 2 deletions imports/api/globalsettings/globalsettings.js
Expand Up @@ -143,10 +143,10 @@ defaultSettings.push({
name: 'enableTransactions', description: 'transactions.enable_transactions', type: 'checkbox', value: false, category: 'settings.categories.global',
})
defaultSettings.push({
name: 'enableLogForOtherUsers', description: 'settings.enable_log_for_other_users', type: 'checkbox', value: false, category: 'settings.categories.time_tracking',
name: 'enableLogForOtherUsers', description: 'settings.enable_log_for_other_users', type: 'checkbox', value: false, category: 'settings.categories.time_tracking',
})
defaultSettings.push({
name: 'userSearchNumResults', description: 'settings.user_search_num_results', type: 'number', value: 5, category: 'settings.categories.time_tracking',
name: 'userSearchNumResults', description: 'settings.user_search_num_results', type: 'number', value: 5, category: 'settings.categories.time_tracking',
})
defaultSettings.push({
name: 'customLogo', description: 'settings.custom_logo', type: 'textarea', value: '', category: 'settings.categories.customization',
Expand All @@ -163,4 +163,10 @@ defaultSettings.push({
defaultSettings.push({
name: 'showResourceInDetails', description: 'settings.show_resource_in_details', type: 'checkbox', value: true, category: 'settings.categories.customization',
})
defaultSettings.push({
name: 'allowIndividualTaskRates', description: 'settings.allow_individual_task_rates', type: 'checkbox', value: false, category: 'settings.categories.time_tracking',
})
defaultSettings.push({
name: 'showRateInDetails', description: 'settings.show_rate_in_details', type: 'checkbox', value: false, category: 'settings.categories.customization',
})
export { defaultSettings, Globalsettings }
26 changes: 20 additions & 6 deletions imports/api/projects/server/publications.js
Expand Up @@ -4,7 +4,7 @@ import isBetween from 'dayjs/plugin/isBetween'
import { check } from 'meteor/check'
import Projects from '../projects'
import Timecards from '../../timecards/timecards.js'
import { checkAuthentication } from '../../../utils/server_method_helpers.js'
import { checkAuthentication, getGlobalSettingAsync } from '../../../utils/server_method_helpers.js'

Meteor.publish('myprojects', async function myProjects({ projectLimit }) {
await checkAuthentication(this)
Expand Down Expand Up @@ -64,11 +64,25 @@ Meteor.publish('projectStats', async function projectStats(projectId) {
}, {
$group: { _id: '$userId', totalHours: { $sum: '$hours' } },
}]).toArray()
for (const revenue of totalTimecardsRawForRevenue) {
totalRevenue = project.rates && project.rates[revenue._id]
? totalRevenue += Number.parseFloat(revenue.totalHours)
if (await getGlobalSettingAsync('allowIndividualTaskRates')) {
const individualRateRevenue = await Timecards.find({ projectId }).fetchAsync()
for (const timecard of individualRateRevenue) {
if (timecard.taskRate) {
totalRevenue += Number.parseFloat(timecard.hours) * Number.parseFloat(timecard.taskRate)
} else {
totalRevenue = project.rates && project.rates[timecard.userId]
? totalRevenue += Number.parseFloat(timecard.hours)
* Number.parseFloat(project.rates[timecard.userId])
: totalRevenue += Number.parseFloat(timecard.hours) * Number.parseFloat(project.rate)
}
}
} else {
for (const revenue of totalTimecardsRawForRevenue) {
totalRevenue = project.rates && project.rates[revenue._id]
? totalRevenue += Number.parseFloat(revenue.totalHours)
* Number.parseFloat(project.rates[revenue._id])
: totalRevenue += Number.parseFloat(revenue.totalHours) * Number.parseFloat(project.rate)
: totalRevenue += Number.parseFloat(revenue.totalHours) * Number.parseFloat(project.rate)
}
}
totalHours = Number.parseFloat(totalTimecardsRaw[0]?.totalHours)
const currentMonthTimeCardsRaw = await Timecards.rawCollection().aggregate([{ $match: { projectId, date: { $gte: currentMonthStart, $lte: currentMonthEnd } } }, { $group: { _id: null, currentMonthHours: { $sum: '$hours' } } }]).toArray()
Expand Down Expand Up @@ -171,7 +185,7 @@ Meteor.publish('projectStats', async function projectStats(projectId) {
.isBetween(beforePreviousMonthStart, beforePreviousMonthEnd)) {
beforePreviousMonthHours += Number.parseFloat(timecard.hours)
}
if (project.rates[timecard.userId]) {
if (project?.rates[timecard.userId]) {
totalRevenue += Number.parseFloat(timecard.hours)
* Number.parseFloat(project.rates[timecard.userId])
} else {
Expand Down
41 changes: 28 additions & 13 deletions imports/api/timecards/server/methods.js
Expand Up @@ -75,13 +75,14 @@ async function checkTimeEntryRule({
* @param {Date} args.date - The date of the timecard.
* @param {number} args.hours - The number of hours for the timecard.
* @param {string} [args.userId] - The ID of the user for the timecard.
* @param {number} taskRate - The rate of the task for the time card.
* @param {Object} [args.customfields] - The custom fields for the timecard.
* @throws {Meteor.Error} If user is not authenticated.
* @returns {String} 'notifications.success' if successful
* @throws {Meteor.Error} If time entry rule fails.
* @throws {Meteor.Error} If time entry rule throws an error.
*/
async function insertTimeCard(projectId, task, date, hours, userId, customfields) {
async function insertTimeCard(projectId, task, date, hours, userId, taskRate, customfields) {
const newTimeCard = {
userId,
projectId,
Expand All @@ -90,6 +91,9 @@ async function insertTimeCard(projectId, task, date, hours, userId, customfields
task: await emojify(task),
...customfields,
}
if (taskRate) {
newTimeCard.taskRate = taskRate
}
if (!await Tasks.findOneAsync({ $or: [{ userId }, { projectId }], name: await emojify(task) })) {
await Tasks.insertAsync({
userId, lastUsed: new Date(), name: await emojify(task), ...customfields,
Expand Down Expand Up @@ -203,12 +207,13 @@ const insertTimeCardMethod = new ValidatedMethod({
check(args.task, String)
check(args.date, Date)
check(args.hours, Number)
check(args.taskRate, Match.Maybe(Number))
check(args.customfields, Match.Maybe(Object))
check(args.user, String)
},
mixins: [authenticationMixin, transactionLogMixin],
async run({
projectId, task, date, hours, customfields, user,
projectId, task, date, hours, taskRate, customfields, user,
}) {
let { userId } = this
if (user !== userId) {
Expand All @@ -217,7 +222,7 @@ const insertTimeCardMethod = new ValidatedMethod({
const check = await checkTimeEntryRule({
userId, projectId, task, state: 'new', date, hours,
})
await insertTimeCard(projectId, task, date, hours, userId, customfields)
await insertTimeCard(projectId, task, date, hours, userId, taskRate, customfields)
},
})
/**
Expand Down Expand Up @@ -287,12 +292,13 @@ const updateTimeCard = new ValidatedMethod({
check(args.task, String)
check(args.date, Date)
check(args.hours, Number)
check(args.taskRate, Match.Maybe(Number))
check(args.customfields, Match.Maybe(Object))
check(args.user, String)
},
mixins: [authenticationMixin, transactionLogMixin],
async run({
projectId, _id, task, date, hours, customfields, user,
projectId, _id, task, date, hours, taskRate, customfields, user,
}) {
let { userId } = this
if (user !== userId) {
Expand All @@ -305,15 +311,24 @@ const updateTimeCard = new ValidatedMethod({
if (!await Tasks.findOneAsync({ userId, name: await emojify(task) })) {
await Tasks.insertAsync({ userId, name: await emojify(task), ...customfields })
}
await Timecards.updateAsync({ _id }, {
$set: {
projectId,
date,
hours,
task: await emojify(task),
...customfields,
},
})
const fieldsToSet = {
projectId,
date,
hours,
task: await emojify(task),
...customfields,
}
if (taskRate) {
fieldsToSet.taskRate = taskRate
await Timecards.updateAsync({ _id }, {
$set: fieldsToSet,
})
} else {
await Timecards.updateAsync({ _id }, {
$set: fieldsToSet,
$unset: { taskRate: '' },
})
}
},
})
/**
Expand Down
9 changes: 7 additions & 2 deletions imports/api/timecards/server/publications.js
Expand Up @@ -10,8 +10,13 @@ Meteor.publish('periodTimecards', async function periodTimecards({ startDate, en
check(userId, String)
await checkAuthentication(this)
let projectList = await Projects.find(
{ $or: [{ userId: this.userId }, { public: true }, { team: this.userId }] },
{ $fields: { _id: 1 } },
{
$and: [
{ $or: [{ userId: this.userId }, { public: true }, { team: this.userId }] },
{ $or: [{ archived: false }, { archived: { $exists: false } }] },
],
},
{ fields: { _id: 1 } },
).fetchAsync()
projectList = projectList.map((value) => value._id)

Expand Down
35 changes: 29 additions & 6 deletions imports/ui/pages/details/components/detailtimetable.js
Expand Up @@ -71,6 +71,14 @@ function detailedDataTableMapper(entry, forExport) {
mapping.push(dayjs.utc(entry.date).local().add(entry.hours, 'hour').format('HH:mm'))
}
mapping.push(Number(timeInUserUnit(entry.hours)))
if (getGlobalSetting('showRateInDetails')) {
let resourceRate
if (project.rates) {
resourceRate = project.rates[entry.userId]
}
const rate = entry.taskRate || resourceRate || project.rate || 0
mapping.push(rate)
}
mapping.push(entry._id)
return mapping
}
Expand Down Expand Up @@ -235,13 +243,21 @@ Template.detailtimetable.onRendered(() => {
},
)
}
columns.push(
{
name: getUserTimeUnitVerbose(),
id: 'hours',
columns.push({
name: getUserTimeUnitVerbose(),
id: 'hours',
editable: false,
format: numberWithUserPrecision,
})
if (getGlobalSetting('showRateInDetails')) {
columns.push({
name: t('project.rate'),
id: 'rate',
editable: false,
format: numberWithUserPrecision,
},
})
}
columns.push(
{
name: t('navigation.edit'),
id: 'actions',
Expand Down Expand Up @@ -453,7 +469,11 @@ Template.detailtimetable.events({
csvArray[0] = `${csvArray[0]},${t('details.startTime')}`
csvArray[0] = `${csvArray[0]},${t('details.endTime')}`
}
csvArray[0] = `${csvArray[0]},${getUserTimeUnitVerbose()}\r\n`
csvArray[0] = `${csvArray[0]},${getUserTimeUnitVerbose()}`
if (getGlobalSetting('showRateInDetails')) {
csvArray[0] = `${csvArray[0]},${t('project.rate')}`
}
csvArray[0] = `${csvArray[0]}\r\n`
const selector = structuredClone(templateInstance.selector.get()[0])
selector.state = { $in: ['new', undefined] }
for (const timeEntry of Timecards
Expand Down Expand Up @@ -510,6 +530,9 @@ Template.detailtimetable.events({
data[0].push(t('details.endTime'))
}
data[0].push(getUserTimeUnitVerbose())
if (getGlobalSetting('showRateInDetails')) {
data[0].push(t('project.rate'))
}
const selector = structuredClone(templateInstance.selector.get()[0])
selector.state = { $in: ['new', undefined] }
for (const timeEntry of Timecards
Expand Down
7 changes: 7 additions & 0 deletions imports/ui/pages/track/components/tasksearch.html
Expand Up @@ -13,6 +13,13 @@
<i class="fas fa-list-alt text-body"></i>
</button>
{{/if}}
{{#if getGlobalSetting "allowIndividualTaskRates"}}
<button type="button" class="btn btn-outline-secondary js-show-task-rate"><i class="fa-solid fa-money-bill-1 text-body"></i></button>
<div class="form-floating js-task-rate-container d-none" style="max-width:10rem">
<input type="number" class="form-control" name="taskRate" id="taskRate" value="{{taskRate}}" placeholder="{{t "project.rate"}}"/>
<label class="form-label" for="taskRate">{{t "project.rate"}}</label>
</div>
{{/if}}
</div>
{{/if}}
</template>
5 changes: 5 additions & 0 deletions imports/ui/pages/track/components/tasksearch.js
Expand Up @@ -26,6 +26,10 @@ Template.tasksearch.events({
templateInstance.filter.set('')
templateInstance.targetTask.renderIfNeeded()
},
'click .js-show-task-rate': (event, templateInstance) => {
event.preventDefault()
templateInstance.$('.js-task-rate-container').toggleClass('d-none')
},
})

Template.tasksearch.onCreated(function tasksearchcreated() {
Expand Down Expand Up @@ -207,6 +211,7 @@ Template.tasksearch.onCreated(function tasksearchcreated() {
Template.tasksearch.helpers({
displayTaskSelectionIcon: () => (Template.instance()?.data?.projectId
? Template.instance()?.data?.projectId?.get() : false),
taskRate: () => Timecards.findOne({ _id: Template.instance().data.tcid?.get() })?.taskRate,
})
Template.tasksearch.onRendered(() => {
Template.instance().$('#edit-tc-entry-modal').on('hidden.bs.modal', () => {
Expand Down
4 changes: 2 additions & 2 deletions imports/ui/pages/track/components/timetracker.html
Expand Up @@ -14,7 +14,7 @@
<label class="form-label">{{t "tracktime.timer"}}</label>
</div>
<button type="button" class="btn btn-outline-secondary js-stop" aria-label="Stop timer">
<i class="fa fa-stop"></i>
<i class="fa fa-stop text-body"></i>
</button>
</div>
{{/if}}
Expand All @@ -25,7 +25,7 @@
<label class="form-label">{{t "tracktime.timer"}}</label>
</div>
<button type="button" class="btn btn-outline-secondary js-start rounded-end" aria-label="Start timer">
<i class="fa fa-circle"></i>
<i class="fa fa-circle text-body"></i>
</button>
</div>
{{/if}}
Expand Down
2 changes: 1 addition & 1 deletion imports/ui/pages/track/components/usersearch.js
Expand Up @@ -26,7 +26,7 @@ Template.usersearch.onCreated(function usersearchcreated() {
const handle = this.subscribe('singleTimecard', tcid)
if (handle.ready()) {
const card = Timecards.findOne({ _id: tcid })
const user = this.users.get().find((u) => u._id === card.userId)
const user = this.users?.get()?.find((u) => u._id === card.userId)
if (user?.profile) {
this.$('.js-usersearch-input').val(user.profile.name)
}
Expand Down
17 changes: 15 additions & 2 deletions imports/ui/pages/track/tracktime.js
Expand Up @@ -158,6 +158,12 @@ Template.tracktime.events({
const selectedProjectElement = templateInstance.$('.js-tracktime-projectselect > div > div > .js-target-project')
templateInstance.projectId.set(selectedProjectElement.get(0).getAttribute('data-value'))
let hours = templateInstance.$('#hours').val()
let taskRate
if (getGlobalSetting('allowIndividualTaskRates')) {
if (templateInstance.$('#taskRate').val() && templateInstance.$('#taskRate').val() !== '0' && templateInstance.$('#taskRate').val() !== '') {
taskRate = Number(templateInstance.$('#taskRate').val())
}
}
if (!templateInstance.projectId.get()) {
selectedProjectElement.addClass('is-invalid')
showToast(t('notifications.select_project'))
Expand Down Expand Up @@ -224,7 +230,14 @@ Template.tracktime.events({
}
}
Meteor.call('updateTimeCard', {
_id: templateInstance.tcid.get(), projectId, date, hours, task, customfields, user,
_id: templateInstance.tcid.get(),
projectId,
date,
hours,
task,
customfields,
user,
taskRate,
}, (error) => {
if (error) {
console.error(error)
Expand All @@ -244,7 +257,7 @@ Template.tracktime.events({
})
} else {
Meteor.call('insertTimeCard', {
projectId, date, hours, task, customfields, user,
projectId, date, hours, task, customfields, user, taskRate,
}, (error) => {
if (error) {
console.error(error)
Expand Down
6 changes: 4 additions & 2 deletions imports/ui/translations/de.json
Expand Up @@ -140,7 +140,7 @@
"zh": "Chinesisch",
"ru": "Russisch",
"ukr": "Ukrainisch",
"es": "Spanisch",
"es": "Spanisch",
"user_interface": "Benutzeroberfläche",
"theme": "Thema",
"auto_detect": "Automatisch",
Expand Down Expand Up @@ -226,7 +226,9 @@
"google_clientid": "Google Workspace Client ID",
"google_secret": "Google Workspace Client Secret",
"openai": "Open AI API Key",
"show_resource_in_details": "Ressource in Detailansicht anzeigen/exportieren?"
"show_resource_in_details": "Ressource in Detailansicht anzeigen/exportieren?",
"allow_individual_task_rates": "Individuelle Stundensätze für Tätigkeiten erlauben?",
"show_rate_in_details": "Rate in Detailansicht anzeigen/exportieren?"
},
"customer": {
"select_customer": "Kunde auswählen",
Expand Down
6 changes: 4 additions & 2 deletions imports/ui/translations/en.json
Expand Up @@ -140,7 +140,7 @@
"zh": "Chinese",
"ru": "Russian",
"ukr": "Ukrainian",
"es": "Spanish",
"es": "Spanish",
"user_interface": "User interface",
"theme": "Theme",
"auto_detect": "Auto detect",
Expand Down Expand Up @@ -226,7 +226,9 @@
"google_clientid": "Google Workspace Client ID",
"google_secret": "Google Workspace Client Secret",
"openai": "Open AI API Key",
"show_resource_in_details": "Show/export resource field in details view?"
"show_resource_in_details": "Show/export resource field in details view?",
"allow_individual_task_rates": "Allow individual task rates?",
"show_rate_in_details": "Show/export rate field in details view?"
},
"customer": {
"select_customer": "Select a customer",
Expand Down

0 comments on commit e3b602c

Please sign in to comment.