diff --git a/package.json b/package.json index db03063..f506785 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,7 @@ "uuid": "ca6b733a-92c2-453b-9dbb-89dbf373fb2d" }, "engines": { - "node": ">=16.x.x <=18.x.x", + "node": ">=16.x.x <=20.x.x", "npm": ">=6.0.0" }, "license": "MIT" diff --git a/src/api/rse/services/rse.js b/src/api/rse/services/rse.js index 53a3204..a2a210c 100644 --- a/src/api/rse/services/rse.js +++ b/src/api/rse/services/rse.js @@ -12,27 +12,26 @@ module.exports = createCoreService('api::rse.rse', () => ({ async find(...args) { let populate = { - assignments: true, - capacities: true + assignments: false, + capacities: false } - // If populate is not set, populate assignments and capacities - if(args[0].populate && !args[0].populate.isArray && args[0].populate.assignments && args[0].populate.capacities) { - args[0].populate = ['assignments', 'capacities'] + if(!args[0].populate || Array.isArray(args[0].populate)) { + args[0].populate = { assignments: true, capacities: true } } else { - if(!args[0] || !args[0].populate.isArray) { - args[0].populate = [] + if(Object.keys(args[0].populate).includes('assignments')) { + populate.assignments = true } - - if(!args[0].populate.includes('assignments')) { - populate.assignments = false - args[0].populate.push('assignments') + else { + args[0].populate['assignments'] = true } - if(!args[0].populate.includes('capacities')) { - populate.capacities = false - args[0].populate.push('capacities') + if(Object.keys(args[0].populate).includes('capacities')) { + populate.capacities = true + } + else { + args[0].populate['capacities'] = true } } diff --git a/src/api/timesheet/controllers/timesheet.js b/src/api/timesheet/controllers/timesheet.js index ed9b697..4db1077 100644 --- a/src/api/timesheet/controllers/timesheet.js +++ b/src/api/timesheet/controllers/timesheet.js @@ -34,5 +34,13 @@ module.exports = { ctx.body = err console.error(err) } + }, + utilisation: async (ctx) => { + try { + ctx.body = await strapi.service('api::timesheet.timesheet').utilisation(ctx.request.query) + } catch (err) { + ctx.body = err + console.error(err) + } } } \ No newline at end of file diff --git a/src/api/timesheet/routes/timesheet.js b/src/api/timesheet/routes/timesheet.js index bb96064..f116fcc 100644 --- a/src/api/timesheet/routes/timesheet.js +++ b/src/api/timesheet/routes/timesheet.js @@ -35,6 +35,15 @@ module.exports = { policies: [], middlewares: ['api::timesheet.financial-year'], }, + }, + { + method: "GET", + path: "/timesheets/utilisation", + handler: "timesheet.utilisation", + config: { + policies: [], + middlewares: ['api::timesheet.financial-year'], + }, } ], }; \ No newline at end of file diff --git a/src/api/timesheet/services/timesheet.js b/src/api/timesheet/services/timesheet.js index 24fdd9f..0d721d1 100644 --- a/src/api/timesheet/services/timesheet.js +++ b/src/api/timesheet/services/timesheet.js @@ -87,6 +87,37 @@ async function fetchBankHolidays(year) { return [...closures, ...bankHolidays] } +async function fetchSummaryReport(year, userIDs, projectIDs) { + let startDate = DateTime.utc(year, 8), + endDate = startDate.plus({ year: 1 }).minus({ days: 1 }).endOf('day') + + let payload = { + dateRangeStart: startDate.toISO(), + dateRangeEnd: endDate.toISO(), + summaryFilter: { + groups: ['USER', 'MONTH', 'PROJECT'] + } + } + + if(userIDs) { + payload.users = { + ids: userIDs, + contains: 'CONTAINS', + status: 'ALL', + } + } + + if(projectIDs) { + payload.projects = { + ids: projectIDs, + contains: 'CONTAINS', + status: 'ALL', + } + } + + return await axios.post('/summary', payload, clockifyConfig) +} + async function fetchDetailedReport(year, userIDs, projectIDs, page = 1, timeEntries = []) { let startDate = DateTime.utc(year, 8), endDate = startDate.plus({ year: 1 }).minus({ days: 1 }).endOf('day') @@ -406,7 +437,7 @@ module.exports = ({ strapi }) => ({ const timesheets = await strapi.services['api::timesheet.timesheet'].find(...args), assignments = await strapi.services['api::assignment.assignment'].find({filters: dateRangeFilter}), - capacities = await strapi.services['api::capacity.capacity'].find({filters: { $or: [...dateRangeFilter.$or, { end: { $null: true }} ]} }), + capacities = await strapi.services['api::capacity.capacity'].find({filters: { $or: [...dateRangeFilter, { end: { $null: true }} ]} }), holidays = await fetchBankHolidays(args[0].filters.year.$eq), annualLeave = await strapi.services['api::timesheet.timesheet'].leave({...args[0]}) @@ -506,6 +537,157 @@ module.exports = ({ strapi }) => ({ summary.totals.volunteered = (summary.days.volunteered.reduce((total, entry) => total + Number(entry), 0)).toFixed(1) return { data: summary } - } + }, + + utilisation: async(...args) => { + + const query = args[0] + + const startDate = DateTime.fromISO(`${args[0].filters.year.$eq}-08-01`), + endDate = DateTime.fromISO(`${(Number(args[0].filters.year.$eq)+1)}-07-31`) + const dateRangeFilter = { + $or: [ + { + start: { + $between: [startDate.toISODate(), endDate.toISODate() ] + } + }, + { + $or: [ + { + end: { + $between: [startDate.toISODate(), endDate.toISODate() ] + } + }, + { + end: { + $null: true + } + } + ] + }, + { + $and: [ + { + start: { + $lt: startDate.toISODate() + }, + }, + { + end: { + $gt: endDate.toISODate() + } + } + ] + } + ] + } + + // Year is always present due to middleware + const year = Number(query.filters.year.$eq) + + // Optional query parameters + const userIDs = query.filters.userIDs ? query.filters.userIDs.$in : null, + projectIDs = query.filters.projectIDs ? query.filters.projectIDs.$in : null + + clockifyConfig.cache.override = query.clearCache && query.clearCache === 'true' + + const summary = await fetchSummaryReport(year, userIDs, projectIDs), + annuaLeave = await strapi.services['api::timesheet.timesheet'].leave(query), + holidays = await fetchBankHolidays(year), + rses = await strapi.services['api::rse.rse'].find({populate: { capacities: { filters: dateRangeFilter } }, filters: { active: true } }) + + + const holidayDates = holidays.map(holiday => DateTime.fromISO(holiday.date).toISODate()) + + let data = { + total: { + billable: summary.data.totals[0].totalBillableTime, + nonBillable: summary.data.totals[0].totalTime - summary.data.totals[0].totalBillableTime, + recorded: summary.data.totals[0].totalTime + }, + months: {}, + rses: {} + } + + summary.data.groupOne.forEach(rse => { + + let profile = rses.results.find(r => r.clockifyID === rse._id) + + const rseLeave = annuaLeave.data.filter(leave => leave.ID === profile.username) + + let months = [] + + rse.children.forEach(month => { + + let billableTime = month.children.reduce((total, project) => project.amount > 0 ? total + project.duration : 0, 0) + + const start = DateTime.fromFormat(month.name, 'MMM yyyy').startOf('month'), + end = start.month === DateTime.now().month ? DateTime.now() : start.endOf('month') + + let date = start + + let monthlyCapacity = 0 + + // calculate seconds available in the month + while(date < end) { + if(!date.isWeekend && !holidayDates.includes(date.toISODate())) { + + let leaveDay = rseLeave.find(leave => leave.DATE === date.toISODate()) + + if(leaveDay) { + // add 3.7 hours for half day leave + monthlyCapacity += leaveDay.DURATION === 'Y' ? 0 : 3.7 * 60 * 60 + } + else { + monthlyCapacity += 7.4 * 60 * 60 + } + } + date = date.plus({days: 1}) + } + + // pro-rata based on rse capacity + monthlyCapacity = monthlyCapacity * (profile.capacities[0].capacity / 100) + + months.push({ + month: start.month, + year: start.year, + recorded: month.duration, + billable: billableTime, + nonBillable: month.duration - billableTime, + capacity: Number(monthlyCapacity.toFixed(0)) + }) + + if(!data.months[`${start.toFormat('MMMM')}`]) { + data.months[`${start.toFormat('MMMM')}`] = { + recorded: 0, + billable: 0, + nonBillable: 0, + capacity: 0 + } + } + + data.months[`${start.toFormat('MMMM')}`].recorded += month.duration + data.months[`${start.toFormat('MMMM')}`].billable += billableTime + data.months[`${start.toFormat('MMMM')}`].nonBillable += (month.duration - billableTime) + data.months[`${start.toFormat('MMMM')}`].capacity += Number(monthlyCapacity.toFixed(0)) + }) + + data.rses[profile.id] = { + name: profile.displayName, + total: { + recorded: rse.duration, + billable: months.reduce((total, month) => total + month.billable, 0), + nonBillable: months.reduce((total, month) => total + month.nonBillable, 0), + capacity: months.reduce((total, month) => total + month.capacity, 0), + }, + months: months + } + }) + + data.total.capacity = Object.values(data.rses).reduce((total, rse) => total + rse.total.capacity, 0) + + return data + } }) diff --git a/src/extensions/documentation/documentation/1.0.0/full_documentation.json b/src/extensions/documentation/documentation/1.0.0/full_documentation.json index 0c68b84..af6fc31 100644 --- a/src/extensions/documentation/documentation/1.0.0/full_documentation.json +++ b/src/extensions/documentation/documentation/1.0.0/full_documentation.json @@ -14,7 +14,7 @@ "name": "Apache 2.0", "url": "https://www.apache.org/licenses/LICENSE-2.0.html" }, - "x-generation-date": "2024-09-18T08:16:42.272Z" + "x-generation-date": "2024-09-21T09:02:52.009Z" }, "x-strapi-config": { "path": "/documentation",