Merge pull request 'dev/nicolas/timesheet-validation-implementation' (#28) from dev/nicolas/timesheet-validation-implementation into main

Reviewed-on: Targo/targo_frontend#28
This commit is contained in:
Nicolas 2025-11-25 14:05:00 -05:00
commit 4c79820128
12 changed files with 135 additions and 145 deletions

View File

@ -191,6 +191,8 @@ export default {
INVALID_SHIFT_TIME: "In and Out shift times are reversed",
SHIFT_OVERLAP: "An overlaps occured between 2 or more shifts",
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",
PAY_PERIOD_NOT_FOUND: "No pay period matching given dates",
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",
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",
SHIFT_TIME_REQUIRED: "Heure requise",
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",
EMPLOYEE_NOT_FOUND: "Aucun employé ne correspond aux détails de votre connexion",

View File

@ -26,12 +26,12 @@
@hide="is_dialog_open = false"
>
<q-card
class="shadow-12 rounded-15 column no-wrap relative bg-secondary hide-scrollbar"
class="shadow-12 rounded-15 bg-secondary hide-scrollbar"
:style="($q.screen.lt.md ? '' : 'width:80vw !important;') + ($q.dark.isActive ? ' border: 2px solid var(--q-accent)' : '')"
>
<!-- employee name -->
<q-card-section class="col-auto text-h4 text-weight-bolder text-center text-uppercase q-px-none q-py-sm">
<q-card-section class="text-h4 text-weight-bolder text-center text-uppercase q-px-none q-py-sm">
<span>{{ timesheet_store.selected_employee_name }}</span>
</q-card-section>
@ -39,24 +39,22 @@
<q-card-section
v-if="is_dialog_open"
:horizontal="!$q.screen.lt.md"
class="col-auto q-px-md rounded-10 no-wrap"
class="q-px-md rounded-10 no-wrap"
>
<DetailsDialogChartHoursWorked class="col" />
<DetailsDialogChartShiftTypes class="col q-ma-lg" />
<DetailsDialogChartExpenses class="col" />
</q-card-section>
<q-card-section class="col-auto">
<q-card-section>
<ExpenseDialogList />
</q-card-section>
<!-- list of shifts -->
<q-card-section
:horizontal="$q.screen.gt.sm"
class="col-auto q-px-sm rounded-5 no-wrap"
>
<q-card-section class="col-auto">
<TimesheetWrapper mode="approval" />
</q-card-section>
<q-separator />
</q-card>
</q-dialog>
</template>

View File

@ -4,8 +4,8 @@ import type { PayPeriodOverviewResponse } from "src/modules/timesheet-approval/m
export const timesheetApprovalService = {
getPayPeriodOverviews: async (year: number, period_number: number): Promise<PayPeriodOverviewResponse> => {
const response = await api.get(`pay-periods/overview/${year}/${period_number}`);
return response.data;
const response = await api.get<{success: boolean, data: PayPeriodOverviewResponse, error? : string}>(`pay-periods/overview/${year}/${period_number}`);
return response.data.data;
},
getPayPeriodReportByYearAndPeriodNumber: async (year: number, period_number: number, report_filters?: TimesheetApprovalCSVReportFilters) => {

View File

@ -3,21 +3,17 @@
lang="ts"
>
/* eslint-disable*/
import { onBeforeUnmount, onMounted, ref, useTemplateRef } from 'vue';
import { onBeforeUnmount, onMounted, ref, useTemplateRef, watch, nextTick } from 'vue';
import { useI18n } from 'vue-i18n';
import { QSelect } from 'quasar';
import { Shift, ShiftType } from 'src/modules/timesheets/models/shift.models';
import { QSelect, QInput } from 'quasar';
import { Shift, type ShiftOption } from 'src/modules/timesheets/models/shift.models';
import { useUiStore } from 'src/stores/ui-store';
import { useShiftRules } from 'src/modules/timesheets/utils/shift.util';
let timer: NodeJS.Timeout;
const { t } = useI18n();
const ui_store = useUiStore();
interface ShiftOption {
label: string;
value: ShiftType;
icon: string;
icon_color: string;
}
const shift_rules = useShiftRules(t('timesheet.errors.SHIFT_TIME_REQUIRED'),);
const COMMENT_LENGTH_MAX = 280;
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.SICK'), value: 'SICK', icon: 'medication_liquid', icon_color: 'light-blue-6' },
];
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<{
dense?: boolean;
hasShiftAfter?: boolean;
@ -41,12 +41,6 @@
'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 = () => {
if (shift_type_selected.value === undefined) {
shift.value.type = 'REGULAR';
@ -80,6 +74,10 @@
onBeforeUnmount(() => {
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>
<template>
@ -98,8 +96,8 @@
<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' : 'col-4'"
class="row items-start text-uppercase rounded-5"
:class="ui_store.is_mobile_mode ? 'col q-mb-xs q-px-xs' : 'col-4'"
>
<!-- mobile comment button -->
<q-btn
@ -168,10 +166,10 @@
menu-anchor="bottom middle"
menu-self="top middle"
: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'"
:style="shift.is_approved ? 'background-color: #0a7d32 !important;' : ''"
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"
@ -198,9 +196,10 @@
</q-select>
</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 -->
<q-input
ref="start_time"
v-model="shift.start_time"
dense
:borderless="(shift.is_approved && isTimesheetApproved)"
@ -208,11 +207,15 @@
type="time"
:standout="$q.dark.isActive ? 'bg-blue-grey-3' : 'bg-blue-grey-9'"
label-slot
lazy-rules
no-error-icon
hide-bottom-space
:rules="[shift_rules.isTimeRequired]"
: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 && !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;' : ''"
>
<template #label>
@ -226,6 +229,7 @@
<!-- punch out field -->
<q-input
ref="end_time"
v-model="shift.end_time"
:standout="$q.dark.isActive ? 'bg-blue-grey-3' : 'bg-blue-grey-9'"
dense
@ -233,6 +237,10 @@
:readonly="(shift.is_approved && isTimesheetApproved)"
type="time"
label-slot
lazy-rules
no-error-icon
hide-bottom-space
:rules="[shift_rules.isTimeRequired]"
: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;"
@ -250,7 +258,7 @@
</q-input>
<!-- 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
v-if="shift.type && dense"
:name="shift.comment ? 'comment' : ''"
@ -347,3 +355,17 @@
class="q-mx-md"
/>
</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,31 +2,34 @@
setup
lang="ts"
>
/* eslint-disable */
import ShiftList from 'src/modules/timesheets/components/shift-list.vue';
import ExpenseDialog from 'src/modules/timesheets/components/expense-dialog.vue';
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 { 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 { 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 timesheet_store = useTimesheetStore();
const timesheet_api = useTimesheetApi();
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<{
mode?: 'approval' | 'normal';
}>();
</script>
<template>
<div
class="column flex-center full-width"
:class="mode === 'approval' ? 'bg-dark q-px-sm q-pb-sm q-mb-md rounded-10 shadow-10' : ''"
>
<div class="column flex-center full-width">
<LoadingOverlay v-model="timesheet_store.is_loading" />
<q-card
@ -65,8 +68,8 @@
v-if="mode === 'normal'"
push
rounded
:disable="timesheet_store.is_loading"
color="accent"
:disable="timesheet_store.is_loading || has_shift_errors"
:color="timesheet_store.is_loading || has_shift_errors ? 'grey-5' : 'accent'"
icon="upload"
:label="$t('shared.label.save')"
class="q-mr-md"
@ -92,11 +95,7 @@
<ShiftList :mode="mode" />
<q-card-section
horizontal
class="q-my-md"
>
<q-space />
<q-card-actions align="right">
<q-btn
v-if="mode === 'approval'"
push
@ -108,7 +107,7 @@
class="q-mr-md"
@click="shift_api.saveShiftChanges"
/>
</q-card-section>
</q-card-actions>
</q-card>
<ExpenseDialog />
</div>

View File

@ -1,78 +0,0 @@
// import { type Expense, EXPENSE_TYPE, type ExpenseType } from "src/modules/timesheets/models/expense.models";
// import type { Normalizer } from "src/utils/normalize-object";
// export interface ApiErrorPayload {
// status_code: number;
// error_code?: string;
// message?: string;
// context?: Record<string, unknown>;
// };
// export abstract class ApiError extends Error {
// status_code: number;
// error_code?: string;
// context?: Record<string, unknown>;
// constructor(payload: ApiErrorPayload, defaultMessage: string) {
// super(payload.message || defaultMessage);
// this.status_code = payload.status_code;
// this.error_code = payload.error_code ?? "unknown";
// this.context = payload.context ?? {'unknown': 'unknown error has occured', };
// }
// };
// export class GenericApiError extends ApiError {
// constructor(payload: ApiErrorPayload) {
// super(payload, 'Encountered an error processing request');
// this.name = 'GenericApiError';
// }
// };
// export class ExpensesValidationError extends ApiError {
// constructor(payload: ApiErrorPayload) {
// super(payload, 'Invalid expense payload');
// this.name = 'ExpensesValidationError';
// }
// };
// export class ExpensesApiError extends ApiError {
// constructor(payload: ApiErrorPayload) {
// super(payload, 'Request failed');
// this.name = 'ExpensesApiError';
// }
// };
// export const expense_validation_schema: Normalizer<Expense> = {
// id: v => typeof v === 'number' ? v : -1,
// date: v => typeof v === 'string' ? v.trim() : '1970-01-01',
// type: v => EXPENSE_TYPE.includes(v as ExpenseType) ? v as ExpenseType : "EXPENSES",
// amount: v => typeof v === "number" ? v : -1,
// mileage: v => typeof v === "number" ? v : undefined,
// comment: v => typeof v === 'string' ? v.trim() : '',
// supervisor_comment: v => typeof v === 'string' ? v.trim() : '',
// is_approved: v => !!v,
// };
// export function toExpensesError(err: unknown): ExpensesValidationError | ExpensesApiError {
// if (err instanceof ExpensesValidationError || err instanceof ExpensesApiError) {
// return err;
// }
// if (typeof err === 'object' && err !== null && 'status_code' in err) {
// const payload = err as ApiErrorPayload;
// // Don't know how to differentiate both types of errors, can be updated here
// if (payload.error_code?.startsWith('API_')) {
// return new ExpensesApiError(payload);
// }
// return new ExpensesValidationError(payload);
// }
// // Fallback with ValidationError as default
// return new ExpensesValidationError({
// status_code: 500,
// message: err instanceof Error ? err.message : 'Unknown error',
// context: { original: err }
// });
// }

View File

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

View File

@ -5,18 +5,18 @@ import type { TimesheetOverview } from "src/modules/timesheet-approval/models/ti
export const timesheetService = {
getPayPeriodByDate: async (date_string: string): Promise<PayPeriod> => {
const response = await api.get(`pay-periods/date/${date_string}`);
return response.data;
const response = await api.get<{success: boolean, data: PayPeriod, error? : string}>(`pay-periods/date/${date_string}`);
return response.data.data;
},
getPayPeriodByYearAndPeriodNumber: async (year: number, period_number: number): Promise<PayPeriod> => {
const response = await api.get(`pay-periods/${year}/${period_number}`);
return response.data;
const response = await api.get<{success: boolean, data: PayPeriod, error? : string}>(`pay-periods/${year}/${period_number}`);
return response.data.data;
},
getTimesheetOverviewsByPayPeriodAndSupervisorEmail: async (year: number, period_number: number, supervisor_email: string): Promise<TimesheetOverview[]> => {
const response = await api.get(`pay-periods/${year}/${period_number}/${supervisor_email}`);
return response.data;
const response = await api.get<{success: boolean, data: TimesheetOverview[], error? : string}>(`pay-periods/${year}/${period_number}/${supervisor_email}`);
return response.data.data;
},
getTimesheetsByPayPeriodAndOptionalEmail: async (year: number, period_number: number, employee_email?: string): Promise<TimesheetResponse> => {

View File

@ -0,0 +1,34 @@
import { date, patterns, type ValidationRule } from "quasar";
import type { Shift } from "src/modules/timesheets/models/shift.models";
export const isShiftOverlap = (shifts: Shift[]): boolean => {
if (shifts.length < 2) return false;
const parsed_shifts = shifts.map(shift => ({
start: date.extractDate(`${shift.date} ${shift.start_time}`, 'YYYY-MM-DD HH:mm').getTime(),
end: date.extractDate(`${shift.date} ${shift.end_time}`, 'YYYY-MM-DD HH:mm').getTime(),
}));
for (let i = 0; i < parsed_shifts.length; i++) {
for (let j = i + 1; j < parsed_shifts.length; j++) {
const parsed_shift_a = parsed_shifts[i];
const parsed_shift_b = parsed_shifts[j];
if (parsed_shift_a === undefined || parsed_shift_b === undefined) continue;
if (Math.max(parsed_shift_a.start, parsed_shift_b.start) < Math.min(parsed_shift_a.end, parsed_shift_b.end)) {
return true; // overlap found
}
}
}
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,3 +1,4 @@
/* eslint-disable */
import { ref } from "vue";
import { Notify } from "quasar";
import { defineStore } from "pinia";
@ -20,13 +21,12 @@ export const useShiftStore = defineStore('shift_store', () => {
};
const createNewShifts = async (): Promise<boolean> => {
if (timesheet_store.timesheets === undefined) {
console.log('no changes in existing shifts detected');
return false;
}
if (timesheet_store.timesheets === undefined) return false;
const has_errors = false;
try {
const new_shifts = timesheet_store.timesheets.flatMap(week => week.days).flatMap(day => day.shifts).filter(shift => shift.id < 0);
const days = timesheet_store.timesheets.flatMap(week => week.days);
const new_shifts = days.flatMap(day => day.shifts).filter(shift => shift.id < 0);
if (new_shifts?.length > 0) {
const response = await ShiftService.createNewShifts(new_shifts);
@ -41,7 +41,7 @@ export const useShiftStore = defineStore('shift_store', () => {
}
return false;
} catch (error) {
console.error('Error creating new shifts: ', error);
Notify.create('Error creating new shifts');
return false;
}
};
@ -60,11 +60,10 @@ export const useShiftStore = defineStore('shift_store', () => {
}
}
console.log('No shifts to update');
Notify.create('no shifts to update')
Notify.create('No shifts to update')
return false;
} catch (error) {
console.error('Error updating shifts: ', error);
Notify.create('Error updating shifts');
return false;
}
}

View File

@ -1,4 +1,4 @@
import { ref } from 'vue';
import { computed, ref } from 'vue';
import { defineStore } from 'pinia';
import { unwrapAndClone } from 'src/utils/unwrap-and-clone';
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 pay_period = ref<PayPeriod>();
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 pay_period_overviews = ref<TimesheetOverview[]>([]);
@ -69,6 +70,7 @@ export const useTimesheetStore = defineStore('timesheet', () => {
const getTimesheetsByOptionalEmployeeEmail = async (employee_email?: string) => {
if (pay_period.value === undefined) return;
console.log('pay period: ', pay_period.value);
is_loading.value = true;
let response;
@ -118,6 +120,7 @@ export const useTimesheetStore = defineStore('timesheet', () => {
current_pay_period_overview,
selected_employee_name,
timesheets,
all_current_shifts,
initial_timesheets,
getPayPeriodByDateOrYearAndNumber,
getTimesheetOverviews,