feat(timesheet-approval): add toggle of approval for timesheets through overview card

This commit is contained in:
Nicolas Drolet 2025-12-22 15:51:11 -05:00
parent 9a70875f78
commit ec0ea14a91
10 changed files with 153 additions and 63 deletions

View File

@ -3,14 +3,6 @@
.rounded-#{$size} { .rounded-#{$size} {
border-radius: #{$size}px !important; border-radius: #{$size}px !important;
} }
.rounded-#{$size} > div:first-child {
border-radius: #{$size}px #{$size}px 0 0 !important;
}
.rounded-#{$size} > div:last-child {
border-radius: 0 0 #{$size}px #{$size}px !important;
}
} }
.text-fb-blue { .text-fb-blue {

View File

@ -4,6 +4,7 @@
> >
/* eslint-disable */ /* eslint-disable */
import { onMounted, ref } from 'vue'; import { onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { colors } from 'quasar'; import { colors } from 'quasar';
import { useQuasar } from 'quasar'; import { useQuasar } from 'quasar';
import { Doughnut } from 'vue-chartjs'; import { Doughnut } from 'vue-chartjs';
@ -11,6 +12,7 @@
import { useTimesheetStore } from 'src/stores/timesheet-store'; import { useTimesheetStore } from 'src/stores/timesheet-store';
const $q = useQuasar(); const $q = useQuasar();
const { t } = useI18n();
ChartJS.register(Title, Tooltip, Legend, ArcElement, CategoryScale, LinearScale); ChartJS.register(Title, Tooltip, Legend, ArcElement, CategoryScale, LinearScale);
ChartJS.defaults.font.family = '"Roboto", sans-serif'; ChartJS.defaults.font.family = '"Roboto", sans-serif';
@ -20,10 +22,10 @@
const timesheet_store = useTimesheetStore(); const timesheet_store = useTimesheetStore();
const shift_type_labels = ref<string[]>([ const shift_type_labels = ref<string[]>([
timesheet_store.current_pay_period_overview!.regular_hours.toString() + 'h', t('shared.shift_type.regular'),
timesheet_store.current_pay_period_overview!.other_hours.evening_hours.toString() + 'h', t('shared.shift_type.evening'),
timesheet_store.current_pay_period_overview!.other_hours.emergency_hours.toString() + 'h', t('shared.shift_type.emergency'),
timesheet_store.current_pay_period_overview!.other_hours.overtime_hours.toString() + 'h', t('shared.shift_type.overtime'),
]); ]);
const shift_type_totals = ref<ChartDataset<'doughnut'>[]>([]); const shift_type_totals = ref<ChartDataset<'doughnut'>[]>([]);

View File

@ -25,36 +25,34 @@
@show="is_dialog_open = true" @show="is_dialog_open = true"
@hide="is_dialog_open = false" @hide="is_dialog_open = false"
> >
<q-card <div
class="shadow-12 rounded-15 bg-secondary hide-scrollbar" class="column bg-secondary hide-scrollbar shadow-12 rounded-15 q-pa-sm no-wrap"
:style="($q.screen.lt.md ? '' : 'width:80vw !important;') + ($q.dark.isActive ? ' border: 2px solid var(--q-accent)' : '')" :style="($q.screen.lt.md ? '' : 'width:80vw !important;') + ($q.dark.isActive ? ' border: 2px solid var(--q-accent)' : '')"
> >
<!-- employee name --> <!-- employee name -->
<q-card-section class="text-h4 text-weight-bolder text-center text-uppercase q-px-none q-py-sm"> <div class="col-auto text-h4 text-weight-bolder text-center text-uppercase q-px-none q-py-sm">
<span>{{ timesheet_store.selected_employee_name }}</span> <span>{{ timesheet_store.selected_employee_name }}</span>
</q-card-section> </div>
<!-- employee pay period details using chart --> <!-- employee pay period details using chart -->
<q-card-section <div
v-if="is_dialog_open" v-if="is_dialog_open && !$q.platform.is.mobile"
:horizontal="!$q.screen.lt.md" class="col-4 q-px-md no-wrap"
class="q-px-md rounded-10 no-wrap" :class="$q.platform.is.mobile ? 'column' : 'row'"
> >
<DetailsDialogChartHoursWorked class="col" /> <DetailsDialogChartHoursWorked class="col" />
<DetailsDialogChartShiftTypes class="col q-ma-lg" /> <DetailsDialogChartShiftTypes class="col" />
<DetailsDialogChartExpenses class="col" /> <DetailsDialogChartExpenses class="col" />
</q-card-section> </div>
<q-card-section> <div class="col-auto">
<ExpenseDialogList /> <ExpenseDialogList />
</q-card-section> </div>
<!-- list of shifts --> <!-- list of shifts -->
<q-card-section class="col-auto"> <div class="col column no-wrap">
<TimesheetWrapper mode="approval" /> <TimesheetWrapper mode="approval" class="col"/>
</q-card-section> </div>
<q-separator /> </div>
</q-card>
</q-dialog> </q-dialog>
</template> </template>

View File

@ -13,7 +13,20 @@
const emit = defineEmits<{ const emit = defineEmits<{
'clickDetails': [overview: TimesheetOverview]; 'clickDetails': [overview: TimesheetOverview];
'clickApprovalAll' : [is_approved: boolean];
}>(); }>();
const getMinutes = (hours: number) => {
const minutes_percent = hours - Math.floor(hours);
const minutes = Math.round(minutes_percent * 60);
return minutes > 1 ? minutes.toString() : '0';
}
const getHoursMinutesString = (hours: number): string => {
const flat_hours = Math.floor(hours);
const minutes = Math.round((hours - flat_hours) * 60);
return `${flat_hours}h ${minutes > 1 ? minutes : ''}`
}
</script> </script>
<template> <template>
@ -60,7 +73,10 @@
{{ $t('timesheet_approvals.tooltip.button_detailed_view') }} {{ $t('timesheet_approvals.tooltip.button_detailed_view') }}
</q-tooltip> </q-tooltip>
<q-icon name="las la-chart-bar" color="accent"/> <q-icon
name="las la-chart-bar"
color="accent"
/>
</q-btn> </q-btn>
</q-card-section> </q-card-section>
@ -79,7 +95,7 @@
<span <span
class="text-weight-bolder text-h3 q-py-none" class="text-weight-bolder text-h3 q-py-none"
:class="row.regular_hours > 80 || !row.is_active ? 'text-negative' : ''" :class="row.regular_hours > 80 || !row.is_active ? 'text-negative' : ''"
> {{ row.regular_hours }} </span> > {{ getHoursMinutesString(row.regular_hours) }} </span>
<q-separator class="q-mr-sm" /> <q-separator class="q-mr-sm" />
</div> </div>
@ -94,11 +110,13 @@
<span <span
class="text-weight-bold text-accent text-uppercase q-pa-none q-my-none" class="text-weight-bold text-accent text-uppercase q-pa-none q-my-none"
style="font-size: 0.7em;" style="font-size: 0.7em;"
> {{ $t(`shared.shift_type.${index.replace('_hours', '')}`) }} </span> >
{{ $t(`shared.shift_type.${index.replace('_hours', '')}`) }}
</span>
<span <span
class="text-weight-bolder q-pa-none q-mb-xs" class="text-weight-bolder q-pa-none q-mb-xs"
style="font-size: 1.2em; line-height: 1em;" style="font-size: 1.2em; line-height: 1em;"
> {{ hour_type }} </span> > {{ getHoursMinutesString(hour_type) }} </span>
</div> </div>
</div> </div>
</div> </div>
@ -139,21 +157,24 @@
<q-card-section <q-card-section
horizontal horizontal
class="justify-between items-center text-weight-bold q-pa-none" class="justify-between items-center text-weight-bold q-pa-none"
:class="row.is_active ? (row.is_approved ? 'text-white bg-accent' : 'bg-dark text-accent') : 'bg-transparent'" :class="row.is_active ? (row.is_approved ? 'text-white bg-accent' : 'bg-dark') : 'bg-transparent'"
> >
<div <div
v-if="row.is_active" v-if="row.is_active"
class="col row full-width" class="col row full-width"
> >
<div class="col"> <div
<span class="text-uppercase text-h6 q-ml-sm text-weight-bolder"> {{ row.total_hours }} class="col text-uppercase"
</span> :class="row.total_hours > 80 || !row.is_active ? 'text-negative' : ''"
<span class="text-uppercase text-weight-bold text-caption q-ml-xs"> total </span> >
<span class="text-h6 q-ml-sm text-weight-bolder">{{ 'Total : ' + Math.floor(row.total_hours)
}}</span>
<span class="text-uppercase text-weight-medium text-caption">H</span>
<span class="text-h6 q-ml-sm text-weight-bolder">{{ getMinutes(row.total_hours) }}</span>
<span class="text-uppercase text-weight-medium text-caption">M</span>
</div> </div>
<div <div class="col-auto q-py-xs q-px-md">
class="col-auto q-py-xs q-px-md"
>
<q-checkbox <q-checkbox
v-model="modelApproval" v-model="modelApproval"
dense dense
@ -166,6 +187,7 @@
:label="row.is_approved ? $t('timesheet_approvals.table.verified') : $t('timesheet_approvals.table.unverified')" :label="row.is_approved ? $t('timesheet_approvals.table.verified') : $t('timesheet_approvals.table.unverified')"
class="text-uppercase" class="text-uppercase"
:class="row.is_approved ? '' : 'text-accent'" :class="row.is_approved ? '' : 'text-accent'"
@update:model-value="value => $emit('clickApprovalAll', value)"
/> />
</div> </div>
</div> </div>

View File

@ -46,6 +46,10 @@
timesheet_store.is_details_dialog_open = true; timesheet_store.is_details_dialog_open = true;
}; };
const onClickApproveAll = async (email: string, is_approved: boolean) => {
await timesheet_approval_api.toggleTimesheetsApprovalByEmployeeEmail(email, is_approved);
}
const filterEmployeeRows = (rows: readonly TimesheetOverview[], terms: PayPeriodOverviewFilters): TimesheetOverview[] => { const filterEmployeeRows = (rows: readonly TimesheetOverview[], terms: PayPeriodOverviewFilters): TimesheetOverview[] => {
let result = [...rows]; let result = [...rows];
@ -244,7 +248,8 @@
:key="props.row.email + timesheet_store.pay_period?.pay_period_no" :key="props.row.email + timesheet_store.pay_period?.pay_period_no"
:index="props.rowIndex" :index="props.rowIndex"
:row="props.row" :row="props.row"
@click-details="overview => onClickedDetails(overview)" @click-details="onClickedDetails"
@click-approval-all="is_approved => onClickApproveAll(props.row.email, is_approved)"
/> />
</template> </template>

View File

@ -26,6 +26,20 @@ export const useTimesheetApprovalApi = () => {
timesheet_store.is_loading = false; timesheet_store.is_loading = false;
}; };
const toggleTimesheetsApprovalByEmployeeEmail = async (email: string, approval_status: boolean) => {
timesheet_store.is_loading = true;
const success = await timesheet_store.getTimesheetsByOptionalEmployeeEmail(email);
if (success) {
const approval_success = await timesheet_store.toggleTimesheetsApprovalByEmployeeEmail(email, approval_status);
const overview = timesheet_store.pay_period_overviews.find(overview => overview.email === email);
if (overview && approval_success) overview.is_approved = approval_status;
}
timesheet_store.is_loading = false;
};
const getTimesheetApprovalCSVReport = async (report_filter_company: boolean[], report_filter_type: boolean[]) => { const getTimesheetApprovalCSVReport = async (report_filter_company: boolean[], report_filter_type: boolean[]) => {
if (timesheet_store.pay_period === undefined) return; if (timesheet_store.pay_period === undefined) return;
@ -40,6 +54,7 @@ export const useTimesheetApprovalApi = () => {
return { return {
getTimesheetOverviewsByDate, getTimesheetOverviewsByDate,
toggleTimesheetsApprovalByEmployeeEmail,
getTimesheetApprovalCSVReport, getTimesheetApprovalCSVReport,
getTimesheetOverviews, getTimesheetOverviews,
} }

View File

@ -1,4 +1,5 @@
import { api } from "src/boot/axios"; import { api } from "src/boot/axios";
import type { BackendResponse } from "src/modules/shared/models/backend-response.models";
import type { TimesheetApprovalCSVReportFilters } from "src/modules/timesheet-approval/models/timesheet-approval-csv-report.models"; import type { TimesheetApprovalCSVReportFilters } from "src/modules/timesheet-approval/models/timesheet-approval-csv-report.models";
import type { PayPeriodOverviewResponse } from "src/modules/timesheet-approval/models/timesheet-overview.models"; import type { PayPeriodOverviewResponse } from "src/modules/timesheet-approval/models/timesheet-overview.models";
@ -12,4 +13,9 @@ export const timesheetApprovalService = {
const response = await api.get(`exports/csv/${year}/${period_number}`, { params: filters, responseType: 'arraybuffer' }); const response = await api.get(`exports/csv/${year}/${period_number}`, { params: filters, responseType: 'arraybuffer' });
return response; return response;
}, },
updateTimesheetsApprovalStatus: async (email: string, timesheet_ids: number[], is_approved: boolean): Promise<BackendResponse<{shifts: number, expenses: number}>> => {
const response = await api.patch<BackendResponse<{shifts: number, expenses: number}>>('pay-periods/pay-period-approval', { email, timesheet_ids, is_approved});
return response.data;
},
}; };

View File

@ -20,6 +20,10 @@
const timesheet_store = useTimesheetStore(); const timesheet_store = useTimesheetStore();
const timesheet_api = useTimesheetApi(); const timesheet_api = useTimesheetApi();
const { mode = 'normal'} = defineProps<{
mode: 'normal' | 'approval';
}>();
const mobile_animation_direction = ref('fadeInLeft'); const mobile_animation_direction = ref('fadeInLeft');
const animation_style = computed(() => ui_store.is_mobile_mode ? mobile_animation_direction.value : 'fadeInDown'); const animation_style = computed(() => ui_store.is_mobile_mode ? mobile_animation_direction.value : 'fadeInDown');
@ -67,7 +71,6 @@
class="absolute-full hide-scrollbar" class="absolute-full hide-scrollbar"
:thumb-style="{ opacity: '0' }" :thumb-style="{ opacity: '0' }"
:bar-style="{ opacity: '0' }" :bar-style="{ opacity: '0' }"
style="min-height: 50vh;"
> >
<!-- Show if no timesheets found (further than one month from present) --> <!-- Show if no timesheets found (further than one month from present) -->
<div <div
@ -103,13 +106,12 @@
leave-active-class="animated fadeOutUp" leave-active-class="animated fadeOutUp"
> >
<q-btn <q-btn
v-if="!$q.platform.is.mobile" v-if="!$q.platform.is.mobile && timesheet.days.every(day => day.shifts.length < 1)"
:disable="!timesheet.days.every(day => day.shifts.length < 1)" :disable="!timesheet.days.every(day => day.shifts.length < 1)"
flat flat
dense dense
:label="$t('timesheet.apply_preset_week')" :label="$t('timesheet.apply_preset_week')"
class="col-auto text-uppercase text-weight-bold text-accent q-mx-lg q-py-none rounded-5" class="col-auto text-uppercase text-weight-bold text-accent q-mx-lg q-py-none rounded-5"
:class="timesheet.days.every(day => day.shifts.length < 1) ? '' : 'invisible'"
@click="timesheet_api.applyPreset(timesheet.timesheet_id)" @click="timesheet_api.applyPreset(timesheet.timesheet_id)"
> >
<q-icon <q-icon
@ -135,7 +137,7 @@
class="col column full-width q-px-md q-py-sm" class="col column full-width q-px-md q-py-sm"
> >
<q-card <q-card
class="rounded-10 shadow-12" class="mobile-rounded-10 shadow-12"
:class="(getDayApproval(day) || timesheet.is_approved) ? 'bg-accent' : 'bg-dark'" :class="(getDayApproval(day) || timesheet.is_approved) ? 'bg-accent' : 'bg-dark'"
> >
@ -192,13 +194,11 @@
<div <div
v-else v-else
class="col row full-width shadow-8 rounded-10" class="col row full-width rounded-10 ellipsis shadow-10"
:class="(getDayApproval(day) || timesheet.is_approved) ? 'rounded-10 bg-accent' : ''"
> >
<div <div
class="col row bg-dark" class="col row"
:class="(getDayApproval(day) || timesheet.is_approved) ? 'bg-transparent' : ''" :class="(getDayApproval(day) || timesheet.is_approved) ? 'bg-accent' : 'bg-dark'"
style="border-radius: 10px 0 0 10px;"
> >
<!-- Date block --> <!-- Date block -->
<ShiftListDateWidget <ShiftListDateWidget
@ -225,17 +225,19 @@
color="white" color="white"
size="xl" size="xl"
class="full-height" class="full-height"
:class="(getDayApproval(day) || timesheet.is_approved) ? 'bg-accent' : ''"
/> />
<q-btn <q-btn
v-else v-else
:dense="!$q.platform.is.mobile" :dense="!$q.platform.is.mobile"
square
icon="more_time" icon="more_time"
size="lg" size="lg"
color="accent" color="accent"
text-color="white" text-color="white"
class="full-height" class="full-height"
:class="$q.screen.lt.md ? 'q-px-xs ' : ' '" :class="$q.platform.is.mobile ? 'q-px-xs' : ''"
style="border-radius: 0 10px 10px 0;"
@click="addNewShift(day.shifts, day.date, timesheet.timesheet_id)" @click="addNewShift(day.shifts, day.date, timesheet.timesheet_id)"
/> />
</div> </div>
@ -247,6 +249,7 @@
</q-scroll-area> </q-scroll-area>
<q-page-sticky <q-page-sticky
v-if="mode === 'normal'"
position="bottom-right" position="bottom-right"
:offset="$q.screen.width > $q.screen.height ? [15, 15] : [15, 65]" :offset="$q.screen.width > $q.screen.height ? [15, 15] : [15, 65]"
class="z-top" class="z-top"
@ -270,3 +273,20 @@
</div> </div>
</template> </template>
<style scoped lang="scss">
@each $size in (1, 2, 3, 4, 5, 10, 15, 20, 25, 50, 75, 100, 200) {
.mobile-rounded-#{$size} {
border-radius: #{$size}px !important;
}
.mobile-rounded-#{$size} > div:first-child {
border-radius: #{$size}px #{$size}px 0 0 !important;
}
.mobile-rounded-#{$size} > div:last-child {
border-radius: 0 0 #{$size}px #{$size}px !important;
}
}
</style>

View File

@ -42,8 +42,8 @@
<!-- top menu --> <!-- top menu -->
<div <div
class="col-auto row items-center full-width q-px-lg" class="col-auto row items-center full-width"
:class="$q.platform.is.mobile && ($q.screen.width < $q.screen.height) ? 'justify-between' : 'q-mt-md q-pb-sm q-px-md'" :class="$q.platform.is.mobile && ($q.screen.width < $q.screen.height) ? 'justify-between' : 'q-pb-sm q-px-md'"
> >
<!-- navigation btn --> <!-- navigation btn -->
<PayPeriodNavigator <PayPeriodNavigator
@ -66,6 +66,9 @@
@click="expenses_store.open" @click="expenses_store.open"
/> />
<!-- label for approval mode to delimit that this is the timesheet -->
<span class="col-auto text-uppercase text-bold text-h5"> {{ $t('timesheet.page_header') }}</span>
<q-space v-if="$q.screen.width > $q.screen.height" /> <q-space v-if="$q.screen.width > $q.screen.height" />
<!-- desktop expenses button --> <!-- desktop expenses button -->
@ -81,7 +84,7 @@
<!-- desktop save timesheet changes button --> <!-- desktop save timesheet changes button -->
<q-btn <q-btn
v-if="mode === 'normal' && !is_timesheets_approved && $q.screen.width > $q.screen.height" v-if="!is_timesheets_approved && $q.screen.width > $q.screen.height"
push push
rounded rounded
:disable="timesheet_store.is_loading || has_shift_errors" :disable="timesheet_store.is_loading || has_shift_errors"
@ -99,7 +102,7 @@
<!-- mobile weekly overview widget --> <!-- mobile weekly overview widget -->
<ShiftListWeeklyOverview /> <ShiftListWeeklyOverview />
<ShiftList /> <ShiftList :mode="mode" />
<q-btn <q-btn
v-if="$q.platform.is.mobile && $q.screen.width < $q.screen.height" v-if="$q.platform.is.mobile && $q.screen.width < $q.screen.height"
@ -114,6 +117,9 @@
@click="shift_api.saveShiftChanges" @click="shift_api.saveShiftChanges"
/> />
<ExpenseDialog :is-approved="is_timesheets_approved" class="z-top"/> <ExpenseDialog
:is-approved="is_timesheets_approved"
class="z-top"
/>
</div> </div>
</template> </template>

View File

@ -85,8 +85,8 @@ export const useTimesheetStore = defineStore('timesheet', () => {
} }
}; };
const getTimesheetsByOptionalEmployeeEmail = async (employee_email?: string) => { const getTimesheetsByOptionalEmployeeEmail = async (employee_email?: string): Promise<boolean> => {
if (pay_period.value === undefined) return; if (pay_period.value === undefined) return false;
is_loading.value = true; is_loading.value = true;
let response; let response;
@ -106,15 +106,38 @@ export const useTimesheetStore = defineStore('timesheet', () => {
timesheets.value = []; timesheets.value = [];
initial_timesheets.value = []; initial_timesheets.value = [];
} }
is_loading.value = false; is_loading.value = false;
return response.success;
} catch (error) { } catch (error) {
console.error('There was an error retrieving timesheet details for this employee: ', error); console.error('There was an error retrieving timesheet details for this employee: ', error);
// TODO: More in-depth error-handling here // TODO: More in-depth error-handling here
timesheets.value = []; timesheets.value = [];
is_loading.value = false; is_loading.value = false;
return false;
} }
}; };
const toggleTimesheetsApprovalByEmployeeEmail = async (email: string, approval_status: boolean): Promise<boolean> => {
try {
const timesheet_ids = timesheets.value.map(timesheet => timesheet.timesheet_id);
// Backend returns the amount of shifts and expenses successfully updated, could be useful for error handling???
// const shift_expense_count = timesheets.value.reduce((timesheets_sum, timesheet) => {
// return timesheets_sum + timesheet.days.reduce((day_sum, day) => {
// return day_sum + day.shifts.length + day.expenses.length
// }, 0);
// }, 0);
const response = await timesheetApprovalService.updateTimesheetsApprovalStatus(email, timesheet_ids, approval_status);
return response.success;
} catch (error) {
console.error("couldn't approve timesheets for employee: ", error);
}
return false;
};
const getPayPeriodReport = async (report_filters: TimesheetApprovalCSVReportFilters) => { const getPayPeriodReport = async (report_filters: TimesheetApprovalCSVReportFilters) => {
try { try {
if (!pay_period.value) return false; if (!pay_period.value) return false;
@ -158,6 +181,7 @@ export const useTimesheetStore = defineStore('timesheet', () => {
getPayPeriodByDateOrYearAndNumber, getPayPeriodByDateOrYearAndNumber,
getTimesheetOverviews, getTimesheetOverviews,
getTimesheetsByOptionalEmployeeEmail, getTimesheetsByOptionalEmployeeEmail,
toggleTimesheetsApprovalByEmployeeEmail,
getPayPeriodReport, getPayPeriodReport,
openReportDialog, openReportDialog,
closeReportDialog, closeReportDialog,