feat(profile): finalize get/update of user preferences, begin planning for employee-management module

This commit is contained in:
Nicolas Drolet 2025-11-28 10:54:38 -05:00
parent 4c79820128
commit a4904ee80d
24 changed files with 493 additions and 296 deletions

View File

@ -1,7 +1,9 @@
<script setup lang="ts"> <script
// setup
lang="ts"
>
</script> </script>
<template> <template>
<router-view /> <router-view />
</template> </template>

View File

@ -61,10 +61,14 @@ export default {
}, },
preferences: { preferences: {
tab_title: "preferences", tab_title: "preferences",
display_options: "display options", display_options: "Color mode",
language_options: "language options", language_options: "language options",
'fr-FR': "French",
'en-CA': "English",
dark_mode: "dark", dark_mode: "dark",
light_mode: "light", light_mode: "light",
update_successful: "Preferences saved",
update_failed: "Failed to save preferences",
}, },
schedule_presets: { schedule_presets: {
tab_title: "Schedule", tab_title: "Schedule",

View File

@ -61,10 +61,14 @@ export default {
}, },
preferences: { preferences: {
tab_title: "préférences", tab_title: "préférences",
display_options: "Options d'affichage", display_options: "Mode d'affichage",
language_options: "Options de langue", language_options: "Options de langue",
'fr-FR': "Français",
'en-CA': "Anglais",
dark_mode: "sombre", dark_mode: "sombre",
light_mode: "clair", light_mode: "clair",
update_successful: "Préférences enregistrées",
update_failed: "Échec de sauvegarde",
}, },
schedule_presets: { schedule_presets: {
tab_title: "horaire", tab_title: "horaire",

View File

@ -1,17 +1,46 @@
<script lang="ts" setup> <script
import { RouterView } from 'vue-router'; lang="ts"
import HeaderBar from 'src/layouts/components/main-layout-header-bar.vue'; setup
import FooterBar from 'src/layouts/components/main-layout-footer-bar.vue'; >
import LeftDrawer from 'src/layouts/components/main-layout-left-drawer.vue'; import { RouterView } from 'vue-router';
import HeaderBar from 'src/layouts/components/main-layout-header-bar.vue';
import FooterBar from 'src/layouts/components/main-layout-footer-bar.vue';
import LeftDrawer from 'src/layouts/components/main-layout-left-drawer.vue';
import { useUiStore } from 'src/stores/ui-store';
import { onMounted, watch, ref } from 'vue';
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
const ui_store = useUiStore();
const user_preferences = ref(ui_store.user_preferences);
onMounted(async () => {
console.log('current preferences on load: ', ui_store.user_preferences);
if (ui_store.user_preferences.id === -1) {
console.log('fetching preferences');
await ui_store.getUserPreferences();
}
});
watch(user_preferences, async () => {
if (ui_store.user_preferences.id !== -1) {
console.log('triggered watcher');
await ui_store.updateUserPreferences(t);
return
}
console.log('watcher triggered but store has no preferences')
await ui_store.getUserPreferences();
}, {deep: true});
</script> </script>
<template> <template>
<q-layout view="hHh lpR fFf"> <q-layout view="hHh lpR fFf">
<HeaderBar /> <HeaderBar />
<LeftDrawer /> <LeftDrawer />
<q-page-container> <q-page-container>
<router-view class="q-pa-sm bg-secondary" /> <router-view class="q-pa-sm bg-secondary" />
</q-page-container> </q-page-container>
<FooterBar /> <FooterBar />
</q-layout> </q-layout>
</template> </template>

View File

@ -55,18 +55,14 @@
color="accent" color="accent"
class="col-auto" class="col-auto"
/> />
<span
<transition :key="is_remembered ? 'yep' : 'nope'"
enter-active-class="animated rubberBand fast" class="col-auto text-weight-bold self-center q-ml-sm"
leave-active-class="" :class="is_remembered ? 'text-accent' : ''"
mode="out-in"
> >
<span {{ $t('login.button.remember_me') }}
:key="is_remembered ? 'yep' : 'nope'" </span>
class="col-auto text-weight-bold self-center q-ml-sm"
:class="is_remembered ? 'text-accent' : ''"
>{{ $t('login.button.remember_me') }}</span>
</transition>
</q-card-section> </q-card-section>
<q-card-actions> <q-card-actions>
@ -90,14 +86,14 @@
<q-card-section class="row q-pt-sm"> <q-card-section class="row q-pt-sm">
<q-separator <q-separator
size="2px" size="2px"
color="primary" color="accent"
class="col self-center" class="col self-center"
/> />
<span class="col text-primary text-weight-bolder text-center text-uppercase self-center">{{ <span class="col text-accent text-weight-bolder text-center text-uppercase self-center">{{
$t('shared.misc.or') }}</span> $t('shared.misc.or') }}</span>
<q-separator <q-separator
size="2px" size="2px"
color="primary" color="accent"
class="col self-center" class="col self-center"
/> />
</q-card-section> </q-card-section>

View File

@ -2,18 +2,20 @@
setup setup
lang="ts" lang="ts"
> >
import { onMounted, ref } from 'vue';
import { useEmployeeListApi } from 'src/modules/employee-list/composables/use-employee-api';
import { useEmployeeStore } from 'src/stores/employee-store';
import EmployeeListTableItem from 'src/modules/employee-list/components/employee-list-table-item.vue'; import EmployeeListTableItem from 'src/modules/employee-list/components/employee-list-table-item.vue';
import { employee_list_columns } from 'src/modules/employee-list/models/employee-profile.models';
import { onMounted, ref } from 'vue';
import { useUiStore } from 'src/stores/ui-store';
import { useEmployeeStore } from 'src/stores/employee-store';
import { useEmployeeListApi } from 'src/modules/employee-list/composables/use-employee-api';
import { employee_list_columns, getCompanyName } from 'src/modules/employee-list/models/employee-profile.models';
const employee_list_api = useEmployeeListApi(); const employee_list_api = useEmployeeListApi();
const employee_store = useEmployeeStore(); const employee_store = useEmployeeStore();
const ui_store = useUiStore();
const is_loading_list = ref<boolean>(true); const is_loading_list = ref<boolean>(true);
const filter = ref(""); const filter = ref("");
const is_grid_mode = ref(true);
onMounted(async () => { onMounted(async () => {
is_loading_list.value = true; is_loading_list.value = true;
@ -26,7 +28,6 @@
<div class="q-pa-lg"> <div class="q-pa-lg">
<q-table <q-table
dense dense
flat
hide-pagination hide-pagination
virtual-scroll virtual-scroll
title=" " title=" "
@ -36,14 +37,13 @@
row-key="name" row-key="name"
:rows-per-page-options="[0]" :rows-per-page-options="[0]"
:filter="filter" :filter="filter"
class="q-pa-md bg-transparent" class="bg-transparent no-shadow sticky-header-table"
:class="is_grid_mode ? '' : 'sticky-header-table'"
:style="$q.screen.lt.md ? '' : 'width: 80vw;'" :style="$q.screen.lt.md ? '' : 'width: 80vw;'"
:table-class="$q.dark.isActive ? 'q-px-md q-py-none q-mx-md rounded-10 bg-dark' : 'q-px-md q-py-none q-mx-md rounded-10 bg-white'" :table-class="$q.dark.isActive ? 'q-py-none q-mx-md rounded-10 bg-dark shadow-10' : 'q-py-none q-mx-md rounded-10 bg-white shadow-10'"
color="accent" color="accent"
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="is_grid_mode" :grid="ui_store.user_preferences.is_employee_list_grid"
:loading="is_loading_list" :loading="is_loading_list"
: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')"
@ -87,7 +87,7 @@
<q-space /> <q-space />
<q-btn-toggle <q-btn-toggle
v-model="is_grid_mode" v-model="ui_store.user_preferences.is_employee_list_grid"
push push
rounded rounded
color="white" color="white"
@ -119,6 +119,16 @@
</div> </div>
</template> </template>
<template #body-cell="scope">
<q-td
:props="scope"
class="text-weight-medium"
>
<span v-if="scope.col.name === 'company_name'"> {{ getCompanyName(scope.value) }}</span>
<span v-else>{{ scope.value }}</span>
</q-td>
</template>
<!-- Template for custome failed-to-load state --> <!-- Template for custome failed-to-load state -->
<template v-slot:no-data="{ message, filter }"> <template v-slot:no-data="{ message, filter }">
<div class="full-width column items-center text-accent q-gutter-sm"> <div class="full-width column items-center text-accent q-gutter-sm">
@ -138,14 +148,14 @@
<style lang="sass"> <style lang="sass">
.sticky-header-table .sticky-header-table
thead tr:first-child th thead tr:first-child th
background-color: var(--q-dark) background-color: var(--q-accent)
margin-top: none margin-top: none
thead tr th thead tr th
position: sticky position: sticky
z-index: 1 z-index: 1
thead tr:first-child th thead tr:first-child th
top: 0 top: 0px
&.q-table--loading thead tr:last-child th &.q-table--loading thead tr:last-child th
top: 48px top: 48px

View File

@ -1,16 +1,16 @@
<script setup lang="ts"> <script setup lang="ts">
import { useEmployeeStore } from 'src/stores/employee-store'; import { useEmployeeStore } from 'src/stores/employee-store';
const employeeStore = useEmployeeStore(); const employee_store = useEmployeeStore();
</script> </script>
<template> <template>
<q-dialog v-model="employeeStore.isShowingEmployeeAddModifyWindow"> <q-dialog v-model="employee_store.isShowingEmployeeAddModifyWindow">
<q-card> <q-card>
<q-card-section> <q-card-section>
LOL LOL
</q-card-section> </q-card-section>
<q-inner-loading :showing="employeeStore.isLoadingEmployeeProfile"/> <q-inner-loading :showing="employee_store.is_loading"/>
</q-card> </q-card>
</q-dialog> </q-dialog>
</template> </template>

View File

@ -1,6 +1,6 @@
import type { QTableColumn } from "quasar"; import type { QTableColumn } from "quasar";
export interface EmployeeProfile { export class EmployeeProfile {
first_name: string; first_name: string;
last_name: string; last_name: string;
supervisor_full_name: string; supervisor_full_name: string;
@ -12,57 +12,65 @@ export interface EmployeeProfile {
last_work_day: string; last_work_day: string;
residence: string; residence: string;
birth_date: string; birth_date: string;
}
constructor() {
export const default_employee_profile: EmployeeProfile = { this.first_name = '';
first_name: '', this.last_name = '';
last_name: '', this.supervisor_full_name = '';
supervisor_full_name: '', this.company_name = 271583;
company_name: -1, this.job_title = '';
job_title: '', this.email = '';
email: '', this.phone_number = '';
phone_number: '', this.first_work_day = '';
first_work_day: '', this.last_work_day = '';
last_work_day: '', this.residence = '';
residence: '', this.birth_date = '';
birth_date: '', }
} }
export const employee_list_columns: QTableColumn<EmployeeProfile>[] = [ export const employee_list_columns: QTableColumn<EmployeeProfile>[] = [
{ {
name: 'first_name', name: 'first_name',
label: 'employee_list.table.first_name', label: 'employee_list.table.first_name',
field: 'first_name', field: 'first_name',
align: 'left' align: 'left'
}, },
{ {
name: 'last_name', name: 'last_name',
label: 'employee_list.table.last_name', label: 'employee_list.table.last_name',
field: 'last_name', field: 'last_name',
align: 'left' align: 'left'
}, },
{ {
name: 'email', name: 'email',
label: 'employee_list.table.email', label: 'employee_list.table.email',
field: 'email', field: 'email',
align: 'left' align: 'left'
}, },
{ {
name: 'supervisor_full_name', name: 'supervisor_full_name',
label: 'employee_list.table.supervisor', label: 'employee_list.table.supervisor',
field: 'supervisor_full_name', field: 'supervisor_full_name',
align: 'left' align: 'left'
}, },
{ {
name: 'company_name', name: 'company_name',
label: 'employee_list.table.company', label: 'employee_list.table.company',
field: 'company_name', field: 'company_name',
align: 'left' align: 'left'
}, },
{ {
name: 'job_title', name: 'job_title',
label: 'employee_list.table.role', label: 'employee_list.table.role',
field: 'job_title', field: 'job_title',
align: 'left' align: 'left'
}, },
]; ];
export const getCompanyName = (company_code: number) => {
switch (company_code) {
case 271583: return 'Targo';
case 271585: return 'Solucom';
default: return 'N / A';
}
}

View File

@ -7,8 +7,13 @@ export const EmployeeListService = {
return response.data.data; return response.data.data;
}, },
getEmployeeDetails: async (email: string): Promise<EmployeeProfile> => { getEmployeeDetails: async (): Promise<EmployeeProfile> => {
const response = await api.get<EmployeeProfile>('employees/profile/' + email); const response = await api.get<{success: boolean, data: EmployeeProfile, error?: string}>('employees/profile');
return response.data; return response.data.data;
}, },
getEmployeeDetailsWithEmployeeEmail: async (employee_email: string): Promise<EmployeeProfile> => {
const response = await api.get<{success: boolean, data: EmployeeProfile, error?: string}>(`employees/profile?employee_email=${employee_email}`);
return response.data.data;
}
}; };

View File

@ -7,7 +7,7 @@
import MenuPanelPreferences from 'src/modules/profile/components/shared/menu-panel-preferences.vue'; import MenuPanelPreferences from 'src/modules/profile/components/shared/menu-panel-preferences.vue';
import MenuPanelSchedulePresets from 'src/modules/profile/components/shared/menu-panel-schedule-presets.vue'; import MenuPanelSchedulePresets from 'src/modules/profile/components/shared/menu-panel-schedule-presets.vue';
import MenuTemplate from 'src/modules/profile/components/shared/menu-template.vue'; import MenuTemplate from 'src/modules/profile/components/shared/menu-template.vue';
import { default_employee_profile, type EmployeeProfile } from 'src/modules/employee-list/models/employee-profile.models'; import { EmployeeProfile } from 'src/modules/employee-list/models/employee-profile.models';
import { useAuthStore } from 'src/stores/auth-store'; import { useAuthStore } from 'src/stores/auth-store';
const auth_store = useAuthStore(); const auth_store = useAuthStore();
@ -19,7 +19,7 @@
SCHEDULE_PRESETS: 'schedule_presets', SCHEDULE_PRESETS: 'schedule_presets',
}; };
const employee_profile = defineModel<EmployeeProfile>({ default: default_employee_profile }); const employee_profile = defineModel<EmployeeProfile>({ default: new EmployeeProfile });
</script> </script>

View File

@ -2,38 +2,49 @@
setup setup
lang="ts" lang="ts"
> >
import { ref } from 'vue';
import { deepEqual } from 'src/utils/deep-equal';
import MenuPanelInputField from 'src/modules/profile/components/shared/menu-panel-input-field.vue'; import MenuPanelInputField from 'src/modules/profile/components/shared/menu-panel-input-field.vue';
import MenuPanelSelectField from 'src/modules/profile/components/shared/menu-panel-select-field.vue'; import MenuPanelSelectField from 'src/modules/profile/components/shared/menu-panel-select-field.vue';
import type { EmployeeProfile } from 'src/modules/employee-list/models/employee-profile.models';
import { unwrapAndClone } from 'src/utils/unwrap-and-clone';
const employee_profile = defineModel<EmployeeProfile>({required: true}); import { ref } from 'vue';
import { deepEqual } from 'src/utils/deep-equal';
import { unwrapAndClone } from 'src/utils/unwrap-and-clone';
import { useAuthStore } from 'src/stores/auth-store';
import { useEmployeeStore } from 'src/stores/employee-store';
import { CAN_APPROVE_PAY_PERIODS } from 'src/modules/shared/models/user.models';
import type { EmployeeProfile } from 'src/modules/employee-list/models/employee-profile.models';
const COMPANY_OPTIONS = [
{ label: 'Targo', value: 271583 },
{ label: 'Solucom', value: 271585 }
];
const SUPERVISOR_OPTIONS = [{ label: 'AAA', value: '1' }, { label: 'BBB', value: '2' }, { label: 'CCC', value: '3' }, { label: 'DDD', value: '4' }];
const auth_store = useAuthStore();
const employee_store = useEmployeeStore();
const is_editing = ref<boolean>(false); const is_editing = ref<boolean>(false);
let initial_info: EmployeeProfile = unwrapAndClone(employee_profile.value); const current_company_option = ref(COMPANY_OPTIONS.find(option => option.value === employee_store.employee.company_name) ?? { label: '', value: 0 })
let initial_info: EmployeeProfile = unwrapAndClone(employee_store.employee);
const supervisor_options = [{ label: 'AAA', value: '1' }, { label: 'BBB', value: '2' }, { label: 'CCC', value: '3' }, { label: 'DDD', value: '4' }];
const onSubmit = () => { const onSubmit = () => {
if (!is_editing.value) { if (!is_editing.value) {
is_editing.value = true; is_editing.value = true;
console.log('clicky!');
return; return;
} }
is_editing.value = false; is_editing.value = false;
initial_info = unwrapAndClone(employee_profile.value); // update initial value for future possible resets initial_info = unwrapAndClone(employee_store.employee); // update initial value for future possible resets
employee_store.employee.company_name = current_company_option.value.value;
if (!deepEqual(employee_profile.value, initial_info)) { if (!deepEqual(employee_store.employee, initial_info)) {
// save the new data here // save the new data here
return; return;
} }
}; };
const onReset = () => { const onReset = () => {
employee_profile.value = unwrapAndClone(initial_info); employee_store.employee = unwrapAndClone(initial_info);
is_editing.value = false; is_editing.value = false;
} }
</script> </script>
@ -46,23 +57,24 @@
> >
<div :class="$q.screen.lt.md ? 'column' : 'row'"> <div :class="$q.screen.lt.md ? 'column' : 'row'">
<MenuPanelInputField <MenuPanelInputField
v-model="employee_profile.job_title" v-model="employee_store.employee.job_title"
class="col" class="col"
:is-editing="is_editing" :is-editing="is_editing"
:label-string="$t('profile.employee.job_title')" :label-string="$t('profile.employee.job_title')"
/> />
<MenuPanelInputField <MenuPanelSelectField
v-model="employee_profile.company_name" v-model="current_company_option"
:options="COMPANY_OPTIONS"
class="col" class="col"
:is-editing="is_editing" :is-editing="is_editing"
:label-string="$t('profile.employee.company')" :label-string="$t('profile.employee.company')"
/> />
</div> </div>
<div class="q-mx-xs"> <div>
<MenuPanelSelectField <MenuPanelSelectField
v-model="employee_profile.supervisor_full_name" v-model="employee_store.employee.supervisor_full_name"
:options="supervisor_options" :options="SUPERVISOR_OPTIONS"
:label-string="$t('profile.employee.supervisor')" :label-string="$t('profile.employee.supervisor')"
:is-editing="is_editing" :is-editing="is_editing"
/> />
@ -71,15 +83,15 @@
<div :class="$q.screen.lt.md ? 'column' : 'row'"> <div :class="$q.screen.lt.md ? 'column' : 'row'">
<MenuPanelInputField <MenuPanelInputField
v-model="employee_profile.email" v-model="employee_store.employee.email"
class="col" class="col"
:is-editing="is_editing" :is-editing="is_editing"
:label-string="$t('profile.employee.email')" :label-string="$t('profile.employee.email')"
/> />
<MenuPanelInputField <MenuPanelInputField
v-model="employee_profile.first_work_day" v-model="employee_store.employee.first_work_day"
readonly readonly
class="col" class="col-auto"
type="date" type="date"
:is-editing="is_editing" :is-editing="is_editing"
:label-string="$t('profile.employee.hired_date')" :label-string="$t('profile.employee.hired_date')"
@ -87,6 +99,7 @@
</div> </div>
<div <div
v-if="CAN_APPROVE_PAY_PERIODS.includes(auth_store.user?.role ?? 'GUEST')"
class="absolute-bottom" class="absolute-bottom"
:class="$q.screen.lt.md ? 'column' : 'row'" :class="$q.screen.lt.md ? 'column' : 'row'"
> >
@ -94,20 +107,18 @@
<q-btn <q-btn
v-if="is_editing" v-if="is_editing"
push push
size="sm"
color="negative" color="negative"
type="reset" type="reset"
icon="cancel" icon="cancel"
class="q-ma-sm" class="q-ma-sm q-py-xs"
:label="$t('shared.label.cancel')" :label="$t('shared.label.cancel')"
/> />
<q-btn <q-btn
push push
size="sm" color="accent"
color="primary"
type="submit" type="submit"
:icon="is_editing ? 'save_alt' : 'create'" :icon="is_editing ? 'save_alt' : 'create'"
class="q-ma-sm" class="q-ma-sm q-py-xs"
:label="is_editing ? $t('shared.label.save') : $t('shared.label.update')" :label="is_editing ? $t('shared.label.save') : $t('shared.label.update')"
/> />
</div> </div>

View File

@ -2,35 +2,36 @@
setup setup
lang="ts" lang="ts"
> >
import MenuPanelInputField from 'src/modules/profile/components/shared/menu-panel-input-field.vue';
import { ref } from 'vue'; import { ref } from 'vue';
import { deepEqual } from 'src/utils/deep-equal'; import { deepEqual } from 'src/utils/deep-equal';
import MenuPanelInputField from 'src/modules/profile/components/shared/menu-panel-input-field.vue';
import type { EmployeeProfile } from 'src/modules/employee-list/models/employee-profile.models';
import { unwrapAndClone } from 'src/utils/unwrap-and-clone'; import { unwrapAndClone } from 'src/utils/unwrap-and-clone';
import { useEmployeeStore } from 'src/stores/employee-store';
import type { EmployeeProfile } from 'src/modules/employee-list/models/employee-profile.models';
const employee_profile = defineModel<EmployeeProfile>({required: true}); const employee_store = useEmployeeStore();
const is_editing = ref<boolean>(false); const is_editing = ref<boolean>(false);
const initial_info = ref<EmployeeProfile>(unwrapAndClone(employee_store.employee));
let initial_info: EmployeeProfile = unwrapAndClone(employee_profile.value);
const onSubmit = () => { const onSubmit = () => {
if (!is_editing.value) { if (!is_editing.value) {
is_editing.value = true; is_editing.value = true;
initial_info.value = unwrapAndClone(employee_store.employee);
return; return;
} }
is_editing.value = false; is_editing.value = false;
initial_info = unwrapAndClone(employee_profile.value); // update initial value for future possible resets initial_info.value = unwrapAndClone(employee_store.employee); // update initial value for future possible resets
if (!deepEqual(employee_profile.value, initial_info)) { if (!deepEqual(employee_store.employee, initial_info)) {
// save the new data here // save the new data here
return; return;
} }
}; };
const onReset = () => { const onReset = () => {
employee_profile.value = unwrapAndClone(initial_info); employee_store.employee = unwrapAndClone(initial_info.value);
is_editing.value = false; is_editing.value = false;
} }
</script> </script>
@ -43,14 +44,14 @@
> >
<div :class="$q.screen.lt.md ? 'column' : 'row'"> <div :class="$q.screen.lt.md ? 'column' : 'row'">
<MenuPanelInputField <MenuPanelInputField
v-model="employee_profile.first_name" v-model="employee_store.employee.first_name"
type="text" type="text"
class="col" class="col"
:is-editing="is_editing" :is-editing="is_editing"
:label-string="$t('profile.personal.first_name')" :label-string="$t('profile.personal.first_name')"
/> />
<MenuPanelInputField <MenuPanelInputField
v-model="employee_profile.last_name" v-model="employee_store.employee.last_name"
class="col" class="col"
type="text" type="text"
:is-editing="is_editing" :is-editing="is_editing"
@ -60,14 +61,14 @@
<div :class="$q.screen.lt.md ? 'column' : 'row'"> <div :class="$q.screen.lt.md ? 'column' : 'row'">
<MenuPanelInputField <MenuPanelInputField
v-model="employee_profile.phone_number" v-model="employee_store.employee.phone_number"
class="col" class="col"
type="text" type="text"
:is-editing="is_editing" :is-editing="is_editing"
:label-string="$t('profile.personal.phone_number')" :label-string="$t('profile.personal.phone_number')"
/> />
<MenuPanelInputField <MenuPanelInputField
v-model="employee_profile.birth_date" v-model="employee_store.employee.birth_date"
class="col" class="col"
mask="#### / ## / ##" mask="#### / ## / ##"
hint="ex: 1970 / 01 / 01" hint="ex: 1970 / 01 / 01"
@ -78,7 +79,7 @@
<div :class="$q.screen.lt.md ? 'column' : 'row'"> <div :class="$q.screen.lt.md ? 'column' : 'row'">
<MenuPanelInputField <MenuPanelInputField
v-model="employee_profile.residence" v-model="employee_store.employee.residence"
class="col" class="col"
:is-editing="is_editing" :is-editing="is_editing"
:label-string="$t('profile.personal.address')" :label-string="$t('profile.personal.address')"
@ -93,21 +94,19 @@
<q-space /> <q-space />
<q-btn <q-btn
v-if="is_editing" v-if="is_editing"
push flat
size="sm"
color="negative" color="negative"
type="reset" type="reset"
icon="cancel" icon="cancel"
class="q-ma-sm" class="q-ma-sm q-py-xs"
:label="$t('timesheet.cancel_button')" :label="$t('timesheet.cancel_button')"
/> />
<q-btn <q-btn
push push
size="sm" color="accent"
color="primary"
type="submit" type="submit"
:icon="is_editing ? 'save_alt' : 'create'" :icon="is_editing ? 'save_alt' : 'create'"
class="q-ma-sm" class="q-ma-sm q-py-xs"
:label="is_editing ? $t('shared.label.save') : $t('shared.label.update')" :label="is_editing ? $t('shared.label.save') : $t('shared.label.update')"
/> />
</div> </div>

View File

@ -1,7 +1,10 @@
<script setup lang="ts"> <script
setup
lang="ts"
>
import type { ValidationRule } from 'quasar'; import type { ValidationRule } from 'quasar';
const model = defineModel<string | number>({ required: true }); const model = defineModel<string | number | undefined>({ required: true });
const { readonly = false, hint = '' } = defineProps<{ const { readonly = false, hint = '' } = defineProps<{
labelString: string; labelString: string;
@ -23,14 +26,19 @@
:standout="$q.dark.isActive ? 'bg-blue-grey-3' : 'bg-blue-grey-9'" :standout="$q.dark.isActive ? 'bg-blue-grey-3' : 'bg-blue-grey-9'"
debounce="500" debounce="500"
label-color="accent" label-color="accent"
class="q-ma-xs text-uppercase" label-slot
input-class="text-weight-medium text-h6" class="q-ma-xs"
input-class="text-weight-light"
input-style="font-size: 1.2em"
:hide-hint="hint === ''" :hide-hint="hint === ''"
:hint="isEditing ? hint : ''" :hint="isEditing ? hint : ''"
:mask="mask" :mask="mask"
:readonly="readonly || !isEditing" :readonly="readonly || !isEditing"
:type="type" :type="type"
:label="labelString"
:rules="rules" :rules="rules"
/> >
<template #label>
<span class="text-weight-bolder text-accent text-uppercase">{{ labelString }}</span>
</template>
</q-input>
</template> </template>

View File

@ -1,47 +1,40 @@
<script setup lang="ts"> <script
setup
lang="ts"
>
import LanguageSwitch from 'src/modules/shared/components/language-switch.vue';
import { ref } from 'vue'; import { ref } from 'vue';
import { Dark } from 'quasar'; import { Dark } from 'quasar';
import LanguageSwitch from 'src/modules/shared/components/language-switch.vue'; import { useUiStore } from 'src/stores/ui-store';
const ui_store = useUiStore();
const initial_dark_mode_value = Dark.isActive; const initial_dark_mode_value = Dark.isActive;
const is_dark_mode = ref<boolean>(initial_dark_mode_value); const is_dark_mode = ref<boolean>(initial_dark_mode_value);
const toggle_dark_mode = (value: boolean) => { const toggle_dark_mode = (value: boolean) => {
is_dark_mode.value = value; if (ui_store.user_preferences) ui_store.user_preferences.is_dark_mode = value;
Dark.set(value); Dark.set(value);
} }
</script> </script>
<template> <template>
<q-form class="q-pa-md column fit"> <q-form class="q-pa-md column fit">
<div class="col-auto text-uppercase rounded-5" style="line-height: 1em;">{{ $t('profile.preferences.display_options') }}</div> <q-card
<q-card
flat flat
class="col-auto column justify-center items-center content-center q-mb-lg q-pa-md" class="col-auto justify-center content-center q-mb-lg q-pa-none"
style="border: solid #AAA 1px;" :style="$q.dark.isActive ? 'background-color: #FFF1;' : 'background-color: #0001;'"
> >
<q-card-section class="q-py-none">
<span class="text-uppercase text-weight-bold text-accent">{{ $t('profile.preferences.display_options')
}}</span>
</q-card-section>
<div class="row col"> <q-card-section
<q-card flat class="col column q-pa-xs bg-white" style="border: solid 1px var(--q-primary);"> horizontal
<div class="flex-center text-uppercase"
class="col-auto column rounded-4 ellipsis" >
style="height: 90px; min-width: 80px; background-color: #DAE0E7;" <span class="q-mx-md text-weight-medium">{{ $t('profile.preferences.light_mode') }}</span>
> <q-toggle
<div class="bg-primary col-1"></div>
<div class=" row col">
<div class="col-8 q-ma-xs rounded-borders" style="background-color: white;"></div>
<div class="col column q-gutter-xs q-py-xs q-pr-xs">
<div class="col rounded-borders" style="background-color: white;"></div>
<div class="col rounded-borders" style="background-color: white;"></div>
<div class="col rounded-borders" style="background-color: white;"></div>
</div>
</div>
<div class="bg-primary col-1"></div>
</div>
<span class="col-auto text-subtitle2 text-primary text-center text-uppercase">{{$t('profile.preferences.light_mode')}}</span>
</q-card>
<q-toggle
v-model="is_dark_mode" v-model="is_dark_mode"
@update:model-value="value => toggle_dark_mode(value)" @update:model-value="value => toggle_dark_mode(value)"
size="xl" size="xl"
@ -49,36 +42,26 @@
checked-icon="dark_mode" checked-icon="dark_mode"
unchecked-icon="light_mode" unchecked-icon="light_mode"
/> />
<span class="q-mx-md text-weight-medium">{{ $t('profile.preferences.dark_mode') }}</span>
<q-card flat class="col column q-pa-xs bg-white" style="border: solid 1px var(--q-primary);"> </q-card-section>
<div
class="col-auto column rounded-4 ellipsis"
style="height: 90px; min-width: 80px; background-color: #0f1114;"
>
<div class="bg-primary col-1"></div>
<div class=" row col">
<div class="col-8 q-ma-xs rounded-borders" style="background-color: #333;"></div>
<div class="col column q-gutter-xs q-py-xs q-pr-xs">
<div class="col rounded-borders" style="background-color: #333;"></div>
<div class="col rounded-borders" style="background-color: #333;"></div>
<div class="col rounded-borders" style="background-color: #333;"></div>
</div>
</div>
<div class="bg-primary col-1"></div>
</div>
<span class="col-auto text-subtitle2 text-primary text-center text-uppercase">{{$t('profile.preferences.dark_mode')}}</span>
</q-card>
</div>
</q-card> </q-card>
<div class="col-auto text-uppercase rounded-5" style="line-height: 1em;">{{ $t('profile.preferences.language_options') }}</div> <q-card
<q-card
flat flat
class="col-auto column justify-center items-center content-center q-mb-lg q-pa-md" class="col-auto justify-center content-center q-mb-lg q-pa-none"
style="border: solid #AAA 1px;" :style="$q.dark.isActive ? 'background-color: #FFF1;' : 'background-color: #0001;'"
> >
<q-card-section class="q-py-none">
<LanguageSwitch class="q-mr-xs col-auto" /> <span class="text-uppercase text-weight-bold text-accent">{{ $t('profile.preferences.language_options')
}}</span>
</q-card-section>
<q-card-section
horizontal
class="flex-center"
>
<LanguageSwitch class="col-auto" />
</q-card-section>
</q-card> </q-card>
</q-form> </q-form>
</template> </template>

View File

@ -1,8 +1,11 @@
<script setup lang="ts"> <script
const model = defineModel<string>(); setup
lang="ts"
>
const model = defineModel<string | number | {label: string, value: unknown} | undefined>();
const { readonly = false, localizeOptions = false } = defineProps<{ const { readonly = false, localizeOptions = false } = defineProps<{
options: { label: string, value: string }[]; options: { label: string, value: string | number }[];
labelString: string; labelString: string;
isEditing: boolean; isEditing: boolean;
readonly?: boolean; readonly?: boolean;
@ -12,20 +15,29 @@
</script> </script>
<template> <template>
<q-select <q-select
v-model="model" v-model="model"
dense dense
:stack-label="!isEditing" :stack-label="!isEditing"
:standout="$q.dark.isActive ? 'bg-blue-grey-3' : 'bg-blue-grey-9'" :standout="$q.dark.isActive ? 'bg-blue-grey-3' : 'bg-blue-grey-9'"
label-color="accent" label-color="accent"
class="q-ma-xs text-h6 text-uppercase" class="q-ma-xs"
popup-content-class="text-weight-medium text-h6" popup-content-class="text-weight-medium text-h6 rounded-5"
input-class="text-weight-medium" options-selected-class="bg-accent text-white"
:menu-offset="[0, 10]"
:options="options" :options="options"
:readonly="readonly || !isEditing" :readonly="readonly || !isEditing"
:hide-dropdown-icon="!isEditing" :hide-dropdown-icon="!isEditing"
:label="labelString" :label="labelString"
:option-label="opt => localizeOptions ? $t(opt) : opt" :option-label="opt => localizeOptions ? $t(opt.label) : opt.label ?? opt"
hint='' hint=''
/> >
<template #label>
<span class="text-weight-bolder text-accent text-uppercase">{{ labelString }}</span>
</template>
<template #selected-item="scope">
<span class="text-weight-light" style="font-size: 1.2em;">{{ scope.opt.label }}</span>
</template>
</q-select>
</template> </template>

View File

@ -1,4 +1,7 @@
<script setup lang="ts"> <script
setup
lang="ts"
>
import { ref } from 'vue'; import { ref } from 'vue';
import MenuHeader from 'src/modules/profile/components/shared/menu-header.vue'; import MenuHeader from 'src/modules/profile/components/shared/menu-header.vue';
@ -12,39 +15,32 @@
</script> </script>
<template> <template>
<div <div
:class="$q.screen.lt.md ? 'column no-wrap' : 'row'" :class="$q.screen.lt.md ? 'column no-wrap flex-center' : 'row'"
:style="$q.screen.lt.md ? 'width: 90vw;' : 'width: 40vw;'" :style="$q.screen.lt.md ? 'width: 90vw;' : 'width: 40vw;'"
> >
<MenuHeader <MenuHeader
:user-first-name="firstName" :user-first-name="firstName"
:user-last-name="lastName" :user-last-name="lastName"
/> />
<div <q-card
class="col-3 no-wrap" class="col-auto q-pa-xs"
:class="$q.screen.lt.md ? '' : 'column'" :class="$q.screen.lt.md ? 'q-mb-sm' : 'q-mr-sm'"
> >
<q-card <q-tabs
class="col-auto q-pa-xs" v-model="current_menu"
:class="$q.screen.lt.md ? 'q-mb-sm' : 'q-mr-sm'" :vertical="$q.screen.gt.sm"
active-color="accent"
indicator-color="accent"
> >
<q-tabs <slot name="tabs"></slot>
v-model="current_menu" </q-tabs>
:vertical="$q.screen.gt.sm" </q-card>
dense
active-color="accent"
indicator-color="accent"
>
<slot name="tabs"></slot>
</q-tabs>
</q-card>
<div class="col"></div>
</div>
<q-card <q-card
class="col" class="col"
:class="$q.screen.lt.md ? '' : 'q-ml-sm'" :class="$q.screen.lt.md ? 'full-width' : 'q-ml-sm'"
> >
<q-tab-panels <q-tab-panels
v-model="current_menu" v-model="current_menu"

View File

@ -0,0 +1,21 @@
import type { MessageLanguages } from "src/boot/i18n";
export class Preferences {
id: number;
notifications: boolean;
is_dark_mode: boolean | null;
display_language: MessageLanguages;
is_lefty_mode: boolean;
is_employee_list_grid: boolean;
is_timesheet_approval_grid: boolean;
constructor() {
this.id = -1;
this.notifications = true;
this.is_dark_mode = null;
this.display_language = 'fr-FR';
this.is_lefty_mode = false;
this.is_employee_list_grid = true;
this.is_timesheet_approval_grid = true;
}
}

View File

@ -0,0 +1,15 @@
import { api } from "src/boot/axios";
import type { Preferences } from "src/modules/profile/models/preferences.models";
import type { BackendResponse } from "src/modules/shared/models/backend-response.models";
export const ProfileService = {
getUserPreferences: async (): Promise<BackendResponse<Preferences>> => {
const response = await api.get<BackendResponse<Preferences>>(`/preferences`);
return response.data;
},
updateUserPreferences: async (new_preferences: Preferences): Promise<BackendResponse<Preferences>> => {
const response = await api.patch<BackendResponse<Preferences>>(`/preferences/update`, new_preferences);
return response.data;
},
};

View File

@ -1,36 +1,45 @@
<script lang="ts" setup> <script
import { useI18n } from 'vue-i18n'; lang="ts"
setup
>
import { useUiStore } from 'src/stores/ui-store';
import type { MessageLanguages } from 'src/boot/i18n';
const { locale } = useI18n(); const ui_store = useUiStore();
const localeOptions = [
{ value: 'en-CA', label: 'English' }, const setDisplayLanguage = (locale: MessageLanguages) => {
{ value: 'fr-FR', label: 'Francais' }, if (ui_store.user_preferences !== undefined) {
]; ui_store.user_preferences.display_language = locale;
console.log('triggered language change: ', ui_store.user_preferences.display_language);
}
}
</script> </script>
<template> <template>
<div> <q-list
<q-list dense class="row"> dense
<q-item v-for="option in localeOptions" class="row full-width"
:key="option.value" >
tag="label" <q-item
v-ripple v-for="locale in $i18n.availableLocales"
> :key="locale"
<q-item-section avatar> clickable
<q-radio v-model="locale" :val="option.value" /> dense
</q-item-section> v-ripple
class="col rounded-5 q-ma-sm shadow-1 "
<q-item-section> :class="locale === $i18n.locale ? 'bg-accent text-white text-weight-bolder' : ''"
<q-item-label>{{ option.label }}</q-item-label> @click="setDisplayLanguage(locale as MessageLanguages)"
</q-item-section> >
</q-item> <q-item-section class="text-uppercase justify-center">
</q-list> <q-item-label> {{ $t(`profile.preferences.${locale}`) }}</q-item-label>
</div> </q-item-section>
<!-- <q-btn-dropdown push color="primary" :label="$t('shared.languageLabel')" icon="language"> <q-item-section side>
<q-list> <q-icon
<q-item clickable v-close-popup v-for="option in localeOptions" :key="option.value" @click="locale = option.value"> v-if="locale === $i18n.locale"
<q-item-section>{{ option.label }}</q-item-section> name="check"
</q-item> color="white"
</q-list> />
</q-btn-dropdown> --> </q-item-section>
</q-item>
</q-list>
</template> </template>

View File

@ -0,0 +1,5 @@
export interface BackendResponse<T> {
success: boolean;
data?: T | undefined;
error?: string | undefined;
};

View File

@ -4,19 +4,34 @@
> >
import MenuEmployee from 'src/modules/profile/components/employee/menu-employee.vue'; import MenuEmployee from 'src/modules/profile/components/employee/menu-employee.vue';
import { useAuthStore } from 'src/stores/auth-store'; import { useAuthStore } from 'src/stores/auth-store';
// import type { EmployeeProfile } from 'src/modules/employee-list/models/employee-profile.models'; import { useEmployeeStore } from 'src/stores/employee-store';
import { onMounted } from 'vue';
const auth_store = useAuthStore(); const auth_store = useAuthStore();
const employee_store = useEmployeeStore();
const employee_roles = ['SUPERVISOR', 'EMPLOYEE', 'ADMIN', 'HR', 'ACCOUNTING']; const employee_roles = ['SUPERVISOR', 'EMPLOYEE', 'ADMIN', 'HR', 'ACCOUNTING'];
// const employee_profile = defineModel<EmployeeProfile>({ required: true }); const is_employee = employee_roles.includes(auth_store.user?.role.toUpperCase() ?? 'GUEST');
onMounted(async () => {
if (is_employee) {
await employee_store.getEmployeeDetails();
}
})
</script> </script>
<template> <template>
<q-page class="bg-secondary column items-center justify-center"> <q-page class="bg-secondary column items-center justify-center">
<MenuEmployee <MenuEmployee
v-if="employee_roles.includes(auth_store.user?.role.toUpperCase() ?? 'GUEST')" v-if="employee_roles.includes(auth_store.user?.role.toUpperCase() ?? 'GUEST')"
class="col-auto" class="col-sm-12 col-md-10 col-lg-9 col-xl-8"
/> />
</q-page> </q-page>
</template> </template>
<style scoped>
:deep(.q-field--standout.q-field--readonly .q-field__control:before) {
border: none !important;
background-color: transparent;
}
</style>

View File

@ -3,8 +3,9 @@ export enum RouteNames {
LOGIN = 'login', LOGIN = 'login',
LOGIN_SUCCESS = 'login-success', LOGIN_SUCCESS = 'login-success',
DASHBOARD = 'dashboard', DASHBOARD = 'dashboard',
TIMESHEET_APPROVALS = 'timesheet-approvals', TIMESHEET_APPROVALS = 'timesheets_approval',
EMPLOYEE_LIST = 'employee-list', EMPLOYEE_LIST = 'employee_list',
PROFILE = 'user/profile', EMPLOYEE_MANAGEMENT = 'employee_management',
TIMESHEET = 'timesheet' PROFILE = 'personal_profile',
TIMESHEET = 'timesheets'
} }

View File

@ -1,13 +1,13 @@
import { ref } from "vue"; import { ref } from "vue";
import { defineStore } from "pinia"; import { defineStore } from "pinia";
import { EmployeeListService } from "src/modules/employee-list/services/employee-list-service"; import { EmployeeListService } from "src/modules/employee-list/services/employee-list-service";
import { default_employee_profile, type EmployeeProfile } from "src/modules/employee-list/models/employee-profile.models"; import { EmployeeProfile } from "src/modules/employee-list/models/employee-profile.models";
export const useEmployeeStore = defineStore('employee', () => { export const useEmployeeStore = defineStore('employee', () => {
const employee = ref<EmployeeProfile>( default_employee_profile ); const employee = ref<EmployeeProfile>(new EmployeeProfile);
const employee_list = ref<EmployeeProfile[]>([]); const employee_list = ref<EmployeeProfile[]>([]);
const isShowingEmployeeAddModifyWindow = ref<boolean>(false); const isShowingEmployeeAddModifyWindow = ref<boolean>(false);
const isLoadingEmployeeProfile = ref(false); const is_loading = ref(false);
const isLoadingEmployeeList = ref(false); const isLoadingEmployeeList = ref(false);
const getEmployeeList = async () => { const getEmployeeList = async () => {
@ -22,19 +22,24 @@ export const useEmployeeStore = defineStore('employee', () => {
isLoadingEmployeeList.value = false; isLoadingEmployeeList.value = false;
}; };
const getEmployeeDetails = async (email: string) => { const getEmployeeDetails = async (email?: string) => {
isLoadingEmployeeProfile.value = true; is_loading.value = true;
try { try {
const response = await EmployeeListService.getEmployeeDetails(email); if (email === undefined) {
employee.value = response; const response = await EmployeeListService.getEmployeeDetails();
employee.value = response;
} else{
const response = await EmployeeListService.getEmployeeDetailsWithEmployeeEmail(email);
employee.value = response;
}
} catch (error) { } catch (error) {
console.error('There was an error retrieving employee info: ', error); console.error('There was an error retrieving employee info: ', error);
//TODO: trigger an alert window with an error message here! //TODO: trigger an alert window with an error message here!
} }
isLoadingEmployeeProfile.value = false; is_loading.value = false;
}; };
return { employee, employee_list, isShowingEmployeeAddModifyWindow, isLoadingEmployeeList, isLoadingEmployeeProfile, getEmployeeList, getEmployeeDetails }; return { employee, employee_list, isShowingEmployeeAddModifyWindow, isLoadingEmployeeList, is_loading, getEmployeeList, getEmployeeDetails };
}); });

View File

@ -1,22 +1,81 @@
import { defineStore } from 'pinia'; import { defineStore } from 'pinia';
import { useQuasar } from 'quasar'; import { Notify, LocalStorage, useQuasar, Dark } from 'quasar';
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
import { Preferences } from 'src/modules/profile/models/preferences.models';
import { ProfileService } from 'src/modules/profile/services/profile-service';
import { useI18n, type ComposerTranslation } from 'vue-i18n';
export const useUiStore = defineStore('ui', () => { export const useUiStore = defineStore('ui', () => {
const q = useQuasar(); const q = useQuasar();
const is_left_drawer_open = ref(true); const { locale } = useI18n();
const is_left_drawer_open = ref(false);
const focus_next_component = ref(false); const focus_next_component = ref(false);
const is_mobile_mode = computed(() => q.screen.lt.md); const is_mobile_mode = computed(() => q.screen.lt.md);
const user_preferences = ref<Preferences>(new Preferences);
const toggleRightDrawer = () => { const toggleRightDrawer = () => {
is_left_drawer_open.value = !is_left_drawer_open.value; is_left_drawer_open.value = !is_left_drawer_open.value;
};
const getUserPreferences = async () => {
try {
const local_user_preferences = LocalStorage.getItem<Preferences>('user_preferences');
if (local_user_preferences !== null) {
if (local_user_preferences.id !== -1) {
Object.assign(user_preferences.value, local_user_preferences);
setPreferences();
return;
}
}
const response = await ProfileService.getUserPreferences();
if (response.success && response.data) {
LocalStorage.setItem('user_preferences', response.data);
Object.assign(user_preferences.value, response.data);
setPreferences();
}
} catch (error) {
user_preferences.value = new Preferences;
console.error('Could not retrieve user preferences: ', error);
}
};
const updateUserPreferences = async (t: ComposerTranslation) => {
try {
if (user_preferences.value.id === -1) return;
const response = await ProfileService.updateUserPreferences(user_preferences.value);
if (response.success && response.data) {
Object.assign(user_preferences.value, response.data);
LocalStorage.setItem('user_preferences', response.data);
setPreferences();
Notify.create({ message: t('profile.preferences.update_successful'), color: 'accent' });
return;
}
} catch (error) {
console.error('Could not update user preferences: ', error);
}
Notify.create({ message: t('profile.preferences.update_failed'), color: 'negative' })
};
const setPreferences = () => {
if (user_preferences.value !== undefined) {
Dark.set(user_preferences.value.is_dark_mode ?? 'auto');
locale.value = user_preferences.value.display_language;
}
} }
return { return {
is_mobile_mode, is_mobile_mode,
focus_next_component, focus_next_component,
is_left_drawer_open, is_left_drawer_open,
toggleRightDrawer user_preferences,
toggleRightDrawer,
getUserPreferences,
updateUserPreferences,
}; };
}); });