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} {
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 {

View File

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

View File

@ -25,36 +25,34 @@
@show="is_dialog_open = true"
@hide="is_dialog_open = false"
>
<q-card
class="shadow-12 rounded-15 bg-secondary hide-scrollbar"
<div
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)' : '')"
>
<!-- 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>
</q-card-section>
</div>
<!-- employee pay period details using chart -->
<q-card-section
v-if="is_dialog_open"
:horizontal="!$q.screen.lt.md"
class="q-px-md rounded-10 no-wrap"
<div
v-if="is_dialog_open && !$q.platform.is.mobile"
class="col-4 q-px-md no-wrap"
:class="$q.platform.is.mobile ? 'column' : 'row'"
>
<DetailsDialogChartHoursWorked class="col" />
<DetailsDialogChartShiftTypes class="col q-ma-lg" />
<DetailsDialogChartShiftTypes class="col" />
<DetailsDialogChartExpenses class="col" />
</q-card-section>
</div>
<q-card-section>
<div class="col-auto">
<ExpenseDialogList />
</q-card-section>
</div>
<!-- list of shifts -->
<q-card-section class="col-auto">
<TimesheetWrapper mode="approval" />
</q-card-section>
<q-separator />
</q-card>
<div class="col column no-wrap">
<TimesheetWrapper mode="approval" class="col"/>
</div>
</div>
</q-dialog>
</template>

View File

@ -13,7 +13,20 @@
const emit = defineEmits<{
'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>
<template>
@ -60,7 +73,10 @@
{{ $t('timesheet_approvals.tooltip.button_detailed_view') }}
</q-tooltip>
<q-icon name="las la-chart-bar" color="accent"/>
<q-icon
name="las la-chart-bar"
color="accent"
/>
</q-btn>
</q-card-section>
@ -79,7 +95,7 @@
<span
class="text-weight-bolder text-h3 q-py-none"
: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" />
</div>
@ -94,11 +110,13 @@
<span
class="text-weight-bold text-accent text-uppercase q-pa-none q-my-none"
style="font-size: 0.7em;"
> {{ $t(`shared.shift_type.${index.replace('_hours', '')}`) }} </span>
>
{{ $t(`shared.shift_type.${index.replace('_hours', '')}`) }}
</span>
<span
class="text-weight-bolder q-pa-none q-mb-xs"
style="font-size: 1.2em; line-height: 1em;"
> {{ hour_type }} </span>
> {{ getHoursMinutesString(hour_type) }} </span>
</div>
</div>
</div>
@ -139,21 +157,24 @@
<q-card-section
horizontal
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
v-if="row.is_active"
class="col row full-width"
>
<div class="col">
<span class="text-uppercase text-h6 q-ml-sm text-weight-bolder"> {{ row.total_hours }}
</span>
<span class="text-uppercase text-weight-bold text-caption q-ml-xs"> total </span>
<div
class="col text-uppercase"
:class="row.total_hours > 80 || !row.is_active ? 'text-negative' : ''"
>
<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
class="col-auto q-py-xs q-px-md"
>
<div class="col-auto q-py-xs q-px-md">
<q-checkbox
v-model="modelApproval"
dense
@ -166,6 +187,7 @@
:label="row.is_approved ? $t('timesheet_approvals.table.verified') : $t('timesheet_approvals.table.unverified')"
class="text-uppercase"
:class="row.is_approved ? '' : 'text-accent'"
@update:model-value="value => $emit('clickApprovalAll', value)"
/>
</div>
</div>

View File

@ -46,6 +46,10 @@
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[] => {
let result = [...rows];
@ -244,7 +248,8 @@
:key="props.row.email + timesheet_store.pay_period?.pay_period_no"
:index="props.rowIndex"
:row="props.row"
@click-details="overview => onClickedDetails(overview)"
@click-details="onClickedDetails"
@click-approval-all="is_approved => onClickApproveAll(props.row.email, is_approved)"
/>
</template>

View File

@ -26,6 +26,20 @@ export const useTimesheetApprovalApi = () => {
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[]) => {
if (timesheet_store.pay_period === undefined) return;
@ -40,6 +54,7 @@ export const useTimesheetApprovalApi = () => {
return {
getTimesheetOverviewsByDate,
toggleTimesheetsApprovalByEmployeeEmail,
getTimesheetApprovalCSVReport,
getTimesheetOverviews,
}

View File

@ -1,4 +1,5 @@
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 { 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' });
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_api = useTimesheetApi();
const { mode = 'normal'} = defineProps<{
mode: 'normal' | 'approval';
}>();
const mobile_animation_direction = ref('fadeInLeft');
const animation_style = computed(() => ui_store.is_mobile_mode ? mobile_animation_direction.value : 'fadeInDown');
@ -67,7 +71,6 @@
class="absolute-full hide-scrollbar"
:thumb-style="{ opacity: '0' }"
:bar-style="{ opacity: '0' }"
style="min-height: 50vh;"
>
<!-- Show if no timesheets found (further than one month from present) -->
<div
@ -103,13 +106,12 @@
leave-active-class="animated fadeOutUp"
>
<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)"
flat
dense
: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="timesheet.days.every(day => day.shifts.length < 1) ? '' : 'invisible'"
@click="timesheet_api.applyPreset(timesheet.timesheet_id)"
>
<q-icon
@ -135,7 +137,7 @@
class="col column full-width q-px-md q-py-sm"
>
<q-card
class="rounded-10 shadow-12"
class="mobile-rounded-10 shadow-12"
:class="(getDayApproval(day) || timesheet.is_approved) ? 'bg-accent' : 'bg-dark'"
>
@ -192,13 +194,11 @@
<div
v-else
class="col row full-width shadow-8 rounded-10"
:class="(getDayApproval(day) || timesheet.is_approved) ? 'rounded-10 bg-accent' : ''"
class="col row full-width rounded-10 ellipsis shadow-10"
>
<div
class="col row bg-dark"
:class="(getDayApproval(day) || timesheet.is_approved) ? 'bg-transparent' : ''"
style="border-radius: 10px 0 0 10px;"
class="col row"
:class="(getDayApproval(day) || timesheet.is_approved) ? 'bg-accent' : 'bg-dark'"
>
<!-- Date block -->
<ShiftListDateWidget
@ -225,17 +225,19 @@
color="white"
size="xl"
class="full-height"
:class="(getDayApproval(day) || timesheet.is_approved) ? 'bg-accent' : ''"
/>
<q-btn
v-else
:dense="!$q.platform.is.mobile"
square
icon="more_time"
size="lg"
color="accent"
text-color="white"
class="full-height"
:class="$q.screen.lt.md ? 'q-px-xs ' : ' '"
style="border-radius: 0 10px 10px 0;"
:class="$q.platform.is.mobile ? 'q-px-xs' : ''"
@click="addNewShift(day.shifts, day.date, timesheet.timesheet_id)"
/>
</div>
@ -247,6 +249,7 @@
</q-scroll-area>
<q-page-sticky
v-if="mode === 'normal'"
position="bottom-right"
:offset="$q.screen.width > $q.screen.height ? [15, 15] : [15, 65]"
class="z-top"
@ -270,3 +273,20 @@
</div>
</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 -->
<div
class="col-auto row items-center full-width q-px-lg"
:class="$q.platform.is.mobile && ($q.screen.width < $q.screen.height) ? 'justify-between' : 'q-mt-md q-pb-sm q-px-md'"
class="col-auto row items-center full-width"
:class="$q.platform.is.mobile && ($q.screen.width < $q.screen.height) ? 'justify-between' : 'q-pb-sm q-px-md'"
>
<!-- navigation btn -->
<PayPeriodNavigator
@ -66,6 +66,9 @@
@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" />
<!-- desktop expenses button -->
@ -81,7 +84,7 @@
<!-- desktop save timesheet changes button -->
<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
rounded
:disable="timesheet_store.is_loading || has_shift_errors"
@ -99,7 +102,7 @@
<!-- mobile weekly overview widget -->
<ShiftListWeeklyOverview />
<ShiftList />
<ShiftList :mode="mode" />
<q-btn
v-if="$q.platform.is.mobile && $q.screen.width < $q.screen.height"
@ -114,6 +117,9 @@
@click="shift_api.saveShiftChanges"
/>
<ExpenseDialog :is-approved="is_timesheets_approved" class="z-top"/>
<ExpenseDialog
:is-approved="is_timesheets_approved"
class="z-top"
/>
</div>
</template>

View File

@ -85,8 +85,8 @@ export const useTimesheetStore = defineStore('timesheet', () => {
}
};
const getTimesheetsByOptionalEmployeeEmail = async (employee_email?: string) => {
if (pay_period.value === undefined) return;
const getTimesheetsByOptionalEmployeeEmail = async (employee_email?: string): Promise<boolean> => {
if (pay_period.value === undefined) return false;
is_loading.value = true;
let response;
@ -106,15 +106,38 @@ export const useTimesheetStore = defineStore('timesheet', () => {
timesheets.value = [];
initial_timesheets.value = [];
}
is_loading.value = false;
return response.success;
} catch (error) {
console.error('There was an error retrieving timesheet details for this employee: ', error);
// TODO: More in-depth error-handling here
timesheets.value = [];
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) => {
try {
if (!pay_period.value) return false;
@ -158,6 +181,7 @@ export const useTimesheetStore = defineStore('timesheet', () => {
getPayPeriodByDateOrYearAndNumber,
getTimesheetOverviews,
getTimesheetsByOptionalEmployeeEmail,
toggleTimesheetsApprovalByEmployeeEmail,
getPayPeriodReport,
openReportDialog,
closeReportDialog,