fix(timesheet): more refactors and fixes to timesheet, mostly error handling, mobile UI/UX adjustments

This commit is contained in:
Nicolas Drolet 2025-12-19 15:36:15 -05:00
parent 4231b51c11
commit b28f8768d2
21 changed files with 587 additions and 577 deletions

View File

@ -3,6 +3,14 @@
.rounded-#{$size} {
border-radius: #{$size}px !important;
}
.rounded-#{$size} > div:first-child {
border-radius: #{$size}px #{$size}px 0 0 !important;
}
.rounded-#{$size} > div:last-child {
border-radius: 0 0 #{$size}px #{$size}px !important;
}
}
.text-fb-blue {

View File

@ -53,6 +53,12 @@ export default {
},
},
error :{
not_found_header: "page not found",
not_found_description: "You may have entered the wrong URL, or you may not have access to this page",
go_back: "go back",
},
login: {
page_header: "account login",
email: "e-mail",
@ -244,7 +250,7 @@ export default {
SHIFT_OVERLAP: "An overlaps occured between 2 or more shifts",
SHIFT_OVERLAP_SHORT: "Overlap",
INVALID_SHIFT: "A shift contains missing or corrupted data",
SHIFT_TIME_REQUIRED: "Time required",
SHIFT_TIME_REQUIRED: "Time missing",
SHIFT_TYPE_REQUIRED: "Shift type required",
SHIFT_NOT_FOUND: "Shift missing or deleted",
PAY_PERIOD_NOT_FOUND: "No pay period matching given dates",

View File

@ -53,6 +53,12 @@ export default {
},
},
error :{
not_found_header: "page introuvable",
not_found_description: "Vous avez possiblement entré une mauvaise addresse URL, ou vous n'avez pas accès à cette section du site",
go_back: "retour en arrière",
},
login: {
page_header: "connexion au compte",
email: "courriel",
@ -245,7 +251,7 @@ export default {
SHIFT_OVERLAP: "Il y a un chevauchement entre deux ou plusieurs quarts",
SHIFT_OVERLAP_SHORT: "Chevauchement",
INVALID_SHIFT: "Un quart de travail contient des données manquantes ou corrompues",
SHIFT_TIME_REQUIRED: "Heure requise",
SHIFT_TIME_REQUIRED: "Heures manquantes",
SHIFT_TYPE_REQUIRED: "Type requis",
SHIFT_NOT_FOUND: "Quart de travail manquant ou supprimé",
PAY_PERIOD_NOT_FOUND: "Aucune période de paie ne correspond aux dates fournies",

View File

@ -7,7 +7,7 @@
import { useUiStore } from 'src/stores/ui-store';
import { ref } from 'vue';
import { RouteNames } from 'src/router/router-constants';
import { CAN_APPROVE_PAY_PERIODS } from 'src/modules/shared/models/user.models';
import { ModuleNames } from 'src/modules/shared/models/user.models';
const auth_store = useAuthStore();
const ui_store = useUiStore();
@ -32,155 +32,109 @@
<template>
<q-drawer
v-model="ui_store.is_left_drawer_open"
:persistent="!ui_store.is_mobile_mode"
:persistent="!$q.platform.is.mobile"
mini-to-overlay
elevated
side="left"
:mini="is_mini"
@mouseenter="is_mini = false"
@mouseleave="is_mini = true"
class="bg-dark z-max"
class="bg-dark z-max column no-wrap"
:class="!$q.platform.is.mobile && is_mini ? 'items-center' : 'items-start'"
>
<q-scroll-area class="fit">
<q-list>
<!-- Home -->
<q-item
v-ripple
clickable
side
<q-btn
flat
dense
no-wrap
size="lg"
icon="home"
:label="!$q.platform.is.mobile && is_mini ? '' : $t('nav_bar.home')"
class="col-auto text-uppercase text-weight-bold q-my-xs"
:class="!$q.platform.is.mobile && is_mini ? '': 'q-px-sm'"
@click="goToPageName(RouteNames.DASHBOARD)"
>
<q-item-section avatar>
<q-icon
name="home"
color="accent"
size="lg"
/>
</q-item-section>
<q-item-section>
<q-item-label class="text-uppercase text-weight-bold">{{ $t('nav_bar.home') }}</q-item-label>
</q-item-section>
</q-item>
<!-- Timesheet Validation -- Supervisor and Accounting only -->
<q-item
v-ripple
clickable
side
<!-- Timesheet Validation -->
<q-btn
v-if="auth_store.user?.user_module_access.includes(ModuleNames.TIMESHEETS_APPROVAL)"
flat
dense
no-wrap
size="lg"
icon="event_available"
:label="!$q.platform.is.mobile && is_mini ? '' : $t('nav_bar.timesheet_approvals')"
class="col-auto text-uppercase text-weight-bold q-my-xs"
:class="!$q.platform.is.mobile && is_mini ? '': 'q-px-sm'"
@click="goToPageName(RouteNames.TIMESHEET_APPROVALS)"
v-if="CAN_APPROVE_PAY_PERIODS.includes(auth_store.user?.role ?? 'GUEST')"
>
<q-item-section avatar>
<q-icon
name="event_available"
color="accent"
size="lg"
/>
</q-item-section>
<q-item-section>
<q-item-label class="text-uppercase text-weight-bold">{{ $t('nav_bar.timesheet_approvals')
}}</q-item-label>
</q-item-section>
</q-item>
<!-- Employee List -- Supervisor, Accounting and HR only -->
<q-item
v-ripple
clickable
side
<!-- Employee List -->
<q-btn
v-if="auth_store.user?.user_module_access.includes(ModuleNames.EMPLOYEE_LIST)"
flat
dense
no-wrap
size="lg"
icon="groups"
:label="!$q.platform.is.mobile && is_mini ? '' : $t('nav_bar.employee_list')"
class="col-auto text-uppercase text-weight-bold q-my-xs"
:class="!$q.platform.is.mobile && is_mini ? '': 'q-px-sm'"
@click="goToPageName(RouteNames.EMPLOYEE_LIST)"
v-if="CAN_APPROVE_PAY_PERIODS.includes(auth_store.user?.role ?? 'GUEST')"
>
<q-item-section avatar>
<q-icon
name="groups"
color="accent"
size="lg"
/>
</q-item-section>
<q-item-section>
<q-item-label class="text-uppercase text-weight-bold">{{ $t('nav_bar.employee_list')
}}</q-item-label>
</q-item-section>
</q-item>
<!-- Employee Timesheet temp -- Employee, Supervisor, Accounting only -->
<q-item
v-ripple
clickable
side
@click="goToPageName(RouteNames.TIMESHEET)"
v-if="CAN_APPROVE_PAY_PERIODS.includes(auth_store.user?.role ?? 'GUEST')"
>
<q-item-section avatar>
<q-icon
name="punch_clock"
color="accent"
<!-- Employee Timesheet -->
<q-btn
v-if="auth_store.user?.user_module_access.includes(ModuleNames.TIMESHEETS)"
flat
dense
no-wrap
size="lg"
icon="punch_clock"
:label="!$q.platform.is.mobile && is_mini ? '' : $t('nav_bar.timesheet')"
class="col-auto text-uppercase text-weight-bold q-my-xs"
:class="!$q.platform.is.mobile && is_mini ? '': 'q-px-sm'"
@click="goToPageName(RouteNames.TIMESHEET)"
/>
</q-item-section>
<q-item-section>
<q-item-label class="text-uppercase text-weight-bold">{{ $t('nav_bar.timesheet')
}}</q-item-label>
</q-item-section>
</q-item>
<!-- Profile -->
<q-item
v-ripple
clickable
side
@click="goToPageName(RouteNames.PROFILE)"
>
<q-item-section avatar>
<q-icon
name="account_box"
color="accent"
<q-btn
v-if="auth_store.user?.user_module_access.includes(ModuleNames.PERSONAL_PROFILE)"
flat
dense
no-wrap
size="lg"
icon="account_box"
:label="!$q.platform.is.mobile && is_mini ? '' : $t('nav_bar.profile')"
class="col-auto text-uppercase text-weight-bold q-my-xs"
:class="!$q.platform.is.mobile && is_mini ? '': 'q-px-sm'"
@click="goToPageName(RouteNames.PROFILE)"
/>
</q-item-section>
<q-item-section>
<q-item-label class="text-uppercase text-weight-bold">{{ $t('nav_bar.profile') }}</q-item-label>
</q-item-section>
</q-item>
<!-- Help -->
<q-item
v-ripple
clickable
@click="goToPageName('help')"
>
<q-item-section avatar>
<q-icon
name="contact_support"
color="accent"
<q-btn
flat
dense
no-wrap
size="lg"
icon="contact_support"
:label="!$q.platform.is.mobile && is_mini ? '' : $t('nav_bar.help')"
class="col-auto text-uppercase text-weight-bold q-my-xs"
:class="!$q.platform.is.mobile && is_mini ? '': 'q-px-sm'"
@click="goToPageName(RouteNames.HELP)"
/>
</q-item-section>
<q-item-section>
<q-item-label class="text-uppercase text-weight-bold">{{ $t('nav_bar.help') }}</q-item-label>
</q-item-section>
</q-item>
</q-list>
<!-- Logout -->
<q-item
v-ripple
clickable
@click="handleLogout"
class="absolute-bottom"
>
<q-item-section avatar>
<q-icon
name="exit_to_app"
color="accent"
<q-btn
flat
dense
no-wrap
size="lg"
icon="exit_to_app"
:label="!$q.platform.is.mobile && is_mini ? '' : $t('nav_bar.logout')"
class="col-auto text-uppercase text-weight-bold q-my-xs"
:class="!$q.platform.is.mobile && is_mini ? 'absolute-bottom': 'absolute-bottom-left'"
@click="handleLogout"
/>
</q-item-section>
<q-item-section>
<q-item-label class="text-uppercase text-weight-bold">{{ $t('nav_bar.logout') }}</q-item-label>
</q-item-section>
</q-item>
</q-scroll-area>
</q-drawer>
</template>

View File

@ -5,12 +5,13 @@
import { ref } from 'vue';
import { unwrapAndClone } from 'src/utils/unwrap-and-clone';
import { useEmployeeStore } from 'src/stores/employee-store';
import { employee_access_options, type ModuleAccessPreset, type ModuleAccessName, employee_access_presets, getEmployeeAccessOptionIcon } from 'src/modules/employee-list/models/employee-profile.models';
import { employee_access_options, type ModuleAccessPreset, employee_access_presets, getEmployeeAccessOptionIcon } from 'src/modules/employee-list/models/employee-profile.models';
import type { UserModuleAccess } from 'src/modules/shared/models/user.models';
const employee_store = useEmployeeStore();
const preset_preview = ref<ModuleAccessPreset>();
const toggleInSelected = (value: ModuleAccessName) => {
const toggleInSelected = (value: UserModuleAccess) => {
const i = employee_store.employee.user_module_access.indexOf(value);
if (i === -1) employee_store.employee.user_module_access.push(value);
else employee_store.employee.user_module_access.splice(i, 1);
@ -21,7 +22,7 @@
}
const getPreviewBackgroundColor = (name: ModuleAccessName) => {
const getPreviewBackgroundColor = (name: UserModuleAccess) => {
if (employee_access_presets[preset_preview.value!].includes(name)) {
if (!employee_store.employee.user_module_access.includes(name)) return 'bg-info text-white';
@ -33,7 +34,7 @@
return 'bg-dark';
};
const getBackgroundColor = (name: ModuleAccessName) => {
const getBackgroundColor = (name: UserModuleAccess) => {
if (employee_store.employee.user_module_access.includes(name)) return 'bg-accent text-white';
return 'bg-dark';
@ -143,7 +144,11 @@
:class="preset_preview !== undefined ? getPreviewBackgroundColor(option.value) : getBackgroundColor(option.value)"
@click="toggleInSelected(option.value)"
>
<q-icon :name="getEmployeeAccessOptionIcon(option.value)" size="sm" class="q-mr-sm"/>
<q-icon
:name="getEmployeeAccessOptionIcon(option.value)"
size="sm"
class="q-mr-sm"
/>
<span class="text-uppercase text-weight-bold non-selectable">
{{ $t('employee_management.module_access.' + option.value) }}
</span>

View File

@ -1,6 +1,6 @@
import type { QSelectOption, QTableColumn } from "quasar";
import type { UserModuleAccess } from "src/modules/shared/models/user.models";
export type ModuleAccessName = 'dashboard' | 'employee_list' | 'employee_management' | 'personal_profile' | 'timesheets' | 'timesheets_approval';
export type ModuleAccessPreset = 'admin' | 'supervisor' | 'employee' | 'none';
export type CompanyNames = 'Targo' | 'Solucom';
@ -18,7 +18,7 @@ export class EmployeeProfile {
residence: string;
birth_date: string;
is_supervisor: boolean;
user_module_access: ModuleAccessName[];
user_module_access: UserModuleAccess[];
preset_id?: number | null;
constructor() {
@ -100,7 +100,7 @@ export const employee_list_columns: QTableColumn<EmployeeProfile>[] = [
},
];
export const employee_access_options: QSelectOption<ModuleAccessName>[] = [
export const employee_access_options: QSelectOption<UserModuleAccess>[] = [
{ label: 'dashboard', value: 'dashboard' },
{ label: 'employee_list', value: 'employee_list' },
{ label: 'personal_profile', value: 'personal_profile' },
@ -109,14 +109,14 @@ export const employee_access_options: QSelectOption<ModuleAccessName>[] = [
{ label: 'timesheets_approval', value: 'timesheets_approval' },
]
export const employee_access_presets: Record<ModuleAccessPreset, ModuleAccessName[]> = {
export const employee_access_presets: Record<ModuleAccessPreset, UserModuleAccess[]> = {
'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 getEmployeeAccessOptionIcon = (module: ModuleAccessName): string => {
export const getEmployeeAccessOptionIcon = (module: UserModuleAccess): string => {
switch (module) {
case 'dashboard': return 'home';
case 'employee_list' : return 'groups';

View File

@ -3,13 +3,18 @@ export interface User {
last_name: string;
email: string;
role: UserRole;
user_module_access: UserModuleAccess;
}
export type UserRole = 'ADMIN' |'SUPERVISOR' | 'HR' | 'ACCOUNTING' | 'EMPLOYEE' | 'DEALER' | 'CUSTOMER' | 'GUEST';
export type UserRole = 'ADMIN' | 'SUPERVISOR' | 'HR' | 'ACCOUNTING' | 'EMPLOYEE' | 'DEALER' | 'CUSTOMER' | 'GUEST';
export const CAN_APPROVE_PAY_PERIODS: UserRole[] = [
'ADMIN',
'SUPERVISOR',
'HR',
'ACCOUNTING',
]
export const ModuleNames = {
DASHBOARD: 'dashboard',
EMPLOYEE_LIST: 'employee_list',
EMPLOYEE_MANAGEMENT: 'employee_management',
PERSONAL_PROFILE: 'personal_profile',
TIMESHEETS: 'timesheets',
TIMESHEETS_APPROVAL: 'timesheets_approval',
} as const;
export type UserModuleAccess = typeof ModuleNames[keyof typeof ModuleNames];

View File

@ -101,6 +101,7 @@
v-model="is_navigator_open"
transition-show="jump-right"
transition-hide="jump-right"
class="z-top"
>
<q-date
v-model="expenses_store.current_expense.date"

View File

@ -56,15 +56,16 @@
>
<template #header>
<div class="row items-center">
<span class="col-auto text-uppercase text-weight-bold text-h6 q-ml-lg q-mr-sm">
{{ $t('timesheet.expense.add_expense') }}
</span>
<q-icon
v-if="expense_store.mode !== 'create'"
name="las la-plus-square"
name="add_circle_outline"
size="md"
class="col-auto"
:class="expense_store.is_showing_create_form ? 'invisible' : ''"
/>
<span class="col-auto text-uppercase text-weight-bold text-h6 q-ml-xs q-mr-sm">
{{ $t('timesheet.expense.add_expense') }}
</span>
</div>
</template>

View File

@ -94,6 +94,7 @@
v-model="is_navigator_open"
transition-show="jump-right"
transition-hide="jump-right"
class="z-top"
>
<q-date
v-model="expenses_store.current_expense.date"
@ -217,12 +218,12 @@
class="col-auto"
/>
<q-dialog v-model="is_showing_comment_dialog_mobile">
<q-card class="full-width bg-secondary rounded-10">
<q-dialog v-model="is_showing_comment_dialog_mobile" class="z-top">
<q-card class="full-width bg-primary rounded-10">
<q-card-section class="q-pa-none">
<span
class="text-weight-bold text-accent text-uppercase text-caption"
style="font-size: 1.5em;"
class="text-weight-bold text-accent text-uppercase text-caption q-mx-md"
style="font-size: 1.2em;"
>
{{ $t('timesheet.expense.employee_comment') }}
</span>
@ -231,14 +232,13 @@
<q-card-section class="q-pa-none bg-primary rounded-10">
<q-input
v-model="expenses_store.current_expense.comment"
standout="bg-blue-grey-9"
filled
dense
hide-bottom-space
color="primary"
color="accent"
type="textarea"
:maxlength="COMMENT_MAX_LENGTH"
lazy-rules="ondemand"
:rules="[rules.commentRequired]"
class="bg-white no-border"
>
</q-input>
</q-card-section>

View File

@ -17,11 +17,13 @@
const select_ref = ref<QSelect | null>(null);
const is_showing_comment_popup = ref(false);
const comment_length = computed(() => shift.value.comment?.length ?? 0);
const error_message = ref('');
const { dense = false, hasShiftAfter = false, isTimesheetApproved = false } = defineProps<{
const { errorMessage = undefined, dense = false, hasShiftAfter = false, isTimesheetApproved = false } = defineProps<{
dense?: boolean;
hasShiftAfter?: boolean;
isTimesheetApproved?: boolean;
errorMessage?: string | undefined;
}>();
const emit = defineEmits<{
@ -37,6 +39,17 @@
}
};
const onTimeFieldBlur = (time_string: string) => {
if (time_string.length < 1 || !time_string) {
shift.value.has_error = true;
error_message.value = 'timesheet.errors.SHIFT_TIME_REQUIRED';
} else {
shift.value.has_error = false;
error_message.value = '';
emit('onTimeFieldBlur');
}
}
const getCommentCounterColor = (comment_length: number) => {
if (comment_length < 200) return 'primary';
if (comment_length < 250) return 'warning';
@ -50,6 +63,9 @@
shift_type_selected.value = undefined;
ui_store.focus_next_component = false;
}
if (errorMessage)
error_message.value = errorMessage;
});
</script>
@ -162,6 +178,7 @@
anchor="top middle"
self="bottom middle"
:offset="[0, 10]"
:hide-delay="1000"
class="text-uppercase text-weight-bold text-white bg-primary"
>
{{ shift.is_remote ? $t('timesheet.shift.types.REMOTE') :
@ -184,6 +201,7 @@
anchor="top middle"
self="bottom middle"
:offset="[0, 10]"
:hide-delay="1000"
class="text-uppercase text-weight-medium text-white bg-accent"
>
{{ shift.is_remote ? $t('timesheet.shift.types.REMOTE') :
@ -208,13 +226,15 @@
lazy-rules
no-error-icon
hide-bottom-space
:error="shift.has_error"
:error-message="errorMessage || error_message !== '' ? $t(errorMessage ?? error_message) : ''"
:label-color="!shift.is_approved ? 'accent' : 'white'"
class="rounded-5 bg-dark"
:class="(shift.id === -2 ? 'bg-negative ' : ' ') + (!shift.is_approved && !isTimesheetApproved ? '' : 'cursor-not-allowed inset-shadow')"
:input-class="'text-weight-medium ' + (shift.id === -2 ? 'text-white ' : ' ') + (shift.is_approved ? 'text-white cursor-not-allowed q-px-sm' : '')"
input-style="font-size: 1.2em;"
:style="shift.is_approved ? 'background-color: #0a7d32 !important;' : ''"
@blur="emit('onTimeFieldBlur')"
@blur="onTimeFieldBlur(shift.start_time)"
>
<template #label>
<span
@ -239,13 +259,15 @@
label-slot
no-error-icon
hide-bottom-space
:error="shift.has_error"
:error-message="errorMessage || error_message !== '' ? $t(errorMessage ?? error_message) : ''"
:label-color="!shift.is_approved ? 'accent' : 'white'"
:input-class="'text-weight-medium ' + (shift.id === -2 ? 'text-white ' : ' ') + (shift.is_approved ? 'text-white cursor-not-allowed q-px-sm' : '')"
input-style="font-size: 1.2em;"
class="rounded-5 bg-dark"
:class="(shift.id === -2 ? 'bg-negative ' : ' ') + (shift.is_approved ? 'cursor-not-allowed q-px-xs transparent inset-shadow' : (isTimesheetApproved ? 'inset-shadow' : ''))"
:style="shift.is_approved ? 'background-color: #0a7d32 !important;' : ''"
@blur="emit('onTimeFieldBlur')"
@blur="onTimeFieldBlur(shift.end_time)"
>
<template #label>
<span

View File

@ -0,0 +1,91 @@
<script
setup
lang="ts"
>
import { useTimesheetApi } from 'src/modules/timesheets/composables/use-timesheet-api';
import { useTimesheetStore } from 'src/stores/timesheet-store';
const timesheet_store = useTimesheetStore();
const timesheet_api = useTimesheetApi();
</script>
<template>
<div
v-if="$q.platform.is.mobile && $q.screen.width < $q.screen.height"
class="col-auto row items-start q-px-sm q-pt-sm full-width"
>
<!-- per timesheet -->
<div
v-for="timesheet, timesheet_index in timesheet_store.timesheets"
:key="timesheet_index"
class="col column flex-center q-pa-sm"
>
<!-- container -->
<div
class="rounded-5 relative-position q-px-sm q-pt-sm q-pb-xs full-width shadow-4"
style="border: 1px solid var(--q-accent);"
>
<!-- label for week number -->
<div
class="self-start text-uppercase text-weight-bolder text-accent bg-secondary absolute-top-left q-px-xs"
style="font-size: 0.8em; top: -7px; left: 10px; line-height: 1em;"
>{{ $t('timesheet.week') + ` ${timesheet_index + 1}` }}</div>
<!-- hours worked in the week -->
<div class="col-auto row">
<span class="text-weight-bolder text-uppercase text-accent text-caption q-mr-sm">{{
$t('timesheet.total_hours') }}</span>
<span>{{
(timesheet.weekly_hours.regular +
timesheet.weekly_hours.evening +
timesheet.weekly_hours.emergency +
timesheet.weekly_hours.overtime).toFixed(2)
}}</span>
</div>
<!-- label for current shifts preview -->
<div
class="col-auto full-width text-center text-weight-medium text-caption text-uppercase q-mt-xs"
style="font-size: 0.65em; line-height: 1.2em;"
> {{ $t('timesheet.current_shifts') }}</div>
<!-- preview of current number of shifts -->
<div
class="col row flex-center"
style="height: 20px;"
>
<div
v-for="day, day_index in timesheet.days"
:key="day_index"
class="col row flex-center"
>
<q-badge
:color="day.shifts.length > 0 ? (day.shifts.every(shift => shift.is_approved) ? 'accent shadow-2' : 'dark shadow-2') : 'blue-grey-5'"
:class="day.shifts.length > 0 ? (day.shifts.every(shift => shift.is_approved) ? 'q-px-xs' : 'q-pa-sm') : ''"
:style="day.shifts.length > 0 ? '' : 'opacity: 0.5'"
>
<q-icon
v-if="day.shifts.every(shift => shift.is_approved) && day.shifts.length > 0"
name="check"
class="q-pa-none"
/>
</q-badge>
</div>
</div>
</div>
<!-- button to apply weekly schedule preset -->
<div class="col-auto flex-center row q-pt-xs full-width">
<q-btn
v-if="timesheet.days.every(day => day.shifts.length < 1)"
push
dense
color="accent"
:label="$t('timesheet.apply_preset')"
class="full-width"
@click="timesheet_api.applyPreset(timesheet.timesheet_id)"
/>
</div>
</div>
</div>
</template>

View File

@ -2,52 +2,42 @@
setup
lang="ts"
>
/* eslint-disable*/
import { onBeforeUnmount, onMounted, ref, useTemplateRef, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { onMounted, ref } from 'vue';
import { QSelect, QInput } from 'quasar';
import { Shift } from 'src/modules/timesheets/models/shift.models';
import { useUiStore } from 'src/stores/ui-store';
import { SHIFT_OPTIONS } from 'src/modules/timesheets/utils/shift.util';
import type { Shift } from 'src/modules/timesheets/models/shift.models';
let timer: NodeJS.Timeout;
const { t } = useI18n();
const ui_store = useUiStore();
const COMMENT_LENGTH_MAX = 280;
const shift = defineModel<Shift>('shift', { required: true });
const shift_type_selected = ref(SHIFT_OPTIONS.find(option => option.value == shift.value.type));
const select_ref = useTemplateRef<QSelect>('select');
const start_time_ref = useTemplateRef<QInput>('start_time');
const end_time_ref = useTemplateRef<QInput>('end_time');
const select_ref = ref<QSelect | null>(null);
const error_message = ref('');
const { dense = false, hasShiftAfter = false, isTimesheetApproved = false } = defineProps<{
const { errorMessage = undefined, isTimesheetApproved = false } = defineProps<{
dense?: boolean;
hasShiftAfter?: boolean;
isTimesheetApproved?: boolean;
errorMessage?: string | undefined;
}>();
const emit = defineEmits<{
'saveComment': [comment: string, shift_id: number];
'requestDelete': [void];
'onTimeFieldBlur': [void];
}>();
const onBlurShiftTypeSelect = () => {
if (shift_type_selected.value === undefined) {
shift.value.type = 'REGULAR';
shift.value.id = 0;
emit('requestDelete');
const onTimeFieldBlur = (time_string: string) => {
if (time_string.length < 1 || !time_string) {
shift.value.has_error = true;
error_message.value = 'timesheet.errors.SHIFT_TIME_REQUIRED'
} else {
shift.value.has_error = false;
error_message.value = '';
emit('onTimeFieldBlur');
}
}
};
const slideDeleteShift = async (reset: () => void) => {
timer = setTimeout(() => {
reset();
emit('requestDelete');
}, 200);
};
const getCommentCounterColor = (comment_length: number) => {
if (comment_length < 200) return 'primary';
@ -62,89 +52,18 @@
shift_type_selected.value = undefined;
ui_store.focus_next_component = false;
}
});
onBeforeUnmount(() => {
clearTimeout(timer);
if (errorMessage)
error_message.value = errorMessage;
});
watch(() => [start_time_ref.value?.hasError, end_time_ref.value?.hasError], ([start_error, end_error]) => {
shift.value.has_error = (start_error || end_error) ?? false;
})
</script>
<template>
<q-slide-item
right-color="negative"
class="rounded-5 transparent"
:class="ui_store.is_mobile_mode ? 'q-my-md' : 'q-mr-xs'"
@right="details => slideDeleteShift(details.reset)"
>
<template
#right
v-if="ui_store.is_mobile_mode"
>
<q-icon name="delete" />
</template>
<div :class="ui_store.is_mobile_mode ? 'column' : 'row'">
<div
class="row items-center text-uppercase rounded-5"
:class="ui_store.is_mobile_mode ? 'col q-mb-xs q-px-xs' : 'col-4'"
>
<!-- mobile comment button -->
<q-btn
v-if="ui_store.is_mobile_mode && !dense"
:icon="shift.comment ? 'chat' : 'chat_bubble_outline'"
:text-color="shift.comment ? ((shift.is_approved && isTimesheetApproved) ? 'white' : 'accent') : 'grey-5'"
class="col-auto full-height q-mx-xs rounded-5 shadow-1"
>
<q-popup-edit
v-model="shift.comment"
:title="$t('timesheet.shift.fields.header_comment')"
auto-save
v-slot="scope"
class="bg-dark"
>
<q-input
color="white"
v-model="scope.value"
dense
:readonly="(shift.is_approved || isTimesheetApproved)"
autofocus
counter
bottom-slots
:maxlength="COMMENT_LENGTH_MAX"
class="q-pb-lg"
:class="(shift.is_approved || isTimesheetApproved) ? 'cursor-not-allowed' : ''"
@keyup.enter="scope.set"
>
<template #append>
<q-icon name="edit" />
</template>
<template #counter>
<div class="row flex-center">
<q-space />
<q-knob
:model-value="scope.value?.length"
readonly
:max="COMMENT_LENGTH_MAX"
size="1.6em"
:thickness="0.4"
:color="getCommentCounterColor(scope.value?.length ?? 0)"
track-color="grey-4"
class="col-auto q-mr-xs"
/>
<span
:class="'col-auto text-weight-bolder text-' + getCommentCounterColor(scope.value?.length ?? 0)"
>{{ 280 - (scope.value?.length ?? 0) }}</span>
</div>
</template>
</q-input>
</q-popup-edit>
</q-btn>
<!-- shift type -->
<q-select
ref="select"
@ -164,7 +83,6 @@
popup-content-class="text-uppercase text-weight-bold text-center rounded-5"
:style="shift.is_approved ? 'background-color: #0a7d32 !important;' : ''"
popup-content-style="border: 2px solid var(--q-accent)"
@blur="onBlurShiftTypeSelect"
@update:model-value="option => shift.type = option.value"
>
<template #selected-item="scope">
@ -178,7 +96,7 @@
:color="scope.opt.icon_color"
size="sm"
class="col-auto"
:class="shift.is_approved ? 'q-mx-xs': 'q-mr-xs'"
:class="shift.is_approved ? 'q-mx-xs' : 'q-mr-xs'"
/>
<span
style="line-height: 1.2em;"
@ -248,13 +166,15 @@
lazy-rules
no-error-icon
hide-bottom-space
:error="shift.has_error"
:error-message="errorMessage || error_message !== '' ? $t(errorMessage ?? error_message) : ''"
:label-color="!shift.is_approved ? 'accent' : 'white'"
class="col rounded-5 bg-dark"
:class="(shift.id === -2 ? 'bg-negative ' : ' ') + (ui_store.is_mobile_mode ? 'q-mr-xs ' : 'q-mx-xs ') + (!shift.is_approved && !isTimesheetApproved ? '' : 'cursor-not-allowed inset-shadow')"
class="col rounded-5 bg-dark q-mx-xs"
:class="(shift.id === -2 ? 'bg-negative ' : ' ') + (!shift.is_approved && !isTimesheetApproved ? '' : 'cursor-not-allowed inset-shadow')"
:input-class="'text-weight-medium ' + (shift.id === -2 ? 'text-white ' : ' ') + (shift.is_approved ? 'text-white cursor-not-allowed q-px-sm' : '')"
input-style="font-size: 1.2em;"
:style="shift.is_approved ? 'background-color: #0a7d32 !important;' : ''"
@blur="emit('onTimeFieldBlur')"
@blur="onTimeFieldBlur(shift.start_time)"
>
<template #label>
<span
@ -277,13 +197,15 @@
label-slot
no-error-icon
hide-bottom-space
:error="shift.has_error"
:error-message="errorMessage || error_message !== '' ? $t(errorMessage ?? error_message) : ''"
:label-color="!shift.is_approved ? 'accent' : 'white'"
:input-class="'text-weight-medium ' + (shift.id === -2 ? 'text-white ' : ' ') + (shift.is_approved ? 'text-white cursor-not-allowed q-px-sm' : '')"
input-style="font-size: 1.2em;"
class="col rounded-5 bg-dark"
:class="(shift.id === -2 ? 'bg-negative ' : ' ') + (ui_store.is_mobile_mode ? 'q-mr-xs ' : 'q-mx-xs ') + (shift.is_approved ? 'cursor-not-allowed q-px-xs transparent inset-shadow' : (isTimesheetApproved ? 'inset-shadow' : ''))"
class="col rounded-5 bg-dark q-mx-xs"
:class="(shift.id === -2 ? 'bg-negative ' : ' ') + (shift.is_approved ? 'cursor-not-allowed q-px-xs transparent inset-shadow' : (isTimesheetApproved ? 'inset-shadow' : ''))"
:style="shift.is_approved ? 'background-color: #0a7d32 !important;' : ''"
@blur="emit('onTimeFieldBlur')"
@blur="onTimeFieldBlur(shift.end_time)"
>
<template #label>
<span
@ -295,7 +217,10 @@
</q-input>
<!-- comment and delete buttons -->
<div class="row full-height" :class="ui_store.is_mobile_mode ? 'col-12' : 'col-auto flex-center'">
<div
class="row full-height"
:class="ui_store.is_mobile_mode ? 'col-12' : 'col-auto flex-center'"
>
<!-- desktop comment button -->
<q-btn
v-if="!ui_store.is_mobile_mode"
@ -313,6 +238,7 @@
rounded
color="negative"
/>
<q-popup-edit
v-model="shift.comment"
:title="$t('timesheet.shift.fields.header_comment')"
@ -360,7 +286,7 @@
</q-btn>
<q-btn
v-if="!ui_store.is_mobile_mode && !shift.is_approved"
v-if="!shift.is_approved"
flat
dense
:disable="shift.is_approved"
@ -376,14 +302,6 @@
</div>
</div>
</div>
</q-slide-item>
<q-separator
v-if="hasShiftAfter && ui_store.is_mobile_mode"
spaced
color="accent"
class="q-mx-md"
/>
</template>
<style scoped>

View File

@ -10,9 +10,11 @@
import { useTimesheetApi } from 'src/modules/timesheets/composables/use-timesheet-api';
import type { Shift } from 'src/modules/timesheets/models/shift.models';
import type { TimesheetDay } from 'src/modules/timesheets/models/timesheet.models';
import { isShiftOverlap } from 'src/modules/timesheets/utils/shift.util';
const shift_api = useShiftApi();
const timesheet_api = useTimesheetApi();
const shift_error_message = ref<string | undefined>();
const { day, dense = false, approved = false } = defineProps<{
timesheetId: number;
@ -36,6 +38,15 @@
}
await shift_api.deleteShiftById(shift.id);
};
const onTimeFieldBlur = () => {
const is_error = isShiftOverlap(day.shifts);
day.shifts.map(shift => shift.has_error = is_error);
if (is_error)
shift_error_message.value = 'timesheet.errors.SHIFT_OVERLAP_SHORT';
else
shift_error_message.value = undefined;
}
</script>
<template>
@ -73,24 +84,26 @@
<div
v-for="shift, shift_index in day.shifts"
:key="shift_index"
class="col"
class="col-auto"
>
<ShiftListDayRowMobile
v-if="$q.platform.is.mobile"
v-model:shift="day.shifts[shift_index]!"
:is-timesheet-approved="approved"
:error-message="shift_error_message"
:dense="dense"
:has-shift-after="shift_index < day.shifts.length - 1"
@request-delete="deleteCurrentShift(shift)"
@on-time-field-blur="onTimeFieldBlur()"
/>
<ShiftListDayRow
v-else
v-model:shift="day.shifts[shift_index]!"
:is-timesheet-approved="approved"
:dense="dense"
:has-shift-after="shift_index < day.shifts.length - 1"
:error-message="shift_error_message"
@request-delete="deleteCurrentShift(shift)"
@on-time-field-blur="onTimeFieldBlur()"
/>
</div>

View File

@ -95,7 +95,7 @@
<div
v-for="timesheet, timesheet_index of timesheet_store.timesheets"
:key="timesheet.timesheet_id"
class="col fit"
class="col column fit flex-center"
>
<transition
appear
@ -108,7 +108,7 @@
flat
dense
:label="$t('timesheet.apply_preset_week')"
class="text-uppercase text-weight-bold text-accent q-mx-lg q-py-none rounded-5"
class="col-auto text-uppercase text-weight-bold text-accent q-mx-lg q-py-none rounded-5"
:class="timesheet.days.every(day => day.shifts.length < 1) ? '' : 'invisible'"
@click="timesheet_api.applyPreset(timesheet.timesheet_id)"
>
@ -127,7 +127,7 @@
<div
v-for="day, day_index in timesheet.days"
:key="day.date"
class="col-auto row q-ma-sm"
class="col-auto row q-pa-sm fit"
:style="`animation-delay: ${day_index / 15}s;`"
>
<div
@ -135,7 +135,7 @@
class="col column full-width q-px-md q-py-sm"
>
<q-card
class="rounded-5 shadow-12"
class="rounded-10 shadow-12"
:class="(getDayApproval(day) || timesheet.is_approved) ? 'bg-accent' : 'bg-dark'"
>
@ -174,7 +174,7 @@
/>
</q-card-section>
<q-card-actions class="q-pa-none">
<q-card-section class="q-pa-none">
<q-btn
v-if="!(getDayApproval(day) || timesheet.is_approved)"
square
@ -183,15 +183,16 @@
color="accent"
icon="more_time"
class="full-width"
style="border-radius: 0 0 10px 10px;"
@click="addNewShift(day.shifts, day.date, timesheet.timesheet_id)"
/>
</q-card-actions>
</q-card-section>
</q-card>
</div>
<div
v-else
class="col row full-width"
class="col row full-width shadow-8 rounded-10"
:class="(getDayApproval(day) || timesheet.is_approved) ? 'rounded-10 bg-accent' : ''"
>
<div

View File

@ -8,6 +8,7 @@
import PayPeriodNavigator from 'src/modules/shared/components/pay-period-navigator.vue';
import TimesheetErrorWidget from 'src/modules/timesheets/components/timesheet-error-widget.vue';
import LoadingOverlay from 'src/modules/shared/components/loading-overlay.vue';
import ShiftListWeeklyOverview from 'src/modules/timesheets/components/mobile/shift-list-weekly-overview.vue';
import { computed, onMounted } from 'vue';
import { useShiftApi } from 'src/modules/timesheets/composables/use-shift-api';
@ -38,9 +39,10 @@
<div class="column items-center full-height">
<LoadingOverlay v-model="timesheet_store.is_loading" />
<!-- top menu -->
<div
class="col-auto row items-center full-width q-px-lg"
:class="$q.platform.is.mobile && ($q.screen.width < $q.screen.height) ? 'justify-between' : 'q-mt-md q-px-md'"
:class="$q.platform.is.mobile && ($q.screen.width < $q.screen.height) ? 'justify-between' : 'q-mt-md q-pb-sm q-px-md'"
>
<!-- navigation btn -->
<PayPeriodNavigator
@ -90,80 +92,11 @@
/>
</div>
<!-- error message widget for potential backend-provided errors -->
<TimesheetErrorWidget class="col-auto" />
<!-- mobile weekly overview widget -->
<div
v-if="$q.platform.is.mobile && $q.screen.width < $q.screen.height"
class="col-auto row items-start q-px-sm q-pt-sm full-width"
>
<!-- per timesheet -->
<div
v-for="timesheet, timesheet_index in timesheet_store.timesheets"
:key="timesheet_index"
class="col column flex-center q-pa-sm"
>
<!-- container -->
<div
class="rounded-5 relative-position q-px-sm q-pt-sm q-pb-xs full-width shadow-4"
style="border: 1px solid var(--q-accent);"
>
<!-- label for week number -->
<div
class="self-start text-uppercase text-weight-bolder text-accent bg-secondary absolute-top-left q-px-xs"
style="font-size: 0.8em; top: -7px; left: 10px; line-height: 1em;"
>{{ $t('timesheet.week') + ` ${timesheet_index + 1}` }}</div>
<!-- hours worked in the week -->
<div class="col-auto row">
<span class="text-weight-bolder text-uppercase text-accent text-caption q-mr-sm">{{
$t('timesheet.total_hours') }}</span>
<span>{{
(timesheet.weekly_hours.regular +
timesheet.weekly_hours.evening +
timesheet.weekly_hours.emergency +
timesheet.weekly_hours.overtime).toFixed(2)
}}</span>
</div>
<!-- label for current shifts preview -->
<div
class="col-auto full-width text-center text-weight-medium text-caption text-uppercase q-mt-xs"
style="font-size: 0.65em; line-height: 1.2em;"
> {{ $t('timesheet.current_shifts') }}</div>
<!-- preview of current number of shifts -->
<div
class="col row flex-center"
style="height: 20px;"
>
<div
v-for="day, day_index in timesheet.days"
:key="day_index"
class="col row flex-center"
>
<q-badge :color="day.shifts.length > 0 ? 'accent' : 'blue-grey-4'">
<span class="text-weight-bolder">{{ day.shifts.length > 0 ? day.shifts.length : ''
}}</span>
</q-badge>
</div>
</div>
</div>
<!-- button to apply weekly schedule preset -->
<div class="col-auto flex-center row q-pt-xs full-width">
<q-btn
v-if="timesheet.days.every(day => day.shifts.length < 1)"
push
dense
color="accent"
:label="$t('timesheet.apply_preset')"
class="full-width"
@click="timesheet_api.applyPreset(timesheet.timesheet_id)"
/>
</div>
</div>
</div>
<ShiftListWeeklyOverview />
<ShiftList />
@ -180,6 +113,6 @@
@click="shift_api.saveShiftChanges"
/>
<ExpenseDialog :is-approved="is_timesheets_approved" />
<ExpenseDialog :is-approved="is_timesheets_approved" class="z-top"/>
</div>
</template>

View File

@ -1,19 +1,43 @@
<script setup lang="ts">
import { useRouter } from 'vue-router';
const router = useRouter();
</script>
<template>
<q-layout view="hHh lpR fFf">
<q-page-container>
<q-page padding class="column justify-center items-center bg-secondary">
<q-card class="col-shrink rounded-20">
<q-img src="src/assets/line-truck-1.jpg" height="20vh">
<div
class="absolute-bottom text-h4 text-center text-weight-bolder justify-center items-center row">
<div class="q-pr-md text-primary text-h3 text-weight-bolder">404</div>
PAGE NOT FOUND
<q-page class="row justify-center bg-secondary">
<div class=" column justify-center" :class="$q.platform.is.mobile ? 'col-11' : 'col-8'">
<div class="column rounded-20 q-pa-xs bg-accent" :class="$q.platform.is.mobile ? 'col-5' : 'col-4'">
<div class="col">
<q-img src="src/assets/line-truck-1.jpg" fit="cover" class="relative-position fit border-radius-inherit">
<div class="absolute-bottom text-center column flex-center">
<div class="q-pr-md text-white text-h2 text-weight-bolder">404</div>
<div class="q-pr-md text-white text-h5 text-weight-bold">{{
$t('error.not_found_header')
}}</div>
</div>
</q-img>
<q-card-section class="text-center text-h5 text-primary">
{{ $t('notFoundPage.pageText') }}
</q-card-section>
</q-card>
</div>
<div class="col-auto text-center text-h6 text-weight-light bg-dark q-pa-md">
<div>{{ $t('error.not_found_description') }}</div>
</div>
</div>
<div class="col-auto row self-center q-py-md">
<q-btn
push
dense
color="accent"
:label="$t('error.go_back')"
class="col-auto q-px-md q-py-xs"
@click="router.go(-2)"
/>
</div>
</div>
</q-page>
</q-page-container>
</q-layout>

View File

@ -3,6 +3,7 @@ import { createMemoryHistory, createRouter, createWebHashHistory, createWebHisto
import routes from './routes';
import { useAuthStore } from 'src/stores/auth-store';
import { RouteNames } from 'src/router/router-constants';
import type { UserModuleAccess } from 'src/modules/shared/models/user.models';
/*
* If not building with SSR mode, you can
@ -28,14 +29,19 @@ export default defineRouter(function (/* { store, ssrContext } */) {
history: createHistory(process.env.VUE_ROUTER_BASE),
});
Router.beforeEach(async (destinationPage) => {
const authStore = useAuthStore();
const result = await authStore.getProfile() ?? { status: 400, message: 'unknown error occured' };
Router.beforeEach(async (destination_page) => {
const auth_store = useAuthStore();
const result = await auth_store.getProfile() ?? { status: 400, message: 'unknown error occured' };
if ((destinationPage.meta.requiresAuth && !authStore.isAuthorizedUser) || (result.status >= 400 && destinationPage.name !== RouteNames.LOGIN)) {
if (destination_page.meta.requires_auth && !auth_store.user || (result.status >= 400 && destination_page.name !== RouteNames.LOGIN)) {
console.error('no user account found');
return { name: 'login' };
}
if (destination_page.meta.required_module && auth_store.user) {
if (!auth_store.user.user_module_access.includes(destination_page.meta.required_module as UserModuleAccess))
return {name: 'error'};
}
})
return Router;

View File

@ -6,5 +6,8 @@ export enum RouteNames {
EMPLOYEE_LIST = 'employee_list',
EMPLOYEE_MANAGEMENT = 'employee_management',
PROFILE = 'personal_profile',
TIMESHEET = 'timesheets'
TIMESHEET = 'timesheets',
HELP = 'help',
ERROR = 'error',
}

View File

@ -1,36 +1,42 @@
import type { RouteRecordRaw } from 'vue-router';
import { RouteNames } from './router-constants';
import { ModuleNames } from 'src/modules/shared/models/user.models';
const routes: RouteRecordRaw[] = [
{
path: '/',
component: () => import('src/layouts/main-layout.vue'),
meta: { requiresAuth: true },
meta: { requires_auth: true },
children: [
{
path: '',
name: RouteNames.DASHBOARD,
component: () => import('src/pages/dashboard-page.vue'),
meta: { required_module: ModuleNames.DASHBOARD }
},
{
path: 'timesheet-approvals',
name: RouteNames.TIMESHEET_APPROVALS,
component: () => import('src/pages/timesheet-approval-page.vue'),
meta: { required_module: ModuleNames.TIMESHEETS_APPROVAL }
},
{
path: 'employees',
name: RouteNames.EMPLOYEE_LIST,
component: () => import('src/pages/employee-list-page.vue'),
meta: { required_module: ModuleNames.EMPLOYEE_LIST }
},
{
path: 'timesheet',
name: RouteNames.TIMESHEET,
component: () => import('src/pages/timesheet-page.vue')
component: () => import('src/pages/timesheet-page.vue'),
meta: { required_module: ModuleNames.TIMESHEETS },
},
{
path: 'user/profile',
name: RouteNames.PROFILE,
component: () => import('src/pages/profile-page.vue'),
meta: { required_module: ModuleNames.PERSONAL_PROFILE },
},
],
},
@ -39,22 +45,23 @@ const routes: RouteRecordRaw[] = [
path: '/v1/login',
name: RouteNames.LOGIN,
component: () => import('src/pages/login-page.vue'),
meta: { requiresAuth: false },
meta: { requires_auth: false },
},
{
path: '/login-success',
name: RouteNames.LOGIN_SUCCESS,
component: () => import('src/modules/auth/pages/auth-login-popup-success.vue'),
meta: { requiresAuth: false },
meta: { requires_auth: false },
},
// Always leave this as last one,
// but you can also remove it
{
path: '/:catchAll(.*)*',
name: RouteNames.ERROR,
component: () => import('src/pages/error-page.vue'),
meta: { requiresAuth: false },
meta: { requires_auth: false },
},
];

View File

@ -1,14 +1,13 @@
import { computed, ref } from "vue";
import { ref } from "vue";
import { Notify } from "quasar";
import { defineStore } from "pinia";
import { AuthService } from "../modules/auth/services/services-auth";
import { CAN_APPROVE_PAY_PERIODS, type User } from "src/modules/shared/models/user.models";
import { useRouter } from "vue-router";
import { Notify } from "quasar";
import type { User } from "src/modules/shared/models/user.models";
export const useAuthStore = defineStore('auth', () => {
const user = ref<User>();
const authError = ref("");
const isAuthorizedUser = computed(() => CAN_APPROVE_PAY_PERIODS.includes(user.value?.role ?? 'GUEST'));
const router = useRouter();
const login = () => {
@ -62,6 +61,13 @@ export const useAuthStore = defineStore('auth', () => {
return { status: 400, message: 'unknown error occured' };
}
return { user, authError, isAuthorizedUser, login, oidcLogin, logout, getProfile };
return {
user,
authError,
login,
oidcLogin,
logout,
getProfile
};
});