feat(approvals): add chart to track total hours for each type, chart to track expenses, begin work on widget to display detailed shift info.

This commit is contained in:
Nicolas Drolet 2025-09-02 16:56:04 -04:00
parent 24a700d6f6
commit 89148343b6
10 changed files with 282 additions and 51 deletions

View File

@ -246,7 +246,7 @@ export default {
newUsers: 'New user', newUsers: 'New user',
updateUsers: 'Update user', updateUsers: 'Update user',
timeSheets: 'Time sheet', timeSheets: 'Time sheet',
timeSheetValidations: 'Time sheet', timeSheetValidations: 'Time sheet approvals',
}, },
timeSheet: { timeSheet: {
timeSheetTab_1: 'Shifts', timeSheetTab_1: 'Shifts',

View File

@ -6,6 +6,7 @@
import type { PayPeriodEmployeeDetails } from 'src/modules/timesheet-approval/types/timesheet-approval-pay-period-employee-details-interface'; import type { PayPeriodEmployeeDetails } from 'src/modules/timesheet-approval/types/timesheet-approval-pay-period-employee-details-interface';
const { t } = useI18n(); const { t } = useI18n();
ChartJS.register(Title, Tooltip, Legend, BarElement, CategoryScale, LinearScale); ChartJS.register(Title, Tooltip, Legend, BarElement, CategoryScale, LinearScale);
ChartJS.defaults.font.family = '"Roboto", sans-serif'; ChartJS.defaults.font.family = '"Roboto", sans-serif';
@ -21,8 +22,7 @@
}); });
const hours_worked_labels = ref<string[]>([]); const hours_worked_labels = ref<string[]>([]);
const hours_worked_dataset = ref<ChartDataset<'bar'>[]>([]); const hours_worked_dataset = ref<ChartDataset<'bar'>[]>([]);
const getHoursWorkedData = (): ChartData<'bar'> => { const getHoursWorkedData = (): ChartData<'bar'> => {
if (props.rawData) { if (props.rawData) {
@ -63,30 +63,12 @@
datasets: hours_worked_dataset.value, datasets: hours_worked_dataset.value,
}; };
}; };
</script> </script>
<template> <template>
<Bar <Bar
:data="getHoursWorkedData()" :data="getHoursWorkedData()"
:options="({ :options="props.options"
indexAxis: $q.screen.lt.md? 'y' : 'x',
plugins: {
title: {
display: true,
text: t('timeSheetValidations.hoursWorkedChartTitle'),
color: '#616161'
}
},
scales: {
x: {
stacked: true,
},
y: {
stacked: true,
}
}
})"
/> />
</template> </template>

View File

@ -0,0 +1,63 @@
<script setup lang="ts">
/* eslint-disable */
import { ref } from 'vue';
import { Doughnut } from 'vue-chartjs';
import { useI18n } from 'vue-i18n';
import { Chart as ChartJS, Title, Tooltip, Legend, ArcElement, CategoryScale, LinearScale, type ChartData, type ChartOptions, type Plugin, type ChartDataset } from 'chart.js';
import type { PayPeriodOverviewEmployee } from 'src/modules/timesheet-approval/types/timesheet-approval-pay-period-overview-employee-interface';
const { t } = useI18n();
ChartJS.register(Title, Tooltip, Legend, ArcElement, CategoryScale, LinearScale);
ChartJS.defaults.font.family = '"Roboto", sans-serif';
ChartJS.defaults.maintainAspectRatio = false;
const props = withDefaults(defineProps<{
rawData: PayPeriodOverviewEmployee | undefined;
options?: ChartOptions<"doughnut"> | undefined;
plugins?: Plugin<"doughnut">[] | undefined;
}>(), {
options: () => ({}),
plugins: () => [],
});
const shift_type_labels = ref<string[]>([]);
const shift_type_totals = ref<ChartDataset<'doughnut'>[]>([{ data: [40, 0, 2, 5], }]);
if (props.rawData){
shift_type_totals.value = [{
data: [
props.rawData.regular_hours,
props.rawData.evening_hours,
props.rawData.emergency_hours,
props.rawData.overtime_hours,
],
backgroundColor: [
getComputedStyle(document.body).getPropertyValue('--q-primary').trim(),
getComputedStyle(document.body).getPropertyValue('--q-info').trim(),
getComputedStyle(document.body).getPropertyValue('--q-warning').trim(),
getComputedStyle(document.body).getPropertyValue('--q-negative').trim(),
]
}];
shift_type_labels.value = [
props.rawData.regular_hours.toString() + 'h',
props.rawData.evening_hours.toString() + 'h',
props.rawData.emergency_hours.toString() + 'h',
props.rawData.overtime_hours.toString() + 'h',
]
}
const data = {
labels: shift_type_labels.value,
datasets: shift_type_totals.value,
}
</script>
<template>
<Doughnut
:data="data"
:options="props.options"
/>
</template>

View File

@ -0,0 +1,81 @@
<script setup lang="ts">
/*eslint-disable*/
import { ref } from 'vue';
import { Line } from 'vue-chartjs';
import { useI18n } from 'vue-i18n';
import { Chart as ChartJS, Title, Tooltip, Legend, LineElement, PointElement, LineController, CategoryScale, LinearScale, TimeScale, type ChartData, type ChartOptions, type Plugin, type ChartDataset } from 'chart.js';
import type { PayPeriodEmployeeDetails } from 'src/modules/timesheet-approval/types/timesheet-approval-pay-period-employee-details-interface';
import type { Expense } from 'src/modules/timesheets/types/timesheet-details-interface';
const { t } = useI18n();
ChartJS.register(Title, Tooltip, Legend, LineElement, LineController, PointElement, CategoryScale, LinearScale, TimeScale);
ChartJS.defaults.font.family = '"Roboto", sans-serif';
// ChartJS.defaults.maintainAspectRatio = false;
const props = withDefaults(defineProps<{
rawData: PayPeriodEmployeeDetails | undefined;
options?: ChartOptions<"line"> | undefined;
plugins?: Plugin<"line">[] | undefined;
}>(), {
options: () => ({}),
plugins: () => [],
});
const expenses_dataset = ref<ChartDataset<'line'>[]>([]);
const expenses_labels = ref<string[]>([]);
const getExpensesData = (): ChartData<'line'> => {
if (props.rawData) {
const all_weeks = [props.rawData.week1, props.rawData.week2];
const all_days = all_weeks.flatMap( week => Object.values(week.expenses));
console.log('all days: ', all_days);
const all_days_dates = all_weeks.flatMap( week => Object.values(week.shifts))
const all_costs = all_days.map( day => getTotalAmounts(day.cash));
const all_mileage = all_days.map( day => getTotalAmounts(day.km));
expenses_dataset.value = [
{
label: t('timeSheet.refund'),
data: all_costs,
borderColor: getComputedStyle(document.body).getPropertyValue('--q-primary').trim(),
backgroundColor: getComputedStyle(document.body).getPropertyValue('--q-primary').trim(),
borderWidth: 2,
},
{
label: t('timeSheet.mileage'),
data: all_mileage,
borderColor: getComputedStyle(document.body).getPropertyValue('--q-info').trim(),
backgroundColor: getComputedStyle(document.body).getPropertyValue('--q-info').trim(),
borderWidth: 2,
}
]
expenses_labels.value = all_days_dates.map( day => day.short_date);
}
return {
datasets: expenses_dataset.value,
labels: expenses_labels.value
};
};
const getTotalAmounts = (expenses: Expense[]): number => {
let total_amount = 0;
for (const expense of expenses) {
total_amount += expense.amount;
}
return total_amount;
}
</script>
<template>
<Line
:data="getExpensesData()"
:options="props.options"
/>
</template>

View File

@ -0,0 +1,14 @@
<script setup lang="ts">
import { PayPeriodEmployeeDetails } from 'src/modules/timesheet-approval/types/timesheet-approval-pay-period-employee-details-interface';
const props = defineProps<{
rawData: PayPeriodEmployeeDetails;
}>();
</script>
<template>
</template>

View File

@ -1,17 +1,25 @@
<script setup lang="ts"> <script setup lang="ts">
import type { PayPeriodEmployeeDetails } from '../types/timesheet-approval-pay-period-employee-details-interface'; /*eslint-disable*/
import { useI18n } from 'vue-i18n';
import TimesheetApprovalEmployeeDetailsHoursWorkedChart from 'src/modules/timesheet-approval/components/graphs/timesheet-approval-employee-details-hours-worked-chart.vue'; import TimesheetApprovalEmployeeDetailsHoursWorkedChart from 'src/modules/timesheet-approval/components/graphs/timesheet-approval-employee-details-hours-worked-chart.vue';
import TimesheetApprovalEmployeeDetailsShiftTypesChart from 'src/modules/timesheet-approval/components/graphs/timesheet-approval-employee-details-shift-types-chart.vue';
import TimesheetApprovalEmployeeExpensesChart from 'src/modules/timesheet-approval/components/graphs/timesheet-approval-employee-expenses-chart.vue';
import type { PayPeriodOverviewEmployee } from 'src/modules/timesheet-approval/types/timesheet-approval-pay-period-overview-employee-interface';
import type { PayPeriodEmployeeDetails } from '../types/timesheet-approval-pay-period-employee-details-interface';
const props = defineProps<{ const props = defineProps<{
isLoading: boolean; isLoading: boolean;
employeeName: string; employeeName: string;
employeeOverview: PayPeriodOverviewEmployee | undefined;
employeeDetails: PayPeriodEmployeeDetails | undefined; employeeDetails: PayPeriodEmployeeDetails | undefined;
updateKey: number; updateKey: number;
}>(); }>();
const { t } = useI18n();
</script> </script>
<template> <template>
<q-card class="q-pa-md bg-white shadow-12"> <q-card class="q-pa-md bg-white shadow-12 full-width rounded-15">
<!-- loader --> <!-- loader -->
<q-card-section <q-card-section
v-if="props.isLoading" v-if="props.isLoading"
@ -28,21 +36,90 @@
</q-card-section> </q-card-section>
<!-- employee name --> <!-- employee name -->
<q-card-section v-if="!props.isLoading" class="text-h5 text-weight-bolder text-center full-width text-primary q-pt-none"> <q-card-section v-if="!props.isLoading" class="text-h5 text-weight-bolder text-center full-width text-primary q-pa-none text-uppercase">
{{ props.employeeName }} {{ props.employeeName }}
<q-separator class="q-mb-sm" color="accent" size="2px" />
</q-card-section> </q-card-section>
<!-- employee timesheet details --> <!-- employee timesheet details -->
<q-card-section <q-card-section v-if="!props.isLoading" class="q-pa-none justify-center">
v-if="!props.isLoading" <TimesheetApprovalEmployeeDetailsHoursWorkedChart
class="q-pa-none justify-center" ref="chart1"
:class="$q.screen.lt.lg? 'column': 'row'"
>
<TimesheetApprovalEmployeeDetailsHoursWorkedChart
:key="props.updateKey" :key="props.updateKey"
:raw-data="props.employeeDetails" :raw-data="props.employeeDetails"
style="min-height: 300px;" style="min-height: 300px;"
:options="({
indexAxis: $q.screen.lt.md? 'y' : 'x',
plugins: {
legend: {
labels: {
boxWidth: 15,
},
},
title: {
display: true,
text: t('timeSheetValidations.hoursWorkedChartTitle'),
color: '#616161'
}
},
scales: {
x: {
stacked: true,
},
y: {
stacked: true,
}
}
})"
/> />
</q-card-section> </q-card-section>
<q-separator class="q-my-sm"/>
<q-card-section :horizontal="$q.screen.gt.sm">
<q-card-section class="q-pa-none q-ma-none">
<TimesheetApprovalEmployeeDetailsShiftTypesChart
:key="props.updateKey + 1"
style="max-height: 200px;"
:raw-data="props.employeeOverview"
:options="({
plugins:{
legend:{
labels:{
boxWidth: 15,
}
}
}
})"
/>
</q-card-section>
<q-separator :vertical="$q.screen.gt.sm" class="q-my-sm" />
<q-card-section class="q-py-none q-ma-none">
<TimesheetApprovalEmployeeExpensesChart
:style="$q.screen.lt.md ? 'min-height: 200px;': '' "
:raw-data="props.employeeDetails"
:options="({
plugins: {
title: {
display: true,
text: t('timeSheetValidations.reportFilterExpenses'),
color: '#616161'
},
legend:{
labels:{
boxWidth: 15,
}
}
},
elements: {
point:{
pointStyle: false,
}
},
})"
/>
</q-card-section>
</q-card-section>
</q-card> </q-card>
</template> </template>

View File

@ -26,6 +26,7 @@
const report_filter_company = ref<boolean[]>([true, true]); const report_filter_company = ref<boolean[]>([true, true]);
const report_filter_type = ref<boolean[]>([true, true, true, true]); const report_filter_type = ref<boolean[]>([true, true, true, true]);
const clicked_employee_name = ref<string>(''); const clicked_employee_name = ref<string>('');
const clicked_employee_email = ref<string>('');
const update_key = ref<number>(0); const update_key = ref<number>(0);
const columns = computed((): QTableColumn<PayPeriodOverviewEmployee>[] => [ const columns = computed((): QTableColumn<PayPeriodOverviewEmployee>[] => [
@ -114,13 +115,19 @@
} }
} }
const getEmployeeOverview = (email: string): PayPeriodOverviewEmployee | undefined => {
return timesheet_approval_api.getPayPeriodOverviewByEmployeeEmail(email);
}
const onDateSelected = async (date_string: string) => { const onDateSelected = async (date_string: string) => {
await timesheet_approval_api.getPayPeriodOverviewByDate(date_string); await timesheet_approval_api.getPayPeriodOverviewByDate(date_string);
}; };
const onClickedDetails = async (email: string, name: string) => { const onClickedDetails = async (email: string, name: string) => {
clicked_employee_name.value = name; clicked_employee_name.value = name;
clicked_employee_email.value = email;
is_showing_details.value = true; is_showing_details.value = true;
await timesheet_approval_api.getTimesheetsByPayPeriodAndEmail(email); await timesheet_approval_api.getTimesheetsByPayPeriodAndEmail(email);
}; };
@ -143,13 +150,14 @@
transition-show="jump-down" transition-show="jump-down"
transition-hide="jump-down" transition-hide="jump-down"
@before-show="() => update_key += 1" @before-show="() => update_key += 1"
class="full-width"
> >
<TimesheetApprovalEmployeeDetails <TimesheetApprovalEmployeeDetails
:is-loading="timesheet_store.is_loading" :is-loading="timesheet_store.is_loading"
:employee-name="clicked_employee_name" :employee-name="clicked_employee_name"
:employee-overview="getEmployeeOverview(clicked_employee_email)"
:employee-details="timesheet_store.pay_period_employee_details" :employee-details="timesheet_store.pay_period_employee_details"
:update-key="update_key" :update-key="update_key"
class="full-width"
/> />
</q-dialog> </q-dialog>
<div class="q-pa-md"> <div class="q-pa-md">

View File

@ -1,6 +1,7 @@
import { useTimesheetStore } from "src/stores/timesheet-store"; import { useTimesheetStore } from "src/stores/timesheet-store";
import { useAuthStore } from "src/stores/auth-store"; import { useAuthStore } from "src/stores/auth-store";
import type { PayPeriodReportFilters } from "../types/timesheet-approval-pay-period-report-interface"; import type { PayPeriodReportFilters } from "../types/timesheet-approval-pay-period-report-interface";
import type { PayPeriodOverviewEmployee } from "../types/timesheet-approval-pay-period-overview-employee-interface";
export const useTimesheetApprovalApi = () => { export const useTimesheetApprovalApi = () => {
const timesheet_store = useTimesheetStore(); const timesheet_store = useTimesheetStore();
@ -15,6 +16,10 @@ export const useTimesheetApprovalApi = () => {
} }
} }
const getPayPeriodOverviewByEmployeeEmail = (email: string): PayPeriodOverviewEmployee | undefined => {
return timesheet_store.pay_period_overview_employees.find(overview => overview.email === email);
};
/* This method attempts to get the next or previous pay period. /* This method attempts to get the next or previous pay period.
It checks if pay period number is within a certain range, adjusts pay period and year accordingly. It checks if pay period number is within a certain range, adjusts pay period and year accordingly.
It then requests the matching pay period object to set as current pay period from server. It then requests the matching pay period object to set as current pay period from server.
@ -39,11 +44,11 @@ export const useTimesheetApprovalApi = () => {
if (success) { if (success) {
await timesheet_store.getTimesheetApprovalPayPeriodEmployeeOverviews(new_pay_year, new_pay_period_no, auth_store.user.email); await timesheet_store.getTimesheetApprovalPayPeriodEmployeeOverviews(new_pay_year, new_pay_period_no, auth_store.user.email);
} }
} };
const getTimesheetsByPayPeriodAndEmail = async (employee_email: string) => { const getTimesheetsByPayPeriodAndEmail = async (employee_email: string) => {
await timesheet_store.getTimesheetsByPayPeriodAndEmail(employee_email); await timesheet_store.getTimesheetsByPayPeriodAndEmail(employee_email);
} };
const getTimesheetApprovalCSVReport = async ( report_filter_company: boolean[], report_filter_type: boolean[] ) => { const getTimesheetApprovalCSVReport = async ( report_filter_company: boolean[], report_filter_type: boolean[] ) => {
const [ targo, solucom ] = report_filter_company; const [ targo, solucom ] = report_filter_company;
@ -54,11 +59,12 @@ export const useTimesheetApprovalApi = () => {
} as PayPeriodReportFilters; } as PayPeriodReportFilters;
await timesheet_store.getTimesheetApprovalCSVReport(options); await timesheet_store.getTimesheetApprovalCSVReport(options);
} };
return { return {
getPayPeriodOverviewByDate, getPayPeriodOverviewByDate,
getNextPayPeriodOverview, getNextPayPeriodOverview,
getPayPeriodOverviewByEmployeeEmail,
getTimesheetsByPayPeriodAndEmail, getTimesheetsByPayPeriodAndEmail,
getTimesheetApprovalCSVReport getTimesheetApprovalCSVReport
} }

View File

@ -19,13 +19,12 @@ export const timesheetApprovalService = {
getPayPeriodEmployeeOverviews: async (year: number, period_number: number, supervisor_email: string): Promise<PayPeriodOverview> => { getPayPeriodEmployeeOverviews: async (year: number, period_number: number, supervisor_email: string): Promise<PayPeriodOverview> => {
// TODO: REMOVE MOCK DATA PEFORE PUSHING TO PROD // TODO: REMOVE MOCK DATA PEFORE PUSHING TO PROD
const response = await api.get(`pay-periods/${year}/${period_number}/${supervisor_email}`); const response = await api.get(`pay-periods/${year}/${period_number}/${supervisor_email}`);
console.log('all employee data: ', response.data); console.log('pay period data: ', response.data);
return response.data; return response.data;
}, },
getTimesheetsByPayPeriodAndEmail: async (year: number, period_no: number, email: string): Promise<PayPeriodEmployeeDetails> => { getTimesheetsByPayPeriodAndEmail: async (year: number, period_no: number, email: string): Promise<PayPeriodEmployeeDetails> => {
const response = await api.get('timesheets', { params: { year, period_no, email, }}); const response = await api.get('timesheets', { params: { year, period_no, email, }});
console.log('employee details: ', response.data);
return response.data; return response.data;
}, },

View File

@ -6,6 +6,16 @@ export interface TimesheetDetailsWeek {
expenses: WeekDay<TimesheetDetailsDailyExpenses>; expenses: WeekDay<TimesheetDetailsDailyExpenses>;
} }
export interface TimesheetDetailsDailySchedule {
shifts: Shift[];
regular_hours: number;
evening_hours: number;
emergency_hours: number;
overtime_hours: number;
short_date: string; // ex. 08/24
break_duration?: number;
}
type WeekDay<T> = { type WeekDay<T> = {
sun: T; sun: T;
mon: T; mon: T;
@ -16,23 +26,14 @@ type WeekDay<T> = {
sat: T; sat: T;
} }
interface TimesheetDetailsDailySchedule {
shifts: Shift[];
regular_hours: number;
evening_hours: number;
emergency_hours: number;
overtime_hours: number;
short_date: string; // ex. 08/24
break_duration?: number;
}
interface TimesheetDetailsDailyExpenses { interface TimesheetDetailsDailyExpenses {
costs: Expense[]; cash: Expense[];
mileage: Expense[]; km: Expense[];
[otherType: string]: Expense[]; //for possible future types of expenses [otherType: string]: Expense[]; //for possible future types of expenses
} }
interface Expense { export interface Expense {
is_approved: boolean; is_approved: boolean;
amount: number; amount: number;
}; };