Merge branch 'dev/nicolas/employee-management' of git.targo.ca:Targo/targo_frontend into dev/matthieu/csv

This commit is contained in:
Matthieu Haineault 2025-12-08 09:08:03 -05:00
commit 36612b5f4a
34 changed files with 740 additions and 726 deletions

View File

@ -41,6 +41,11 @@ export default defineConfigWithVueTs(
'error',
{ prefer: 'type-imports' }
],
"no-unused-vars": "off",
'@typescript-eslint/no-unused-vars': [
'warn',
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_' }
],
}
},
// https://github.com/vuejs/eslint-config-typescript
@ -63,15 +68,15 @@ export default defineConfigWithVueTs(
}
},
files: ['**/*.ts', '**/*.vue'],
// add your custom rules here
rules: {
'prefer-promise-reject-errors': 'off',
// warn about unused but underscored variables
'no-unused-vars': [
'warn',
{ argsIgnorePattern: '^_' }
],
'no-unused-vars': 'off',
// allow debugger during development only
'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off'

BIN
src/assets/en-CA.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

BIN
src/assets/fr-FR.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 880 B

View File

@ -9,10 +9,17 @@ export default {
role: "Role",
supervisor: "Supervisor",
company: "Company",
is_supervisor: "is a supervisor",
active: "active",
inactive: "inactive",
},
},
employee_management: {
add_employee: "Add employee",
modify_employee: "Modify employee",
access_label: "access",
details_label: "details",
module_access: {
dashboard: "Dashboard",
employee_list: "employee list",
@ -31,10 +38,10 @@ export default {
none_description: "Uncheck all modules",
usage_description: "You can use roles to enable preset modules, add or remove modules individually, or both",
},
add_employee: "Add employee",
modify_employee: "Modify employee",
access_label: "access",
details_label: "details",
filter: {
hide_terminated: "Hide inactive employees",
sort_by_tags: "sort by tags",
},
},
login: {
@ -83,6 +90,7 @@ export default {
company: "company",
supervisor: "supervisor",
hired_date: "hiring date",
fired_date: "departure date",
bankroll_id: "payroll ID",
},
preferences: {
@ -93,6 +101,7 @@ export default {
'en-CA': "English",
dark_mode: "dark",
light_mode: "light",
auto_mode: "auto",
update_successful: "Preferences saved",
update_failed: "Failed to save preferences",
},

View File

@ -9,10 +9,17 @@ export default {
role: "rôle",
supervisor: "superviseur",
company: "Compagnie",
is_supervisor: "est un superviseur",
active: "actif",
inactive: "inactif",
},
},
employee_management: {
add_employee: "Ajouter employé",
modify_employee: "Modifier employé",
access_label: "accès",
details_label: "détails",
module_access: {
dashboard: "Accueil",
employee_list: "Répertoire du personnel",
@ -31,10 +38,10 @@ export default {
none_description: "Enlever tous les accès",
usage_description: "Vous pouvez utiliser les rôles pour sélectionner des modules prédéfinis, enlever ou ajouter des modules individuellement, ou les deux",
},
add_employee: "Ajouter employé",
modify_employee: "Modifier employé",
access_label: "accès",
details_label: "détails",
filter: {
hide_terminated: "Cacher les employés inactifs",
sort_by_tags: "filtrer par identifiants",
},
},
login: {
@ -83,6 +90,7 @@ export default {
company: "compagnie",
supervisor: "nom du superviseur",
hired_date: "date d'embauche",
fired_date: "date de départ",
bankroll_id: "identifiant de paie",
},
preferences: {
@ -93,6 +101,7 @@ export default {
'en-CA': "Anglais",
dark_mode: "sombre",
light_mode: "clair",
auto_mode: "automatique",
update_successful: "Préférences enregistrées",
update_failed: "Échec de sauvegarde",
},

View File

@ -21,19 +21,21 @@
<template>
<transition
appear
enter-active-class="animated fadeInUp slow"
enter-active-class="animated fadeInUp fast"
leave-active-class="animated fadeOutDown fast"
mode="out-in"
>
<q-card
v-ripple
class="column col-xs-6 col-sm-4 col-md-3 col-lg-2 no-wrap rounded-15 cursor-pointer q-ma-sm"
style="max-width: 230px;"
:style="`animation-delay: ${index / 25}s;`"
:style="(`animation-delay: ${index / 25}s; `) + (row.last_work_day === null ? '' : 'opacity: 0.6;')"
@click="emit('onProfileClick', row.email)"
>
<q-card-section class="col-6 text-center">
<q-avatar
:color="row.last_work_day === undefined ? 'accent' : 'negative'"
:color="row.last_work_day === null ? 'accent' : 'negative'"
size="8em"
class="shadow-3 q-mb-md"
>
@ -51,12 +53,12 @@
>
<div
class="ellipsis"
:class="row.last_work_day === undefined ? 'text-accent' : 'text-negative'"
:class="row.last_work_day === null ? 'text-accent' : 'text-negative'"
>
{{ row.first_name }} {{ row.last_name }}
</div>
<q-separator
color="accent"
:color="row.last_work_day === null ? 'accent' : 'negative'"
class="q-mx-sm q-mt-xs"
/>
<div class=" ellipsis-2-lines text-caption">{{ row.job_title }}</div>

View File

@ -5,38 +5,72 @@
import EmployeeListTableItem from 'src/modules/employee-list/components/employee-list-table-item.vue';
import { onMounted, ref } from 'vue';
import { date, type QTableColumn } from 'quasar';
import { useUiStore } from 'src/stores/ui-store';
import { useEmployeeStore } from 'src/stores/employee-store';
import { useTimesheetStore } from 'src/stores/timesheet-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';
import { employee_list_columns, type EmployeeProfile, type EmployeeListFilters } from 'src/modules/employee-list/models/employee-profile.models';
const employee_list_api = useEmployeeListApi();
const employee_store = useEmployeeStore();
const timesheet_store = useTimesheetStore();
const ui_store = useUiStore();
const is_loading_list = ref<boolean>(true);
const visible_columns = ref<(keyof EmployeeProfile)[]>(['first_name', 'email', 'company_name', 'supervisor_full_name', 'company_name', 'job_title', 'last_work_day']);
const filter = ref("");
const filters = ref<EmployeeListFilters>({
search_bar_string: '',
hide_inactive_users: true,
});
const filterEmployeeRows = (rows: readonly EmployeeProfile[], terms: EmployeeListFilters, _cols: readonly QTableColumn<EmployeeProfile>[]): EmployeeProfile[] => {
let result = [...rows];
if (terms.hide_inactive_users) {
const now = new Date();
result = result.filter(row => {
if (!row.last_work_day) return true;
const inactiveDate = date.extractDate(row.last_work_day, 'YYYY-MM-DD');
const limit = new Date(inactiveDate);
limit.setDate(limit.getDate() + 14);
return limit >= now;
});
}
if (terms.search_bar_string.trim().length > 0) {
const searchTerms = terms.search_bar_string.split(' ').map(s => s.trim().toLowerCase());
result = result.filter(row => {
const rowValues = Object.values(row).map(v => String(v ?? '').toLowerCase());
return searchTerms.every(term =>
rowValues.some(value => value.includes(term))
);
});
}
return result;
};
onMounted(async () => {
is_loading_list.value = true;
await employee_list_api.getEmployeeList();
is_loading_list.value = false;
})
</script>
<template>
<div class="q-pa-lg">
<q-table
:key="filters.hide_inactive_users ? '1' : '0'"
dense
hide-pagination
virtual-scroll
title=" "
card-style="max-height: 70vh;"
:rows="employee_store.employee_list"
:columns="employee_list_columns"
row-key="name"
row-key="email"
:rows-per-page-options="[0]"
:filter="filter"
:pagination="{ sortBy: 'last_work_day', descending: true, }"
:filter="filters"
:filter-method="filterEmployeeRows"
class="bg-transparent no-shadow sticky-header-table"
:style="$q.screen.lt.md ? '' : 'width: 80vw;'"
: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'"
@ -44,38 +78,13 @@
table-header-class="text-accent text-uppercase"
card-container-class="justify-center"
:grid="ui_store.user_preferences.is_employee_list_grid"
:loading="is_loading_list"
:loading="employee_store.is_loading"
:no-data-label="$t('shared.error.no_data_found')"
:no-results-label="$t('shared.error.no_search_results')"
:loading-label="$t('shared.label.loading')"
@row-click="() => console.log('click!')"
:visible-columns="visible_columns"
>
<template #header="props">
<q-tr
:props="props"
class="bg-accent"
>
<q-th
v-for="col in props.cols"
:key="col.name"
:props="props"
>
<span class="text-uppercase text-weight-bolder text-white">
{{ $t(col.label) }}
</span>
</q-th>
</q-tr>
</template>
<template v-slot:item="props">
<EmployeeListTableItem
:row="props.row"
:index="props.rowIndex"
@on-profile-click="employee_store.openAddModifyDialog"
/>
</template>
<template v-slot:top>
<template #top>
<div class="row full-width q-mb-sm">
<q-btn
push
@ -102,13 +111,14 @@
]"
/>
<q-input
v-model="filter"
v-model="filters.search_bar_string"
outlined
dense
rounded
color="accent"
bg-color="white"
label-color="accent"
debounce="300"
:label="$t('shared.label.search')"
>
<template v-slot:append>
@ -119,20 +129,87 @@
</template>
</q-input>
</div>
<div class="row">
<q-space />
<q-checkbox
v-model="filters.hide_inactive_users"
color="accent"
:label="$t('employee_management.filter.hide_terminated')"
/>
</div>
</template>
<template #header="props">
<q-tr :props="props">
<q-th
v-for="col in props.cols"
:key="col.name"
:props="props"
>
<span class="text-uppercase text-weight-bolder text-white" style="font-size: 1.2em;">
{{ $t(col.label) }}
</span>
</q-th>
</q-tr>
</template>
<template #item="props">
<EmployeeListTableItem
:row="props.row"
:index="props.rowIndex"
@on-profile-click="employee_store.openAddModifyDialog"
/>
</template>
<template #body-cell="scope">
<q-td
:props="scope"
class="text-weight-medium"
@click="employee_store.openAddModifyDialog(scope.row.email)"
>
<span v-if="scope.col.name === 'company_name'"> {{ getCompanyName(scope.value) }}</span>
<span v-else>{{ scope.value }}</span>
<transition
appear
enter-active-class="animated fadeInUp slow"
leave-active-class="animated fadeOutDown"
mode="out-in"
>
<div
:key="scope.rowIndex + (timesheet_store.pay_period?.pay_period_no ?? 0)"
class="rounded-5 cursor-pointer"
style="font-size: 1.2em;"
:style="`animation-delay: ${scope.rowIndex / 30}s; ` + (scope.row.last_work_day === null ? '' : 'opacity: 0.5;')"
>
<div v-if="scope.col.name === 'first_name'">
<span
class="text-h5 text-uppercase q-mr-xs"
:class="scope.row.last_work_day === null ? 'text-accent' : 'text-negative'"
>{{ scope.value }}</span>
<span class="text-uppercase text-weight-light">{{ scope.row.last_name }}</span>
</div>
<div v-else-if="scope.col.name === 'last_work_day'">
<q-badge
:color="scope.row.last_work_day === null ? 'accent' : 'negative'"
class="row rounded-50 q-px-sm self-center"
>
<span class="text-bold text-uppercase q-mr-sm">
{{ scope.row.last_work_day === null ? $t('employee_list.table.active') :
$t('employee_list.table.inactive') }}
</span>
<q-icon
:name="scope.row.last_work_day === null ? 'check' : 'clear'"
size="xs"
/>
</q-badge>
</div>
<span v-else>{{ scope.value }}</span>
</div>
</transition>
</q-td>
</template>
<!-- Template for custome failed-to-load state -->
<template v-slot:no-data="{ message, filter }">
<template #no-data="{ message, filter }">
<div class="full-width column items-center text-accent q-gutter-sm">
<span class="text-h6 q-mt-xl">
{{ message }}
@ -147,21 +224,35 @@
</div>
</template>
<style lang="sass">
.sticky-header-table
thead tr:first-child th
background-color: var(--q-accent)
margin-top: none
<style scoped>
:deep(.q-table__card .q-table__sort-icon) {
fill: white !important;
color: white !important;
}
thead tr th
position: sticky
z-index: 1
thead tr:first-child th
top: 0px
:deep(.q-table--dense .q-table__sort-icon) {
font-size: 150%;
}
&.q-table--loading thead tr:last-child th
top: 48px
.sticky-header-table thead tr:first-child th {
background-color: var(--q-primary);
margin-top: none;
}
tbody
scroll-margin-top: 48px
thead tr th {
position: sticky;
z-index: 1;
}
thead tr:first-child th {
top: 0px;
}
&.q-table--loading thead tr:last-child th {
top: 48px;
}
tbody {
scroll-margin-top: 48px;
}
</style>

View File

@ -40,8 +40,8 @@
</script>
<template>
<div class="row full-width items-start content-start">
<div class="column col-3">
<div class="row full-width items-start content-start overflow-hidden-y">
<div class="column col-3 overflow-hidden-y">
<span class="text-uppercase text-weight-medium q-mx-sm">
{{ $t('employee_management.module_access.by_role') }}
</span>

View File

@ -2,17 +2,57 @@
setup
lang="ts"
>
import { ref, computed } from 'vue';
import { useEmployeeStore } from 'src/stores/employee-store';
const employee_store = useEmployeeStore();
const last_work_day = computed(() => employee_store.employee.last_work_day ?? '---');
const is_first_day_picker_open = ref(false);
const is_last_day_picker_open = ref(false);
const company_options = [
{ label: 'Targo', value: 'Targo' },
{ label: 'Solucom', value: 'Solucom' },
]
const supervisor_options = computed(() => {
const supervisors = employee_store.employee_list.filter(employee => employee.is_supervisor === true);
return supervisors.map(supervisor => supervisor.first_name + ' ' + supervisor.last_name);
})
const setLastWorkDay = (date: string | number | null) => {
if (typeof date === 'string' && date.length > 0) {
employee_store.employee.last_work_day = date;
}
employee_store.employee.last_work_day = null;
}
</script>
<template>
<div>
<q-form>
<div class="row flex-center">
<transition
enter-active-class="animated pulse fast"
mode="out-in"
>
<q-checkbox
v-model="employee_store.employee.is_supervisor"
:key="employee_store.employee.is_supervisor ? '1' : '0'"
dense
left-label
:label="$t('employee_list.table.is_supervisor')"
size="lg"
color="accent"
class="col-auto text-uppercase q-py-xs q-px-lg q-ma-xs rounded-25"
:class="employee_store.employee.is_supervisor ? 'bg-accent text-white text-weight-bold' : ''"
/>
</transition>
</div>
<div
class="q-ma-xs"
:class="$q.screen.lt.md ? 'column' : 'row'"
:class="$q.screen.lt.sm ? 'column' : 'row'"
>
<q-input
v-model="employee_store.employee.first_name"
@ -51,7 +91,7 @@
<div
class="q-ma-xs"
:class="$q.screen.lt.md ? 'column' : 'row'"
:class="$q.screen.lt.sm ? 'column' : 'row'"
>
<q-input
v-model="employee_store.employee.email"
@ -75,6 +115,7 @@
color="accent"
stack-label
label-slot
mask="(###) ### - ####"
class="col q-mx-md"
>
<template #label>
@ -90,7 +131,7 @@
<div
class="q-ma-xs"
:class="$q.screen.lt.md ? 'column' : 'row'"
:class="$q.screen.lt.sm ? 'column' : 'row'"
>
<q-input
v-model="employee_store.employee.job_title"
@ -109,10 +150,12 @@
</template>
</q-input>
<q-input
<q-select
v-model="employee_store.employee.company_name"
color="accent"
:options="company_options"
stack-label
emit-value
label-slot
class="col q-mx-md"
>
@ -124,18 +167,20 @@
{{ $t('profile.employee.company') }}
</span>
</template>
</q-input>
</q-select>
</div>
<div
class="q-ma-xs"
:class="$q.screen.lt.md ? 'column' : 'row'"
:class="$q.screen.lt.sm ? 'column' : 'row'"
>
<q-input
<q-select
v-model="employee_store.employee.supervisor_full_name"
color="accent"
stack-label
label-slot
:options="supervisor_options"
options-selected-class="text-white text-bold bg-accent"
class="col q-mx-md"
>
<template #label>
@ -146,10 +191,11 @@
{{ $t('profile.employee.supervisor') }}
</span>
</template>
</q-input>
</q-select>
<q-input
v-model="employee_store.employee.phone_number"
v-if="employee_store.management_mode === 'modify_employee'"
v-model="employee_store.employee.external_payroll_id"
color="accent"
stack-label
label-slot
@ -165,6 +211,94 @@
</template>
</q-input>
</div>
<div
class="q-ma-xs"
:class="$q.screen.lt.md ? 'column' : 'row'"
>
<q-input
v-model="employee_store.employee.first_work_day"
color="accent"
stack-label
label-slot
mask="####-##-##"
class="col q-mx-md"
>
<template #label>
<span
class="text-weight-bolder text-uppercase"
style="font-size: 0.85em;"
>
{{ $t('profile.employee.hired_date') }}
</span>
</template>
<template #append>
<q-btn
flat
dense
size="lg"
icon="calendar_month"
color="accent"
@click="is_first_day_picker_open = true"
>
<q-dialog
v-model="is_first_day_picker_open"
backdrop-filter="none"
>
<q-date
v-model="employee_store.employee.first_work_day"
mask="YYYY-MM-DD"
color="accent"
@update:model-value="is_first_day_picker_open = false"
/>
</q-dialog>
</q-btn>
</template>
</q-input>
<q-input
v-model="last_work_day"
color="accent"
stack-label
label-slot
mask="####-##-##"
class="col q-mx-md"
@update:model-value="setLastWorkDay"
>
<template #label>
<span
class="text-weight-bolder text-uppercase"
style="font-size: 0.85em;"
>
{{ $t('profile.employee.fired_date') }}
</span>
</template>
<template #append>
<q-btn
flat
dense
size="lg"
icon="calendar_month"
color="accent"
@click="is_last_day_picker_open = true"
>
<q-dialog
v-model="is_last_day_picker_open"
backdrop-filter="none"
>
<q-date
v-model="employee_store.employee.last_work_day"
mask="YYYY-MM-DD"
color="accent"
@update:model-value="is_last_day_picker_open = false"
/>
</q-dialog>
</q-btn>
</template>
</q-input>
</div>
</q-form>
</div>
</template>

View File

@ -5,13 +5,15 @@
import AddModifyDialogForm from 'src/modules/employee-list/components/employee/add-modify-dialog-form.vue';
import AddModifyDialogAccess from 'src/modules/employee-list/components/employee/add-modify-dialog-access.vue';
import { useEmployeeStore } from 'src/stores/employee-store';
import { ref } from 'vue';
import { useEmployeeStore } from 'src/stores/employee-store';
import { EmployeeProfile } from 'src/modules/employee-list/models/employee-profile.models';
const employee_store = useEmployeeStore();
const current_step = ref<'form' | 'access'>('form');
const transition_in_animation = ref('fadeInRight');
const transition_out_animation = ref('fadeOutLeft');
const initial_employee_profile = ref(new EmployeeProfile)
const getNextMenu = (animation_in: string, animation_out: string, next_step: 'form' | 'access') => {
transition_in_animation.value = animation_in;
@ -25,91 +27,98 @@
v-model="employee_store.is_add_modify_dialog_open"
full-width
@beforeShow="current_step = 'form'"
@show="Object.assign(initial_employee_profile, employee_store.employee)"
>
<q-card
class="bg-secondary rounded-10"
<div
class="column bg-secondary rounded-10 no-wrap"
:class="$q.dark.isActive ? 'shadow-24' : 'shadow-10'"
:style="($q.screen.lt.md ? ' ' : 'max-width: 70vw !important; height: 50vh') +
:style="($q.screen.lt.md ? ' ' : 'max-width: 60vw !important; height: 60vh') +
($q.dark.isActive ? 'border: 2px solid var(--q-accent)' : '')"
>
<q-card-section class="row text-weight-bolder text-white text-h5 bg-primary flex-center shadow-5 q-pa-none">
<div class="q-py-sm text-uppercase">
<div class="row col-auto text-white bg-primary flex-center shadow-5">
<div class="q-py-sm text-uppercase text-weight-bolder text-h5 ">
{{ $t('employee_management.' + employee_store.management_mode) }}
</div>
<div
v-if="employee_store.employee.first_name.length > 0"
class="text-uppercase text-weight-light text-h6 q-ml-sm"
>
{{ `${employee_store.employee.first_name} ${employee_store.employee.last_name}` }}
</div>
<q-slide-transition>
<div
v-if="current_step === 'access'"
class="col-12 row flex-center q-px-sm q-py-xs bg-accent"
class="col-12 row flex-center q-px-sm q-py-xs bg-accent no-wrap"
>
<q-icon
name="info_outline"
color="white"
size="sm"
class="self-end q-mr-sm"
class="col-auto q-mr-sm"
/>
<q-item-label
caption
class="text-white text-weight-medium"
class="col text-white"
>{{ $t('employee_management.module_access.usage_description') }}</q-item-label>
</div>
</q-slide-transition>
</q-card-section>
<q-card-section class="q-pt-sm">
<div class="full-height column">
<div class="col">
<transition
:enter-active-class="'animated ' + transition_in_animation"
:leave-active-class="'animated ' + transition_out_animation"
mode="out-in"
>
<div
v-if="current_step === 'form'"
class="rounded-5 q-pb-sm bg-dark shadow-10"
>
<AddModifyDialogForm />
</div>
<AddModifyDialogAccess v-else />
</transition>
</div>
</div>
<div class="row col-auto">
<q-btn
v-if="current_step === 'access'"
flat
size="lg"
color="accent"
icon="arrow_back"
:label="$t('employee_management.details_label')"
@click="getNextMenu('fadeInLeft', 'fadeOutRight', 'form')"
/>
<q-space />
<q-btn
<div class="col column q-pa-md no-wrap scroll">
<div class="col">
<transition
:enter-active-class="'animated ' + transition_in_animation"
:leave-active-class="'animated ' + transition_out_animation"
mode="out-in"
>
<div
v-if="current_step === 'form'"
flat
size="lg"
color="accent"
icon-right="arrow_forward"
:label="$t('employee_management.access_label')"
@click="getNextMenu('fadeInRight', 'fadeOutLeft', 'access')"
/>
</div>
</div>
</q-card-section>
class="rounded-5 q-pb-sm bg-dark shadow-10"
>
<AddModifyDialogForm />
</div>
<q-inner-loading :showing="employee_store.is_loading" />
</q-card>
<AddModifyDialogAccess v-else />
</transition>
</div>
</div>
<div class="row col-auto">
<q-btn
v-if="current_step === 'access'"
flat
size="lg"
color="accent"
icon="arrow_back"
:label="$t('employee_management.details_label')"
@click="getNextMenu('fadeInLeft', 'fadeOutRight', 'form')"
/>
<q-space />
<q-btn
v-if="current_step === 'form'"
flat
size="lg"
color="accent"
icon-right="arrow_forward"
:label="$t('employee_management.access_label')"
@click="getNextMenu('fadeInRight', 'fadeOutLeft', 'access')"
/>
</div>
<q-btn
square
color="accent"
:label="employee_store.management_mode === 'add_employee' ? $t('shared.label.save') : $t('shared.label.update')"
class="col-auto q-py-sm shadow-up-5"
@click="employee_store.createOrUpdateEmployee(employee_store.employee)"
/>
<!-- <q-inner-loading :showing="employee_store.is_loading" /> -->
</div>
</q-dialog>
</template>

View File

@ -1,76 +1,102 @@
import type { QSelectOption, QTableColumn } from "quasar";
export type ModuleAccessName = 'dashboard' | 'employee_list' | 'employee_management' | 'personal_profile' | 'timesheets' | 'timesheets_approval';
export type ModuleAccessPreset = 'admin' | 'employee' | 'none';
export type ModuleAccessPreset = 'admin' | 'supervisor' | 'employee' | 'none';
export type CompanyNames = 'Targo' | 'Solucom';
export class EmployeeProfile {
first_name: string;
last_name: string;
supervisor_full_name: string;
company_name: number;
company_name: CompanyNames;
job_title: string;
email: string;
phone_number: string;
first_work_day: string;
last_work_day: string;
last_work_day?: string | null;
external_payroll_id: number;
residence: string;
birth_date: string;
is_supervisor: boolean;
user_module_access: ModuleAccessName[];
constructor() {
this.first_name = '';
this.last_name = '';
this.supervisor_full_name = '';
this.company_name = 271583;
this.company_name = 'Targo';
this.job_title = '';
this.email = '';
this.phone_number = '';
this.first_work_day = '';
this.last_work_day = '';
this.last_work_day = null;
this.residence = '';
this.birth_date = '';
this.is_supervisor = false;
this.external_payroll_id = -1;
this.user_module_access = ['dashboard',];
}
}
export interface EmployeeListFilters {
search_bar_string: string;
hide_inactive_users: boolean;
};
export const employee_list_columns: QTableColumn<EmployeeProfile>[] = [
{
name: 'first_name',
label: 'employee_list.table.first_name',
label: 'timesheet_approvals.table.full_name',
field: 'first_name',
align: 'left'
align: 'left',
sortable: true,
},
{
name: 'last_name',
label: 'employee_list.table.last_name',
field: 'last_name',
align: 'left'
align: 'left',
sortable: true,
},
{
name: 'email',
label: 'employee_list.table.email',
field: 'email',
align: 'left'
align: 'left',
sortable: true,
},
{
name: 'supervisor_full_name',
label: 'employee_list.table.supervisor',
field: 'supervisor_full_name',
align: 'left'
align: 'left',
sortable: true,
},
{
name: 'company_name',
label: 'employee_list.table.company',
field: 'company_name',
align: 'left'
align: 'left',
sortable: true,
},
{
name: 'job_title',
label: 'employee_list.table.role',
field: 'job_title',
align: 'left'
align: 'left',
sortable: true,
},
{
name: 'last_work_day',
label: 'status',
field: 'last_work_day',
align: 'center',
sortable: true,
sort: (a: string | null, b: string | null) => {
if (a === null && b === null) return 0;
else if (a === null && b !== null) return 1;
else return -1;
},
},
];
@ -85,14 +111,7 @@ export const employee_access_options: QSelectOption<ModuleAccessName>[] = [
export const employee_access_presets: Record<ModuleAccessPreset, ModuleAccessName[]> = {
'admin' : ['dashboard', 'employee_list', 'employee_management', 'personal_profile', 'timesheets', 'timesheets_approval'],
'supervisor' : ['dashboard', 'employee_list', 'personal_profile', 'timesheets', 'timesheets_approval'],
'employee' : ['dashboard', 'timesheets', 'personal_profile', 'employee_list'],
'none' : [],
}
export const getCompanyName = (company_code: number) => {
switch (company_code) {
case 271583: return 'Targo';
case 271585: return 'Solucom';
default: return 'N / A';
}
}

View File

@ -1,19 +1,33 @@
import { api } from 'src/boot/axios';
import type { EmployeeProfile } from 'src/modules/employee-list/models/employee-profile.models';
import { EmployeeProfile } from 'src/modules/employee-list/models/employee-profile.models';
import type { BackendResponse } from 'src/modules/shared/models/backend-response.models';
export const EmployeeListService = {
getEmployeeList: async (): Promise<EmployeeProfile[]> => {
const response = await api.get<{success: boolean, data: EmployeeProfile[], error?: string }>('/employees/employee-list')
return response.data.data;
const response = await api.get<BackendResponse<EmployeeProfile[]>>('/employees/employee-list')
if (response.data.data) return response.data.data;
return [];
},
getEmployeeDetails: async (): Promise<EmployeeProfile> => {
const response = await api.get<{success: boolean, data: EmployeeProfile, error?: string}>('employees/profile');
return response.data.data;
const response = await api.get<BackendResponse<EmployeeProfile>>('employees/profile');
if (response.data.data) return response.data.data;
return new EmployeeProfile;
},
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;
}
const response = await api.get<BackendResponse<EmployeeProfile>>(`employees/profile?employee_email=${employee_email}`);
if (response.data.data) return response.data.data;
return new EmployeeProfile;
},
createNewEmployee: async (profile: Omit<EmployeeProfile, 'last_work_day' | 'birth_date' | 'external_payroll_id'>): Promise<boolean> => {
const response = await api.post<BackendResponse<EmployeeProfile>>('employees/create', profile);
return response.data.success;
},
updateEmployee: async (profile: EmployeeProfile): Promise<boolean> => {
const response = await api.patch<BackendResponse<EmployeeProfile>>('employees/update', profile);
return response.data.success;
},
};

View File

@ -5,7 +5,6 @@
import MenuPanelPersonal from 'src/modules/profile/components/employee/menu-panel-personal.vue';
import MenuPanelEmployee from 'src/modules/profile/components/employee/menu-panel-employee.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 MenuTemplate from 'src/modules/profile/components/shared/menu-template.vue';
import { EmployeeProfile } from 'src/modules/employee-list/models/employee-profile.models';
import { useAuthStore } from 'src/stores/auth-store';
@ -16,7 +15,6 @@
PERSONAL_INFO: 'personal_info',
EMPLOYEE_INFO: 'employee_info',
PREFERENCES: 'references',
SCHEDULE_PRESETS: 'schedule_presets',
};
const employee_profile = defineModel<EmployeeProfile>({ default: new EmployeeProfile });
@ -24,66 +22,57 @@
<template>
<q-card
flat
class="rounded-5 bg-transparent q-pa-none"
>
<MenuTemplate
:first-name="employee_profile.first_name === '' ? auth_store.user?.first_name ?? '' : employee_profile.first_name"
:last-name="employee_profile.last_name === '' ? auth_store.user?.last_name ?? '' : employee_profile.last_name"
:initial-menu="PanelNames.PERSONAL_INFO"
<div>
<q-card
flat
class="rounded-5 bg-transparent q-pa-none"
>
<template #tabs>
<q-tab
:name='PanelNames.PERSONAL_INFO'
icon='person_outline'
:label="$q.screen.lt.md ? '' : $t('profile.personal.tab_title')"
/>
<q-tab
:name="PanelNames.EMPLOYEE_INFO"
icon="work_outline"
:label="$q.screen.lt.md ? '' : $t('profile.employee.tab_title')"
/>
<q-tab
:name="PanelNames.PREFERENCES"
icon="display_settings"
:label="$q.screen.lt.md ? '' : $t('profile.preferences.tab_title')"
/>
<q-tab
:name="PanelNames.SCHEDULE_PRESETS"
icon="list_alt"
:label="$q.screen.lt.md ? '' : $t('profile.schedule_presets.tab_title')"
/>
</template>
<MenuTemplate
:first-name="employee_profile.first_name === '' ? auth_store.user?.first_name ?? '' : employee_profile.first_name"
:last-name="employee_profile.last_name === '' ? auth_store.user?.last_name ?? '' : employee_profile.last_name"
:initial-menu="PanelNames.PERSONAL_INFO"
>
<template #tabs>
<q-tab
:name='PanelNames.PERSONAL_INFO'
icon='person_outline'
:label="$q.screen.lt.md ? '' : $t('profile.personal.tab_title')"
/>
<q-tab
:name="PanelNames.EMPLOYEE_INFO"
icon="work_outline"
:label="$q.screen.lt.md ? '' : $t('profile.employee.tab_title')"
/>
<q-tab
:name="PanelNames.PREFERENCES"
icon="display_settings"
:label="$q.screen.lt.md ? '' : $t('profile.preferences.tab_title')"
/>
</template>
<template #panels>
<q-tab-panel
:name="PanelNames.PERSONAL_INFO"
class="q-pa-none"
>
<MenuPanelPersonal v-model="employee_profile" />
</q-tab-panel>
<template #panels>
<q-tab-panel
:name="PanelNames.PERSONAL_INFO"
class="q-pa-none"
>
<MenuPanelPersonal v-model="employee_profile" />
</q-tab-panel>
<q-tab-panel
:name="PanelNames.EMPLOYEE_INFO"
class="q-pa-none"
>
<MenuPanelEmployee v-model="employee_profile" />
</q-tab-panel>
<q-tab-panel
:name="PanelNames.EMPLOYEE_INFO"
class="q-pa-none"
>
<MenuPanelEmployee v-model="employee_profile" />
</q-tab-panel>
<q-tab-panel
:name="PanelNames.PREFERENCES"
class="q-pa-none"
>
<MenuPanelPreferences />
</q-tab-panel>
<q-tab-panel
:name="PanelNames.SCHEDULE_PRESETS"
class="q-pa-none"
>
<MenuPanelSchedulePresets />
</q-tab-panel>
</template>
</MenuTemplate>
</q-card>
<q-tab-panel
:name="PanelNames.PREFERENCES"
class="q-pa-none"
>
<MenuPanelPreferences />
</q-tab-panel>
</template>
</MenuTemplate>
</q-card>
</div>
</template>

View File

@ -3,102 +3,42 @@
lang="ts"
>
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 { 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 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 onSubmit = () => {
if (!is_editing.value) {
is_editing.value = true;
return;
}
is_editing.value = false;
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_store.employee, initial_info)) {
// save the new data here
return;
}
};
const onReset = () => {
employee_store.employee = unwrapAndClone(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'">
<div class="column q-pa-md full-height">
<MenuPanelInputField
v-model="employee_store.employee.job_title"
class="col"
:is-editing="is_editing"
class="col-auto"
:label-string="$t('profile.employee.job_title')"
/>
<MenuPanelSelectField
v-model="current_company_option"
:options="COMPANY_OPTIONS"
class="col"
:is-editing="is_editing"
<MenuPanelInputField
v-model="employee_store.employee.company_name"
class="col-auto"
:label-string="$t('profile.employee.company')"
/>
</div>
<div>
<MenuPanelSelectField
<MenuPanelInputField
v-model="employee_store.employee.supervisor_full_name"
:options="SUPERVISOR_OPTIONS"
class="col-auto"
:label-string="$t('profile.employee.supervisor')"
:is-editing="is_editing"
/>
</div>
<div :class="$q.screen.lt.md ? 'column' : 'row'">
<MenuPanelInputField
v-model="employee_store.employee.email"
class="col"
:is-editing="is_editing"
class="col-auto"
:label-string="$t('profile.employee.email')"
/>
<MenuPanelInputField
v-model="employee_store.employee.first_work_day"
readonly
class="col-auto"
type="date"
:is-editing="is_editing"
:label-string="$t('profile.employee.hired_date')"
/>
</div>
<div
<!-- DEV NOTE: May revisit later for changes by employee that will need approval -->
<!-- <div
v-if="CAN_APPROVE_PAY_PERIODS.includes(auth_store.user?.role ?? 'GUEST')"
class="absolute-bottom"
:class="$q.screen.lt.md ? 'column' : 'row'"
@ -121,6 +61,6 @@
class="q-ma-sm q-py-xs"
:label="is_editing ? $t('shared.label.save') : $t('shared.label.update')"
/>
</div>
</q-form>
</div> -->
</div>
</template>

View File

@ -4,90 +4,44 @@
>
import MenuPanelInputField from 'src/modules/profile/components/shared/menu-panel-input-field.vue';
import { ref } from 'vue';
import { deepEqual } from 'src/utils/deep-equal';
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_store = useEmployeeStore();
const is_editing = ref<boolean>(false);
const initial_info = ref<EmployeeProfile>(unwrapAndClone(employee_store.employee));
const onSubmit = () => {
if (!is_editing.value) {
is_editing.value = true;
initial_info.value = unwrapAndClone(employee_store.employee);
return;
}
is_editing.value = false;
initial_info.value = unwrapAndClone(employee_store.employee); // update initial value for future possible resets
if (!deepEqual(employee_store.employee, initial_info)) {
// save the new data here
return;
}
};
const onReset = () => {
employee_store.employee = unwrapAndClone(initial_info.value);
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'">
<MenuPanelInputField
v-model="employee_store.employee.first_name"
type="text"
class="col"
:is-editing="is_editing"
:label-string="$t('profile.personal.first_name')"
/>
<MenuPanelInputField
v-model="employee_store.employee.last_name"
class="col"
type="text"
:is-editing="is_editing"
:label-string="$t('profile.personal.last_name')"
/>
</div>
<div class="column q-pa-md full-height">
<MenuPanelInputField
v-model="employee_store.employee.first_name"
class="col-auto"
:label-string="$t('profile.personal.first_name')"
/>
<MenuPanelInputField
v-model="employee_store.employee.last_name"
class="col-auto"
:label-string="$t('profile.personal.last_name')"
/>
<MenuPanelInputField
v-model="employee_store.employee.phone_number"
class="col-auto"
:label-string="$t('profile.personal.phone_number')"
/>
<div :class="$q.screen.lt.md ? 'column' : 'row'">
<MenuPanelInputField
v-model="employee_store.employee.phone_number"
class="col"
type="text"
:is-editing="is_editing"
:label-string="$t('profile.personal.phone_number')"
/>
<MenuPanelInputField
v-model="employee_store.employee.birth_date"
class="col"
mask="#### / ## / ##"
hint="ex: 1970 / 01 / 01"
:is-editing="is_editing"
:label-string="$t('profile.personal.birthdate')"
/>
</div>
<MenuPanelInputField
v-model="employee_store.employee.birth_date"
class="col-auto"
:label-string="$t('profile.personal.birthdate')"
/>
<div :class="$q.screen.lt.md ? 'column' : 'row'">
<MenuPanelInputField
v-model="employee_store.employee.residence"
class="col"
:is-editing="is_editing"
:label-string="$t('profile.personal.address')"
:hint="$t('profile.personal.address_hint')"
/>
</div>
<MenuPanelInputField
v-model="employee_store.employee.residence"
class="col-auto"
:label-string="$t('profile.personal.address')"
/>
<div
<!-- DEV NOTE: May revisit later for changes by employee that will need approval -->
<!-- <div
class="absolute-bottom"
:class="$q.screen.lt.md ? 'column' : 'row'"
>
@ -109,6 +63,6 @@
class="q-ma-sm q-py-xs"
:label="is_editing ? $t('shared.label.save') : $t('shared.label.update')"
/>
</div>
</q-form>
</div> -->
</div>
</template>

View File

@ -12,8 +12,7 @@
<q-img
src="src/assets/profile_header_default.png"
height="15vh"
:width="$q.screen.lt.md ? '80vw' : '40vw'"
class="rounded-5 q-mb-md shadow-2 col-auto"
class="rounded-5 q-mb-md shadow-2"
fit="cover"
>
<div

View File

@ -2,43 +2,46 @@
setup
lang="ts"
>
import type { ValidationRule } from 'quasar';
const model = defineModel<string | number | undefined>({ required: true });
const { readonly = false, hint = '' } = defineProps<{
defineProps<{
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[];
}>();
</script>
<template>
<q-input
v-model="model"
readonly
dense
:stack-label="!isEditing"
stack-label
autogrow
:standout="$q.dark.isActive ? 'bg-blue-grey-3' : 'bg-blue-grey-9'"
debounce="500"
outlined
label-color="accent"
label-slot
class="q-ma-xs"
class="q-mx-xs q-my-sm"
input-class="text-weight-light"
input-style="font-size: 1.2em"
:hide-hint="hint === ''"
:hint="isEditing ? hint : ''"
:mask="mask"
:readonly="readonly || !isEditing"
:type="type"
:rules="rules"
input-style="font-size: 1.5em"
>
<template #label>
<span class="text-weight-bolder text-accent text-uppercase">{{ labelString }}</span>
</template>
</q-input>
</template>
<style scoped>
:deep(.q-field__label) {
background-color: var(--q-dark);
padding: 0 5px;
transform: translateY(-60%) scale(0.75) !important;
}
:deep(.q-field__control-container) {
padding-left: 16px;
}
:deep(.q-field__control::before) {
border: 1px solid var(--q-accent) !important;
background-color: transparent;
}
</style>

View File

@ -2,66 +2,72 @@
setup
lang="ts"
>
import { dark_mode_options } from 'src/modules/profile/models/preferences.models';
import LanguageSwitch from 'src/modules/shared/components/language-switch.vue';
import { ref } from 'vue';
import { Dark } from 'quasar';
import { useUiStore } from 'src/stores/ui-store';
const ui_store = useUiStore();
const initial_dark_mode_value = Dark.isActive;
const is_dark_mode = ref<boolean>(initial_dark_mode_value);
const toggle_dark_mode = (value: boolean) => {
if (ui_store.user_preferences) ui_store.user_preferences.is_dark_mode = value;
Dark.set(value);
}
</script>
<template>
<q-form class="q-pa-md column fit">
<q-card
flat
class="col-auto justify-center content-center q-mb-lg q-pa-none"
:style="$q.dark.isActive ? 'background-color: #FFF1;' : 'background-color: #0001;'"
<div class="q-pa-md column fit">
<div
class="col-auto"
style="transform: translate(10px, 12px);"
>
<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>
<span class="text-uppercase text-weight-bold text-accent bg-dark q-px-sm">
{{ $t('profile.preferences.display_options') }}
</span>
</div>
<q-card-section
horizontal
class="flex-center text-uppercase"
>
<span class="q-mx-md text-weight-medium">{{ $t('profile.preferences.light_mode') }}</span>
<q-toggle
v-model="is_dark_mode"
@update:model-value="value => toggle_dark_mode(value)"
size="xl"
class="col-auto"
checked-icon="dark_mode"
unchecked-icon="light_mode"
/>
<span class="q-mx-md text-weight-medium">{{ $t('profile.preferences.dark_mode') }}</span>
</q-card-section>
</q-card>
<q-card
flat
class="col-auto justify-center content-center q-mb-lg q-pa-none"
:style="$q.dark.isActive ? 'background-color: #FFF1;' : 'background-color: #0001;'"
<div
class="col-auto row justify-center content-center q-mb-sm q-pa-sm rounded-5"
style="border: 1px solid var(--q-accent);"
>
<q-card-section class="q-py-none">
<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"
<q-item
v-for="mode of dark_mode_options"
:key="mode.label"
clickable
dense
v-ripple
class="col rounded-5 q-ma-sm shadow-4"
:class="mode.quasar_value === $q.dark.mode ? 'bg-accent text-white text-weight-bolder' : ''"
@click="ui_store.user_preferences.is_dark_mode = mode.value"
>
<LanguageSwitch class="col-auto" />
</q-card-section>
</q-card>
</q-form>
<q-item-section avatar>
<q-icon
:name="mode.icon"
size="md"
:color="mode.quasar_value === $q.dark.mode ? 'white' : ''"
/>
</q-item-section>
<q-item-section class="text-uppercase justify-center">
<q-item-label> {{ $t(mode.label) }}</q-item-label>
</q-item-section>
<q-item-section side>
<q-icon
v-if="mode.quasar_value === $q.dark.mode"
name="check"
color="white"
/>
</q-item-section>
</q-item>
</div>
<div
class="col-auto row text-uppercase text-weight-bold text-accent"
style="transform: translate(10px, 12px);"
>
<div class="col-auto bg-dark q-px-sm">{{ $t('profile.preferences.language_options') }}</div>
</div>
<div
class="col-auto justify-center content-center q-pa-sm rounded-5"
style="border: 1px solid var(--q-accent);"
>
<LanguageSwitch class="col-auto" />
</div>
</div>
</template>

View File

@ -1,43 +0,0 @@
<script
setup
lang="ts"
>
const model = defineModel<string | number | {label: string, value: unknown} | undefined>();
const { readonly = false, localizeOptions = false } = defineProps<{
options: { label: string, value: string | number }[];
labelString: string;
isEditing: boolean;
readonly?: boolean;
localizeOptions?: boolean;
type?: "number" | "textarea" | "time" | "text" | "tel" | "password" | "email" | "search" | "file" | "url" | "date" | "datetime-local" | undefined;
}>();
</script>
<template>
<q-select
v-model="model"
dense
:stack-label="!isEditing"
:standout="$q.dark.isActive ? 'bg-blue-grey-3' : 'bg-blue-grey-9'"
label-color="accent"
class="q-ma-xs"
popup-content-class="text-weight-medium text-h6 rounded-5"
options-selected-class="bg-accent text-white"
:menu-offset="[0, 10]"
:options="options"
:readonly="readonly || !isEditing"
:hide-dropdown-icon="!isEditing"
:label="labelString"
:option-label="opt => localizeOptions ? $t(opt.label) : opt.label ?? opt"
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>

View File

@ -16,44 +16,46 @@
<template>
<div
:class="$q.screen.lt.md ? 'column no-wrap flex-center' : 'row'"
:style="$q.screen.lt.md ? 'width: 90vw;' : 'width: 40vw;'"
class="column flex-center"
>
<MenuHeader
:user-first-name="firstName"
:user-last-name="lastName"
class="col-auto"
/>
<q-card
class="col-auto q-pa-xs"
:class="$q.screen.lt.md ? 'q-mb-sm' : 'q-mr-sm'"
>
<q-tabs
v-model="current_menu"
:vertical="$q.screen.gt.sm"
active-color="accent"
indicator-color="accent"
<div class="row col full-width">
<div
class="col-auto q-pa-xs bg-dark rounded-5 shadow-2"
:class="$q.screen.lt.md ? 'q-mb-sm' : 'q-mr-sm'"
>
<slot name="tabs"></slot>
</q-tabs>
</q-card>
<q-tabs
v-model="current_menu"
:vertical="$q.screen.gt.sm"
active-color="accent"
indicator-color="accent"
>
<slot name="tabs"></slot>
</q-tabs>
</div>
<q-card
class="col"
:class="$q.screen.lt.md ? 'full-width' : 'q-ml-sm'"
>
<q-tab-panels
v-model="current_menu"
animated
vertical
transition-prev="jump-up"
transition-next="jump-up"
class="rounded-5"
style="height: 50vh;"
<q-card
class="col"
:class="$q.screen.lt.md ? 'full-width' : 'q-ml-sm'"
>
<slot name="panels"></slot>
</q-tab-panels>
</q-card>
<q-tab-panels
v-model="current_menu"
animated
vertical
transition-prev="jump-up"
transition-next="jump-up"
class="rounded-5"
style="height: 50vh;"
>
<slot name="panels"></slot>
</q-tab-panels>
</q-card>
</div>
</div>
</template>

View File

@ -1,5 +1,17 @@
import type { MessageLanguages } from "src/boot/i18n";
export interface DarkModeOption {
label: string;
value: boolean | null;
quasar_value: boolean | "auto";
icon: string;
}
export const dark_mode_options: DarkModeOption[] = [
{ label: 'profile.preferences.dark_mode', value: true, quasar_value: true, icon: 'dark_mode'},
{ label: 'profile.preferences.light_mode', value: false, quasar_value: false, icon: 'light_mode'},
{ label: 'profile.preferences.auto_mode', value: null, quasar_value: "auto", icon: 'brightness_auto'},
]
export class Preferences {
id: number;
notifications: boolean;

View File

@ -26,13 +26,18 @@
clickable
dense
v-ripple
class="col rounded-5 q-ma-sm shadow-1 "
class="col rounded-5 q-ma-sm shadow-4"
:class="locale === $i18n.locale ? 'bg-accent text-white text-weight-bolder' : ''"
@click="setDisplayLanguage(locale as MessageLanguages)"
>
<q-item-section avatar>
<q-img :src="`src/assets/${locale}.png`"/>
</q-item-section>
<q-item-section class="text-uppercase justify-center">
<q-item-label> {{ $t(`profile.preferences.${locale}`) }}</q-item-label>
</q-item-section>
<q-item-section side>
<q-icon
v-if="locale === $i18n.locale"

View File

@ -113,7 +113,7 @@
dense
:icon="props.value ? 'lock' : 'lock_open'"
:color="props.value ? 'white' : 'grey-5'"
class="rounded-5 z-top"
class="rounded-5 "
:class="props.value ? 'bg-accent' : ''"
@click.stop="props.row.is_approved = !props.row.is_approved"
/>

View File

@ -1,43 +0,0 @@
export interface shiftColor {
type_label: string;
background_color: string;
font_color: string;
}
export const shift_type_legend: shiftColor[] = [
{
type_label: 'shared.shift_type.regular',
background_color: 'blue-grey-4',
font_color: 'blue-grey-8',
},
{
type_label: 'shared.shift_type.evening',
background_color: 'warning',
font_color: 'blue-grey-2',
},
{
type_label: 'shared.shift_type.emergency',
background_color: 'amber-10',
font_color: 'blue-grey-2',
},
{
type_label: 'shared.shift_type.overtime',
background_color: 'negative',
font_color: 'blue-grey-2',
},
{
type_label: 'shared.shift_type.vacation',
background_color: 'purple-10',
font_color: 'blue-grey-2',
},
{
type_label: 'shared.shift_type.holiday',
background_color: 'purple-8',
font_color: 'blue-grey-2',
},
{
type_label: 'shared.shift_type.sick',
background_color: 'grey-8',
font_color: 'blue-grey-2',
},
]

View File

@ -1,11 +1,5 @@
import type { QTableColumn } from "quasar";
/* eslint-disable */
export enum NavigatorConstants {
NEXT_PERIOD = 1,
PREVIOUS_PERIOD = -1,
}
export interface TimesheetOverview {
email: string;
employee_name: string;

View File

@ -1,71 +0,0 @@
<script
setup
lang="ts"
>
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import type { ShiftLegendItem } from 'src/modules/timesheets/models/shift.models';
const { t } = useI18n();
const is_showing_legend = ref(false);
const legend: ShiftLegendItem[] = [
{ type: 'REGULAR', color: 'secondary', label_type: 'timesheet.shift.types.REGULAR' },
{ type: 'EVENING', color: 'warning', label_type: 'timesheet.shift.types.EVENING' },
{ type: 'EMERGENCY', color: 'amber-10', label_type: 'timesheet.shift.types.EMERGENCY' },
{ type: 'VACATION', color: 'purple-10', label_type: 'timesheet.shift.types.VACATION' },
{ type: 'HOLIDAY', color: 'purple-5', label_type: 'timesheet.shift.types.HOLIDAY' },
{ type: 'SICK', color: 'grey-8', label_type: 'timesheet.shift.types.SICK' },
]
const shift_type_legend = computed(() =>
legend.map(item => ({ ...item, label: t(item.label_type) }))
);
</script>
<template>
<div
class="items-center"
:class="$q.screen.lt.md ? 'column' : 'row'"
>
<q-btn
flat
dense
rounded
color="primary"
class="col-auto q-my-sm"
@click="is_showing_legend = !is_showing_legend"
>
<template #default>
<q-icon
:name="is_showing_legend ? 'close' : 'info_outline'"
size="md"
class="col-auto"
/>
</template>
</q-btn>
<transition
appear
enter-active-class="animated fadeIn"
leave-active-class="animated fadeOut"
class="col-auto"
>
<div
v-if="is_showing_legend"
class="q-py-xs bg-white rounded-5 shadow-2 text-center q-my-xs"
>
<q-badge
v-for="shift_type in shift_type_legend"
:key="shift_type.type"
:color="shift_type.color"
:label="shift_type.label"
:text-color="shift_type.text_color || 'white'"
class="q-pa-xs q-mx-xs q-my-none text-uppercase text-weight-bolder justify-center"
style="font-size: 0.8em;"
/>
</div>
</transition>
</div>
</template>

View File

@ -27,26 +27,8 @@
class="row items-center full-width bg-dark shadow-2 rounded-5 q-my-xs"
style="border: 2px solid var(--q-negative)"
>
<q-item-section class="col-auto">
<q-badge
outline
color="negative"
class="bg-dark text-weight-bolder"
>{{ error.conflicts.date }}</q-badge>
</q-item-section>
<q-item-section class="col-auto">
<q-badge
outline
color="negative"
class="bg-dark text-weight-bolder"
>
{{ error.conflicts.start_time }} - {{ error.conflicts.end_time }}
</q-badge>
</q-item-section>
<q-item-label class="text-weight-medium text-caption q-ml-md">
{{ $t('timesheet.shift.errors.' + error.error_code) }}
{{ $t('timesheet.shift.errors.' + error) }}
</q-item-label>
</q-item>
</q-list>

View File

@ -11,13 +11,6 @@ export type ShiftType = 'REGULAR' | 'EVENING' | 'EMERGENCY' | 'HOLIDAY' | 'VACAT
export type ShiftErrorCode = 'SHIFT_OVERLAP' | 'MISSING_START_TIME' | 'MISSING_END_TIME' | 'COMMENT_LENGTH_EXCEEDED' | 'APPROVAL_LOCK' | 'INVALID_DATE' | 'INVALID TYPE' | 'INVALID_TIMESHEET';
export type ShiftLegendItem = {
type: ShiftType;
color: string;
label_type: string;
text_color?: string;
};
export class Shift {
id: number;
timesheet_id: number;
@ -50,22 +43,3 @@ export interface ShiftOption {
icon: string;
icon_color: string;
}
export interface ShiftAPIResponse {
ok: boolean;
data?: {
shift: Shift;
overtime: unknown;
}
error?: ShiftAPIError;
}
export interface ShiftAPIError {
error_code: ShiftErrorCode;
conflicts:
{
date: string;
start_time: string;
end_time: string;
}
}

View File

@ -1,5 +1,6 @@
import { api } from "src/boot/axios";
import type { Shift, ShiftAPIResponse } from "src/modules/timesheets/models/shift.models";
import type { BackendResponse } from "src/modules/shared/models/backend-response.models";
import type { Shift } from "src/modules/timesheets/models/shift.models";
export const ShiftService = {
deleteShiftById: async (shift_id: number) => {
@ -7,14 +8,14 @@ export const ShiftService = {
return response.data;
},
createNewShifts: async (new_shifts: Shift[]):Promise<ShiftAPIResponse[]> => {
createNewShifts: async (new_shifts: Shift[]):Promise<BackendResponse<Shift>> => {
const response = await api.post(`/shift/create`, new_shifts);
return response.data;
},
updateShifts: async (existing_shifts: Shift[]) => {
updateShifts: async (existing_shifts: Shift[]):Promise<BackendResponse<Shift>> => {
console.log('sent shifts: ', existing_shifts)
const response = await api.patch(`/shift/update`, existing_shifts);
return response;
return response.data;
}
};

View File

@ -21,17 +21,10 @@ import { onMounted } from 'vue';
</script>
<template>
<q-page class="bg-secondary column items-center justify-center">
<q-page class="bg-secondary row items-center justify-center">
<MenuEmployee
v-if="employee_roles.includes(auth_store.user?.role.toUpperCase() ?? 'GUEST')"
class="col-sm-12 col-md-10 col-lg-9 col-xl-8"
class="col-sm-12 col-md-10 col-lg-7 col-xl-5"
/>
</q-page>
</template>
<style scoped>
:deep(.q-field--standout.q-field--readonly .q-field__control:before) {
border: none !important;
background-color: transparent;
}
</style>

View File

@ -1,5 +1,4 @@
export enum RouteNames {
/* eslint-disable */
LOGIN = 'login',
LOGIN_SUCCESS = 'login-success',
DASHBOARD = 'dashboard',

View File

@ -1,7 +1,9 @@
/* eslint-disable */
import { ref } from "vue";
import { defineStore } from "pinia";
import { EmployeeListService } from "src/modules/employee-list/services/employee-list-service";
import { EmployeeProfile } from "src/modules/employee-list/models/employee-profile.models";
import { Notify } from "quasar";
export const useEmployeeStore = defineStore('employee', () => {
const employee = ref<EmployeeProfile>(new EmployeeProfile);
@ -10,7 +12,7 @@ export const useEmployeeStore = defineStore('employee', () => {
const management_mode = ref<'modify_employee' | 'add_employee'>('add_employee');
const is_loading = ref(false);
const openAddModifyDialog = async (employee_email?: string) =>{
const openAddModifyDialog = async (employee_email?: string) => {
is_add_modify_dialog_open.value = true;
if (employee_email === undefined) {
@ -25,6 +27,12 @@ export const useEmployeeStore = defineStore('employee', () => {
is_loading.value = false;
}
const closeAddModifyDialog = () => {
is_add_modify_dialog_open.value = false;
management_mode.value = 'add_employee';
employee.value = new EmployeeProfile;
};
const getEmployeeList = async () => {
is_loading.value = true;
try {
@ -43,7 +51,7 @@ export const useEmployeeStore = defineStore('employee', () => {
if (email === undefined) {
const response = await EmployeeListService.getEmployeeDetails();
employee.value = response;
} else{
} else {
const response = await EmployeeListService.getEmployeeDetailsWithEmployeeEmail(email);
employee.value = response;
}
@ -55,6 +63,26 @@ export const useEmployeeStore = defineStore('employee', () => {
is_loading.value = false;
};
const createOrUpdateEmployee = async (profile: EmployeeProfile) => {
let success = false;
if (management_mode.value === 'add_employee') {
const { birth_date, external_payroll_id, last_work_day, ...create_payload} = profile;
success = await EmployeeListService.createNewEmployee(create_payload);
} else {
success = await EmployeeListService.updateEmployee(profile);
}
closeAddModifyDialog();
if (success) { await getEmployeeList(); }
else { Notify.create({
message: 'failed to update or create employee',
color: 'negative',
})}
};
return {
employee,
employee_list,
@ -64,6 +92,8 @@ export const useEmployeeStore = defineStore('employee', () => {
getEmployeeList,
getEmployeeDetails,
openAddModifyDialog,
closeAddModifyDialog,
createOrUpdateEmployee,
};
});

View File

@ -4,11 +4,10 @@ import { Notify } from "quasar";
import { defineStore } from "pinia";
import { ShiftService } from "src/modules/timesheets/services/shift-service";
import { useTimesheetStore } from "src/stores/timesheet-store";
import type { ShiftAPIError } from "src/modules/timesheets/models/shift.models";
export const useShiftStore = defineStore('shift_store', () => {
const timesheet_store = useTimesheetStore();
const shift_errors = ref<ShiftAPIError[]>([]);
const shift_errors = ref<string[]>([]);
const deleteShiftById = async (shift_id: number): Promise<boolean> => {
try {
@ -22,7 +21,6 @@ export const useShiftStore = defineStore('shift_store', () => {
const createNewShifts = async (): Promise<boolean> => {
if (timesheet_store.timesheets === undefined) return false;
const has_errors = false;
try {
const days = timesheet_store.timesheets.flatMap(week => week.days);
@ -30,14 +28,10 @@ export const useShiftStore = defineStore('shift_store', () => {
if (new_shifts?.length > 0) {
const response = await ShiftService.createNewShifts(new_shifts);
if (response.every(res => res.ok)) {
if (response.success) {
return true;
}
else {
response.forEach(res => {
shift_errors.value.push(res.error!);
});
}
else { shift_errors.value.push(response.error!) }
}
return false;
} catch (error) {
@ -55,7 +49,7 @@ export const useShiftStore = defineStore('shift_store', () => {
if (existing_shifts?.length > 0) {
const response = await ShiftService.updateShifts(existing_shifts);
if (response.status < 400) {
if (response.success) {
return true;
}
}

View File

@ -64,9 +64,11 @@ export const useUiStore = defineStore('ui', () => {
const setPreferences = () => {
if (user_preferences.value !== undefined) {
Dark.set(user_preferences.value.is_dark_mode ?? 'auto');
// if user_preferences.value.is_dark_mode === null
Dark.set(user_preferences.value.is_dark_mode ?? "auto");
locale.value = user_preferences.value.display_language;
}
console.log('quasar dark mode: ', q.dark.mode, 'preferences: ', user_preferences.value.is_dark_mode);
}
return {