fix(approvals): progress on layout, dynamic resizing of table scroll area, UI/UX improvements, redo of left drawer

This commit is contained in:
Nicolas Drolet 2025-12-30 17:15:47 -05:00
parent 720417ab16
commit 8989a7d9c0
18 changed files with 501 additions and 472 deletions

View File

@ -97,7 +97,8 @@ export default defineConfig((ctx) => {
// Full list of options: https://v2.quasar.dev/quasar-cli-vite/quasar-config-file#devserver // Full list of options: https://v2.quasar.dev/quasar-cli-vite/quasar-config-file#devserver
devServer: { devServer: {
// https: true, // https: true,
open: true // opens browser window automatically open: true, // opens browser window automatically
allowedHosts: true
}, },
// https://v2.quasar.dev/quasar-cli-vite/quasar-config-file#framework // https://v2.quasar.dev/quasar-cli-vite/quasar-config-file#framework

Binary file not shown.

Before

Width:  |  Height:  |  Size: 938 KiB

After

Width:  |  Height:  |  Size: 578 KiB

View File

@ -329,6 +329,7 @@ export default {
}, },
print_report: { print_report: {
title: "Download options", title: "Download options",
description: "Choose what to include in the report",
company: "companies", company: "companies",
type: "type", type: "type",
shifts: "shifts", shifts: "shifts",
@ -345,7 +346,7 @@ export default {
unverified: "pending", unverified: "pending",
inactive: "inactive", inactive: "inactive",
filter_active: "show only active employees", filter_active: "show only active employees",
filter_team: "", filter_team: "show my team only",
}, },
tooltip: { tooltip: {
button_detailed_view: "detailed view", button_detailed_view: "detailed view",

View File

@ -207,7 +207,7 @@ export default {
update: "mettre à jour", update: "mettre à jour",
modify: "modifier", modify: "modifier",
close: "fermer", close: "fermer",
download: "téléchargement", download: "télécharger",
}, },
misc: { misc: {
or: "ou", or: "ou",
@ -330,6 +330,7 @@ export default {
}, },
print_report: { print_report: {
title: "options de téléchargement", title: "options de téléchargement",
description: "Choisissez ce qui sera inclu dans le rapport",
company: "compagnies", company: "compagnies",
type: "types de données", type: "types de données",
shifts: "quarts de travail", shifts: "quarts de travail",
@ -345,8 +346,8 @@ export default {
verified: "approuvé", verified: "approuvé",
unverified: "à vérifier", unverified: "à vérifier",
inactive: "inactif", inactive: "inactif",
filter_active: "", filter_active: "montrer les employés inactifs",
filter_team: "", filter_team: "montrer mon équipe seulement",
}, },
tooltip: { tooltip: {
button_detailed_view: "vue détaillée", button_detailed_view: "vue détaillée",

View File

@ -2,21 +2,33 @@
setup setup
lang="ts" lang="ts"
> >
import { ref } from 'vue';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import { useAuthStore } from 'src/stores/auth-store'; import { useAuthStore } from 'src/stores/auth-store';
import { useUiStore } from 'src/stores/ui-store'; import { useUiStore } from 'src/stores/ui-store';
import { ref } from 'vue';
import { RouteNames } from 'src/router/router-constants'; import { RouteNames } from 'src/router/router-constants';
import { ModuleNames } from 'src/modules/shared/models/user.models'; import { ModuleNames, type UserModuleAccess } from 'src/modules/shared/models/user.models';
const DRAWER_BUTTONS: { i18n_key: string, icon: string, route: RouteNames, required_module?: UserModuleAccess }[] = [
{ i18n_key: 'nav_bar.home', icon: "home", route: RouteNames.DASHBOARD, required_module: ModuleNames.DASHBOARD },
{ i18n_key: 'nav_bar.timesheet_approvals', icon: "event_available", route: RouteNames.TIMESHEET_APPROVALS, required_module: ModuleNames.TIMESHEETS_APPROVAL },
{ i18n_key: 'nav_bar.employee_list', icon: "groups", route: RouteNames.EMPLOYEE_LIST },
{ i18n_key: 'nav_bar.timesheet', icon: "punch_clock", route: RouteNames.TIMESHEET, required_module: ModuleNames.TIMESHEETS },
{ i18n_key: 'nav_bar.profile', icon: "account_box", route: RouteNames.PROFILE, required_module: ModuleNames.PERSONAL_PROFILE },
{ i18n_key: 'nav_bar.help', icon: "contact_support", route: RouteNames.HELP },
]
const auth_store = useAuthStore(); const auth_store = useAuthStore();
const ui_store = useUiStore(); const ui_store = useUiStore();
const router = useRouter(); const router = useRouter();
const is_mini = ref(true); const is_mini = ref(true);
const goToPageName = (page_name: string) => { const onClickDrawerPage = (page_name: RouteNames) => {
router.push({ name: page_name }).catch(err => { ui_store.current_page = page_name;
console.error('Error with Vue Router: ', err); is_mini.value = true;
router.push({ name: page_name }).catch( error => {
console.error('failed to reach page: ', error);
}); });
}; };
@ -26,7 +38,7 @@
router.push({ name: 'login' }).catch(err => { router.push({ name: 'login' }).catch(err => {
console.error('could not log you out: ', err); console.error('could not log you out: ', err);
}) })
} };
</script> </script>
<template> <template>
@ -39,102 +51,46 @@
:mini="is_mini" :mini="is_mini"
@mouseenter="is_mini = false" @mouseenter="is_mini = false"
@mouseleave="is_mini = true" @mouseleave="is_mini = true"
class="bg-dark z-max column no-wrap" class="bg-dark z-max"
:class="!$q.platform.is.mobile && is_mini ? 'items-center' : 'items-start'"
> >
<!-- Home --> <q-scroll-area class="column fit">
<q-btn <div
flat v-for="button, index in DRAWER_BUTTONS"
dense :key="index"
no-wrap v-show="button.required_module ?? true"
size="lg" class="row items-center full-width q-py-sm cursor-pointer"
icon="home" :class="ui_store.current_page === button.route ? ($q.dark.isActive ? 'bg-green-10' : 'bg-green-2') : ''"
:label="!$q.platform.is.mobile && is_mini ? '' : $t('nav_bar.home')" @click="onClickDrawerPage(button.route)"
class="col-auto text-uppercase text-weight-bold q-my-xs" >
:class="!$q.platform.is.mobile && is_mini ? '': 'q-px-sm'" <q-icon
@click="goToPageName(RouteNames.DASHBOARD)" :name="button.icon"
/> color="accent"
size="lg"
class="col-auto q-pl-sm"
/>
<!-- Timesheet Validation --> <div class="col text-uppercase text-weight-bold text-h6 q-mini-drawer-hide q-pl-sm">
<q-btn {{ $t(button.i18n_key) }}
v-if="auth_store.user?.user_module_access.includes(ModuleNames.TIMESHEETS_APPROVAL)" </div>
flat </div>
dense
no-wrap
size="lg"
icon="event_available"
:label="!$q.platform.is.mobile && is_mini ? '' : $t('nav_bar.timesheet_approvals')"
class="col-auto text-uppercase text-weight-bold q-my-xs"
:class="!$q.platform.is.mobile && is_mini ? '': 'q-px-sm'"
@click="goToPageName(RouteNames.TIMESHEET_APPROVALS)"
/>
<!-- Employee List --> <q-separator spaced />
<q-btn
v-if="auth_store.user?.user_module_access.includes(ModuleNames.EMPLOYEE_LIST)"
flat
dense
no-wrap
size="lg"
icon="groups"
:label="!$q.platform.is.mobile && is_mini ? '' : $t('nav_bar.employee_list')"
class="col-auto text-uppercase text-weight-bold q-my-xs"
:class="!$q.platform.is.mobile && is_mini ? '': 'q-px-sm'"
@click="goToPageName(RouteNames.EMPLOYEE_LIST)"
/>
<!-- Employee Timesheet --> <div
<q-btn class="row items-center full-width cursor-pointer q-py-sm"
v-if="auth_store.user?.user_module_access.includes(ModuleNames.TIMESHEETS)" @click="handleLogout"
flat >
dense <q-icon
no-wrap name="exit_to_app"
size="lg" color="accent"
icon="punch_clock" size="lg"
:label="!$q.platform.is.mobile && is_mini ? '' : $t('nav_bar.timesheet')" class="col-auto q-pl-sm"
class="col-auto text-uppercase text-weight-bold q-my-xs" />
:class="!$q.platform.is.mobile && is_mini ? '': 'q-px-sm'"
@click="goToPageName(RouteNames.TIMESHEET)"
/>
<!-- Profile --> <div class="col text-uppercase text-weight-bold text-h6 q-mini-drawer-hide q-pl-sm">
<q-btn {{ $t('nav_bar.logout') }}
v-if="auth_store.user?.user_module_access.includes(ModuleNames.PERSONAL_PROFILE)" </div>
flat </div>
dense </q-scroll-area>
no-wrap
size="lg"
icon="account_box"
:label="!$q.platform.is.mobile && is_mini ? '' : $t('nav_bar.profile')"
class="col-auto text-uppercase text-weight-bold q-my-xs"
:class="!$q.platform.is.mobile && is_mini ? '': 'q-px-sm'"
@click="goToPageName(RouteNames.PROFILE)"
/>
<!-- Help -->
<q-btn
flat
dense
no-wrap
size="lg"
icon="contact_support"
:label="!$q.platform.is.mobile && is_mini ? '' : $t('nav_bar.help')"
class="col-auto text-uppercase text-weight-bold q-my-xs"
:class="!$q.platform.is.mobile && is_mini ? '': 'q-px-sm'"
@click="goToPageName(RouteNames.HELP)"
/>
<!-- Logout -->
<q-btn
flat
dense
no-wrap
size="lg"
icon="exit_to_app"
:label="!$q.platform.is.mobile && is_mini ? '' : $t('nav_bar.logout')"
class="col-auto text-uppercase text-weight-bold q-my-xs"
:class="!$q.platform.is.mobile && is_mini ? 'absolute-bottom': 'absolute-bottom-left'"
@click="handleLogout"
/>
</q-drawer> </q-drawer>
</template> </template>

View File

@ -32,10 +32,13 @@
<template> <template>
<q-layout view="hHh lpR fFf"> <q-layout view="hHh lpR fFf">
<HeaderBar /> <HeaderBar />
<LeftDrawer /> <LeftDrawer />
<q-page-container> <q-page-container>
<router-view class="q-pa-sm bg-secondary" /> <router-view />
</q-page-container> </q-page-container>
<FooterBar /> <FooterBar />
</q-layout> </q-layout>
</template> </template>

View File

@ -86,16 +86,32 @@
:visible-columns="visible_columns" :visible-columns="visible_columns"
> >
<template #top> <template #top>
<div class="row full-width q-mb-sm"> <div class="row flex-center full-width q-mb-sm">
<q-btn <q-btn
push rounded
color="accent" color="accent"
icon="las la-user-edit" icon="las la-user-edit"
:label="$t('shared.label.add')" :label="$t('shared.label.add')"
class="text-uppercase" class="text-uppercase q-py-sm"
@click.stop="_evt => employee_store.openAddModifyDialog()" @click.stop="_evt => employee_store.openAddModifyDialog()"
/> />
<q-checkbox
v-model="filters.hide_inactive_users"
color="accent"
:label="$t('employee_management.filter.hide_terminated')"
class="text-uppercase q-ml-md text-weight-medium q-px-sm"
:class="filters.hide_inactive_users ? 'rounded-25 bg-accent' : ''"
>
<q-icon
name="las la-user-times"
:color="filters.hide_inactive_users ? 'white' : 'negative'"
size="sm"
class="q-px-sm"
/>
</q-checkbox>
<q-space /> <q-space />
<q-btn-toggle <q-btn-toggle
@ -131,15 +147,6 @@
</template> </template>
</q-input> </q-input>
</div> </div>
<div class="row">
<q-space />
<q-checkbox
v-model="filters.hide_inactive_users"
color="accent"
:label="$t('employee_management.filter.hide_terminated')"
/>
</div>
</template> </template>
<template #header="props"> <template #header="props">

View File

@ -23,36 +23,40 @@
:class="ui_store.is_mobile_mode ? 'column' : 'row'" :class="ui_store.is_mobile_mode ? 'column' : 'row'"
style="border: 1px solid var(--q-accent);" style="border: 1px solid var(--q-accent);"
> >
<q-item <div
v-for="mode of dark_mode_options" v-for="mode of dark_mode_options"
:key="mode.label" :key="mode.label"
clickable class="col q-pa-sm"
dense
v-ripple
class="col row rounded-5 q-ma-sm shadow-4"
:class="(mode.quasar_value === $q.dark.mode ? 'bg-accent text-white text-weight-bolder' : '') + ($q.platform.is.mobile ? ' full-width q-py-xs' : '')"
@click="ui_store.user_preferences.is_dark_mode = mode.value"
> >
<q-item-section avatar> <q-item
<q-icon clickable
:name="mode.icon" dense
size="md" v-ripple
:color="mode.quasar_value === $q.dark.mode ? 'white' : ''" class="rounded-5 shadow-4 q-py-xs"
/> :class="(mode.quasar_value === $q.dark.mode ? 'bg-accent text-white text-weight-bolder' : '') + ($q.platform.is.mobile ? ' full-width q-py-xs' : '')"
</q-item-section> @click="ui_store.user_preferences.is_dark_mode = mode.value"
>
<q-item-section side>
<q-icon
:name="mode.icon"
size="md"
:color="mode.quasar_value === $q.dark.mode ? 'white' : ''"
/>
</q-item-section>
<q-item-section class="text-uppercase justify-center"> <q-item-section>
<q-item-label> {{ $t(mode.label) }}</q-item-label> <q-item-label class="text-uppercase justify-center">{{ $t(mode.label) }}</q-item-label>
</q-item-section> </q-item-section>
<q-item-section side> <q-item-section side>
<q-icon <q-icon
v-if="mode.quasar_value === $q.dark.mode" v-if="mode.quasar_value === $q.dark.mode"
name="check" name="check"
color="white" color="white"
/> />
</q-item-section> </q-item-section>
</q-item> </q-item>
</div>
</div> </div>
<div <div

View File

@ -17,10 +17,10 @@
</script> </script>
<template> <template>
<div class="column text-uppercase text-center text-weight-bolder text-h4"> <div class="column text-uppercase text-center text-weight-bolder text-h4 q-pt-lg">
<span <span
v-if="!$q.platform.is.mobile" v-if="!$q.platform.is.mobile"
class="col q-mt-lg" class="col"
>{{ $t(title) }}</span> >{{ $t(title) }}</span>
<transition <transition

View File

@ -15,17 +15,18 @@
keep-color keep-color
size="lg" size="lg"
color="accent" color="accent"
label="show inactive" :label="$t('timesheet_approvals.table.filter_active')"
class="col" class="col"
:class="filters.is_showing_inactive ? 'text-accent text-weight-bolder' : 'text-white text-weight-medium'" :class="filters.is_showing_inactive ? 'text-accent text-weight-bolder' : 'text-white text-weight-medium'"
/> />
<q-checkbox <q-checkbox
v-model="filters.is_showing_team_only" v-model="filters.is_showing_team_only"
keep-color keep-color
size="lg" size="lg"
val="team" val="team"
color="accent" color="accent"
label="show team only" :label="$t('timesheet_approvals.table.filter_team')"
class="col" class="col"
:class="filters.is_showing_team_only ? 'text-accent text-weight-bolder' : 'text-white text-weight-medium'" :class="filters.is_showing_team_only ? 'text-accent text-weight-bolder' : 'text-white text-weight-medium'"
/> />

View File

@ -154,7 +154,6 @@ import { getHoursMinutesStringFromHoursFloat, getMinutes } from 'src/utils/date-
> >
<div <div
class="col text-uppercase" 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 class="text-h6 q-ml-sm text-weight-bolder">{{ 'Total : ' + Math.floor(row.total_hours)
}}</span> }}</span>

View File

@ -35,6 +35,10 @@
'is_approved', 'is_approved',
]); ]);
const { maxHeight } = defineProps<{
maxHeight: number;
}>();
const is_showing_filters = ref(false); const is_showing_filters = ref(false);
const search_string = ref(''); const search_string = ref('');
@ -80,211 +84,213 @@
</script> </script>
<template> <template>
<div class="q-px-md full-height"> <div class="full-width">
<LoadingOverlay v-model="timesheet_store.is_loading" /> <LoadingOverlay v-model="timesheet_store.is_loading" />
<q-table
<q-table dense
:key="timesheet_store.is_approval_grid_mode ? 'grid' : 'list'" row-key="email"
:visible-columns="VISIBLE_COLUMNS" color="accent"
:rows="overview_rows" hide-pagination
:columns="pay_period_overview_columns" :rows="overview_rows"
row-key="email" :columns="pay_period_overview_columns"
:grid="timesheet_store.is_approval_grid_mode" :visible-columns="VISIBLE_COLUMNS"
:dense="timesheet_store.is_approval_grid_mode" :grid="timesheet_store.is_approval_grid_mode"
hide-pagination :pagination="{ sortBy: 'is_active' }"
:pagination="{ sortBy: 'is_active' }" :filter="overview_filters"
:filter="overview_filters" :filter-method="filterEmployeeRows"
:filter-method="filterEmployeeRows" :rows-per-page-options="[0]"
color="accent" class="bg-transparent"
:rows-per-page-options="[0]" :class="timesheet_store.is_approval_grid_mode ? '' : 'sticky-header-table no-shadow'"
card-container-class="justify-center" card-container-class="justify-center"
class="bg-transparent" table-class="q-pa-none q-mx-md rounded-10 bg-dark shadow-15 hide-scrollbar"
:class="timesheet_store.is_approval_grid_mode ? '' : 'sticky-header-table no-shadow'" :no-data-label="$t('shared.error.no_data_found')"
table-class="q-pa-none q-mx-md rounded-10 bg-dark shadow-15 hide-scrollbar" :no-results-label="$t('shared.error.no_search_results')"
:no-data-label="$t('shared.error.no_data_found')" :loading-label="$t('shared.label.loading')"
:no-results-label="$t('shared.error.no_search_results')" :style="`max-height: ${maxHeight}px;`"
:loading-label="$t('shared.label.loading')" @row-click="(_evt, row: TimesheetApprovalOverview) => onClickedDetails(row)"
:style="$q.platform.is.mobile ? '' : 'max-height: 70vh;'" >
> <template #top>
<template #top> <div class="column full-width">
<div class="column full-width">
<div
class="col-auto row items-start full-width q-px-lg"
:class="$q.platform.is.mobile ? 'column flex-center' : 'row q-mt-md'"
>
<PayPeriodNavigator
@date-selected="timesheet_approval_api.getTimesheetOverviews"
@pressed-next-button="timesheet_approval_api.getTimesheetOverviews"
@pressed-previous-button="timesheet_approval_api.getTimesheetOverviews"
:class="$q.platform.is.mobile ? 'q-mb-sm' : ''"
style="height: 40px;"
/>
<q-space />
<div <div
class="col-auto row no-wrap items-start" class="col-auto row items-start full-width q-px-lg"
:class="$q.platform.is.mobile ? 'q-mb-md' : ''" :class="$q.platform.is.mobile ? 'column flex-center' : 'row q-mt-md'"
> >
<q-btn-toggle <PayPeriodNavigator
v-model="timesheet_store.is_approval_grid_mode" @date-selected="timesheet_approval_api.getTimesheetOverviews"
push @pressed-next-button="timesheet_approval_api.getTimesheetOverviews"
rounded @pressed-previous-button="timesheet_approval_api.getTimesheetOverviews"
color="white" :class="$q.platform.is.mobile ? 'q-mb-sm' : ''"
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-btn <q-space />
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 <div
v-model:search="search_string" class="col-auto row no-wrap items-start"
class="col-auto q-mb-sm" :class="$q.platform.is.mobile ? 'q-mb-md' : ''"
/>
<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
color="primary"
size="5px"
class="q-mx-lg q-my-none q-pa-none"
/>
</div>
</template>
<template #header="props">
<q-tr
:props="props"
class="bg-primary"
>
<q-th
v-for="col in props.cols"
: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-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 <q-btn-toggle
:key="props.row.is_approved" v-model="timesheet_store.is_approval_grid_mode"
flat push
dense rounded
:icon="props.value ? 'lock' : 'lock_open'" color="white"
:color="props.value ? 'white' : 'grey-5'" text-color="accent"
class="rounded-5 " toggle-color="accent"
:class="props.value ? 'bg-accent' : ''" class="col-auto"
@click.stop="onClickApproveAll(props.row.email, props.row.is_approved)" :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;"
/> />
</transition>
<div v-else-if="props.col.name === 'employee_first_name'"> <q-btn
<span class="text-h5 text-uppercase text-accent q-mr-xs"> push
{{ props.value }} rounded
</span> icon="download"
<span class="text-uppercase text-weight-light"> color="accent"
{{ props.row.employee_last_name }} :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
color="primary"
size="5px"
class="q-mx-lg q-my-none q-pa-none"
/>
</div>
</template>
<template #header="props">
<q-tr
:props="props"
class="bg-primary"
>
<q-th
v-for="col in props.cols"
: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-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 / 15}s; opacity: ${props.row.is_active ? '1' : '0.5'};`"
>
<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 ? (props.row.is_active ? 'bg-accent' : 'bg-negative') : ''"
@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 q-mr-xs"
:class="props.row.is_active ? 'text-accent' : 'text-negative'"
>
{{ 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> </span>
</div> </div>
</transition>
</q-td>
</template>
<span <!-- Template for individual employee cards -->
v-else <template #item="props: { row: TimesheetApprovalOverview, rowIndex: number }">
:class="props.col.name === overview_column_names.REGULAR && props.row.overtime > 0 ? 'text-negative text-weight-bolder' : 'text-weight-regular'" <OverviewListItem
> v-model="props.row.is_approved"
{{ TIME_COLUMNS.includes(props.col.name) ? :key="props.row.email + timesheet_store.pay_period?.pay_period_no"
getHoursMinutesStringFromHoursFloat(props.value) : props.value }} :index="props.rowIndex"
</span> :row="props.row"
</div> @click-details="onClickedDetails"
</transition> @click-approval-all="is_approved => onClickApproveAll(props.row.email, is_approved)"
</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'"
/> />
</template>
<span class="text-h6"> <!-- Template for custome failed-to-load state -->
{{ message }} <template #no-data="{ message, filter }">
</span> <div class="full-width column items-center text-accent q-gutter-sm">
</div> <q-icon
</template> size="4em"
</q-table> :name="filter ? 'filter_alt_off' : 'error_outline'"
/>
<span class="text-h6">
{{ message }}
</span>
</div>
</template>
</q-table>
</div> </div>
</template> </template>
@ -305,4 +311,7 @@
tbody tbody
scroll-margin-top: 48px scroll-margin-top: 48px
.q-table__grid-content
overflow: auto
</style> </style>

View File

@ -1,143 +1,163 @@
<script setup lang="ts"> <script
import { computed, ref, watch } from 'vue'; setup
import { useTimesheetStore } from 'src/stores/timesheet-store'; lang="ts"
import { TimesheetApprovalCSVReportFilters } from 'src/modules/timesheet-approval/models/timesheet-approval-csv-report.models'; >
import { computed, ref, watch } from 'vue';
import { useTimesheetStore } from 'src/stores/timesheet-store';
import { TimesheetApprovalCSVReportFilters } from 'src/modules/timesheet-approval/models/timesheet-approval-csv-report.models';
const timesheet_store = useTimesheetStore(); const timesheet_store = useTimesheetStore();
const report_filter_options = ref<TimesheetApprovalCSVReportFilters>(new TimesheetApprovalCSVReportFilters); const report_filter_options = ref<TimesheetApprovalCSVReportFilters>(new TimesheetApprovalCSVReportFilters);
const selected_report_filters = ref<(keyof TimesheetApprovalCSVReportFilters)[]>( const selected_report_filters = ref<(keyof TimesheetApprovalCSVReportFilters)[]>(
Object.entries(report_filter_options.value).filter(([_key, value]) => value).map(([key]) => key as keyof TimesheetApprovalCSVReportFilters) Object.entries(report_filter_options.value).filter(([_key, value]) => value).map(([key]) => key as keyof TimesheetApprovalCSVReportFilters)
); );
interface ReportOptions { interface ReportOptions {
label: string; label: string;
value: keyof TimesheetApprovalCSVReportFilters; value: keyof TimesheetApprovalCSVReportFilters;
}; };
const company_options: ReportOptions[] = [ const company_options: ReportOptions[] = [
{ label: 'Targo', value: 'targo' }, { label: 'Targo', value: 'targo' },
{ label: 'Solucom', value: 'solucom' }, { label: 'Solucom', value: 'solucom' },
]; ];
const type_options: ReportOptions[] = [ const type_options: ReportOptions[] = [
{ label: 'timesheet_approvals.print_report.shifts', value: 'shifts' }, { label: 'timesheet_approvals.print_report.shifts', value: 'shifts' },
{ label: 'timesheet_approvals.print_report.expenses', value: 'expenses' }, { label: 'timesheet_approvals.print_report.expenses', value: 'expenses' },
{ label: 'shared.shift_type.holiday', value: 'holiday' }, { label: 'shared.shift_type.holiday', value: 'holiday' },
{ label: 'shared.shift_type.vacation', value: 'vacation' }, { label: 'shared.shift_type.vacation', value: 'vacation' },
]; ];
const is_download_button_enable = computed(() => const is_download_button_enable = computed(() =>
company_options.map(option => option.value).some(option => selected_report_filters.value.includes(option)) && company_options.map(option => option.value).some(option => selected_report_filters.value.includes(option)) &&
type_options.map(option => option.value).some(option => selected_report_filters.value.includes(option)) type_options.map(option => option.value).some(option => selected_report_filters.value.includes(option))
); );
const onClickedDownload = async () => { const onClickedDownload = async () => {
try { try {
const data = await timesheet_store.getPayPeriodReport(report_filter_options.value); const data = await timesheet_store.getPayPeriodReport(report_filter_options.value);
const companies = Object.entries(report_filter_options.value) const companies = Object.entries(report_filter_options.value)
.filter(([key, value]) => value && (key === 'targo' || key === 'solucom')).map(([key]) => key).join('-'); .filter(([key, value]) => value && (key === 'targo' || key === 'solucom')).map(([key]) => key).join('-');
const types = Object.entries(report_filter_options.value) const types = Object.entries(report_filter_options.value)
.filter(([key, value]) => value && ['shifts', 'expenses', 'holiday', 'vacation'].includes(key)).map(([key]) => key).join('-'); .filter(([key, value]) => value && ['shifts', 'expenses', 'holiday', 'vacation'].includes(key)).map(([key]) => key).join('-');
const file_name = `Desjardins_${companies}_${types}_${new Date().toISOString().split('T')[0]}.csv`; const file_name = `Desjardins_${companies}_${types}_${new Date().toISOString().split('T')[0]}.csv`;
const blob = new Blob([data], { type: 'text/csv;charset=utf-8;' }); const blob = new Blob([data], { type: 'text/csv;charset=utf-8;' });
const url = window.URL.createObjectURL(blob); const url = window.URL.createObjectURL(blob);
const link = document.createElement('a'); const link = document.createElement('a');
link.href = url; link.href = url;
link.setAttribute('download', file_name); link.setAttribute('download', file_name);
document.body.appendChild(link); document.body.appendChild(link);
link.click(); link.click();
document.body.removeChild(link); document.body.removeChild(link);
window.URL.revokeObjectURL(url); window.URL.revokeObjectURL(url);
} catch (error) { } catch (error) {
console.error(`An error occured during the CSV download: `, error) console.error(`An error occured during the CSV download: `, error)
}
} }
}
watch(selected_report_filters, (new_values) => { watch(selected_report_filters, (new_values) => {
Object.keys(report_filter_options.value).forEach(key => { Object.keys(report_filter_options.value).forEach(key => {
const typed_key = key as keyof TimesheetApprovalCSVReportFilters; const typed_key = key as keyof TimesheetApprovalCSVReportFilters;
report_filter_options.value[typed_key] = new_values.includes(key as keyof TimesheetApprovalCSVReportFilters); report_filter_options.value[typed_key] = new_values.includes(key as keyof TimesheetApprovalCSVReportFilters);
});
}); });
});
</script> </script>
<template> <template>
<q-dialog v-model="timesheet_store.is_report_dialog_open"> <q-dialog v-model="timesheet_store.is_report_dialog_open">
<div class="bg-secondary full-width shadow-24 rounded-10 column"> <div
<div class="shadow-1 bg-primary text-accent text-weight-bold text-center text-uppercase"> class="column bg-secondary shadow-24 rounded-10"
<span> {{ $t('timesheet_approvals.print_report.title') }}</span> :style="$q.dark.isActive ? 'border: 2px solid var(--q-accent)' : ''"
>
<!-- main header -->
<div class="col-auto bg-primary text-accent text-weight-bolder text-center text-uppercase text-h6 q-py-xs z-top">
{{ $t('timesheet_approvals.print_report.title') }}
</div> </div>
<div class="row q-py-md q-px-lg">
<div class="col-auto full-width shadow-1 row bg-dark q-py-xs q-px-lg rounded-10"> <!-- info blurb -->
<span class="col q-px-sm q-pt-xs text-weight-bolder text-accent text-uppercase col-3"> <div class="col-auto row flex-center full-width bg-dark shadow-2">
{{ $t('timesheet_approvals.print_report.company') }} <q-icon
</span> name="info_outline"
<div class="row bordered-primary col-auto full-width"> size="sm"
<div class="col-auto q-mr-xs"
v-for="company, index in company_options"
:key="index"
class="q-pa-xs col-6"
>
<q-checkbox
v-model="selected_report_filters"
left-label
color="white"
class="q-px-md shadow-4 rounded-25 full-width"
dense
:class="selected_report_filters.includes(company.value) ? 'bg-accent text-white' : 'bg-dark'"
:label="$t(company.label)"
:val="company.value"
checked-icon="download"
unchecked-icon="highlight_off"
/>
</div>
</div>
</div>
<div class="row q-py-md">
<div class="col-auto full-width shadow-1 row bg-dark q-px-lg rounded-10 q-pb-sm">
<span class="col q-px-sm q-pt-xs text-weight-bolder text-accent text-uppercase col-3">
{{ $t('timesheet_approvals.print_report.options') }}
</span>
<div class=" row bordered-primary col-auto full-width">
<div
v-for="type, index in type_options"
:key="index"
class="q-pa-xs col-6"
>
<q-checkbox
v-model="selected_report_filters"
left-label
color="white"
class="q-px-md shadow-4 rounded-25 full-width"
dense
:class="selected_report_filters.includes(type.value) ? 'bg-accent text-white' : 'bg-dark'"
:label="$t(type.label)"
:val="type.value"
checked-icon="download"
unchecked-icon="highlight_off"
/>
</div>
</div>
</div>
</div>
</div>
<div class="column">
<q-btn
:disable="!is_download_button_enable"
square
size="md"
icon="download"
:color="is_download_button_enable ? 'accent' : 'grey-5'"
:label="$t('shared.label.download')"
@click="onClickedDownload()"
/> />
<span class="col-auto text-weight-light q-mr-sm">
{{ $t('timesheet_approvals.print_report.description') }}
</span>
</div> </div>
<!-- company header -->
<span class="col-auto q-px-sm q-pt-md text-weight-medium text-accent text-uppercase">
{{ $t('timesheet_approvals.print_report.company') }}
</span>
<!-- company options -->
<div class="col row text-uppercase full-width q-px-md">
<div
v-for="company, index in company_options"
:key="index"
class="q-pa-xs col-6"
>
<q-checkbox
v-model="selected_report_filters"
left-label
color="white"
dense
:label="$t(company.label)"
:val="company.value"
checked-icon="download"
unchecked-icon="highlight_off"
class="q-px-md q-py-xs shadow-4 rounded-25 full-width"
:class="selected_report_filters.includes(company.value) ? 'bg-accent text-white text-bold' : 'bg-dark'"
/>
</div>
</div>
<!-- shift type header -->
<span class="col-auto q-px-sm q-pt-md text-weight-medium text-uppercase text-accent">
{{ $t('timesheet_approvals.print_report.options') }}
</span>
<!-- shift type options -->
<div class="col row text-uppercase full-width q-px-md q-pb-md">
<div
v-for="type, index in type_options"
:key="index"
class="q-pa-xs col-6"
>
<q-checkbox
v-model="selected_report_filters"
left-label
color="white"
dense
:val="type.value"
checked-icon="download"
unchecked-icon="highlight_off"
:label="$t(type.label)"
class="q-px-md q-py-xs shadow-4 rounded-25 full-width"
:class="selected_report_filters.includes(type.value) ? 'bg-accent text-white text-bold' : 'bg-white text-primary'"
/>
</div>
</div>
<!-- download button -->
<q-btn
:disable="!is_download_button_enable"
square
icon="download"
:color="is_download_button_enable ? 'accent' : 'grey-5'"
:label="$t('shared.label.download')"
class="col-auto q-py-sm shadow-up-2"
@click="onClickedDownload()"
/>
</div> </div>
</q-dialog> </q-dialog>
</template> </template>

View File

@ -17,7 +17,7 @@
</script> </script>
<template> <template>
<q-page class="column items-center justify-start"> <q-page class="column flex-center bg-secondary">
<AddModifyDialog /> <AddModifyDialog />
<PageHeaderTemplate title="employee_list.page_header" /> <PageHeaderTemplate title="employee_list.page_header" />

View File

@ -9,13 +9,27 @@
import OverviewReport from 'src/modules/timesheet-approval/components/overview-report.vue'; import OverviewReport from 'src/modules/timesheet-approval/components/overview-report.vue';
import { date } from 'quasar'; import { date } from 'quasar';
import { onMounted } from 'vue'; import { computed, onMounted, ref } from 'vue';
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 { useTimesheetStore } from 'src/stores/timesheet-store'; import { useTimesheetStore } from 'src/stores/timesheet-store';
const timesheet_approval_api = useTimesheetApprovalApi(); const timesheet_approval_api = useTimesheetApprovalApi();
const timesheet_store = useTimesheetStore(); const timesheet_store = useTimesheetStore();
const page_height = ref(0);
const headerComponent = ref<HTMLElement | null>(null);
const table_max_height = computed(() => {
const height = page_height.value - (headerComponent.value?.clientHeight ?? 0) - 20;
console.log('offset height of header: ', headerComponent.value?.clientHeight);
console.log('height calculated: ', height);
return height;
});
const tableStyleFunction = (offset: number, height: number) => {
page_height.value = height - offset;
};
onMounted(async () => { onMounted(async () => {
await timesheet_approval_api.getTimesheetOverviewsByDate(date.formatDate(new Date(), 'YYYY-MM-DD')); await timesheet_approval_api.getTimesheetOverviewsByDate(date.formatDate(new Date(), 'YYYY-MM-DD'));
}); });
@ -23,16 +37,9 @@
<template> <template>
<q-page <q-page
padding class="bg-secondary"
class="column q-pa-md bg-secondary" :style-fn="tableStyleFunction"
> >
<PageHeaderTemplate
title="timesheet_approvals.page_title"
:start-date="timesheet_store.pay_period?.period_start ?? ''"
:end-date="timesheet_store.pay_period?.period_end ?? ''"
class="col-auto"
/>
<DetailsDialog <DetailsDialog
:is-loading="timesheet_store.is_loading" :is-loading="timesheet_store.is_loading"
:employee-overview="timesheet_store.current_pay_period_overview" :employee-overview="timesheet_store.current_pay_period_overview"
@ -41,6 +48,24 @@
<OverviewReport /> <OverviewReport />
<OverviewList class="col" /> <div
class="column items-center scroll q-pa-sm"
style="min-height: inherit;"
>
<div
ref="headerComponent"
class="col-auto"
>
<PageHeaderTemplate
title="timesheet_approvals.page_title"
:start-date="timesheet_store.pay_period?.period_start ?? ''"
:end-date="timesheet_store.pay_period?.period_end ?? ''"
/>
</div>
<div class="col">
<OverviewList :max-height="table_max_height" />
</div>
</div>
</q-page> </q-page>
</template> </template>

View File

@ -1,12 +1,11 @@
export enum RouteNames { export enum RouteNames {
LOGIN = 'login', LOGIN = 'login',
LOGIN_SUCCESS = 'login-success', LOGIN_SUCCESS = 'login-success',
DASHBOARD = 'dashboard', DASHBOARD = '/',
TIMESHEET_APPROVALS = 'timesheets_approval', TIMESHEET_APPROVALS = 'timesheet-approvals',
EMPLOYEE_LIST = 'employee_list', EMPLOYEE_LIST = 'employees',
EMPLOYEE_MANAGEMENT = 'employee_management', PROFILE = 'profile',
PROFILE = 'personal_profile', TIMESHEET = 'timesheet',
TIMESHEET = 'timesheets',
HELP = 'help', HELP = 'help',
ERROR = 'error', ERROR = 'error',

View File

@ -33,7 +33,7 @@ const routes: RouteRecordRaw[] = [
meta: { required_module: ModuleNames.TIMESHEETS }, meta: { required_module: ModuleNames.TIMESHEETS },
}, },
{ {
path: 'user/profile', path: 'profile',
name: RouteNames.PROFILE, name: RouteNames.PROFILE,
component: () => import('src/pages/profile-page.vue'), component: () => import('src/pages/profile-page.vue'),
meta: { required_module: ModuleNames.PERSONAL_PROFILE }, meta: { required_module: ModuleNames.PERSONAL_PROFILE },

View File

@ -4,6 +4,7 @@ import { computed, ref } from 'vue';
import { LocalStorage, useQuasar, Dark } from 'quasar'; import { LocalStorage, useQuasar, Dark } from 'quasar';
import { Preferences } from 'src/modules/profile/models/preferences.models'; import { Preferences } from 'src/modules/profile/models/preferences.models';
import { ProfileService } from 'src/modules/profile/services/profile-service'; import { ProfileService } from 'src/modules/profile/services/profile-service';
import { RouteNames } from 'src/router/router-constants';
export const useUiStore = defineStore('ui', () => { export const useUiStore = defineStore('ui', () => {
@ -13,6 +14,7 @@ export const useUiStore = defineStore('ui', () => {
const focus_next_component = ref(false); const focus_next_component = ref(false);
const is_mobile_mode = computed(() => q.screen.lt.md); const is_mobile_mode = computed(() => q.screen.lt.md);
const user_preferences = ref<Preferences>(new Preferences); const user_preferences = ref<Preferences>(new Preferences);
const current_page = ref<RouteNames>(RouteNames.DASHBOARD);
const toggleRightDrawer = () => { const toggleRightDrawer = () => {
@ -69,6 +71,7 @@ export const useUiStore = defineStore('ui', () => {
} }
return { return {
current_page,
is_mobile_mode, is_mobile_mode,
focus_next_component, focus_next_component,
is_left_drawer_open, is_left_drawer_open,