From 13c339953f55b58c942d971413871cff2a8220ed Mon Sep 17 00:00:00 2001 From: Nicolas Drolet Date: Thu, 18 Dec 2025 10:05:31 -0500 Subject: [PATCH] feat(timesheet): add shift overlap verification to shift entries Also refactor mobile UI/UX for timesheet: reduced header bloat, made only shifts scrollable, added left or right swipe to travel between pay periods, showing default 'no data' message when beyond 6-month-back 1-month-forward timesheet scope. --- src/i18n/en-ca/index.ts | 3 +- src/i18n/fr-ca/index.ts | 1 + .../components/pay-period-navigator.vue | 21 +---- .../components/expense-dialog-header.vue | 6 +- .../components/shift-list-day-row.vue | 33 ++++--- .../timesheets/components/shift-list-day.vue | 1 + .../timesheets/components/shift-list.vue | 87 +++++++++++++------ .../components/timesheet-wrapper.vue | 2 +- .../timesheets/composables/use-expense-api.ts | 4 +- .../composables/use-timesheet-api.ts | 11 +++ .../timesheets/services/timesheet-service.ts | 10 +-- src/modules/timesheets/utils/expense.util.ts | 4 +- src/modules/timesheets/utils/shift.util.ts | 19 +++- src/stores/expense-store.ts | 8 +- src/stores/timesheet-store.ts | 31 ++++++- 15 files changed, 152 insertions(+), 89 deletions(-) diff --git a/src/i18n/en-ca/index.ts b/src/i18n/en-ca/index.ts index b020d70..d28ce9e 100644 --- a/src/i18n/en-ca/index.ts +++ b/src/i18n/en-ca/index.ts @@ -237,8 +237,9 @@ export default { errors: { INVALID_SHIFT_TIME: "In and Out shift times are reversed", 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: "Valid time required", + SHIFT_TIME_REQUIRED: "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", diff --git a/src/i18n/fr-ca/index.ts b/src/i18n/fr-ca/index.ts index 1082d02..4a697bd 100644 --- a/src/i18n/fr-ca/index.ts +++ b/src/i18n/fr-ca/index.ts @@ -238,6 +238,7 @@ export default { errors: { 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_SHORT: "Chevauchement", INVALID_SHIFT: "Un quart de travail contient des données manquantes ou corrompues", SHIFT_TIME_REQUIRED: "Heure requise", SHIFT_TYPE_REQUIRED: "Type requis", diff --git a/src/modules/shared/components/pay-period-navigator.vue b/src/modules/shared/components/pay-period-navigator.vue index c83acd3..176b057 100644 --- a/src/modules/shared/components/pay-period-navigator.vue +++ b/src/modules/shared/components/pay-period-navigator.vue @@ -33,30 +33,13 @@ emit('date-selected', value); }; - const getNextOrPreviousPayPeriod = (direction: number) => { - const pay_period = timesheet_store.pay_period; - if (!pay_period) return; - - pay_period.pay_period_no += direction; - - if (pay_period.pay_period_no > 26) { - pay_period.pay_period_no = 1; - pay_period.pay_year += direction; - } - - if (pay_period.pay_period_no < 1) { - pay_period.pay_period_no = 26; - pay_period.pay_year += direction; - } - }; - const getNextPayPeriod = () => { - getNextOrPreviousPayPeriod(NEXT); + timesheet_store.getNextOrPreviousPayPeriod(NEXT); emit('pressed-next-button'); } const getPreviousPayPeriod = () => { - getNextOrPreviousPayPeriod(PREVIOUS); + timesheet_store.getNextOrPreviousPayPeriod(PREVIOUS); emit('pressed-previous-button'); }; diff --git a/src/modules/timesheets/components/expense-dialog-header.vue b/src/modules/timesheets/components/expense-dialog-header.vue index b201811..0602f5e 100644 --- a/src/modules/timesheets/components/expense-dialog-header.vue +++ b/src/modules/timesheets/components/expense-dialog-header.vue @@ -12,8 +12,10 @@ let expenses = 0; let mileage = 0; timesheet_store.timesheets.forEach(timesheet => { - expenses += timesheet.weekly_expenses.expenses ?? 0; - mileage += timesheet.weekly_expenses.mileage ?? 0; + expenses += timesheet.weekly_expenses.expenses; + expenses += timesheet.weekly_expenses.on_call; + expenses += timesheet.weekly_expenses.per_diem; + mileage += timesheet.weekly_expenses.mileage; }); return { expenses, mileage }; diff --git a/src/modules/timesheets/components/shift-list-day-row.vue b/src/modules/timesheets/components/shift-list-day-row.vue index c78624e..3ad6c58 100644 --- a/src/modules/timesheets/components/shift-list-day-row.vue +++ b/src/modules/timesheets/components/shift-list-day-row.vue @@ -8,39 +8,33 @@ 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'; + import { useShiftRules, SHIFT_OPTIONS } from 'src/modules/timesheets/utils/shift.util'; let timer: NodeJS.Timeout; const { t } = useI18n(); const ui_store = useUiStore(); - const shift_rules = useShiftRules(t('timesheet.errors.SHIFT_TIME_REQUIRED'),); - + const COMMENT_LENGTH_MAX = 280; - - const SHIFT_OPTIONS: ShiftOption[] = [ - { label: t('timesheet.shift.types.REGULAR'), value: 'REGULAR', icon: 'wb_sunny', icon_color: 'blue-grey-3' }, - { label: t('timesheet.shift.types.EVENING'), value: 'EVENING', icon: 'bedtime', icon_color: 'indigo-8' }, - { label: t('timesheet.shift.types.EMERGENCY'), value: 'EMERGENCY', icon: 'ring_volume', icon_color: 'red-8' }, - { label: t('timesheet.shift.types.VACATION'), value: 'VACATION', icon: 'beach_access', icon_color: 'yellow-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' }, - ]; - + const shift = defineModel('shift', { required: true }); const shift_type_selected = ref(SHIFT_OPTIONS.find(option => option.value == shift.value.type)); const select_ref = useTemplateRef('select'); const start_time_ref = useTemplateRef('start_time'); const end_time_ref = useTemplateRef('end_time'); - const { dense = false, hasShiftAfter = false, isTimesheetApproved = false } = defineProps<{ + const { dayShifts = [], dense = false, hasShiftAfter = false, isTimesheetApproved = false } = defineProps<{ + dayShifts: Shift[]; dense?: boolean; hasShiftAfter?: boolean; isTimesheetApproved?: boolean; }>(); + const shift_rules = useShiftRules(t('timesheet.errors.SHIFT_TIME_REQUIRED'), t('timesheet.errors.SHIFT_OVERLAP_SHORT'), dayShifts); + const emit = defineEmits<{ 'saveComment': [comment: string, shift_id: number]; 'requestDelete': [void]; + 'onTimeFieldBlur': [void]; }>(); const onBlurShiftTypeSelect = () => { @@ -194,7 +188,7 @@ class="col-auto ellipsis" :class="!shift.is_approved ? '' : 'text-white'" > - {{ scope.opt.label }} + {{ $t(scope.opt.label) }} @@ -235,7 +229,8 @@ :offset="[0, 10]" class="text-uppercase text-weight-medium text-white bg-accent" > - {{ $t('timesheet.remote_button') }} + {{ shift.is_remote ? $t('timesheet.shift.types.REMOTE') : + $t('timesheet.shift.types.OFFICE') }} @@ -256,13 +251,14 @@ lazy-rules no-error-icon hide-bottom-space - :rules="[shift_rules.isTimeRequired]" + :rules="[shift_rules.isTimeRequiredRule, shift_rules.isShiftOverlapRule]" :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')" :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')" >