refactor(approvals): more work on filters, table list view tweaks to columns, plug in list view functionalities

This commit is contained in:
Nicolas Drolet 2025-12-29 10:17:18 -05:00
parent f66934cc4f
commit 9fab8ae1ca
7 changed files with 247 additions and 227 deletions

View File

@ -285,6 +285,8 @@ export default {
verified: "approved", verified: "approved",
unverified: "pending", unverified: "pending",
inactive: "inactive", inactive: "inactive",
filter_active: "show only active employees",
filter_team: "",
}, },
tooltip: { tooltip: {
button_detailed_view: "detailed view", button_detailed_view: "detailed view",

View File

@ -286,6 +286,8 @@ export default {
verified: "approuvé", verified: "approuvé",
unverified: "à vérifier", unverified: "à vérifier",
inactive: "inactif", inactive: "inactif",
filter_active: "",
filter_team: "",
}, },
tooltip: { tooltip: {
button_detailed_view: "vue détaillée", button_detailed_view: "vue détaillée",

View File

@ -3,6 +3,7 @@
lang="ts" lang="ts"
> >
import type { TimesheetApprovalOverview } from 'src/modules/timesheet-approval/models/timesheet-overview.models'; import type { TimesheetApprovalOverview } from 'src/modules/timesheet-approval/models/timesheet-overview.models';
import { getHoursMinutesStringFromHoursFloat, getMinutes } from 'src/utils/date-and-time-utils';
const modelApproval = defineModel<boolean>(); const modelApproval = defineModel<boolean>();
@ -15,18 +16,6 @@
'clickDetails': [overview: TimesheetApprovalOverview]; 'clickDetails': [overview: TimesheetApprovalOverview];
'clickApprovalAll' : [is_approved: boolean]; '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>
@ -95,7 +84,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' : ''"
> {{ getHoursMinutesString(row.regular_hours) }} </span> > {{ getHoursMinutesStringFromHoursFloat(row.regular_hours) }} </span>
<q-separator class="q-mr-sm" /> <q-separator class="q-mr-sm" />
</div> </div>
@ -116,7 +105,7 @@
<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;"
> {{ getHoursMinutesString(hour_type) }} </span> > {{ getHoursMinutesStringFromHoursFloat(hour_type) }} </span>
</div> </div>
</div> </div>
</div> </div>

View File

@ -13,23 +13,28 @@
import { useAuthStore } from 'src/stores/auth-store'; import { useAuthStore } from 'src/stores/auth-store';
import { useTimesheetStore } from 'src/stores/timesheet-store'; import { useTimesheetStore } from 'src/stores/timesheet-store';
import { useTimesheetApprovalApi } from 'src/modules/timesheet-approval/composables/use-timesheet-approval-api'; import { useTimesheetApprovalApi } from 'src/modules/timesheet-approval/composables/use-timesheet-approval-api';
import { overview_column_names, pay_period_overview_columns, PayPeriodOverviewFilters, type TimesheetApprovalOverview } from 'src/modules/timesheet-approval/models/timesheet-overview.models'; import { overview_column_names, OverviewColumns, pay_period_overview_columns, PayPeriodOverviewFilters, type TimesheetApprovalOverview } from 'src/modules/timesheet-approval/models/timesheet-overview.models';
import { getHoursMinutesStringFromHoursFloat } from 'src/utils/date-and-time-utils';
const auth_store = useAuthStore(); const auth_store = useAuthStore();
const timesheet_store = useTimesheetStore(); const timesheet_store = useTimesheetStore();
const timesheet_approval_api = useTimesheetApprovalApi(); const timesheet_approval_api = useTimesheetApprovalApi();
const visible_columns = ref<string[]>([ const TIME_COLUMNS: OverviewColumns[] = ['REGULAR', 'EVENING', 'EMERGENCY', 'OVERTIME', 'HOLIDAY', 'VACATION'];
overview_column_names.REGULAR, const VISIBLE_COLUMNS = ref<OverviewColumns[]>([
overview_column_names.EVENING, 'employee_first_name',
overview_column_names.EMERGENCY, 'REGULAR',
overview_column_names.SICK, 'EVENING',
overview_column_names.VACATION, 'EMERGENCY',
overview_column_names.HOLIDAY, 'OVERTIME',
overview_column_names.OVERTIME, 'HOLIDAY',
overview_column_names.IS_APPROVED, 'VACATION',
'expenses',
'mileage',
'is_approved',
]); ]);
const is_showing_filters = ref(false); const is_showing_filters = ref(false);
const search_string = ref(''); const search_string = ref('');
@ -60,7 +65,7 @@
} }
if (terms.is_showing_team_only) { if (terms.is_showing_team_only) {
result = result.filter(row => row.supervisor !== null && row.supervisor.email === (auth_store.user ? auth_store.user.email : '') ); result = result.filter(row => row.supervisor !== null && row.supervisor.email === (auth_store.user ? auth_store.user.email : ''));
} }
if (terms.name_search_string.length > 0) { if (terms.name_search_string.length > 0) {
@ -77,206 +82,209 @@
<template> <template>
<div class="q-px-md full-height"> <div class="q-px-md full-height">
<LoadingOverlay v-model="timesheet_store.is_loading" /> <LoadingOverlay v-model="timesheet_store.is_loading" />
<transition
appear <q-table
enter-active-class="animated fadeInUp" :key="timesheet_store.is_approval_grid_mode ? 'grid' : 'list'"
leave-active-class="animated fadeOutDown" :visible-columns="VISIBLE_COLUMNS"
mode="out-in" :rows="overview_rows"
:columns="pay_period_overview_columns"
row-key="email"
:grid="timesheet_store.is_approval_grid_mode"
:dense="timesheet_store.is_approval_grid_mode"
hide-pagination
:pagination="{ sortBy: 'is_active' }"
:filter="overview_filters"
:filter-method="filterEmployeeRows"
color="accent"
:rows-per-page-options="[0]"
card-container-class="justify-center"
class="bg-transparent"
:class="timesheet_store.is_approval_grid_mode ? '' : 'sticky-header-table no-shadow'"
table-class="q-pa-none q-mx-md rounded-10 bg-dark shadow-15 hide-scrollbar"
:no-data-label="$t('shared.error.no_data_found')"
:no-results-label="$t('shared.error.no_search_results')"
:loading-label="$t('shared.label.loading')"
:style="$q.platform.is.mobile ? '' : 'max-height: 70vh;'"
> >
<q-table <template #top>
:key="timesheet_store.is_approval_grid_mode ? 'grid' : 'list'" <div class="column full-width">
:visible-columns="visible_columns"
:rows="overview_rows" <div
:columns="pay_period_overview_columns" class="col-auto row items-start full-width q-px-lg"
row-key="email" :class="$q.platform.is.mobile ? 'column flex-center' : 'row q-mt-md'"
:grid="timesheet_store.is_approval_grid_mode" >
:dense="timesheet_store.is_approval_grid_mode" <PayPeriodNavigator
hide-pagination @date-selected="timesheet_approval_api.getTimesheetOverviews"
:pagination="{ sortBy: 'is_active' }" @pressed-next-button="timesheet_approval_api.getTimesheetOverviews"
:filter="overview_filters" @pressed-previous-button="timesheet_approval_api.getTimesheetOverviews"
:filter-method="filterEmployeeRows" :class="$q.platform.is.mobile ? 'q-mb-sm' : ''"
color="accent" style="height: 40px;"
:rows-per-page-options="[0]" />
card-container-class="justify-center"
class="bg-transparent" <q-space />
:class="timesheet_store.is_approval_grid_mode ? '' : 'sticky-header-table no-shadow'"
table-class="q-pa-none q-mx-md rounded-10 bg-dark shadow-15"
:no-data-label="$t('shared.error.no_data_found')"
:no-results-label="$t('shared.error.no_search_results')"
:loading-label="$t('shared.label.loading')"
>
<template #top>
<div class="column full-width">
<div <div
class="col-auto row items-start full-width q-px-lg" class="col-auto row no-wrap items-start"
:class="($q.platform.is.mobile ? 'column flex-center' : 'row q-mt-md') + (timesheet_store.is_approval_grid_mode ? '' : ' q-mb-md')" :class="$q.platform.is.mobile ? 'q-mb-md' : ''"
> >
<PayPeriodNavigator <q-btn-toggle
@date-selected="timesheet_approval_api.getTimesheetOverviews" v-model="timesheet_store.is_approval_grid_mode"
@pressed-next-button="timesheet_approval_api.getTimesheetOverviews" push
@pressed-previous-button="timesheet_approval_api.getTimesheetOverviews" rounded
:class="$q.platform.is.mobile ? 'q-mb-sm' : ''" color="white"
text-color="accent"
toggle-color="accent"
class="col-auto"
:class="$q.platform.is.mobile ? 'q-mb-sm' : 'q-mr-sm'"
:options="[
{ icon: 'grid_view', value: true },
{ icon: 'view_list', value: false },
]"
style="height: 40px;" style="height: 40px;"
/> />
<q-space /> <q-btn
push
<div rounded
class="col-auto row no-wrap items-start" icon="download"
:class="$q.platform.is.mobile ? 'q-mb-md' : ''" color="accent"
> :label="$q.screen.lt.md ? '' : $t('shared.label.download')"
<q-btn-toggle class="col-auto q-mr-sm"
v-model="timesheet_store.is_approval_grid_mode" style="height: 40px;"
push @click="timesheet_store.is_report_dialog_open = true"
rounded
color="white"
text-color="accent"
toggle-color="accent"
class="col-auto"
:class="$q.platform.is.mobile ? 'q-mb-sm' : 'q-mr-sm'"
:options="[
{ icon: 'grid_view', value: true },
{ icon: 'view_list', value: false },
]"
style="height: 40px;"
/>
<q-btn
push
rounded
icon="download"
color="accent"
:label="$q.screen.lt.md ? '' : $t('shared.label.download')"
class="col-auto q-mr-sm"
style="height: 40px;"
@click="timesheet_store.is_report_dialog_open = true"
/>
<QTableFilters
v-model:search="search_string"
class="col-auto q-mb-sm"
/>
<q-btn
flat
icon="filter_alt"
color="white"
:label="$q.platform.is.mobile ? '' : $t('shared.label.filter')"
class="col q-ml-sm self-stretch bg-primary"
style="border-radius: 5px 5px 0 0;"
@click="is_showing_filters = !is_showing_filters"
/>
</div>
</div>
<q-slide-transition>
<OverviewListFilters
v-if="is_showing_filters"
v-model:filters="overview_filters"
class="q-mx-lg col-auto"
/> />
</q-slide-transition>
<q-separator <QTableFilters
color="primary" v-model:search="search_string"
size="5px" class="col-auto q-mb-sm"
class="q-mx-lg q-my-none q-pa-none" />
/>
<q-btn
flat
icon="filter_alt"
color="white"
:label="$q.platform.is.mobile ? '' : $t('shared.label.filter')"
class="col q-ml-sm self-stretch bg-primary"
style="border-radius: 5px 5px 0 0;"
@click="is_showing_filters = !is_showing_filters"
/>
</div>
</div> </div>
</template>
<template #header="props"> <q-slide-transition>
<q-tr <OverviewListFilters
:props="props" v-if="is_showing_filters"
class="bg-primary" v-model:filters="overview_filters"
> class="q-mx-lg col-auto"
<q-th />
v-for="col in props.cols" </q-slide-transition>
:key="col.name"
:props="props"
>
<span class="text-uppercase text-weight-bolder text-white">
{{ $t(col.label) }}
</span>
</q-th>
</q-tr>
</template>
<template #body-cell="props"> <q-separator
<q-td color="primary"
:props="props" size="5px"
class="text-weight-medium" class="q-mx-lg q-my-none q-pa-none"
>
<transition
appear
enter-active-class="animated fadeInUp slow"
leave-active-class="animated fadeOutDown"
mode="out-in"
>
<div
:key="props.rowIndex + (timesheet_store.pay_period?.pay_period_no ?? 0)"
class="rounded-5"
style="font-size: 1.2em;"
:style="`animation-delay: ${props.rowIndex / 30}s;`"
>
<transition
v-if="props.col.name === 'is_approved'"
enter-active-class="animated swing"
mode="out-in"
>
<q-btn
:key="props.row.is_approved"
flat
dense
:icon="props.value ? 'lock' : 'lock_open'"
:color="props.value ? 'white' : 'grey-5'"
class="rounded-5 "
:class="props.value ? 'bg-accent' : ''"
@click.stop="props.row.is_approved = !props.row.is_approved"
/>
</transition>
<div v-else-if="props.col.name === 'employee_name'">
<span class="text-h5 text-uppercase text-accent q-mr-xs">
{{ props.value.split(' ')[0] }}
</span>
<span class="text-uppercase text-weight-light">{{ props.value.split(' ')[1]
}}</span>
</div>
<span v-else>{{ props.value }}</span>
</div>
</transition>
</q-td>
</template>
<!-- Template for individual employee cards -->
<template #item="props: { row: TimesheetApprovalOverview, rowIndex: number }">
<OverviewListItem
v-model="props.row.is_approved"
:key="props.row.email + timesheet_store.pay_period?.pay_period_no"
:index="props.rowIndex"
:row="props.row"
@click-details="onClickedDetails"
@click-approval-all="is_approved => onClickApproveAll(props.row.email, is_approved)"
/> />
</template> </div>
</template>
<!-- Template for custome failed-to-load state --> <template #header="props">
<template #no-data="{ message, filter }"> <q-tr
<div class="full-width column items-center text-accent q-gutter-sm"> :props="props"
<q-icon class="bg-primary"
size="4em" >
:name="filter ? 'filter_alt_off' : 'error_outline'" <q-th
/> v-for="col in props.cols"
:key="col.name"
<span class="text-h6"> :props="props"
{{ message }} >
<span class="text-uppercase text-weight-bolder text-white">
{{ $t(col.label) }}
</span> </span>
</div> </q-th>
</template> </q-tr>
</q-table> </template>
</transition>
<template #body-cell="props">
<q-td
:props="props"
class="text-weight-medium"
>
<transition
appear
enter-active-class="animated fadeInUp slow"
leave-active-class="animated fadeOutDown"
mode="out-in"
>
<div
:key="props.rowIndex + (timesheet_store.pay_period?.pay_period_no ?? 0)"
class="rounded-5"
style="font-size: 1.2em;"
:style="`animation-delay: ${props.rowIndex / 30}s;`"
>
<transition
v-if="props.col.name === 'is_approved'"
enter-active-class="animated swing"
mode="out-in"
>
<q-btn
:key="props.row.is_approved"
flat
dense
:icon="props.value ? 'lock' : 'lock_open'"
:color="props.value ? 'white' : 'grey-5'"
class="rounded-5 "
:class="props.value ? 'bg-accent' : ''"
@click.stop="onClickApproveAll(props.row.email, props.row.is_approved)"
/>
</transition>
<div v-else-if="props.col.name === 'employee_first_name'">
<span class="text-h5 text-uppercase text-accent q-mr-xs">
{{ props.value }}
</span>
<span class="text-uppercase text-weight-light">
{{ props.row.employee_last_name }}
</span>
</div>
<span
v-else
:class="props.col.name === overview_column_names.REGULAR && props.row.overtime > 0 ? 'text-negative text-weight-bolder' : 'text-weight-regular'"
>
{{ TIME_COLUMNS.includes(props.col.name) ?
getHoursMinutesStringFromHoursFloat(props.value) : props.value }}
</span>
</div>
</transition>
</q-td>
</template>
<!-- Template for individual employee cards -->
<template #item="props: { row: TimesheetApprovalOverview, rowIndex: number }">
<OverviewListItem
v-model="props.row.is_approved"
:key="props.row.email + timesheet_store.pay_period?.pay_period_no"
:index="props.rowIndex"
:row="props.row"
@click-details="onClickedDetails"
@click-approval-all="is_approved => onClickApproveAll(props.row.email, is_approved)"
/>
</template>
<!-- Template for custome failed-to-load state -->
<template #no-data="{ message, filter }">
<div class="full-width column items-center text-accent q-gutter-sm">
<q-icon
size="4em"
:name="filter ? 'filter_alt_off' : 'error_outline'"
/>
<span class="text-h6">
{{ message }}
</span>
</div>
</template>
</q-table>
</div> </div>
</template> </template>

View File

@ -63,8 +63,11 @@ export interface PayPeriodOverviewFilters {
name_search_string: string; name_search_string: string;
} }
export type OverviewColumns = 'employee_first_name' | 'employee_last_name' | 'email' | 'REGULAR' | 'EVENING' | 'EMERGENCY' | 'SICK' | 'HOLIDAY' | 'VACATION' | 'OVERTIME' | 'expenses' | 'mileage' | 'is_approved' | 'is_active'
export const overview_column_names = { export const overview_column_names = {
EMPLOYEE_NAME: 'employee_name', FIRST_NAME: 'employee_first_name',
LAST_NAME: 'employee_last_name',
EMAIL: 'email', EMAIL: 'email',
REGULAR: 'REGULAR', REGULAR: 'REGULAR',
EVENING: 'EVENING', EVENING: 'EVENING',
@ -81,18 +84,25 @@ export const overview_column_names = {
export const pay_period_overview_columns: QTableColumn[] = [ export const pay_period_overview_columns: QTableColumn[] = [
{ {
name: overview_column_names.EMPLOYEE_NAME, name: overview_column_names.FIRST_NAME,
label: 'timesheet_approvals.table.full_name', label: 'timesheet_approvals.table.full_name',
align: 'left', align: 'left',
field: 'employee_name', field: overview_column_names.FIRST_NAME,
sortable: true, sortable: true,
required: true, required: true,
}, },
{
name: overview_column_names.LAST_NAME,
label: 'timesheet_approvals.table.full_name',
align: 'left',
field: 'employee_last_name',
sortable: true,
},
{ {
name: overview_column_names.EMAIL, name: overview_column_names.EMAIL,
label: 'timesheet_approvals.table.email', label: 'timesheet_approvals.table.email',
align: 'left', align: 'left',
field: 'email', field: overview_column_names.EMAIL,
sortable: true, sortable: true,
}, },
{ {

View File

@ -70,14 +70,11 @@
</q-carousel> </q-carousel>
</div> </div>
<div class="column col flex-center q-pt-md q-pl-md"> <div class="column col items-center q-pl-md">
<div class="col"></div> <div class="col-auto row justify-end full-width within-iframe" style="height: 50vh;">
<div class="col-auto row justify-end full-width within-iframe">
<iframe <iframe
title="Environment Canada Weather" title="Environment Canada Weather"
height="100%" height="400px"
width="100%"
src="https://weather.gc.ca/wxlink/wxlink.html?coords=45.159%2C-73.676&lang=e" src="https://weather.gc.ca/wxlink/wxlink.html?coords=45.159%2C-73.676&lang=e"
allowtransparency="true" allowtransparency="true"
style="border: 0;" style="border: 0;"

View File

@ -3,13 +3,25 @@ import { date } from 'quasar';
const anchor_date: Date = new Date('2023-12-17'); const anchor_date: Date = new Date('2023-12-17');
export const getCurrentPayPeriod = (today = new Date()): number => { export const getCurrentPayPeriod = (today = new Date()): number => {
const period_length = 14; // days const period_length = 14; // days
const periods_per_year = 26; const periods_per_year = 26;
const days_since_anchor = date.getDateDiff(today, anchor_date, 'days'); const days_since_anchor = date.getDateDiff(today, anchor_date, 'days');
const periods_since_anchor = Math.floor(days_since_anchor / period_length); const periods_since_anchor = Math.floor(days_since_anchor / period_length);
const current_period = (periods_since_anchor % periods_per_year) + 1; const current_period = (periods_since_anchor % periods_per_year) + 1;
return current_period; return current_period;
}
export const getMinutes = (hours: number) => {
const minutes_percent = hours - Math.floor(hours);
const minutes = Math.round(minutes_percent * 60);
return minutes > 1 ? minutes.toString() : '0';
}
export const getHoursMinutesStringFromHoursFloat = (hours: number): string => {
const flat_hours = Math.floor(hours);
const minutes = Math.round((hours - flat_hours) * 60);
return `${flat_hours}h ${minutes > 1 ? minutes : ''}`
} }