feat(profile): add profile template, employee tabs and panels, add some logic to validate entries, i18n implementation

This commit is contained in:
Nicolas Drolet 2025-09-12 16:55:33 -04:00
parent f5ec3025ef
commit b9a549b9f9
14 changed files with 545 additions and 44 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

View File

@ -1,5 +1,5 @@
// app global css in SCSS form
@each $size in (5, 10, 15, 20, 25) {
@each $size in (5, 10, 15, 20, 25, 50, 75, 100) {
.rounded-#{$size} {
border-radius: #{$size}px !important;
}

View File

@ -114,25 +114,36 @@ export default {
close: 'Close',
},
profilePage: {
title: 'Profile',
firstName: 'First name',
lastName: 'Last name',
email: 'Email',
phoneNumber: 'Phone number',
job_title: 'Job title',
company: 'Company',
supervisor: 'Supervisor',
role: 'Role',
address: 'Address',
job_titleValidation: 'Job title must be filled in.',
companyValidation: 'Company must be filled in.',
supervisorValidation: 'Supervisor must be filled in.',
roleValidation: 'Role must be filled in.',
addressValidation: 'Address must be filled in.',
firstNameValidation: 'First Name must be filled in.',
lastNameValidation: 'Last Name must be filled in.',
phoneNumberValidation: 'Phone number must be filled in.',
submit: 'Update Profile',
personalInfo: {
title: 'Profile',
firstName: 'First name',
lastName: 'Last name',
gender: 'Gender',
genderMale: 'Man',
genderFemale: 'Woman',
genderNonBinary: 'Non-binary',
genderUnspecified: 'Unspecified',
phoneNumber: 'Phone number',
jobTitle: 'Job title',
company: 'Company',
supervisor: 'Supervisor',
role: 'Role',
address: 'Address',
addressPlaceholder: '# address, city, region, country',
birthDate: 'Date of birth',
submitInfo: 'Update Profile',
},
employeeInfo: {
title: 'Employee info',
workEmail: 'Work e-mail',
jobTitle: 'Job Title',
companyName: 'Company',
supervisorName: 'Supervisor',
hiredDate: 'Hiring date',
},
errors: {
mustEnterBirthdate: 'You must enter a valid birthdate',
}
},
indexAdminPage: {
card_1: 'Administrators',

View File

@ -190,25 +190,36 @@ export default {
timeSheetValidations: 'Validation cartes de temps',
},
profilePage: {
title: 'Profil',
firstName: 'Prénom',
lastName: 'Nom de famille',
email: 'Email',
phoneNumber: 'Numéro de téléphone',
job_title: 'Titre du poste',
company: 'Entreprise',
supervisor: 'Superviseur',
role: 'Role',
address: 'Adresse',
job_titleValidation: 'Le champ "titre du poste" doit être rempli.',
companyValidation: 'Le champ "entreprise" doit être rempli.',
supervisorValidation: 'Un employé qui na pas le rôle de superviseur doit être attribué à un superviseur.',
roleValidation: 'Le champ "rôle" doit être rempli.',
addressValidation: 'Le champ "adresse" doit être rempli.',
firstNameValidation: 'Le champ "prénom" doit être rempli.',
lastNameValidation: 'Le champ "nom de famille" doit être rempli.',
phoneNumberValidation: 'Le champ "numéro de téléphone" doit être rempli.',
submit: 'Modifier Profil',
personalInfo: {
title: 'Info personnel',
firstName: 'Prénom',
lastName: 'Nom de famille',
gender: 'Genre',
genderMale: 'Homme',
genderFemale: 'Femme',
genderNonBinary: 'Non-binaire',
genderUnspecified: 'Non-spécifié',
phoneNumber: 'Numéro de téléphone',
jobTitle: 'Titre du poste',
company: 'Entreprise',
supervisor: 'Superviseur',
role: 'Role',
address: 'Adresse',
addressPlaceholder: '# addresse, ville, région, pays',
birthDate: 'Date de naissance',
submitInfo: 'Modifier Profil',
},
employeeInfo: {
title: 'Info Employé',
workEmail: 'Courriel employé',
jobTitle: 'Poste',
companyName: 'Compagnie',
supervisorName: 'Nom du superviseur',
hiredDate: 'Date embauché',
},
errors: {
mustEnterBirthdate: 'Vous devez entrer une date de naissance valide',
}
},
resetPage: {
title: 'Réinitialiser votre mot de passe',

View File

@ -0,0 +1,77 @@
<script setup lang="ts">
import { ref } from 'vue';
import { default_employee_job_info, type EmployeeJobInfo } from 'src/modules/profile/types/profile-employee-interface';
import ProfileInputField from 'src/modules/profile/components/shared/profile-input-field.vue';
import { deepEqual } from 'src/utils/deep-equal';
// import ProfileSelectField from 'src/modules/profile/components/shared/profile-select-field.vue';
const props = withDefaults( defineProps<{
employeeInfo?: EmployeeJobInfo;
}>(), {
employeeInfo: () => default_employee_job_info,
});
const initial_info = props.employeeInfo;
const job_info = ref<EmployeeJobInfo>(props.employeeInfo);
const is_editing = ref<boolean>(false);
const onSubmit = () => {
if (!deepEqual(job_info.value, initial_info)) {
// saving profile logic here
console.log('Changes saved!');
}
console.log('Nothing was changed...');
};
const onReset = () => {
job_info.value = initial_info;
is_editing.value = false;
}
</script>
<template>
<q-form
class="q-pa-md full-height"
@submit="onSubmit"
@reset="onReset"
>
<div :class="$q.screen.lt.md ? 'column' : 'row'">
<ProfileInputField
class="col"
:model-reference="job_info.company"
:is-editing="is_editing"
:label-string="$t('profilePage.employeeInfo.firstName')"
/>
<ProfileInputField
class="col"
:model-reference="job_info.email"
:is-editing="is_editing"
:label-string="$t('profilePage.employeeInfo.lastName')"
/>
</div>
<div class="absolute-bottom" :class="$q.screen.lt.md ? 'column' : 'row'">
<q-space />
<q-btn
v-if="is_editing"
push
size="sm"
color="negative"
type="reset"
icon="cancel"
class="q-ma-sm"
:label="$t('timesheet.cancel_button')"
/>
<q-btn
push
size="sm"
color="primary"
:icon="is_editing ? 'save_alt' : 'create'"
class="q-ma-sm"
:label="is_editing ? $t('timesheet.saveButton') : $t('shiftsTemplate.updateButton')"
@click="is_editing = !is_editing"
/>
</div>
</q-form>
</template>

View File

@ -0,0 +1,147 @@
<script setup lang="ts">
import { ref } from 'vue';
import { default_employee_personal_info, type EmployeePersonalInfo } from 'src/modules/profile/types/profile-employee-interface';
import ProfileInputField from 'src/modules/profile/components/shared/profile-input-field.vue';
import ProfileSelectField from 'src/modules/profile/components/shared/profile-select-field.vue';
import { deepEqual } from 'src/utils/deep-equal';
const props = withDefaults( defineProps<{
personalInfo?: EmployeePersonalInfo;
}>(), {
personalInfo: () => default_employee_personal_info,
});
const initial_info = { ...props.personalInfo };
const personal_info = ref<EmployeePersonalInfo>(props.personalInfo);
const is_editing = ref<boolean>(false);
const gender = props.personalInfo.gender === '' ?
'profilePage.personalInfo.genderUnspecified' :
personal_info.value.gender;
const gender_options = [
'profilePage.personalInfo.genderMale',
'profilePage.personalInfo.genderFemale',
'profilePage.personalInfo.genderUnspecified',
// 'profilePage.personalInfo.genderNonBinary',
];
const changeFieldValue = (value: string | number | null, property: keyof EmployeePersonalInfo) => {
if ( typeof value === 'string' ) personal_info.value[property] = value;
else if ( typeof value === 'number' ) personal_info.value[property] = value.toString();
return;
}
const onSubmit = () => {
if (!is_editing.value) {
is_editing.value = true;
return;
}
is_editing.value = false;
if (!deepEqual(personal_info.value, initial_info)) {
// saving profile logic here
console.log('Changes saved! ', initial_info, personal_info.value);
return;
}
console.log('Nothing was changed...', initial_info, personal_info.value);
};
const onReset = () => {
personal_info.value = initial_info;
is_editing.value = false;
}
</script>
<template>
<q-form
class="q-pa-md full-height"
@submit="onSubmit"
@reset="onReset"
>
<div :class="$q.screen.lt.md ? 'column' : 'row'">
<ProfileInputField
class="col"
type="text"
:model-reference="personal_info.first_name"
:is-editing="is_editing"
:label-string="$t('profilePage.personalInfo.firstName')"
@is-value-changed="value => changeFieldValue( value, 'first_name')"
/>
<ProfileInputField
class="col"
type="text"
:model-reference="personal_info.last_name"
:is-editing="is_editing"
:label-string="$t('profilePage.personalInfo.lastName')"
@is-value-changed="value => changeFieldValue( value, 'last_name' )"
/>
</div>
<div :class="$q.screen.lt.md ? 'column' : 'row'">
<ProfileInputField
class="col"
type="text"
:model-reference="personal_info.phone_number"
:is-editing="is_editing"
:label-string="$t('profilePage.personalInfo.phoneNumber')"
@is-value-changed="value => changeFieldValue( value, 'phone_number' )"
/>
<ProfileSelectField
class="col"
:model-reference="gender"
:options="gender_options"
:is-editing="is_editing"
:label-string="$t('profilePage.personalInfo.gender')"
@is-value-changed="value => changeFieldValue( value, 'gender' )"
/>
</div>
<div :class="$q.screen.lt.md ? 'column' : 'row'">
<ProfileInputField
class="col"
:model-reference="personal_info.address"
:is-editing="is_editing"
:label-string="$t('profilePage.personalInfo.address')"
:hint="$t('profilePage.personalInfo.addressPlaceholder')"
@is-value-changed="value => changeFieldValue( value, 'address' )"
/>
</div>
<div :class="$q.screen.lt.md ? 'column' : 'row'">
<ProfileInputField
class="col"
mask="#### / ## / ##"
hint="ex: 1970 / 01 / 01"
:rules="[ val => val.length === 14 || $t('profilePage.errors.mustEnterBirthdate') ]"
:model-reference="personal_info.birth_date"
:is-editing="is_editing"
:label-string="$t('profilePage.personalInfo.birthDate')"
@is-value-changed="value => changeFieldValue( value, 'birth_date' )"
/>
</div>
<div class="absolute-bottom" :class="$q.screen.lt.md ? 'column' : 'row'">
<q-space />
<q-btn
v-if="is_editing"
push
size="sm"
color="negative"
type="reset"
icon="cancel"
class="q-ma-sm"
:label="$t('timesheet.cancel_button')"
/>
<q-btn
push
size="sm"
color="primary"
type="submit"
:icon="is_editing ? 'save_alt' : 'create'"
class="q-ma-sm"
:label="is_editing ? $t('timesheet.saveButton') : $t('shiftsTemplate.updateButton')"
/>
</div>
</q-form>
</template>

View File

@ -0,0 +1,46 @@
<script setup lang="ts">
import type { ValidationRule } from 'quasar';
import { ref } from 'vue';
const props = withDefaults( defineProps<{
modelReference: string;
labelString: string;
isEditing: boolean;
readonly?: boolean;
type?: "number" | "textarea" | "time" | "text" | "tel" | "password" | "email" | "search" | "file" | "url" | "date" | "datetime-local" | undefined;
hint?: string;
mask?: string;
rules?: ValidationRule[];
}>(), {
readonly: false,
});
const emit = defineEmits<{
isValueChanged: [value: string | number | null];
}>();
const input_ref = ref<string>(props.modelReference);
</script>
<template>
<q-input
v-model="input_ref"
dense
stack-label
autogrow
hide-bottom-space
debounce="500"
label-color="primary"
class="q-ma-xs"
input-class="text-weight-bolder text-h6 text-grey-8"
:hint="props.isEditing ? props.hint : ''"
:mask="props.mask"
:readonly="props.readonly || !props.isEditing"
:outlined="props.isEditing"
:borderless="!props.isEditing"
:type="props.type"
:label="props.labelString"
:rules="props.rules"
@update:model-value="value => emit('isValueChanged', value)"
/>
</template>

View File

@ -0,0 +1,40 @@
<script setup lang="ts">
import { ref } from 'vue';
const props = withDefaults( defineProps<{
modelReference: string;
options: string[];
readonly?: boolean;
labelString: string;
isEditing: boolean;
type?: "number" | "textarea" | "time" | "text" | "tel" | "password" | "email" | "search" | "file" | "url" | "date" | "datetime-local" | undefined;
}>(), {
readonly: false,
});
const emit = defineEmits<{
isValueChanged: [value: string];
}>();
const select_ref = ref(props.modelReference);
</script>
<template>
<q-select
v-model="select_ref"
dense
options-dense
stack-label
color="primary"
label-color="primary"
class="q-ma-xs"
:options="props.options"
:outlined="props.isEditing"
:borderless="!props.isEditing"
:readonly="props.readonly || !props.isEditing"
:hide-dropdown-icon="!props.isEditing"
:label="props.labelString"
:option-label="opt => $t(opt)"
@update:model-value="value => emit('isValueChanged', value.toString())"
/>
</template>

View File

@ -0,0 +1,68 @@
<script setup lang="ts">
import { type Component, ref } from 'vue';
interface MenuTab {
name: string;
icon: string;
label: string;
};
const props = defineProps<{
menuTabs: MenuTab[];
tabContents: Component[];
currentMenu?: string | undefined;
}>();
const current_menu = ref<string>(props.currentMenu || '');
</script>
<template>
<div class="row" :class="$q.screen.lt.md ? 'full-width' : ''">
<div class="col-auto column">
<q-card class="col-auto q-mr-sm q-pa-xs">
<q-tabs
v-model="current_menu"
vertical
dense
active-color="primary"
indicator-color="primary"
content-class=""
inline-label
align="left"
>
<q-tab
v-for="tab, index in props.menuTabs"
:key="index"
:name="tab.name"
:icon="tab.icon"
:label="$q.screen.lt.md ? '' : tab.label"
content-class="items-start"
/>
</q-tabs>
</q-card>
<div class="col"></div>
</div>
<q-card class="col q-ml-sm">
<q-tab-panels
v-model="current_menu"
animated
vertical
transition-prev="jump-up"
transition-next="jump-up"
class="rounded-5"
:style="$q.screen.lt.md ? 'height: 70vh;' : 'height: 50vh; width: 40vw;'"
>
<q-tab-panel
v-for="content, index in props.tabContents"
:key="index"
:name="props.menuTabs[index]?.name"
class="q-pa-none"
>
<component :is="content" />
</q-tab-panel>
</q-tab-panels>
</q-card>
</div>
</template>

View File

@ -0,0 +1,32 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n';
import PanelInfoPersonal from 'src/modules/profile/components/employee/panel-info-personal.vue';
import ProfileTabMenuTemplate from 'src/modules/profile/components/shared/profile-tab-menu-template.vue';
const { t } = useI18n();
const tabs = [
{
name: 'personal_info',
icon: 'badge',
label: t('profilePage.personalInfo.title'),
},
{
name: 'employee_info',
icon: 'business',
label: t('profilePage.employeeInfo.title'),
},
];
const components = [ PanelInfoPersonal, ];
</script>
<template>
<q-card flat class="rounded-5 bg-transparent q-pa-none">
<ProfileTabMenuTemplate
:menu-tabs="tabs"
:tab-contents="components"
:current-menu="tabs[0]?.name"
/>
</q-card>
</template>

View File

@ -0,0 +1,31 @@
<script setup lang="ts">
import { useAuthStore } from 'src/stores/auth-store';
import { type Component, h } from 'vue';
import ProfileEmployee from 'src/modules/profile/pages/employee/profile-employee.vue';
const auth_store = useAuthStore();
const getUserProfileComponent = (): Component => {
console.log('user role: ', auth_store.user.role);
switch (auth_store.user.role.toUpperCase()) {
case 'SUPERVISOR': return ProfileEmployee;
default: return h('div', { class: 'empty' }, '');
}
}
</script>
<template>
<q-page class="bg-secondary column items-center justify-center">
<q-img
src="src/assets/profile_header_default.png"
height="15vh"
width="50vw"
class="rounded-5 q-mb-md shadow-5"
fit="cover"
>
<div class="absolute-bottom text-h5 text-uppercase text-weight-bolder" style="line-height: 0.8em;">{{ auth_store.user.firstName }} {{ auth_store.user.lastName }}</div>
</q-img>
<component :is="getUserProfileComponent()" class="col-auto"/>
</q-page>
</template>

View File

@ -0,0 +1,33 @@
export interface EmployeePersonalInfo {
first_name: string;
last_name: string;
gender: string;
phone_number: string;
address: string;
birth_date: string;
}
export interface EmployeeJobInfo {
email: string;
job_title: string;
company: string;
supervisor: string;
hired_date: string;
}
export const default_employee_personal_info: EmployeePersonalInfo = {
first_name: '',
last_name: '',
gender: '',
phone_number: '',
address: '',
birth_date: '1970 / 01 / 01'
}
export const default_employee_job_info: EmployeeJobInfo = {
email: '',
job_title: '',
company: '',
supervisor: '',
hired_date: '',
}

View File

@ -5,7 +5,7 @@
import type { QDateDetails } from 'src/modules/shared/types/q-date-details';
const is_showing_calendar_picker = ref(false);
const calendar_date = ref(date.formatDate( Date.now(), 'YYYY/MM/DD' ));
const calendar_date = ref(date.formatDate( Date.now(), 'YYYY-MM-DD' ));
const props = defineProps<{
isDisabled: boolean,
@ -60,7 +60,7 @@
class="q-mt-xl"
today-btn
mask="YYYY-MM-DD"
:options="date => date > '2023-12-16'"
:options="date => date > '2023/12/16'"
@update:model-value="onDateSelected"
/>
</q-dialog>

View File

@ -25,8 +25,13 @@ const routes: RouteRecordRaw[] = [
{
path: 'timesheet-temp',
name: RouteNames.TIMESHEET_TEMP,
component: () => import('src/modules/timesheets/pages/timesheet-temp-page.vue')
}
component: () => import('src/modules/timesheets/pages/timesheet-temp-page.vue'),
},
{
path: 'user/profile',
name: RouteNames.PROFILE,
component: () => import('src/modules/profile/pages/profile-container.vue'),
},
],
},