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
devServer: {
// 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

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

View File

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

View File

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

View File

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

View File

@ -86,16 +86,32 @@
:visible-columns="visible_columns"
>
<template #top>
<div class="row full-width q-mb-sm">
<div class="row flex-center full-width q-mb-sm">
<q-btn
push
rounded
color="accent"
icon="las la-user-edit"
:label="$t('shared.label.add')"
class="text-uppercase"
class="text-uppercase q-py-sm"
@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-btn-toggle
@ -131,15 +147,6 @@
</template>
</q-input>
</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 #header="props">

View File

@ -23,36 +23,40 @@
:class="ui_store.is_mobile_mode ? 'column' : 'row'"
style="border: 1px solid var(--q-accent);"
>
<q-item
<div
v-for="mode of dark_mode_options"
:key="mode.label"
clickable
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"
class="col q-pa-sm"
>
<q-item-section avatar>
<q-icon
:name="mode.icon"
size="md"
:color="mode.quasar_value === $q.dark.mode ? 'white' : ''"
/>
</q-item-section>
<q-item
clickable
dense
v-ripple
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' : '')"
@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-label> {{ $t(mode.label) }}</q-item-label>
</q-item-section>
<q-item-section>
<q-item-label class="text-uppercase justify-center">{{ $t(mode.label) }}</q-item-label>
</q-item-section>
<q-item-section side>
<q-icon
v-if="mode.quasar_value === $q.dark.mode"
name="check"
color="white"
/>
</q-item-section>
</q-item>
<q-item-section side>
<q-icon
v-if="mode.quasar_value === $q.dark.mode"
name="check"
color="white"
/>
</q-item-section>
</q-item>
</div>
</div>
<div

View File

@ -17,10 +17,10 @@
</script>
<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
v-if="!$q.platform.is.mobile"
class="col q-mt-lg"
class="col"
>{{ $t(title) }}</span>
<transition

View File

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

View File

@ -35,6 +35,10 @@
'is_approved',
]);
const { maxHeight } = defineProps<{
maxHeight: number;
}>();
const is_showing_filters = ref(false);
const search_string = ref('');
@ -80,211 +84,213 @@
</script>
<template>
<div class="q-px-md full-height">
<div class="full-width">
<LoadingOverlay v-model="timesheet_store.is_loading" />
<q-table
:key="timesheet_store.is_approval_grid_mode ? 'grid' : 'list'"
:visible-columns="VISIBLE_COLUMNS"
: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;'"
>
<template #top>
<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 />
<q-table
dense
row-key="email"
color="accent"
hide-pagination
:rows="overview_rows"
:columns="pay_period_overview_columns"
:visible-columns="VISIBLE_COLUMNS"
:grid="timesheet_store.is_approval_grid_mode"
:pagination="{ sortBy: 'is_active' }"
:filter="overview_filters"
:filter-method="filterEmployeeRows"
:rows-per-page-options="[0]"
class="bg-transparent"
:class="timesheet_store.is_approval_grid_mode ? '' : 'sticky-header-table no-shadow'"
card-container-class="justify-center"
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="`max-height: ${maxHeight}px;`"
@row-click="(_evt, row: TimesheetApprovalOverview) => onClickedDetails(row)"
>
<template #top>
<div class="column full-width">
<div
class="col-auto row no-wrap items-start"
:class="$q.platform.is.mobile ? 'q-mb-md' : ''"
class="col-auto row items-start full-width q-px-lg"
:class="$q.platform.is.mobile ? 'column flex-center' : 'row q-mt-md'"
>
<q-btn-toggle
v-model="timesheet_store.is_approval_grid_mode"
push
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 },
]"
<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-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"
/>
<q-space />
<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 / 30}s;`"
>
<transition
v-if="props.col.name === 'is_approved'"
enter-active-class="animated swing"
mode="out-in"
<div
class="col-auto row no-wrap items-start"
:class="$q.platform.is.mobile ? 'q-mb-md' : ''"
>
<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)"
<q-btn-toggle
v-model="timesheet_store.is_approval_grid_mode"
push
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;"
/>
</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 }}
<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
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>
</div>
</transition>
</q-td>
</template>
<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'"
<!-- 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>
<span class="text-h6">
{{ message }}
</span>
</div>
</template>
</q-table>
<!-- 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>
</template>
@ -305,4 +311,7 @@
tbody
scroll-margin-top: 48px
.q-table__grid-content
overflow: auto
</style>

View File

@ -1,143 +1,163 @@
<script setup lang="ts">
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';
<script
setup
lang="ts"
>
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 report_filter_options = ref<TimesheetApprovalCSVReportFilters>(new TimesheetApprovalCSVReportFilters);
const timesheet_store = useTimesheetStore();
const report_filter_options = ref<TimesheetApprovalCSVReportFilters>(new TimesheetApprovalCSVReportFilters);
const selected_report_filters = ref<(keyof TimesheetApprovalCSVReportFilters)[]>(
Object.entries(report_filter_options.value).filter(([_key, value]) => value).map(([key]) => key as 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)
);
interface ReportOptions {
label: string;
value: keyof TimesheetApprovalCSVReportFilters;
};
interface ReportOptions {
label: string;
value: keyof TimesheetApprovalCSVReportFilters;
};
const company_options: ReportOptions[] = [
{ label: 'Targo', value: 'targo' },
{ label: 'Solucom', value: 'solucom' },
];
const company_options: ReportOptions[] = [
{ label: 'Targo', value: 'targo' },
{ label: 'Solucom', value: 'solucom' },
];
const type_options: ReportOptions[] = [
{ label: 'timesheet_approvals.print_report.shifts', value: 'shifts' },
{ label: 'timesheet_approvals.print_report.expenses', value: 'expenses' },
{ label: 'shared.shift_type.holiday', value: 'holiday' },
{ label: 'shared.shift_type.vacation', value: 'vacation' },
];
const type_options: ReportOptions[] = [
{ label: 'timesheet_approvals.print_report.shifts', value: 'shifts' },
{ label: 'timesheet_approvals.print_report.expenses', value: 'expenses' },
{ label: 'shared.shift_type.holiday', value: 'holiday' },
{ label: 'shared.shift_type.vacation', value: 'vacation' },
];
const is_download_button_enable = computed(() =>
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))
);
const is_download_button_enable = computed(() =>
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))
);
const onClickedDownload = async () => {
try {
const data = await timesheet_store.getPayPeriodReport(report_filter_options.value);
const onClickedDownload = async () => {
try {
const data = await timesheet_store.getPayPeriodReport(report_filter_options.value);
const companies = Object.entries(report_filter_options.value)
.filter(([key, value]) => value && (key === 'targo' || key === 'solucom')).map(([key]) => key).join('-');
const companies = Object.entries(report_filter_options.value)
.filter(([key, value]) => value && (key === 'targo' || key === 'solucom')).map(([key]) => key).join('-');
const types = Object.entries(report_filter_options.value)
.filter(([key, value]) => value && ['shifts', 'expenses', 'holiday', 'vacation'].includes(key)).map(([key]) => key).join('-');
const types = Object.entries(report_filter_options.value)
.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 url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
const blob = new Blob([data], { type: 'text/csv;charset=utf-8;' });
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.setAttribute('download', file_name);
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
} catch (error) {
console.error(`An error occured during the CSV download: `, error)
link.href = url;
link.setAttribute('download', file_name);
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
} catch (error) {
console.error(`An error occured during the CSV download: `, error)
}
}
}
watch(selected_report_filters, (new_values) => {
Object.keys(report_filter_options.value).forEach(key => {
const typed_key = key as keyof TimesheetApprovalCSVReportFilters;
report_filter_options.value[typed_key] = new_values.includes(key as keyof TimesheetApprovalCSVReportFilters);
watch(selected_report_filters, (new_values) => {
Object.keys(report_filter_options.value).forEach(key => {
const typed_key = key as keyof TimesheetApprovalCSVReportFilters;
report_filter_options.value[typed_key] = new_values.includes(key as keyof TimesheetApprovalCSVReportFilters);
});
});
});
</script>
<template>
<q-dialog v-model="timesheet_store.is_report_dialog_open">
<div class="bg-secondary full-width shadow-24 rounded-10 column">
<div class="shadow-1 bg-primary text-accent text-weight-bold text-center text-uppercase">
<span> {{ $t('timesheet_approvals.print_report.title') }}</span>
<div
class="column bg-secondary shadow-24 rounded-10"
: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 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">
<span class="col q-px-sm q-pt-xs text-weight-bolder text-accent text-uppercase col-3">
{{ $t('timesheet_approvals.print_report.company') }}
</span>
<div class="row bordered-primary col-auto full-width">
<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"
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()"
<!-- info blurb -->
<div class="col-auto row flex-center full-width bg-dark shadow-2">
<q-icon
name="info_outline"
size="sm"
class="col-auto q-mr-xs"
/>
<span class="col-auto text-weight-light q-mr-sm">
{{ $t('timesheet_approvals.print_report.description') }}
</span>
</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>
</q-dialog>
</template>

View File

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

View File

@ -9,13 +9,27 @@
import OverviewReport from 'src/modules/timesheet-approval/components/overview-report.vue';
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 { useTimesheetStore } from 'src/stores/timesheet-store';
const timesheet_approval_api = useTimesheetApprovalApi();
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 () => {
await timesheet_approval_api.getTimesheetOverviewsByDate(date.formatDate(new Date(), 'YYYY-MM-DD'));
});
@ -23,16 +37,9 @@
<template>
<q-page
padding
class="column q-pa-md bg-secondary"
class="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
:is-loading="timesheet_store.is_loading"
:employee-overview="timesheet_store.current_pay_period_overview"
@ -41,6 +48,24 @@
<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>
</template>

View File

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

View File

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

View File

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