feat(timesheet): add validation for timesheet timestamps, prevent submission if errors present.

This commit is contained in:
Nicolas Drolet 2025-11-25 14:03:45 -05:00
parent 712e831653
commit 5bb02e67a0
7 changed files with 85 additions and 37 deletions

View File

@ -191,6 +191,8 @@ export default {
INVALID_SHIFT_TIME: "In and Out shift times are reversed", INVALID_SHIFT_TIME: "In and Out shift times are reversed",
SHIFT_OVERLAP: "An overlaps occured between 2 or more shifts", SHIFT_OVERLAP: "An overlaps occured between 2 or more shifts",
INVALID_SHIFT: "A shift contains missing or corrupted data", INVALID_SHIFT: "A shift contains missing or corrupted data",
SHIFT_TIME_REQUIRED: "Valid time required",
SHIFT_TYPE_REQUIRED: "Shift type required",
SHIFT_NOT_FOUND: "Shift missing or deleted", SHIFT_NOT_FOUND: "Shift missing or deleted",
PAY_PERIOD_NOT_FOUND: "No pay period matching given dates", PAY_PERIOD_NOT_FOUND: "No pay period matching given dates",
EMPLOYEE_NOT_FOUND: "No employee matching current login details", EMPLOYEE_NOT_FOUND: "No employee matching current login details",

View File

@ -192,6 +192,8 @@ export default {
INVALID_SHIFT_TIME: "Les heures d'entrée et de sortie sont inversées", INVALID_SHIFT_TIME: "Les heures d'entrée et de sortie sont inversées",
SHIFT_OVERLAP: "Il y a un chevauchement entre deux ou plusieurs quarts", SHIFT_OVERLAP: "Il y a un chevauchement entre deux ou plusieurs quarts",
INVALID_SHIFT: "Un quart de travail contient des données manquantes ou corrompues", INVALID_SHIFT: "Un quart de travail contient des données manquantes ou corrompues",
SHIFT_TIME_REQUIRED: "Heure requise",
SHIFT_TYPE_REQUIRED: "Type requis",
SHIFT_NOT_FOUND: "Quart de travail manquant ou supprimé", SHIFT_NOT_FOUND: "Quart de travail manquant ou supprimé",
PAY_PERIOD_NOT_FOUND: "Aucune période de paie ne correspond aux dates fournies", PAY_PERIOD_NOT_FOUND: "Aucune période de paie ne correspond aux dates fournies",
EMPLOYEE_NOT_FOUND: "Aucun employé ne correspond aux détails de votre connexion", EMPLOYEE_NOT_FOUND: "Aucun employé ne correspond aux détails de votre connexion",

View File

@ -3,21 +3,17 @@
lang="ts" lang="ts"
> >
/* eslint-disable*/ /* eslint-disable*/
import { onBeforeUnmount, onMounted, ref, useTemplateRef } from 'vue'; import { onBeforeUnmount, onMounted, ref, useTemplateRef, watch, nextTick } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { QSelect } from 'quasar'; import { QSelect, QInput } from 'quasar';
import { Shift, ShiftType } from 'src/modules/timesheets/models/shift.models'; import { Shift, type ShiftOption } from 'src/modules/timesheets/models/shift.models';
import { useUiStore } from 'src/stores/ui-store'; import { useUiStore } from 'src/stores/ui-store';
import { useShiftRules } from 'src/modules/timesheets/utils/shift.util';
let timer: NodeJS.Timeout;
const { t } = useI18n(); const { t } = useI18n();
const ui_store = useUiStore(); const ui_store = useUiStore();
const shift_rules = useShiftRules(t('timesheet.errors.SHIFT_TIME_REQUIRED'),);
interface ShiftOption {
label: string;
value: ShiftType;
icon: string;
icon_color: string;
}
const COMMENT_LENGTH_MAX = 280; const COMMENT_LENGTH_MAX = 280;
const SHIFT_OPTIONS: ShiftOption[] = [ const SHIFT_OPTIONS: ShiftOption[] = [
@ -28,8 +24,12 @@
{ label: t('timesheet.shift.types.HOLIDAY'), value: 'HOLIDAY', icon: 'forest', icon_color: 'green-8' }, { label: t('timesheet.shift.types.HOLIDAY'), value: 'HOLIDAY', icon: 'forest', icon_color: 'green-8' },
{ label: t('timesheet.shift.types.SICK'), value: 'SICK', icon: 'medication_liquid', icon_color: 'light-blue-6' }, { label: t('timesheet.shift.types.SICK'), value: 'SICK', icon: 'medication_liquid', icon_color: 'light-blue-6' },
]; ];
const shift = defineModel<Shift>('shift', { required: true }); 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 { dense = false, hasShiftAfter = false, isTimesheetApproved = false } = defineProps<{ const { dense = false, hasShiftAfter = false, isTimesheetApproved = false } = defineProps<{
dense?: boolean; dense?: boolean;
hasShiftAfter?: boolean; hasShiftAfter?: boolean;
@ -41,12 +41,6 @@
'requestDelete': [void]; 'requestDelete': [void];
}>(); }>();
const select_ref = useTemplateRef<QSelect>('select');
let timer: NodeJS.Timeout;
const shift_type_selected = ref(SHIFT_OPTIONS.find(option => option.value == shift.value.type));
const onBlurShiftTypeSelect = () => { const onBlurShiftTypeSelect = () => {
if (shift_type_selected.value === undefined) { if (shift_type_selected.value === undefined) {
shift.value.type = 'REGULAR'; shift.value.type = 'REGULAR';
@ -80,6 +74,10 @@
onBeforeUnmount(() => { onBeforeUnmount(() => {
clearTimeout(timer); clearTimeout(timer);
}); });
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> </script>
<template> <template>
@ -98,8 +96,8 @@
<div :class="ui_store.is_mobile_mode ? 'column' : 'row'"> <div :class="ui_store.is_mobile_mode ? 'column' : 'row'">
<div <div
class="row items-center text-uppercase rounded-5" class="row items-start text-uppercase rounded-5"
:class="ui_store.is_mobile_mode ? 'col q-mb-xs' : 'col-4'" :class="ui_store.is_mobile_mode ? 'col q-mb-xs q-px-xs' : 'col-4'"
> >
<!-- mobile comment button --> <!-- mobile comment button -->
<q-btn <q-btn
@ -168,10 +166,10 @@
menu-anchor="bottom middle" menu-anchor="bottom middle"
menu-self="top middle" menu-self="top middle"
:options="SHIFT_OPTIONS" :options="SHIFT_OPTIONS"
class="col rounded-5 q-mx-xs bg-dark" class="col rounded-5 q-mx-xs bg-dark q-pt-xs"
:class="!shift.is_approved && !isTimesheetApproved ? '' : 'inset-shadow'" :class="!shift.is_approved && !isTimesheetApproved ? '' : 'inset-shadow'"
:style="shift.is_approved ? 'background-color: #0a7d32 !important;' : ''"
popup-content-class="text-uppercase text-weight-bold text-center rounded-5" 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)" popup-content-style="border: 2px solid var(--q-accent)"
@blur="onBlurShiftTypeSelect" @blur="onBlurShiftTypeSelect"
@update:model-value="option => shift.type = option.value" @update:model-value="option => shift.type = option.value"
@ -198,9 +196,10 @@
</q-select> </q-select>
</div> </div>
<div class="col row flex-center text-uppercase rounded-5 q-pa-xs"> <div class="col row items-start text-uppercase rounded-5 q-pa-xs">
<!-- punch in field --> <!-- punch in field -->
<q-input <q-input
ref="start_time"
v-model="shift.start_time" v-model="shift.start_time"
dense dense
:borderless="(shift.is_approved && isTimesheetApproved)" :borderless="(shift.is_approved && isTimesheetApproved)"
@ -208,11 +207,15 @@
type="time" type="time"
:standout="$q.dark.isActive ? 'bg-blue-grey-3' : 'bg-blue-grey-9'" :standout="$q.dark.isActive ? 'bg-blue-grey-3' : 'bg-blue-grey-9'"
label-slot label-slot
lazy-rules
no-error-icon
hide-bottom-space
:rules="[shift_rules.isTimeRequired]"
:label-color="!shift.is_approved ? 'accent' : 'white'" :label-color="!shift.is_approved ? 'accent' : 'white'"
:input-class="'text-weight-medium ' + (shift.id === -2 ? 'text-white ' : ' ') + (shift.is_approved ? 'text-white cursor-not-allowed q-px-sm' : '')"
input-style="font-size: 1.2em;"
class="col rounded-5 bg-dark" 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="(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')"
: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;' : ''" :style="shift.is_approved ? 'background-color: #0a7d32 !important;' : ''"
> >
<template #label> <template #label>
@ -226,6 +229,7 @@
<!-- punch out field --> <!-- punch out field -->
<q-input <q-input
ref="end_time"
v-model="shift.end_time" v-model="shift.end_time"
:standout="$q.dark.isActive ? 'bg-blue-grey-3' : 'bg-blue-grey-9'" :standout="$q.dark.isActive ? 'bg-blue-grey-3' : 'bg-blue-grey-9'"
dense dense
@ -233,6 +237,10 @@
:readonly="(shift.is_approved && isTimesheetApproved)" :readonly="(shift.is_approved && isTimesheetApproved)"
type="time" type="time"
label-slot label-slot
lazy-rules
no-error-icon
hide-bottom-space
:rules="[shift_rules.isTimeRequired]"
:label-color="!shift.is_approved ? 'accent' : 'white'" :label-color="!shift.is_approved ? 'accent' : 'white'"
:input-class="'text-weight-medium ' + (shift.id === -2 ? 'text-white ' : ' ') + (shift.is_approved ? 'text-white cursor-not-allowed q-px-sm' : '')" :input-class="'text-weight-medium ' + (shift.id === -2 ? 'text-white ' : ' ') + (shift.is_approved ? 'text-white cursor-not-allowed q-px-sm' : '')"
input-style="font-size: 1.2em;" input-style="font-size: 1.2em;"
@ -250,7 +258,7 @@
</q-input> </q-input>
<!-- comment and delete buttons --> <!-- comment and delete buttons -->
<div :class="ui_store.is_mobile_mode ? 'col-12 row' : 'col-auto'"> <div :class="ui_store.is_mobile_mode ? 'col-12 row' : 'col-auto self-start'">
<q-icon <q-icon
v-if="shift.type && dense" v-if="shift.type && dense"
:name="shift.comment ? 'comment' : ''" :name="shift.comment ? 'comment' : ''"
@ -347,3 +355,17 @@
class="q-mx-md" class="q-mx-md"
/> />
</template> </template>
<style scoped>
:deep(.q-field--error) {
background-color: var(--q-negative) !important;
}
:deep(.q-field--error .q-field__bottom) {
color: white;
font-weight: 900;
border-radius: 0 0 5px 5px;
padding-top: 0;
align-items: center;
}
</style>

View File

@ -2,30 +2,34 @@
setup setup
lang="ts" lang="ts"
> >
/* eslint-disable */
import ShiftList from 'src/modules/timesheets/components/shift-list.vue'; import ShiftList from 'src/modules/timesheets/components/shift-list.vue';
import ExpenseDialog from 'src/modules/timesheets/components/expense-dialog.vue'; import ExpenseDialog from 'src/modules/timesheets/components/expense-dialog.vue';
import PayPeriodNavigator from 'src/modules/shared/components/pay-period-navigator.vue'; import PayPeriodNavigator from 'src/modules/shared/components/pay-period-navigator.vue';
import TimesheetErrorWidget from 'src/modules/timesheets/components/timesheet-error-widget.vue'; import TimesheetErrorWidget from 'src/modules/timesheets/components/timesheet-error-widget.vue';
import LoadingOverlay from 'src/modules/shared/components/loading-overlay.vue'; import LoadingOverlay from 'src/modules/shared/components/loading-overlay.vue';
import { useTimesheetStore } from 'src/stores/timesheet-store';
import { computed } from 'vue';
import { useShiftApi } from 'src/modules/timesheets/composables/use-shift-api';
import { useTimesheetApi } from 'src/modules/timesheets/composables/use-timesheet-api'; import { useTimesheetApi } from 'src/modules/timesheets/composables/use-timesheet-api';
import { useExpensesStore } from 'src/stores/expense-store'; import { useExpensesStore } from 'src/stores/expense-store';
import { useShiftApi } from 'src/modules/timesheets/composables/use-shift-api'; import { useTimesheetStore } from 'src/stores/timesheet-store';
const expenses_store = useExpensesStore(); const expenses_store = useExpensesStore();
const timesheet_store = useTimesheetStore(); const timesheet_store = useTimesheetStore();
const timesheet_api = useTimesheetApi(); const timesheet_api = useTimesheetApi();
const shift_api = useShiftApi(); const shift_api = useShiftApi();
const has_shift_errors = computed(() => timesheet_store.all_current_shifts.filter(shift => shift.has_error === true).length > 0);
const { mode = 'normal' } = defineProps<{ const { mode = 'normal' } = defineProps<{
mode?: 'approval' | 'normal'; mode?: 'approval' | 'normal';
}>(); }>();
</script> </script>
<template> <template>
<div <div class="column flex-center full-width">
class="column flex-center full-width"
>
<LoadingOverlay v-model="timesheet_store.is_loading" /> <LoadingOverlay v-model="timesheet_store.is_loading" />
<q-card <q-card
@ -64,8 +68,8 @@
v-if="mode === 'normal'" v-if="mode === 'normal'"
push push
rounded rounded
:disable="timesheet_store.is_loading" :disable="timesheet_store.is_loading || has_shift_errors"
color="accent" :color="timesheet_store.is_loading || has_shift_errors ? 'grey-5' : 'accent'"
icon="upload" icon="upload"
:label="$t('shared.label.save')" :label="$t('shared.label.save')"
class="q-mr-md" class="q-mr-md"

View File

@ -28,7 +28,7 @@ export class Shift {
comment: string | undefined; comment: string | undefined;
is_approved: boolean; is_approved: boolean;
is_remote: boolean; is_remote: boolean;
error?: string; has_error: boolean;
constructor() { constructor() {
this.id = -1; this.id = -1;
@ -40,9 +40,17 @@ export class Shift {
this.comment = undefined; this.comment = undefined;
this.is_approved = false; this.is_approved = false;
this.is_remote = false; this.is_remote = false;
this.has_error = false;
} }
} }
export interface ShiftOption {
label: string;
value: ShiftType;
icon: string;
icon_color: string;
}
export interface ShiftAPIResponse { export interface ShiftAPIResponse {
ok: boolean; ok: boolean;
data?: { data?: {

View File

@ -1,4 +1,4 @@
import { date } from "quasar"; import { date, patterns, type ValidationRule } from "quasar";
import type { Shift } from "src/modules/timesheets/models/shift.models"; import type { Shift } from "src/modules/timesheets/models/shift.models";
export const isShiftOverlap = (shifts: Shift[]): boolean => { export const isShiftOverlap = (shifts: Shift[]): boolean => {
@ -24,3 +24,11 @@ export const isShiftOverlap = (shifts: Shift[]): boolean => {
return false; return false;
}; };
export const useShiftRules = (time_required_error: string) => {
const isTimeRequired: ValidationRule<string> = (time_string: string) => (!!time_string && patterns.testPattern.time(time_string)) || time_required_error;
return {
isTimeRequired,
};
};

View File

@ -1,4 +1,4 @@
import { ref } from 'vue'; import { computed, ref } from 'vue';
import { defineStore } from 'pinia'; import { defineStore } from 'pinia';
import { unwrapAndClone } from 'src/utils/unwrap-and-clone'; import { unwrapAndClone } from 'src/utils/unwrap-and-clone';
import { timesheetApprovalService } from 'src/modules/timesheet-approval/services/timesheet-approval-service'; import { timesheetApprovalService } from 'src/modules/timesheet-approval/services/timesheet-approval-service';
@ -13,6 +13,7 @@ export const useTimesheetStore = defineStore('timesheet', () => {
const is_loading = ref<boolean>(false); const is_loading = ref<boolean>(false);
const pay_period = ref<PayPeriod>(); const pay_period = ref<PayPeriod>();
const timesheets = ref<Timesheet[]>([]); const timesheets = ref<Timesheet[]>([]);
const all_current_shifts = computed(() => timesheets.value.flatMap(week => week.days.flatMap(day => day.shifts)) ?? []);
const initial_timesheets = ref<Timesheet[]>([]); const initial_timesheets = ref<Timesheet[]>([]);
const pay_period_overviews = ref<TimesheetOverview[]>([]); const pay_period_overviews = ref<TimesheetOverview[]>([]);
@ -119,6 +120,7 @@ export const useTimesheetStore = defineStore('timesheet', () => {
current_pay_period_overview, current_pay_period_overview,
selected_employee_name, selected_employee_name,
timesheets, timesheets,
all_current_shifts,
initial_timesheets, initial_timesheets,
getPayPeriodByDateOrYearAndNumber, getPayPeriodByDateOrYearAndNumber,
getTimesheetOverviews, getTimesheetOverviews,