fix(many): refactor timesheet approval download menu, details window fixes, store refactor

- Timesheet Approval's download menu has had its UI overhauled and the script has been streamlined to better match backend structure and logic

- Details window in timesheet approval has a few bug and oversight fixes.

- Refactored UI store to work with camelCase instead of snake_case
This commit is contained in:
Nic D 2026-03-18 09:18:06 -04:00
parent 9213a42d6b
commit e37ec79827
28 changed files with 666 additions and 308 deletions

View File

@ -401,10 +401,14 @@ export default {
title: "Download options", title: "Download options",
description: "Choose what to include in the report", description: "Choose what to include in the report",
company: "companies", company: "companies",
targo: "Targo",
solucom: "Solucom",
type: "type", type: "type",
shifts: "shifts", shifts: "shifts",
expenses: "expenses", expenses: "expenses",
options: "options", options: "options",
download_failed: "download failed",
download_failed_caption: "an unexpected error occured",
}, },
table: { table: {
full_name: "full name", full_name: "full name",

View File

@ -401,10 +401,14 @@ export default {
title: "options de téléchargement", title: "options de téléchargement",
description: "Choisissez ce qui sera inclu dans le rapport", description: "Choisissez ce qui sera inclu dans le rapport",
company: "compagnies", company: "compagnies",
targo: "Targo",
solucom: "Solucom",
type: "types de données", type: "types de données",
shifts: "quarts de travail", shifts: "quarts de travail",
expenses: "dépenses", expenses: "dépenses",
options: "options", options: "options",
download_failed: "téléchargement échoué",
download_failed_caption: "une erreur inconnue est survenue",
}, },
table: { table: {
full_name: "nom complet", full_name: "nom complet",

View File

@ -21,14 +21,14 @@ import { useAuthApi } from 'src/modules/auth/composables/use-auth-api';
] ]
const q = useQuasar(); const q = useQuasar();
const auth_store = useAuthStore(); const authStore = useAuthStore();
const authApi = useAuthApi(); const authApi = useAuthApi();
const ui_store = useUiStore(); const uiStore = useUiStore();
const router = useRouter(); const router = useRouter();
const is_mini = ref(true); const isMini = ref(true);
const onClickDrawerPage = (page_name: RouteNames) => { const onClickDrawerPage = (page_name: RouteNames) => {
is_mini.value = true; isMini.value = true;
router.push({ name: page_name }).catch(error => { router.push({ name: page_name }).catch(error => {
console.error('failed to reach page: ', error); console.error('failed to reach page: ', error);
@ -41,21 +41,21 @@ import { useAuthApi } from 'src/modules/auth/composables/use-auth-api';
onMounted(() => { onMounted(() => {
if (q.platform.is.mobile) { if (q.platform.is.mobile) {
ui_store.is_left_drawer_open = false; uiStore.isLeftDrawerOpen = false;
} }
}) })
</script> </script>
<template> <template>
<q-drawer <q-drawer
v-model="ui_store.is_left_drawer_open" v-model="uiStore.isLeftDrawerOpen"
:persistent="!$q.platform.is.mobile" :persistent="!$q.platform.is.mobile"
mini-to-overlay mini-to-overlay
elevated elevated
side="left" side="left"
:mini="is_mini" :mini="isMini"
@mouseenter="is_mini = false" @mouseenter="isMini = false"
@mouseleave="is_mini = true" @mouseleave="isMini = true"
class="bg-dark z-max" class="bg-dark z-max"
> >
<q-scroll-area class="column fit"> <q-scroll-area class="column fit">
@ -66,7 +66,7 @@ import { useAuthApi } from 'src/modules/auth/composables/use-auth-api';
@click="onClickDrawerPage(button.route)" @click="onClickDrawerPage(button.route)"
> >
<div <div
v-if="button.required_module ? auth_store.user?.user_module_access.includes(button.required_module) : true" v-if="button.required_module ? authStore.user?.user_module_access.includes(button.required_module) : true"
class="row items-center full-width q-py-sm cursor-pointer" class="row items-center full-width q-py-sm cursor-pointer"
:class="$router.currentRoute.value.name === button.route ? ($q.dark.isActive ? 'bg-green-10' : 'bg-green-2') : ''" :class="$router.currentRoute.value.name === button.route ? ($q.dark.isActive ? 'bg-green-10' : 'bg-green-2') : ''"
> >

View File

@ -15,16 +15,16 @@
const ui_store = useUiStore(); const ui_store = useUiStore();
const auth_store = useAuthStore(); const auth_store = useAuthStore();
const user_preferences = ref(ui_store.user_preferences); const userPreferences = ref(ui_store.userPreferences);
onMounted(async () => { onMounted(async () => {
if (ui_store.user_preferences.id === -1) { if (ui_store.userPreferences.id === -1) {
await ui_store.getUserPreferences(); await ui_store.getUserPreferences();
} }
}); });
watch(user_preferences, async () => { watch(userPreferences, async () => {
if (ui_store.user_preferences.id !== -1) { if (ui_store.userPreferences.id !== -1) {
await ui_store.updateUserPreferences(); await ui_store.updateUserPreferences();
return return
} }

View File

@ -82,13 +82,13 @@
:filter="filters" :filter="filters"
:filter-method="filterEmployeeRows" :filter-method="filterEmployeeRows"
class="bg-transparent no-shadow sticky-header-table full-width q-pt-lg" class="bg-transparent no-shadow sticky-header-table full-width q-pt-lg"
:style="employee_store.employee_list.length > 0 ? `max-height: ${maxHeight - (ui_store.user_preferences.is_employee_list_grid ? 0 : 20)}px;` : ''" :style="employee_store.employee_list.length > 0 ? `max-height: ${maxHeight - (ui_store.userPreferences.is_employee_list_grid ? 0 : 20)}px;` : ''"
:table-class="$q.dark.isActive ? 'q-py-none q-mx-md rounded-10 bg-dark shadow-10 hide-scrollbar' : 'q-py-none q-mx-md rounded-10 bg-white shadow-10 hide-scrollbar'" :table-class="$q.dark.isActive ? 'q-py-none q-mx-md rounded-10 bg-dark shadow-10 hide-scrollbar' : 'q-py-none q-mx-md rounded-10 bg-white shadow-10 hide-scrollbar'"
color="accent" color="accent"
separator="none" separator="none"
table-header-class="text-accent text-uppercase" table-header-class="text-accent text-uppercase"
card-container-class="justify-center" card-container-class="justify-center"
:grid="ui_store.user_preferences.is_employee_list_grid" :grid="ui_store.userPreferences.is_employee_list_grid"
:loading="employee_store.is_loading" :loading="employee_store.is_loading"
:no-data-label="$t('shared.error.no_data_found')" :no-data-label="$t('shared.error.no_data_found')"
:no-results-label="$t('shared.error.no_search_results')" :no-results-label="$t('shared.error.no_search_results')"
@ -126,7 +126,7 @@
<q-space /> <q-space />
<q-btn-toggle <q-btn-toggle
v-model="ui_store.user_preferences.is_employee_list_grid" v-model="ui_store.userPreferences.is_employee_list_grid"
push push
rounded rounded
color="white" color="white"

View File

@ -20,7 +20,7 @@
<div <div
class="col-auto justify-center content-center q-mb-sm q-pa-md rounded-5" class="col-auto justify-center content-center q-mb-sm q-pa-md rounded-5"
:class="ui_store.is_mobile_mode ? 'column' : 'row'" :class="$q.platform.is.mobile ? 'column' : 'row'"
style="border: 1px solid var(--q-accent);" style="border: 1px solid var(--q-accent);"
> >
<div <div
@ -34,7 +34,7 @@
v-ripple v-ripple
class="rounded-5 shadow-4 q-py-xs" 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' : '')" :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" @click="ui_store.userPreferences.is_dark_mode = mode.value"
> >
<q-item-section side> <q-item-section side>
<q-icon <q-icon

View File

@ -8,8 +8,8 @@
const ui_store = useUiStore(); const ui_store = useUiStore();
const setDisplayLanguage = (locale: MessageLanguages) => { const setDisplayLanguage = (locale: MessageLanguages) => {
if (ui_store.user_preferences !== undefined) { if (ui_store.userPreferences !== undefined) {
ui_store.user_preferences.display_language = locale; ui_store.userPreferences.display_language = locale;
} }
} }
</script> </script>
@ -18,7 +18,7 @@
<q-list <q-list
dense dense
class="full-width" class="full-width"
:class="ui_store.is_mobile_mode ? 'column' : 'row'" :class="$q.platform.is.mobile ? 'column' : 'row'"
> >
<q-item <q-item
v-for="locale in $i18n.availableLocales" v-for="locale in $i18n.availableLocales"

View File

@ -57,10 +57,12 @@
datasets: [{ datasets: [{
data: shift_type_data, data: shift_type_data,
backgroundColor: [ backgroundColor: [
colors.getPaletteColor('accent'), // Regular colors.getPaletteColor('accent'), // Regular
colors.getPaletteColor('green-10'), // Evening colors.getPaletteColor('green-10'), // Evening
colors.getPaletteColor('warning'), // Emergency colors.getPaletteColor('warning'), // Emergency
colors.getPaletteColor('negative'), // Overtime colors.getPaletteColor('negative'), // Overtime
colors.getPaletteColor('purple-5'), // Holiday
colors.getPaletteColor('deep-orange-5') // Vacation
] ]
}], }],
}" }"

View File

@ -75,7 +75,7 @@
} }
const onShowDetailsDialog = () => { const onShowDetailsDialog = () => {
isDialogOpen.value = true; isDialogOpen.value = true;
expenseStore.is_showing_create_form = false; expenseStore.is_showing_create_form = false;
} }
</script> </script>
@ -158,7 +158,7 @@
/> />
</div> </div>
<div class="col-auto column"> <div class="col-auto column no-wrap">
<q-separator <q-separator
spaced spaced
size="4px" size="4px"

View File

@ -40,7 +40,7 @@
:class="$q.platform.is.mobile ? 'q-mb-md' : ''" :class="$q.platform.is.mobile ? 'q-mb-md' : ''"
> >
<q-btn-toggle <q-btn-toggle
v-model="uiStore.user_preferences.is_timesheet_approval_grid" v-model="uiStore.userPreferences.is_timesheet_approval_grid"
push push
rounded rounded
color="white" color="white"

View File

@ -62,7 +62,7 @@
// ========== computed ======================================== // ========== computed ========================================
const isGridMode = computed(() => q.platform.is.mobile ? true : const isGridMode = computed(() => q.platform.is.mobile ? true :
uiStore.user_preferences.is_timesheet_approval_grid uiStore.userPreferences.is_timesheet_approval_grid
); );
const overviewRows = computed(() => const overviewRows = computed(() =>
@ -134,7 +134,7 @@
:filter-method="filterEmployeeRows" :filter-method="filterEmployeeRows"
:rows-per-page-options="[0]" :rows-per-page-options="[0]"
class="bg-transparent" class="bg-transparent"
:class="uiStore.user_preferences.is_timesheet_approval_grid ? '' : 'sticky-header-table sticky-first-column-table sticky-last-column-table no-shadow q-pb-sm'" :class="uiStore.userPreferences.is_timesheet_approval_grid ? '' : 'sticky-header-table sticky-first-column-table sticky-last-column-table no-shadow q-pb-sm'"
card-container-class="justify-center" card-container-class="justify-center"
table-class="q-pa-none q-mx-md rounded-10 bg-dark shadow-15" table-class="q-pa-none q-mx-md rounded-10 bg-dark shadow-15"
:no-data-label="$t('shared.error.no_data_found')" :no-data-label="$t('shared.error.no_data_found')"

View File

@ -0,0 +1,37 @@
<script
setup
lang="ts"
>
defineProps<{
isSelected: boolean;
label: string;
}>();
defineEmits<{
'clickOption': [void];
}>();
</script>
<template>
<transition
enter-active-class="animated pulse faster"
mode="out-in"
>
<div
:key="isSelected ? '1' : '0'"
class="row items-center q-pa-xs cursor-pointer rounded-25 shadow-4 relative-position non-selectable"
:class="isSelected ? 'bg-accent text-white text-bold' : 'text-weight-medium bg-dark'"
@click.stop="$emit('clickOption')"
>
<span class="col text-uppercase q-pl-md">
{{ $t(label) }}
</span>
<q-icon
:name="isSelected ? 'las la-check-circle' : 'las la-circle'"
:color="isSelected ? 'white' : ''"
size="sm"
/>
</div>
</transition>
</template>

View File

@ -1,168 +1,166 @@
<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 OverviewReportOption from 'src/modules/timesheet-approval/components/overview-report-option.vue';
const timesheet_store = useTimesheetStore(); import { Notify } from 'quasar';
const report_filter_options = ref<TimesheetApprovalCSVReportFilters>(new TimesheetApprovalCSVReportFilters); import { useI18n } from 'vue-i18n';
const selected_company = ref<'targo' | 'solucom'>('targo'); import { computed, ref } from 'vue';
import { useTimesheetStore } from 'src/stores/timesheet-store';
import { useTimesheetApprovalApi } from 'src/modules/timesheet-approval/composables/use-timesheet-approval-api';
import { companyOptions, CSVReportFilters, typeOptions } from 'src/modules/timesheet-approval/models/timesheet-approval-csv-report.models';
import type { ShiftType } from 'src/modules/timesheets/models/shift.models';
import type { CompanyNames } from 'src/modules/employee-list/models/employee-profile.models';
const selected_report_filters = ref<(keyof TimesheetApprovalCSVReportFilters)[]>( // ========== STATE ========================================
Object.entries(report_filter_options.value).filter(([_key, value]) => value).map(([key]) => key as keyof TimesheetApprovalCSVReportFilters)
);
interface ReportOptions { const { t } = useI18n();
label: string; const timesheetStore = useTimesheetStore();
value: keyof TimesheetApprovalCSVReportFilters; const timesheetApprovalApi = useTimesheetApprovalApi();
}; const reportFilters = ref<CSVReportFilters>(new CSVReportFilters);
const company_options: ReportOptions[] = [ // ========== COMPUTED ========================================
{ label: 'Targo', value: 'targo' },
{ label: 'Solucom', value: 'solucom' },
];
const type_options: ReportOptions[] = [ const isDownloadButtonEnabled = computed(() => reportFilters.value.shiftTypes.length > 0);
{ 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(() => // ========== METHODS ========================================
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 () => { const onClickedDownload = async () => {
try { const response = await timesheetApprovalApi.getTimesheetApprovalCSVReport(reportFilters.value);
const data = await timesheet_store.getPayPeriodReport(report_filter_options.value); console.log(response);
if (!response) {
Notify.create({
message: t('timesheet_approvals.print_report.download_failed'),
caption: t('timesheet_approvals.print_report.download_failed_caption'),
color: 'negative',
textColor: 'white',
icon: 'file_download_off',
iconSize: 'lg',
classes: 'text-uppercase text-center'
});
const companies = Object.entries(report_filter_options.value) return;
.filter(([key, value]) => value && (key === 'targo' || key === 'solucom')).map(([key]) => key).join('-'); }
const types = Object.entries(report_filter_options.value) const url = window.URL.createObjectURL(response.file);
.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 blob = new Blob([data], { type: 'text/csv;charset=utf-8;' });
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', response.fileName);
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) {
console.error(`An error occured during the CSV download: `, error) timesheetStore.is_report_dialog_open = false;
} }
}
watch(selected_report_filters, (new_values) => { const onClickCompanyOption = (companyName: CompanyNames) => {
Object.keys(report_filter_options.value).forEach(key => { if (reportFilters.value.companyName === companyName) return;
const typed_key = key as keyof TimesheetApprovalCSVReportFilters; reportFilters.value.companyName = companyName;
report_filter_options.value[typed_key] = new_values.includes(key as keyof TimesheetApprovalCSVReportFilters); }
});
});
watch(selected_company, (company) => { const onClickShiftOption = (clickedOption: ShiftType) => {
report_filter_options.value.targo = company === 'targo'; const index = reportFilters.value.shiftTypes.findIndex(option => option === clickedOption);
report_filter_options.value.solucom = company === 'solucom';
});
if (index === -1)
reportFilters.value.shiftTypes.push(clickedOption);
else
reportFilters.value.shiftTypes.splice(index, 1);
}
const onClickExpenseOption = () => {
reportFilters.value.includeExpenses = !reportFilters.value.includeExpenses;
}
</script> </script>
<template> <template>
<q-dialog v-model="timesheet_store.is_report_dialog_open"> <q-dialog
v-model="timesheetStore.is_report_dialog_open"
@hide="reportFilters = new CSVReportFilters;"
backdrop-filter="blur(4px)"
>
<div <div
class="column bg-secondary shadow-24 rounded-10" class="column bg-secondary shadow-24 rounded-10"
:style="$q.dark.isActive ? 'border: 2px solid var(--q-accent)' : ''" :style="$q.dark.isActive ? 'border: 2px solid var(--q-accent)' : ''"
> >
<!-- main header --> <!-- main header -->
<div <div class="col-auto column bg-primary text-center text-uppercase">
class="col-auto bg-primary text-accent text-weight-bolder text-center text-uppercase text-h6 q-py-xs z-top"> <span class="text-white text-weight-bolder q-py-sm text-h5">
{{ $t('timesheet_approvals.print_report.title') }} {{ $t('timesheet_approvals.print_report.title') }}
</div> </span>
<!-- info blurb --> <span class="col-auto q-py-xs bg-dark full-width shadow-4">
<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') }} {{ $t('timesheet_approvals.print_report.description') }}
</span> </span>
</div> </div>
<!-- company header --> <!-- groups -->
<span class="col-auto q-px-sm q-pt-md text-weight-medium text-accent text-uppercase"> <div class="col column full-width q-px-md">
{{ $t('timesheet_approvals.print_report.company') }} <!-- company header -->
</span> <span class="col-auto q-px-sm q-pt-md text-bold text-uppercase">
{{ $t('timesheet_approvals.print_report.company') }}
</span>
<!-- company options --> <!-- company options -->
<div class="col row text-uppercase full-width q-px-md"> <div class="row q-pb-sm">
<div <div
v-for="company, index in company_options" v-for="company, companyIndex in companyOptions"
:key="index" :key="companyIndex"
class="q-pa-xs col-6" class="col-6 q-pa-sm"
> >
<q-radio <OverviewReportOption
v-model="selected_company" :is-selected="reportFilters.companyName === company.value"
left-label :label="company.label"
color="white" @click-option="onClickCompanyOption(company.value)"
dense />
:label="company.label" </div>
:val="company.value"
checked-icon="radio_button_checked"
unchecked-icon="radio_button_unchecked"
class="q-px-md q-py-xs shadow-4 rounded-25 full-width"
:class="selected_company.includes(company.value) ? 'bg-accent text-white text-bold' : 'bg-dark'"
/>
</div> </div>
</div>
<!-- shift type header --> <!-- data type header -->
<span class="col-auto q-px-sm q-pt-md text-weight-medium text-uppercase text-accent"> <span class="col-auto q-px-sm q-pt-md text-bold text-uppercase">
{{ $t('timesheet_approvals.print_report.options') }} {{ $t('timesheet_approvals.print_report.type') }}
</span> </span>
<!-- shift type options --> <!-- data type options -->
<div class="col row text-uppercase full-width q-px-md q-pb-md"> <div class="row">
<div <!-- shift types -->
v-for="type, index in type_options" <div
:key="index" v-for="shiftType, typeIndex in typeOptions"
class="q-pa-xs col-6" :key="typeIndex"
> class="col-6 q-pa-sm"
<q-checkbox >
v-model="selected_report_filters" <OverviewReportOption
left-label :is-selected="reportFilters.shiftTypes.includes(shiftType.value)"
color="white" :label="shiftType.label"
dense @click-option="onClickShiftOption(shiftType.value)"
:val="type.value" />
checked-icon="check_box" </div>
unchecked-icon="check_box_outline_blank"
:label="$t(type.label)" <!-- expenses -->
class="q-px-md q-py-xs shadow-4 rounded-25 full-width" <div class="col-6 q-pa-sm">
:class="selected_report_filters.includes(type.value) ? 'bg-accent text-white text-bold' : 'bg-white text-primary'" <OverviewReportOption
/> :is-selected="reportFilters.includeExpenses"
:label="'timesheet_approvals.print_report.expenses'"
@click-option="onClickExpenseOption"
/>
</div>
</div> </div>
</div> </div>
<!-- download button --> <!-- download button -->
<q-btn <div class="col-auto row justify-end q-px-md q-pb-md q-pt-xl">
:disable="!is_download_button_enable" <q-btn
square push
icon="download" :disable="!isDownloadButtonEnabled"
:color="is_download_button_enable ? 'accent' : 'grey-5'" icon="download"
:label="$t('shared.label.download')" :color="isDownloadButtonEnabled ? 'accent' : 'grey-5'"
class="col-auto q-py-sm shadow-up-2" :label="$t('shared.label.download')"
@click="onClickedDownload()" class="col-auto q-py-sm q-px-xl"
/> @click="onClickedDownload"
/>
</div>
</div> </div>
</q-dialog> </q-dialog>
</template> </template>

View File

@ -1,58 +1,72 @@
import { useTimesheetStore } from "src/stores/timesheet-store"; import { useTimesheetStore } from "src/stores/timesheet-store";
import type { TimesheetApprovalCSVReportFilters } from "src/modules/timesheet-approval/models/timesheet-approval-csv-report.models"; import type { CSVReportFilters } from "src/modules/timesheet-approval/models/timesheet-approval-csv-report.models";
import { fromEnToFrShiftType } from "src/utils/translator";
export const useTimesheetApprovalApi = () => { export const useTimesheetApprovalApi = () => {
const timesheet_store = useTimesheetStore(); const timesheetStore = useTimesheetStore();
const DATE_REGEX = /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/; const DATE_REGEX = /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/;
const getTimesheetOverviews = async (date?: string) => { const getTimesheetOverviews = async (date?: string) => {
timesheet_store.is_loading = true; timesheetStore.is_loading = true;
const success = await timesheet_store.getPayPeriodByDateOrYearAndNumber(date); const success = await timesheetStore.getPayPeriodByDateOrYearAndNumber(date);
if (success) await timesheet_store.getTimesheetOverviews(); if (success) await timesheetStore.getTimesheetOverviews();
timesheet_store.is_loading = false; timesheetStore.is_loading = false;
} }
const getTimesheetOverviewsByDate = async (date: string) => { const getTimesheetOverviewsByDate = async (date: string) => {
const valid_date = DATE_REGEX.test(date); const valid_date = DATE_REGEX.test(date);
timesheet_store.is_loading = true; timesheetStore.is_loading = true;
if (valid_date) { if (valid_date) {
const success = await timesheet_store.getPayPeriodByDateOrYearAndNumber(date); const success = await timesheetStore.getPayPeriodByDateOrYearAndNumber(date);
if (success) await timesheet_store.getTimesheetOverviews(); if (success) await timesheetStore.getTimesheetOverviews();
} }
timesheet_store.is_loading = false; timesheetStore.is_loading = false;
}; };
const toggleTimesheetsApprovalByEmployeeEmail = async (email: string, approval_status: boolean) => { const toggleTimesheetsApprovalByEmployeeEmail = async (email: string, approval_status: boolean) => {
timesheet_store.is_loading = true; timesheetStore.is_loading = true;
const success = await timesheet_store.getTimesheetsByOptionalEmployeeEmail(email); const success = await timesheetStore.getTimesheetsByOptionalEmployeeEmail(email);
if (success) { if (success) {
const approval_success = await timesheet_store.toggleTimesheetsApprovalByEmployeeEmail(email, approval_status); const approval_success = await timesheetStore.toggleTimesheetsApprovalByEmployeeEmail(email, approval_status);
const overview = timesheet_store.pay_period_overviews.find(overview => overview.email === email); const overview = timesheetStore.pay_period_overviews.find(overview => overview.email === email);
if (overview && approval_success) { if (overview && approval_success) {
overview.is_approved = approval_status; overview.is_approved = approval_status;
await timesheet_store.getTimesheetsByOptionalEmployeeEmail(email); await timesheetStore.getTimesheetsByOptionalEmployeeEmail(email);
} }
} }
timesheet_store.is_loading = false; timesheetStore.is_loading = false;
}; };
const getTimesheetApprovalCSVReport = async (report_filter_company: boolean[], report_filter_type: boolean[]) => { const getTimesheetApprovalCSVReport = async (selectedFilters: CSVReportFilters): Promise<{file: Blob, fileName: string} | undefined> => {
if (timesheet_store.pay_period === undefined) return; if (timesheetStore.pay_period === undefined) return;
const [targo, solucom] = report_filter_company; const success = await timesheetStore.getPayPeriodReport(selectedFilters);
const [shifts, expenses, holiday, vacation] = report_filter_type; if (!success) return;
const options = {
shifts, expenses, holiday, vacation, targo, solucom
} as TimesheetApprovalCSVReportFilters;
await timesheet_store.getPayPeriodReport(options); // const year = timesheetStore.pay_period?.pay_year;
// const number = timesheetStore.pay_period?.pay_period_no;
const startDate = timesheetStore.pay_period?.period_start;
const endDate = timesheetStore.pay_period?.period_end;
const content: string[] = [];
selectedFilters.shiftTypes.forEach(type => content.push(fromEnToFrShiftType(type)));
content.push(selectedFilters.companyName);
if (selectedFilters.includeExpenses) content.push('Dépenses');
// const fileName = `${year}-${number}_${content.join('_')}.csv`;
const fileName = `${startDate}_AU_${endDate}_${content.join('_')}.csv`;
if (!timesheetStore.payPeriodReport) return;
return {file: timesheetStore.payPeriodReport, fileName};
}; };
return { return {

View File

@ -1,18 +1,28 @@
export class TimesheetApprovalCSVReportFilters { import type { ShiftType } from "src/modules/timesheets/models/shift.models";
shifts: boolean; import type { CompanyNames } from "src/modules/employee-list/models/employee-profile.models";
expenses: boolean;
holiday: boolean; export class CSVReportFilters {
vacation: boolean; companyName: CompanyNames;
targo: boolean; includeExpenses: boolean;
solucom: boolean; shiftTypes: ShiftType[];
constructor() { constructor() {
this.shifts = true; this.companyName = 'Targo';
this.expenses = true; this.includeExpenses = false;
this.holiday = true; this.shiftTypes = ['REGULAR', 'EVENING', 'EMERGENCY', 'SICK']
this.vacation = true; }
this.targo = true; };
this.solucom = false;
};
}
export const companyOptions: {label: string, value: CompanyNames}[] = [
{ label: 'timesheet_approvals.print_report.targo', value: 'Targo' },
{ label: 'timesheet_approvals.print_report.solucom', value: 'Solucom' },
];
export const typeOptions: {label: string, value: ShiftType}[] = [
{ label: 'shared.shift_type.regular', value: 'REGULAR' },
{ label: 'shared.shift_type.evening', value: 'EVENING' },
{ label: 'shared.shift_type.emergency', value: 'EMERGENCY' },
{ label: 'shared.shift_type.sick', value: 'SICK' },
{ label: 'shared.shift_type.holiday', value: 'HOLIDAY' },
{ label: 'shared.shift_type.vacation', value: 'VACATION' },
];

View File

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

View File

@ -6,7 +6,6 @@
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { computed, onMounted, ref } from 'vue'; import { computed, onMounted, ref } from 'vue';
import { useUiStore } from 'src/stores/ui-store';
import { useExpensesStore } from 'src/stores/expense-store'; import { useExpensesStore } from 'src/stores/expense-store';
import { useTimesheetStore } from 'src/stores/timesheet-store'; import { useTimesheetStore } from 'src/stores/timesheet-store';
import { useExpensesApi } from 'src/modules/timesheets/composables/use-expense-api'; import { useExpensesApi } from 'src/modules/timesheets/composables/use-expense-api';
@ -28,7 +27,6 @@
}>(); }>();
const { t } = useI18n(); const { t } = useI18n();
const ui_store = useUiStore();
const timesheetStore = useTimesheetStore(); const timesheetStore = useTimesheetStore();
const expenseStore = useExpensesStore(); const expenseStore = useExpensesStore();
const expensesApi = useExpensesApi(); const expensesApi = useExpensesApi();
@ -168,7 +166,6 @@
<template #selected-item="scope"> <template #selected-item="scope">
<div <div
class="row items-center text-weight-bold q-ma-none q-pa-none no-wrap ellipsis full-width" class="row items-center text-weight-bold q-ma-none q-pa-none no-wrap ellipsis full-width"
:class="ui_store.is_mobile_mode ? 'full-height' : ''"
:tabindex="scope.tabindex" :tabindex="scope.tabindex"
> >
<q-icon <q-icon

View File

@ -4,7 +4,6 @@
> >
import { computed, ref, onMounted } from 'vue'; import { computed, ref, onMounted } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useUiStore } from 'src/stores/ui-store';
import { useExpensesStore } from 'src/stores/expense-store'; import { useExpensesStore } from 'src/stores/expense-store';
import { useTimesheetStore } from 'src/stores/timesheet-store'; import { useTimesheetStore } from 'src/stores/timesheet-store';
import { useExpensesApi } from 'src/modules/timesheets/composables/use-expense-api'; import { useExpensesApi } from 'src/modules/timesheets/composables/use-expense-api';
@ -26,7 +25,6 @@
}>(); }>();
const { t } = useI18n(); const { t } = useI18n();
const ui_store = useUiStore();
const timesheet_store = useTimesheetStore(); const timesheet_store = useTimesheetStore();
const expenses_store = useExpensesStore(); const expenses_store = useExpensesStore();
const auth_store = useAuthStore(); const auth_store = useAuthStore();
@ -168,8 +166,7 @@
<template #selected-item="scope"> <template #selected-item="scope">
<div <div
class="row items-center text-weight-bold q-ma-none q-pa-none full-width" class="row items-center text-weight-bold q-ma-none q-pa-none fit"
:class="ui_store.is_mobile_mode ? 'full-height' : ''"
:tabindex="scope.tabindex" :tabindex="scope.tabindex"
> >
<q-icon <q-icon

View File

@ -37,11 +37,11 @@
'onTimeFieldBlur': [void]; 'onTimeFieldBlur': [void];
}>(); }>();
const ui_store = useUiStore(); const uiStore = useUiStore();
const shiftTypeSelected = ref(SHIFT_OPTIONS.find(option => option.value == shift.value.type)); const shiftTypeSelected = ref(SHIFT_OPTIONS.find(option => option.value == shift.value.type));
const select_ref = ref<QSelect | null>(null); const selectRef = ref<QSelect | null>(null);
const is_showing_comment_popup = ref(false); const isShowingCommentPopup = ref(false);
const error_message = ref(''); const errorMessageRow = ref('');
const isShowingPredefinedTime = ref(shift.value.type === 'HOLIDAY'); const isShowingPredefinedTime = ref(shift.value.type === 'HOLIDAY');
const predefinedHoursString = ref(''); const predefinedHoursString = ref('');
const predefinedHoursBgColor = ref(`bg-${shiftTypeSelected.value?.icon_color ?? ''}`); const predefinedHoursBgColor = ref(`bg-${shiftTypeSelected.value?.icon_color ?? ''}`);
@ -63,10 +63,10 @@
const onTimeFieldBlur = (time_string: string) => { const onTimeFieldBlur = (time_string: string) => {
if (time_string.length < 1 || !time_string) { if (time_string.length < 1 || !time_string) {
shift.value.has_error = true; shift.value.has_error = true;
error_message.value = 'timesheet.errors.SHIFT_TIME_REQUIRED'; errorMessageRow.value = 'timesheet.errors.SHIFT_TIME_REQUIRED';
} else { } else {
shift.value.has_error = false; shift.value.has_error = false;
error_message.value = ''; errorMessageRow.value = '';
emit('onTimeFieldBlur'); emit('onTimeFieldBlur');
} }
} }
@ -102,15 +102,15 @@
} }
onMounted(() => { onMounted(() => {
if (ui_store.focus_next_component) { if (uiStore.focusNextComponent) {
select_ref.value?.focus(); selectRef.value?.focus();
select_ref.value?.showPopup(); selectRef.value?.showPopup();
shiftTypeSelected.value = undefined; shiftTypeSelected.value = undefined;
ui_store.focus_next_component = false; uiStore.focusNextComponent = false;
} }
if (errorMessage) if (errorMessage)
error_message.value = errorMessage; errorMessageRow.value = errorMessage;
}); });
</script> </script>
@ -120,13 +120,13 @@
<div class="col row items-center text-uppercase q-px-xs rounded-5"> <div class="col row items-center text-uppercase q-px-xs rounded-5">
<!-- comment button --> <!-- comment button -->
<q-btn <q-btn
v-if="ui_store.is_mobile_mode && !dense" v-if="!dense"
:icon="shift.comment ? 'chat' : 'chat_bubble_outline'" :icon="shift.comment ? 'chat' : 'chat_bubble_outline'"
:text-color="shift.comment ? ((shift.is_approved && isTimesheetApproved) ? 'white' : 'accent') : 'grey-5'" :text-color="shift.comment ? ((shift.is_approved && isTimesheetApproved) ? 'white' : 'accent') : 'grey-5'"
class="col-auto full-height q-mx-xs rounded-5 shadow-1" class="col-auto full-height q-mx-xs rounded-5 shadow-1"
@click="is_showing_comment_popup = true" @click="isShowingCommentPopup = true"
> >
<q-dialog v-model="is_showing_comment_popup"> <q-dialog v-model="isShowingCommentPopup">
<q-input <q-input
color="white" color="white"
v-model="shift.comment" v-model="shift.comment"
@ -190,8 +190,7 @@
> >
<template #selected-item="scope"> <template #selected-item="scope">
<div <div
class="row items-center text-weight-bold q-ma-none q-pa-none no-wrap ellipsis full-width" class="row items-center text-weight-bold q-ma-none q-pa-none no-wrap ellipsis fit"
:class="ui_store.is_mobile_mode ? 'full-height' : ''"
:tabindex="scope.tabindex" :tabindex="scope.tabindex"
> >
<q-icon <q-icon
@ -305,7 +304,7 @@
no-error-icon no-error-icon
hide-bottom-space hide-bottom-space
:error="shift.has_error" :error="shift.has_error"
:error-message="errorMessage || error_message !== '' ? $t(errorMessage ?? error_message) : ''" :error-message="errorMessage || errorMessageRow !== '' ? $t(errorMessage ?? errorMessageRow) : ''"
:label-color="!shift.is_approved ? 'accent' : 'white'" :label-color="!shift.is_approved ? 'accent' : 'white'"
class="rounded-5 bg-dark" class="rounded-5 bg-dark"
:class="(shift.id === -2 ? 'bg-negative ' : ' ') + (!shift.is_approved && !isTimesheetApproved ? '' : 'cursor-not-allowed inset-shadow')" :class="(shift.id === -2 ? 'bg-negative ' : ' ') + (!shift.is_approved && !isTimesheetApproved ? '' : 'cursor-not-allowed inset-shadow')"
@ -338,7 +337,7 @@
no-error-icon no-error-icon
hide-bottom-space hide-bottom-space
:error="shift.has_error" :error="shift.has_error"
:error-message="errorMessage || error_message !== '' ? $t(errorMessage ?? error_message) : ''" :error-message="errorMessage || errorMessageRow !== '' ? $t(errorMessage ?? errorMessageRow) : ''"
:label-color="!shift.is_approved ? 'accent' : 'white'" :label-color="!shift.is_approved ? 'accent' : 'white'"
:input-class="'text-weight-medium ' + (shift.id === -2 ? 'text-white ' : ' ') + (shift.is_approved ? 'text-white cursor-not-allowed q-px-sm' : '')" :input-class="'text-weight-medium ' + (shift.id === -2 ? 'text-white ' : ' ') + (shift.is_approved ? 'text-white cursor-not-allowed q-px-sm' : '')"
input-style="font-size: 1.2em;" input-style="font-size: 1.2em;"

View File

@ -0,0 +1,287 @@
<script
setup
lang="ts"
>
import ShiftListDay from 'src/modules/timesheets/components/shift-list-day.vue';
import ShiftListDateWidget from 'src/modules/timesheets/components/shift-list-date-widget.vue';
import { date, useQuasar } from 'quasar';
import { ref, computed, watch, onMounted, inject } from 'vue';
import { useUiStore } from 'src/stores/ui-store';
import { useTimesheetStore } from 'src/stores/timesheet-store';
import { Shift } from 'src/modules/timesheets/models/shift.models';
import { useTimesheetApi } from 'src/modules/timesheets/composables/use-timesheet-api';
import type { TimesheetDay } from 'src/modules/timesheets/models/timesheet.models';
import { useI18n } from 'vue-i18n';
// ========== constants ========================================
const CURRENT_DATE_STRING = new Date().toISOString().slice(0, 10);
// ========== state ========================================
const emit = defineEmits<{
'onCurrentDayComponentFound': [component: HTMLElement | undefined];
}>();
const q = useQuasar();
const { extractDate } = date;
const { locale } = useI18n();
const uiStore = useUiStore();
const timesheetApi = useTimesheetApi();
const timesheetStore = useTimesheetStore();
const mobileAnimationDirection = ref('fadeInLeft');
const currentDayComponent = ref<HTMLElement[] | null>(null);
const currentDayComponentWatcher = ref(currentDayComponent);
const employeeEmail = inject<string>('employeeEmail');
// ========== computed ========================================
const animationStyle = computed(() => q.platform.is.mobile ? mobileAnimationDirection.value : 'fadeInDown');
// ========== methods ========================================
// const timesheetRows = computed(() => timesheetStore.timesheets);
const addNewShift = (day_shifts: Shift[], date: string, timesheet_id: number) => {
uiStore.focusNextComponent = true;
const newShift = new Shift;
newShift.date = date;
newShift.timesheet_id = timesheet_id;
day_shifts.push(newShift);
};
const deleteUnsavedShift = (timesheet_index: number, day_index: number) => {
if (timesheetStore.timesheets !== undefined) {
const day = timesheetStore.timesheets[timesheet_index]!.days[day_index]!;
const shifts_without_deleted_shift = day.shifts.filter(shift => shift.id !== 0);
day.shifts = shifts_without_deleted_shift;
}
};
const getDayApproval = (day: TimesheetDay) => {
if (day.shifts.length < 1) return false;
return day.shifts.every(shift => shift.is_approved === true);
};
const getMobileDayRef = (iso_date_string: string): string => {
return iso_date_string === CURRENT_DATE_STRING ? 'currentDayComponent' : '';
};
const getHolidayName = (date: string) => {
const holiday = timesheetStore.federal_holidays.find(holiday => holiday.date === date);
if (!holiday) return;
if (locale.value === 'fr-FR')
return holiday.nameFr;
else if (locale.value === 'en-CA')
return holiday.nameEn;
};
const onClickApplyWeeklyPreset = async (timesheet_id: number) => {
await timesheetApi.applyPreset(timesheet_id, undefined, undefined, employeeEmail);
}
onMounted(async () => {
await timesheetStore.getCurrentFederalHolidays();
});
watch(currentDayComponentWatcher, () => {
if (currentDayComponent.value && q.platform.is.mobile) {
emit('onCurrentDayComponentFound', currentDayComponent.value[0])
}
});
</script>
<template>
<div
class="fit"
:class="$q.platform.is.mobile ? 'column no-wrap q-pb-lg' : 'row'"
>
<div
v-for="timesheet, timesheet_index of timesheetStore.timesheets"
:key="timesheet.timesheet_id"
class="no-wrap"
:class="$q.platform.is.mobile ? 'col-auto column' : 'col column fit items-center'"
>
<transition
appear
enter-active-class="animated fadeInDown"
leave-active-class="animated fadeOutUp"
>
<q-btn
v-if="!$q.platform.is.mobile && timesheet.days.every(day => day.shifts.length < 1) && timesheetStore.has_timesheet_preset"
:disable="!timesheet.days.every(day => day.shifts.length < 1)"
flat
dense
:label="$t('timesheet.apply_preset_week')"
class="col-auto text-uppercase text-weight-bold text-accent q-mx-lg q-py-none rounded-5"
@click="onClickApplyWeeklyPreset(timesheet.timesheet_id)"
>
<q-icon
name="las la-calendar-week"
color="accent"
size="md"
/>
</q-btn>
</transition>
<transition-group
appear
:enter-active-class="`animated ${animationStyle}`"
>
<div
v-for="day, day_index in timesheet.days"
:key="day.date"
:ref="getMobileDayRef(day.date)"
class="col-auto row q-pa-sm full-width relative-position"
:style="`animation-delay: ${day_index / 15}s;`"
>
<!-- optional label indicating which holiday if today is a holiday -->
<span
v-if="timesheetStore.federal_holidays.some(holiday => holiday.date === day.date)"
class="absolute-top-left text-uppercase text-weight-bolder text-purple-5"
style="transform: translate(25px, -7px);"
>
{{ getHolidayName(day.date) }}
</span>
<!-- mobile version in portrait mode -->
<div
v-if="$q.platform.is.mobile && ($q.screen.width < $q.screen.height)"
class="col-auto full-width q-px-md q-py-sm"
>
<q-card
class="shadow-12"
:class="(getDayApproval(day) || timesheet.is_approved) ? 'bg-accent rounded-10' : 'bg-dark mobile-rounded-10'"
>
<q-card-section
class="text-weight-bolder text-uppercase text-h6 q-py-sm text-center relative-position"
:class="(getDayApproval(day) || timesheet.is_approved) ? 'bg-accent text-white' : 'bg-primary text-white'"
style="line-height: 1em;"
>
<span> {{ $d(extractDate(day.date, 'YYYY-MM-DD'), {
weekday: 'long', day: 'numeric', month:
'long'
}) }}</span>
<q-icon
v-if="(getDayApproval(day) || timesheet.is_approved)"
name="verified"
size="3em"
color="white"
class="absolute-top-left z-top"
style="top: -0.2em; left: 0px;"
/>
</q-card-section>
<q-card-section
v-if="day.shifts.filter(shift => shift.id !== 0).length > 0"
class="q-pa-none transparent"
>
<ShiftListDay
outlined
:timesheet-id="timesheet.timesheet_id"
:week-day-index="day_index"
:animation-delay-multiplier="day_index"
:approved="(getDayApproval(day) || timesheet.is_approved)"
:day="day"
@delete-unsaved-shift="deleteUnsavedShift(timesheet_index, day_index)"
/>
</q-card-section>
<q-card-section class="q-pa-none">
<q-btn
v-if="!(getDayApproval(day) || timesheet.is_approved)"
square
dense
size="xl"
color="accent"
icon="more_time"
class="full-width"
style="border-radius: 0 0 10px 10px;"
@click="addNewShift(day.shifts, day.date, timesheet.timesheet_id)"
/>
</q-card-section>
</q-card>
</div>
<!-- desktop version -->
<div
v-else
class="col row full-width rounded-10 ellipsis shadow-10"
:style="timesheetStore.federal_holidays.some(holiday => holiday.date === day.date) ? 'border: 2px solid #ab47bc' : ''"
>
<div
class="col row"
:class="(getDayApproval(day) || timesheet.is_approved) ? (timesheetStore.federal_holidays.some(holiday => holiday.date === day.date) ? 'bg-purple-5' : 'bg-accent') : 'bg-dark'"
>
<!-- Date block -->
<ShiftListDateWidget
:display-date="day.date"
:approved="(getDayApproval(day) || timesheet.is_approved)"
class="col-auto"
/>
<ShiftListDay
:timesheet-id="timesheet.timesheet_id"
:week-day-index="day_index"
:day="day"
:holiday="timesheetStore.federal_holidays.some(holiday => holiday.date === day.date)"
:approved="getDayApproval(day) || timesheet.is_approved"
class="col"
@delete-unsaved-shift="deleteUnsavedShift(timesheet_index, day_index)"
/>
</div>
<div class="col-auto self-stretch">
<q-icon
v-if="(getDayApproval(day) || timesheet.is_approved)"
name="verified"
color="white"
size="xl"
class="full-height"
:class="(getDayApproval(day) || timesheet.is_approved) ? (timesheetStore.federal_holidays.some(holiday => holiday.date === day.date) ? 'bg-purple-5' : 'bg-accent') : ''"
/>
<q-btn
v-else
:dense="!$q.platform.is.mobile"
square
icon="more_time"
size="lg"
:color="timesheetStore.federal_holidays.some(holiday => holiday.date === day.date) ? 'purple-5' : 'accent'"
text-color="white"
class="full-height"
:class="$q.platform.is.mobile ? 'q-px-xs' : ''"
@click="addNewShift(day.shifts, day.date, timesheet.timesheet_id)"
/>
</div>
</div>
</div>
</transition-group>
</div>
</div>
</template>
<style
scoped
lang="scss"
>
@each $size in (1, 2, 3, 4, 5, 10, 15, 20, 25, 50, 75, 100, 200) {
.mobile-rounded-#{$size} {
border-radius: #{$size}px !important;
}
.mobile-rounded-#{$size}>div:first-child {
border-radius: #{$size}px #{$size}px 0 0 !important;
}
.mobile-rounded-#{$size}>div:last-child {
border-radius: 0 0 #{$size}px #{$size}px !important;
}
}
</style>

View File

@ -32,7 +32,7 @@
:class="approved ? 'text-white' : ''" :class="approved ? 'text-white' : ''"
:style="'font-size: ' + weekday_font_size" :style="'font-size: ' + weekday_font_size"
> >
{{ $d(display_date, { weekday: $q.screen.lt.md ? 'short' : 'long'}) }} {{ $d(display_date, { weekday: $q.platform.is.mobile ? 'short' : 'long'}) }}
</span> </span>
<span <span
class="col-auto text-weight-bolder" class="col-auto text-weight-bolder"
@ -46,7 +46,7 @@
:class="approved ? 'text-white' : ''" :class="approved ? 'text-white' : ''"
:style="'font-size: ' + weekday_font_size" :style="'font-size: ' + weekday_font_size"
> >
{{ $d(display_date, { month: $q.screen.lt.md ? 'short' : 'long' }) }} {{ $d(display_date, { month: $q.platform.is.mobile ? 'short' : 'long' }) }}
</span> </span>
</div> </div>
</template> </template>

View File

@ -41,7 +41,7 @@
const q = useQuasar(); const q = useQuasar();
const { t } = useI18n(); const { t } = useI18n();
const ui_store = useUiStore(); const uiStore = useUiStore();
const authStore = useAuthStore(); const authStore = useAuthStore();
const mode = inject<'normal' | 'approval'>('mode'); const mode = inject<'normal' | 'approval'>('mode');
@ -88,7 +88,7 @@
dense: true, dense: true,
borderless: shift.value.is_approved && isTimesheetApproved, borderless: shift.value.is_approved && isTimesheetApproved,
readonly: shift.value.is_approved && isTimesheetApproved, readonly: shift.value.is_approved && isTimesheetApproved,
optionsDense: !ui_store.is_mobile_mode, optionsDense: !q.platform.is.mobile,
hideDropdownIcon: true, hideDropdownIcon: true,
menuOffset: [0, 10], menuOffset: [0, 10],
menuAnchor: "bottom middle", menuAnchor: "bottom middle",
@ -169,11 +169,11 @@
} }
onMounted(() => { onMounted(() => {
if (ui_store.focus_next_component) { if (uiStore.focusNextComponent) {
selectRef.value?.focus(); selectRef.value?.focus();
selectRef.value?.showPopup(); selectRef.value?.showPopup();
shiftTypeSelected.value = undefined; shiftTypeSelected.value = undefined;
ui_store.focus_next_component = false; uiStore.focusNextComponent = false;
} }
}); });
</script> </script>
@ -238,7 +238,7 @@
<div <div
class="row items-center text-uppercase rounded-5" class="row items-center text-uppercase rounded-5"
:class="ui_store.is_mobile_mode ? 'col q-mb-xs q-px-xs' : 'col-4'" :class="$q.platform.is.mobile ? 'col q-mb-xs q-px-xs' : 'col-4'"
> >
<!-- shift type --> <!-- shift type -->
<q-select <q-select
@ -251,7 +251,7 @@
<template #selected-item="scope"> <template #selected-item="scope">
<div <div
class="row items-center text-weight-bold q-ma-none q-pa-none no-wrap ellipsis full-width" class="row items-center text-weight-bold q-ma-none q-pa-none no-wrap ellipsis full-width"
:class="ui_store.is_mobile_mode ? 'full-height' : ''" :class="$q.platform.is.mobile ? 'full-height' : ''"
:tabindex="scope.tabindex" :tabindex="scope.tabindex"
> >
<q-icon <q-icon
@ -386,18 +386,18 @@
<div <div
class="row full-height" class="row full-height"
:class="ui_store.is_mobile_mode ? 'col-12' : 'col-auto flex-center'" :class="$q.platform.is.mobile ? 'col-12' : 'col-auto flex-center'"
> >
<!-- comment button --> <!-- comment button -->
<q-btn <q-btn
v-if="!ui_store.is_mobile_mode" v-if="!$q.platform.is.mobile"
push push
dense dense
:color="shift.is_approved ? 'white' : (shift.comment ? 'accent' : (holiday ? 'purple-5' : 'blue-grey-5'))" :color="shift.is_approved ? 'white' : (shift.comment ? 'accent' : (holiday ? 'purple-5' : 'blue-grey-5'))"
:icon="shift.comment ? 'chat' : 'chat_bubble_outline'" :icon="shift.comment ? 'chat' : 'chat_bubble_outline'"
:text-color="shift.is_approved ? (holiday ? 'purple-5' : 'accent') : 'white'" :text-color="shift.is_approved ? (holiday ? 'purple-5' : 'accent') : 'white'"
class="col" class="col"
:class="ui_store.is_mobile_mode ? 'q-mt-xs bg-dark' : ''" :class="$q.platform.is.mobile ? 'q-mt-xs bg-dark' : ''"
> >
<q-badge <q-badge
v-if="shift.comment" v-if="shift.comment"

View File

@ -27,32 +27,32 @@
const q = useQuasar(); const q = useQuasar();
const { extractDate } = date; const { extractDate } = date;
const { locale } = useI18n(); const { locale } = useI18n();
const ui_store = useUiStore(); const uiStore = useUiStore();
const timesheet_api = useTimesheetApi(); const timesheetApi = useTimesheetApi();
const timesheet_store = useTimesheetStore(); const timesheetStore = useTimesheetStore();
const mobile_animation_direction = ref('fadeInLeft'); const mobileAnimationDirection = ref('fadeInLeft');
const currentDayComponent = ref<HTMLElement[] | null>(null); const currentDayComponent = ref<HTMLElement[] | null>(null);
const currentDayComponentWatcher = ref(currentDayComponent); const currentDayComponentWatcher = ref(currentDayComponent);
const employeeEmail = inject<string>('employeeEmail'); const employeeEmail = inject<string>('employeeEmail');
// ========== computed ======================================== // ========== computed ========================================
const animation_style = computed(() => ui_store.is_mobile_mode ? mobile_animation_direction.value : 'fadeInDown'); const animationStyle = computed(() => q.platform.is.mobile ? mobileAnimationDirection.value : 'fadeInDown');
// ========== methods ======================================== // ========== methods ========================================
const addNewShift = (day_shifts: Shift[], date: string, timesheet_id: number) => { const addNewShift = (day_shifts: Shift[], date: string, timesheet_id: number) => {
ui_store.focus_next_component = true; uiStore.focusNextComponent = true;
const new_shift = new Shift; const newShift = new Shift;
new_shift.date = date; newShift.date = date;
new_shift.timesheet_id = timesheet_id; newShift.timesheet_id = timesheet_id;
day_shifts.push(new_shift); day_shifts.push(newShift);
}; };
const deleteUnsavedShift = (timesheet_index: number, day_index: number) => { const deleteUnsavedShift = (timesheet_index: number, day_index: number) => {
if (timesheet_store.timesheets !== undefined) { if (timesheetStore.timesheets !== undefined) {
const day = timesheet_store.timesheets[timesheet_index]!.days[day_index]!; const day = timesheetStore.timesheets[timesheet_index]!.days[day_index]!;
const shifts_without_deleted_shift = day.shifts.filter(shift => shift.id !== 0); const shifts_without_deleted_shift = day.shifts.filter(shift => shift.id !== 0);
day.shifts = shifts_without_deleted_shift; day.shifts = shifts_without_deleted_shift;
} }
@ -68,7 +68,7 @@
}; };
const getHolidayName = (date: string) => { const getHolidayName = (date: string) => {
const holiday = timesheet_store.federal_holidays.find(holiday => holiday.date === date); const holiday = timesheetStore.federal_holidays.find(holiday => holiday.date === date);
if (!holiday) return; if (!holiday) return;
if (locale.value === 'fr-FR') if (locale.value === 'fr-FR')
@ -79,11 +79,11 @@
}; };
const onClickApplyWeeklyPreset = async (timesheet_id: number) => { const onClickApplyWeeklyPreset = async (timesheet_id: number) => {
await timesheet_api.applyPreset(timesheet_id, undefined, undefined, employeeEmail); await timesheetApi.applyPreset(timesheet_id, undefined, undefined, employeeEmail);
} }
onMounted(async () => { onMounted(async () => {
await timesheet_store.getCurrentFederalHolidays(); await timesheetStore.getCurrentFederalHolidays();
}); });
watch(currentDayComponentWatcher, () => { watch(currentDayComponentWatcher, () => {
@ -99,9 +99,10 @@
:class="$q.platform.is.mobile ? 'column no-wrap q-pb-lg' : 'row'" :class="$q.platform.is.mobile ? 'column no-wrap q-pb-lg' : 'row'"
> >
<div <div
v-for="timesheet, timesheet_index of timesheet_store.timesheets" v-for="timesheet, timesheet_index of timesheetStore.timesheets"
:key="timesheet.timesheet_id" :key="timesheet.timesheet_id"
:class="$q.platform.is.mobile ? 'col-auto column no-wrap' : 'col column fit items-center'" class="no-wrap"
:class="$q.platform.is.mobile ? 'col-auto column' : 'col column fit items-center'"
> >
<transition <transition
appear appear
@ -109,7 +110,7 @@
leave-active-class="animated fadeOutUp" leave-active-class="animated fadeOutUp"
> >
<q-btn <q-btn
v-if="!$q.platform.is.mobile && timesheet.days.every(day => day.shifts.length < 1) && timesheet_store.has_timesheet_preset" v-if="!$q.platform.is.mobile && timesheet.days.every(day => day.shifts.length < 1) && timesheetStore.has_timesheet_preset"
:disable="!timesheet.days.every(day => day.shifts.length < 1)" :disable="!timesheet.days.every(day => day.shifts.length < 1)"
flat flat
dense dense
@ -127,7 +128,7 @@
<transition-group <transition-group
appear appear
:enter-active-class="`animated ${animation_style}`" :enter-active-class="`animated ${animationStyle}`"
> >
<div <div
v-for="day, day_index in timesheet.days" v-for="day, day_index in timesheet.days"
@ -138,7 +139,7 @@
> >
<!-- optional label indicating which holiday if today is a holiday --> <!-- optional label indicating which holiday if today is a holiday -->
<span <span
v-if="timesheet_store.federal_holidays.some(holiday => holiday.date === day.date)" v-if="timesheetStore.federal_holidays.some(holiday => holiday.date === day.date)"
class="absolute-top-left text-uppercase text-weight-bolder text-purple-5" class="absolute-top-left text-uppercase text-weight-bolder text-purple-5"
style="transform: translate(25px, -7px);" style="transform: translate(25px, -7px);"
> >
@ -209,11 +210,11 @@
<div <div
v-else v-else
class="col row full-width rounded-10 ellipsis shadow-10" class="col row full-width rounded-10 ellipsis shadow-10"
:style="timesheet_store.federal_holidays.some(holiday => holiday.date === day.date) ? 'border: 2px solid #ab47bc' : ''" :style="timesheetStore.federal_holidays.some(holiday => holiday.date === day.date) ? 'border: 2px solid #ab47bc' : ''"
> >
<div <div
class="col row" class="col row"
:class="(getDayApproval(day) || timesheet.is_approved) ? (timesheet_store.federal_holidays.some(holiday => holiday.date === day.date) ? 'bg-purple-5' : 'bg-accent') : 'bg-dark'" :class="(getDayApproval(day) || timesheet.is_approved) ? (timesheetStore.federal_holidays.some(holiday => holiday.date === day.date) ? 'bg-purple-5' : 'bg-accent') : 'bg-dark'"
> >
<!-- Date block --> <!-- Date block -->
<ShiftListDateWidget <ShiftListDateWidget
@ -226,7 +227,7 @@
:timesheet-id="timesheet.timesheet_id" :timesheet-id="timesheet.timesheet_id"
:week-day-index="day_index" :week-day-index="day_index"
:day="day" :day="day"
:holiday="timesheet_store.federal_holidays.some(holiday => holiday.date === day.date)" :holiday="timesheetStore.federal_holidays.some(holiday => holiday.date === day.date)"
:approved="getDayApproval(day) || timesheet.is_approved" :approved="getDayApproval(day) || timesheet.is_approved"
class="col" class="col"
@delete-unsaved-shift="deleteUnsavedShift(timesheet_index, day_index)" @delete-unsaved-shift="deleteUnsavedShift(timesheet_index, day_index)"
@ -241,7 +242,7 @@
color="white" color="white"
size="xl" size="xl"
class="full-height" class="full-height"
:class="(getDayApproval(day) || timesheet.is_approved) ? (timesheet_store.federal_holidays.some(holiday => holiday.date === day.date) ? 'bg-purple-5' : 'bg-accent') : ''" :class="(getDayApproval(day) || timesheet.is_approved) ? (timesheetStore.federal_holidays.some(holiday => holiday.date === day.date) ? 'bg-purple-5' : 'bg-accent') : ''"
/> />
<q-btn <q-btn
@ -250,7 +251,7 @@
square square
icon="more_time" icon="more_time"
size="lg" size="lg"
:color="timesheet_store.federal_holidays.some(holiday => holiday.date === day.date) ? 'purple-5' : 'accent'" :color="timesheetStore.federal_holidays.some(holiday => holiday.date === day.date) ? 'purple-5' : 'accent'"
text-color="white" text-color="white"
class="full-height" class="full-height"
:class="$q.platform.is.mobile ? 'q-px-xs' : ''" :class="$q.platform.is.mobile ? 'q-px-xs' : ''"

View File

@ -40,21 +40,14 @@ import { RouteNames } from 'src/router/router-constants';
const hasShiftErrors = computed(() => timesheetStore.all_current_shifts.filter(shift => shift.has_error === true).length > 0); const hasShiftErrors = computed(() => timesheetStore.all_current_shifts.filter(shift => shift.has_error === true).length > 0);
const isTimesheetsApproved = computed(() => timesheetStore.timesheets.every(timesheet => timesheet.is_approved)); const isTimesheetsApproved = computed(() => timesheetStore.timesheets.every(timesheet => timesheet.is_approved));
// const timesheetStore.canSaveTimesheets = computed(() => {
// /* eslint-disable-next-line */
// const currentShifts = timesheetStore.timesheets.flatMap(timesheet => timesheet.days.flatMap(day => day.shifts.map(shift => { const { has_error, ...shft } = shift; return shft; })));
// const initialShifts = timesheetStore.initial_timesheets.flatMap(timesheet => timesheet.days.flatMap(day => day.shifts));
// return JSON.stringify(currentShifts) !== JSON.stringify(initialShifts);
// });
const totalHours = computed(() => timesheetStore.timesheets.reduce((sum, timesheet) => const totalHours = computed(() => timesheetStore.timesheets.reduce((sum, timesheet) =>
sum += timesheet.weekly_hours.regular sum += timesheet.weekly_hours.regular
+ timesheet.weekly_hours.evening + timesheet.weekly_hours.evening
+ timesheet.weekly_hours.emergency + timesheet.weekly_hours.emergency
+ timesheet.weekly_hours.overtime, + timesheet.weekly_hours.overtime,
0) //initial value 0 //initial value
); ));
const totalExpenses = computed(() => timesheetStore.timesheets.reduce((sum, timesheet) => const totalExpenses = computed(() => timesheetStore.timesheets.reduce((sum, timesheet) =>
sum + timesheet.weekly_expenses.expenses sum + timesheet.weekly_expenses.expenses

View File

@ -43,4 +43,9 @@ export interface TotalExpenses {
per_diem: number; per_diem: number;
on_call: number; on_call: number;
mileage: number; mileage: number;
}
export interface TimesheetDayDisplay extends TimesheetDay {
timesheet_id: number;
i18WeekdayKey: string;
} }

View File

@ -10,7 +10,7 @@ import type { PayPeriod } from 'src/modules/shared/models/pay-period.models';
import type { Timesheet } from 'src/modules/timesheets/models/timesheet.models'; import type { Timesheet } from 'src/modules/timesheets/models/timesheet.models';
import type { PaidTimeOff } from 'src/modules/employee-list/models/employee-profile.models'; import type { PaidTimeOff } from 'src/modules/employee-list/models/employee-profile.models';
import type { PayPeriodEvent } from 'src/modules/timesheet-approval/models/pay-period-event.models'; import type { PayPeriodEvent } from 'src/modules/timesheet-approval/models/pay-period-event.models';
import type { TimesheetApprovalCSVReportFilters } from 'src/modules/timesheet-approval/models/timesheet-approval-csv-report.models'; import type { CSVReportFilters } from 'src/modules/timesheet-approval/models/timesheet-approval-csv-report.models';
import { type FederalHoliday, TARGO_HOLIDAY_NAMES_FR } from 'src/modules/timesheets/models/federal-holidays.models'; import { type FederalHoliday, TARGO_HOLIDAY_NAMES_FR } from 'src/modules/timesheets/models/federal-holidays.models';
import type { RouteNames } from 'src/router/router-constants'; import type { RouteNames } from 'src/router/router-constants';
import type { RouteRecordNameGeneric } from 'vue-router'; import type { RouteRecordNameGeneric } from 'vue-router';
@ -42,7 +42,7 @@ export const useTimesheetStore = defineStore('timesheet', () => {
const selected_employee_name = ref<string>(); const selected_employee_name = ref<string>();
const has_timesheet_preset = ref(false); const has_timesheet_preset = ref(false);
const current_pay_period_overview = ref<TimesheetApprovalOverview>(); const current_pay_period_overview = ref<TimesheetApprovalOverview>();
const pay_period_report = ref(); const payPeriodReport = ref<Blob>();
const pay_period_observer = ref<EventSource | undefined>(); const pay_period_observer = ref<EventSource | undefined>();
const federal_holidays = ref<FederalHoliday[]>([]); const federal_holidays = ref<FederalHoliday[]>([]);
@ -191,12 +191,16 @@ export const useTimesheetStore = defineStore('timesheet', () => {
return false; return false;
} }
const getPayPeriodReport = async (report_filters: TimesheetApprovalCSVReportFilters) => { const getPayPeriodReport = async (filters: CSVReportFilters): Promise<boolean> => {
try { try {
if (!pay_period.value) return false; if (!pay_period.value) return false;
const response = await timesheetApprovalService.getPayPeriodReportByYearAndPeriodNumber(pay_period.value.pay_year, pay_period.value.pay_period_no, report_filters);
pay_period_report.value = response; const response = await timesheetApprovalService.getPayPeriodReportByYearAndPeriodNumber(pay_period.value.pay_year, pay_period.value.pay_period_no, filters);
return response.data;
if (response){
payPeriodReport.value = response;
return true;
}
} catch (error) { } catch (error) {
console.error('There was an error retrieving the report CSV: ', error); console.error('There was an error retrieving the report CSV: ', error);
// TODO: More in-depth error-handling here // TODO: More in-depth error-handling here
@ -256,6 +260,7 @@ export const useTimesheetStore = defineStore('timesheet', () => {
return { return {
is_loading, is_loading,
is_report_dialog_open, is_report_dialog_open,
payPeriodReport,
is_details_dialog_open, is_details_dialog_open,
pay_period, pay_period,
pay_period_overviews, pay_period_overviews,

View File

@ -1,33 +1,27 @@
import { ref } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { defineStore } from 'pinia'; import { defineStore } from 'pinia';
import { computed, ref } from 'vue'; import { LocalStorage, 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', () => {
const q = useQuasar();
const { locale } = useI18n(); const { locale } = useI18n();
const is_left_drawer_open = ref(true); const isLeftDrawerOpen = ref(true);
const focus_next_component = ref(false); const focusNextComponent = ref(false);
const is_mobile_mode = computed(() => q.screen.lt.md); const userPreferences = ref<Preferences>(new Preferences);
const user_preferences = ref<Preferences>(new Preferences);
const current_page = ref<RouteNames>(RouteNames.DASHBOARD);
const toggleRightDrawer = () => { const toggleRightDrawer = () => {
is_left_drawer_open.value = !is_left_drawer_open.value; isLeftDrawerOpen.value = !isLeftDrawerOpen.value;
}; };
const getUserPreferences = async () => { const getUserPreferences = async () => {
try { try {
const local_user_preferences = LocalStorage.getItem<Preferences>('user_preferences'); const local_userPreferences = LocalStorage.getItem<Preferences>('userPreferences');
if (local_user_preferences !== null) { if (local_userPreferences !== null) {
if (local_user_preferences.id !== -1) { if (local_userPreferences.id !== -1) {
Object.assign(user_preferences.value, local_user_preferences); Object.assign(userPreferences.value, local_userPreferences);
setPreferences(); setPreferences();
return; return;
} }
@ -36,24 +30,24 @@ export const useUiStore = defineStore('ui', () => {
const response = await ProfileService.getUserPreferences(); const response = await ProfileService.getUserPreferences();
if (response.success && response.data) { if (response.success && response.data) {
LocalStorage.setItem('user_preferences', response.data); LocalStorage.setItem('userPreferences', response.data);
Object.assign(user_preferences.value, response.data); Object.assign(userPreferences.value, response.data);
setPreferences(); setPreferences();
} }
} catch (error) { } catch (error) {
user_preferences.value = new Preferences; userPreferences.value = new Preferences;
console.error('Could not retrieve user preferences: ', error); console.error('Could not retrieve user preferences: ', error);
} }
}; };
const updateUserPreferences = async () => { const updateUserPreferences = async () => {
try { try {
if (user_preferences.value.id === -1) return; if (userPreferences.value.id === -1) return;
const response = await ProfileService.updateUserPreferences(user_preferences.value); const response = await ProfileService.updateUserPreferences(userPreferences.value);
if (response.success && response.data) { if (response.success && response.data) {
Object.assign(user_preferences.value, response.data); Object.assign(userPreferences.value, response.data);
LocalStorage.setItem('user_preferences', response.data); LocalStorage.setItem('userPreferences', response.data);
setPreferences(); setPreferences();
return; return;
} }
@ -63,19 +57,17 @@ export const useUiStore = defineStore('ui', () => {
}; };
const setPreferences = () => { const setPreferences = () => {
if (user_preferences.value !== undefined) { if (userPreferences.value !== undefined) {
// if user_preferences.value.is_dark_mode === null // if userPreferences.value.is_dark_mode === null
Dark.set(user_preferences.value.is_dark_mode ?? "auto"); Dark.set(userPreferences.value.is_dark_mode ?? "auto");
locale.value = user_preferences.value.display_language; locale.value = userPreferences.value.display_language;
} }
} }
return { return {
current_page, focusNextComponent,
is_mobile_mode, isLeftDrawerOpen,
focus_next_component, userPreferences,
is_left_drawer_open,
user_preferences,
toggleRightDrawer, toggleRightDrawer,
getUserPreferences, getUserPreferences,
updateUserPreferences, updateUserPreferences,

13
src/utils/translator.ts Normal file
View File

@ -0,0 +1,13 @@
import type { ShiftType } from "src/modules/timesheets/models/shift.models";
export const fromEnToFrShiftType = (shiftType: ShiftType): string => {
switch(shiftType) {
case "REGULAR": return 'Régulier';
case "EVENING": return 'Soir';
case "EMERGENCY": return 'Urgence';
case "HOLIDAY": return 'Férié';
case "VACATION": return 'Vacances';
case "SICK": return 'Maladie';
case "WITHDRAW_BANKED": return 'Heures-en-Banque';
}
}