fix(timesheets): add autofill functionality to sick, vacation, holiday shifts.

This commit is contained in:
Nic D 2026-02-23 14:36:51 -05:00
parent 6368beb24d
commit 2b1b0dbcbd
7 changed files with 194 additions and 77 deletions

View File

@ -5,32 +5,46 @@
import { computed, onMounted, ref } from 'vue';
import { QSelect, QInput } from 'quasar';
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';
import { getCurrentDailyMinutesWorked, getTimeStringFromMinutes, SHIFT_OPTIONS } from 'src/modules/timesheets/utils/shift.util';
import type { Shift, ShiftOption, ShiftType } from 'src/modules/timesheets/models/shift.models';
import { getHoursMinutesStringFromHoursFloat } from 'src/utils/date-and-time-utils';
// ========== state ========================================
const SHIFT_TYPES_WITH_PREDEFINED_TIMES: ShiftType[] = ['HOLIDAY', 'SICK', 'VACATION'];
const COMMENT_LENGTH_MAX = 280;
const shift = defineModel<Shift>('shift', { required: true });
const { errorMessage = undefined, dense = false, hasShiftAfter = false, isTimesheetApproved = false } = defineProps<{
const {
dense = false,
hasShiftAfter = false,
isTimesheetApproved = false,
errorMessage = undefined,
expectedDailyHours = 8,
currentShifts,
} = defineProps<{
dense?: boolean;
hasShiftAfter?: boolean;
isTimesheetApproved?: boolean;
errorMessage?: string | undefined;
expectedDailyHours?: number;
currentShifts: Shift[];
}>();
const emit = defineEmits<{
'requestDelete': [void];
'onTimeFieldBlur': [void];
}>();
const ui_store = useUiStore();
const shift_type_selected = ref(SHIFT_OPTIONS.find(option => option.value == shift.value.type));
const shiftTypeSelected = ref(SHIFT_OPTIONS.find(option => option.value == shift.value.type));
const select_ref = ref<QSelect | null>(null);
const is_showing_comment_popup = ref(false);
const error_message = ref('');
const isShowingPredefinedTime = ref(shift.value.type === 'HOLIDAY');
const predefinedHoursString = ref('');
const predefinedHoursBgColor = ref(`bg-${shiftTypeSelected.value?.icon_color ?? ''}`);
// ========== computed ========================================
@ -39,7 +53,7 @@
// ========== methods =========================================
const onBlurShiftTypeSelect = () => {
if (shift_type_selected.value === undefined) {
if (shiftTypeSelected.value === undefined) {
shift.value.type = 'REGULAR';
shift.value.id = 0;
emit('requestDelete');
@ -63,11 +77,35 @@
return 'negative';
};
const onShiftTypeChange = (option: ShiftOption) => {
shift.value.type = option.value;
if (SHIFT_TYPES_WITH_PREDEFINED_TIMES.includes(option.value)) {
predefinedHoursBgColor.value = `bg-${option.icon_color}`;
shift.value.start_time = '00:00';
if (option.value === 'SICK' || option.value === 'VACATION') {
const workedMinutes = getCurrentDailyMinutesWorked(currentShifts);
console.log('worked minutes: ', workedMinutes);
const expectedWorkedMinutes = expectedDailyHours * 60;
const leftOverMinutes = expectedWorkedMinutes - workedMinutes;
shift.value.end_time = getTimeStringFromMinutes(leftOverMinutes);
isShowingPredefinedTime.value = false;
} else {
isShowingPredefinedTime.value = true;
predefinedHoursString.value = getHoursMinutesStringFromHoursFloat(expectedDailyHours);
shift.value.end_time = getTimeStringFromMinutes(expectedDailyHours * 60);
}
} else
isShowingPredefinedTime.value = false;
}
onMounted(() => {
if (ui_store.focus_next_component) {
select_ref.value?.focus();
select_ref.value?.showPopup();
shift_type_selected.value = undefined;
shiftTypeSelected.value = undefined;
ui_store.focus_next_component = false;
}
@ -131,7 +169,7 @@
<!-- shift type -->
<q-select
ref="select"
v-model="shift_type_selected"
v-model="shiftTypeSelected"
:standout="$q.dark.isActive ? 'bg-blue-grey-3' : 'bg-blue-grey-9'"
dense
:borderless="(shift.is_approved && isTimesheetApproved)"
@ -148,7 +186,7 @@
: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"
@update:model-value="onShiftTypeChange"
>
<template #selected-item="scope">
<div
@ -234,7 +272,25 @@
</q-select>
</div>
<div class="col row items-start text-uppercase rounded-5 q-pa-xs">
<div
v-if="isShowingPredefinedTime"
class="col row items-start text-uppercase rounded-5 q-pa-xs relative-position"
>
<div
class="absolute-full rounded-5 q-mx-sm q-my-xs"
:class="predefinedHoursBgColor"
style="opacity: 0.3;"
></div>
<span class="col text-center text-uppercase text-h6 text-bold q-py-xs">
{{ getHoursMinutesStringFromHoursFloat(expectedDailyHours) }}
</span>
</div>
<div
v-else
class="col row items-start text-uppercase rounded-5 q-pa-xs"
>
<!-- punch in field -->
<div class="col q-pr-xs">
<q-input

View File

@ -7,14 +7,14 @@
import { QSelect, QInput, useQuasar, type QSelectProps, QPopupProxy } from 'quasar';
import { useUiStore } from 'src/stores/ui-store';
import { useAuthStore } from 'src/stores/auth-store';
import { SHIFT_OPTIONS } from 'src/modules/timesheets/utils/shift.util';
import type { TotalHours } from 'src/modules/timesheets/models/timesheet.models';
import { getCurrentDailyMinutesWorked, getShiftOptions, getTimeStringFromMinutes, SHIFT_OPTIONS } from 'src/modules/timesheets/utils/shift.util';
import type { Shift, ShiftOption, ShiftType } from 'src/modules/timesheets/models/shift.models';
import { getHoursMinutesStringFromHoursFloat } from 'src/utils/date-and-time-utils';
import { getHoursMinutesStringFromHoursFloat } from 'src/utils/date-and-time-utils';
// ========== Constants ========================================
const SHIFT_TYPES_WITH_PREDEFINED_TIMES: ShiftType[] = ['HOLIDAY', 'SICK', 'VACATION'];
const COMMENT_LENGTH_MAX = 280;
// ========== State ========================================
@ -23,11 +23,11 @@ import { getHoursMinutesStringFromHoursFloat } from 'src/utils/date-and-time-uti
const {
errorMessage = undefined,
isTimesheetApproved = false,
currentShifts,
holiday = false,
expectedDailyHours = 8,
dailyHours,
} = defineProps<{
dailyHours: TotalHours;
currentShifts: Shift[];
expectedDailyHours?: number;
isTimesheetApproved?: boolean;
errorMessage?: string | undefined;
@ -39,9 +39,6 @@ import { getHoursMinutesStringFromHoursFloat } from 'src/utils/date-and-time-uti
'onTimeFieldBlur': [void];
}>();
const COMMENT_LENGTH_MAX = 280;
const q = useQuasar();
const { t } = useI18n();
const ui_store = useUiStore();
@ -53,13 +50,15 @@ import { getHoursMinutesStringFromHoursFloat } from 'src/utils/date-and-time-uti
const selectRef = ref<QSelect | null>(null);
const shiftErrorMessage = ref<string | undefined>();
const is_showing_delete_confirm = ref(false);
const isShowingPredefinedTime = ref(false);
const isShowingPredefinedTime = ref(shift.value.type === 'HOLIDAY');
const popupProxyRef = ref<QPopupProxy | null>(null);
const predefinedHours = ref(0);
const predefinedHoursBgColor = ref('');
const predefinedHoursString = ref('');
const predefinedHoursBgColor = ref(`bg-${shiftTypeSelected.value?.icon_color ?? ''}`);
// ================== Computed ==================
const hasPTO = computed(() => currentShifts.some(shift => SHIFT_TYPES_WITH_PREDEFINED_TIMES.includes(shift.type)));
const rightClickMenuIcon = computed(() => shift.value.is_approved ? 'lock_open' : 'lock');
const rightClickMenuLabel = computed(() => shift.value.is_approved ?
@ -94,7 +93,7 @@ import { getHoursMinutesStringFromHoursFloat } from 'src/utils/date-and-time-uti
menuOffset: [0, 10],
menuAnchor: "bottom middle",
menuSelf: "top middle",
options: SHIFT_OPTIONS,
options: getShiftOptions(hasPTO.value, currentShifts.length > 1),
class: `col rounded-5 q-mx-xs bg-dark ${!shift.value.is_approved && !isTimesheetApproved ? '' : 'inset-shadow'}`,
popupContentClass: "text-uppercase text-weight-bold text-center rounded-5",
style: shift.value.is_approved ? (holiday ? 'background-color: #7b1fa2 !important' : 'background-color: #0a7d32 !important;') : '',
@ -149,21 +148,23 @@ import { getHoursMinutesStringFromHoursFloat } from 'src/utils/date-and-time-uti
shift.value.type = option.value;
if (SHIFT_TYPES_WITH_PREDEFINED_TIMES.includes(option.value)) {
isShowingPredefinedTime.value = true;
predefinedHoursBgColor.value = `bg-${option.icon_color}`;
shift.value.start_time = '00:00';
if (option.value === 'SICK') {
const workedHours =
dailyHours.regular +
dailyHours.emergency +
dailyHours.evening +
dailyHours.holiday +
dailyHours.sick +
dailyHours.vacation;
predefinedHours.value = Math.max(expectedDailyHours - workedHours, 0);
if (option.value === 'SICK' || option.value === 'VACATION') {
const workedMinutes = getCurrentDailyMinutesWorked(currentShifts);
console.log('worked minutes: ', workedMinutes);
const expectedWorkedMinutes = expectedDailyHours * 60;
const leftOverMinutes = expectedWorkedMinutes - workedMinutes;
shift.value.end_time = getTimeStringFromMinutes(leftOverMinutes);
} else {
isShowingPredefinedTime.value = true;
predefinedHoursString.value = getHoursMinutesStringFromHoursFloat(expectedDailyHours);
shift.value.start_time = '00:00';
shift.value.end_time = `${expectedDailyHours}:00`;
}
}
else
} else
isShowingPredefinedTime.value = false;
}
@ -334,21 +335,21 @@ import { getHoursMinutesStringFromHoursFloat } from 'src/utils/date-and-time-uti
<!-- If shift type has predefined timestamps -->
<div
v-if="isShowingPredefinedTime"
class="col row q-px-sm relative-position flex-center"
class="col row q-pa-xs relative-position flex-center"
>
<div
class="absolute-full rounded-5 q-mx-sm"
class="absolute-full rounded-5 q-mx-sm q-my-xs"
:class="predefinedHoursBgColor"
style="opacity: 0.3;"
></div>
<span class="col text-center text-uppercase text-h6 text-bold">{{ getHoursMinutesStringFromHoursFloat(predefinedHours) }}</span>
<span class="col text-center text-uppercase text-h6 text-bold q-py-xs">
{{ getHoursMinutesStringFromHoursFloat(expectedDailyHours) }}
</span>
</div>
<!-- Else show input fields for in-out timestamps -->
<div
v-else
class="col row items-start text-uppercase rounded-5 q-pa-xs"
>
<div v-else class="col row items-start text-uppercase rounded-5 q-pa-xs">
<q-input
ref="start_time"
v-model="shift.start_time"

View File

@ -107,6 +107,7 @@
:is-timesheet-approved="approved"
:error-message="shift_error_message"
:dense="dense"
:current-shifts="day.shifts"
:has-shift-after="shift_index < day.shifts.length - 1"
@request-delete="deleteCurrentShift(shift)"
@on-time-field-blur="onTimeFieldBlur()"
@ -116,7 +117,7 @@
v-else
v-model:shift="day.shifts[shift_index]!"
:holiday="holiday"
:daily-hours="day.daily_hours"
:current-shifts="day.shifts"
:is-timesheet-approved="approved"
:error-message="shift_error_message"
@request-delete="deleteCurrentShift(shift)"

View File

@ -46,4 +46,5 @@ export interface ShiftOption extends QSelectOption {
value: ShiftType;
icon: string;
icon_color: string;
disable?: boolean;
}

View File

@ -7,6 +7,7 @@ export const DATE_FORMAT_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
export interface TimesheetResponse {
has_preset_schedule: boolean;
employee_fullname: string;
daily_expected_hours: number;
timesheets: Timesheet[];
}

View File

@ -1,38 +1,82 @@
import { date } from "quasar";
import type { SchedulePresetShift } from "src/modules/employee-list/models/schedule-presets.models";
import type { Shift, ShiftOption } from "src/modules/timesheets/models/shift.models";
import type { Shift, ShiftOption, ShiftType } from "src/modules/timesheets/models/shift.models";
export const isShiftOverlap = (shifts: Shift[] | SchedulePresetShift[]): boolean => {
if (shifts.length < 2) return false;
if (shifts.length < 2) return false;
const parsed_shifts = shifts.map(shift => ({
start: date.extractDate(`2000-01-01 ${shift.start_time}`, 'YYYY-MM-DD HH:mm').getTime(),
end: date.extractDate(`2000-01-01 ${shift.end_time}`, 'YYYY-MM-DD HH:mm').getTime(),
}));
const parsed_shifts = shifts.map(shift => ({
start: date.extractDate(`2000-01-01 ${shift.start_time}`, 'YYYY-MM-DD HH:mm').getTime(),
end: date.extractDate(`2000-01-01 ${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];
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 (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
}
}
}
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;
return false;
};
export const getCurrentDailyMinutesWorked = (shifts: Shift[]): number => {
const shiftTypesToIgnore: ShiftType[] = ['HOLIDAY', 'SICK', 'VACATION'];
let minutes = 0;
shifts.forEach(shift => {
if (shiftTypesToIgnore.includes(shift.type)) return;
const startTime = new Date(`1970-01-01T${shift.start_time}:00`);
const endTime = new Date(`1970-01-01T${shift.end_time}:00`);
const diff = date.getDateDiff(endTime, startTime, 'minutes');
minutes += diff;
});
return minutes;
}
export const getTimeStringFromMinutes = (minutes: number): string => {
const h = Math.floor(minutes / 60);
const m = minutes % 60;
if (h < 10 ) {
if (m < 10)
return `0${h}:0${m}`;
return `0${h}:${m}`;
}
return `${h}:${m}`;
}
export const SHIFT_OPTIONS: ShiftOption[] = [
{ label: 'timesheet.shift.types.REGULAR', value: 'REGULAR', icon: 'wb_sunny', icon_color: 'accent' },
{ label: 'timesheet.shift.types.EVENING', value: 'EVENING', icon: 'bedtime', icon_color: 'orange-5' },
{ label: 'timesheet.shift.types.EMERGENCY', value: 'EMERGENCY', icon: 'ring_volume', icon_color: 'red-5' },
{ label: 'timesheet.shift.types.VACATION', value: 'VACATION', icon: 'beach_access', icon_color: 'deep-orange-5' },
{ label: 'timesheet.shift.types.HOLIDAY', value: 'HOLIDAY', icon: 'event', icon_color: 'purple-5' },
{ label: 'timesheet.shift.types.SICK', value: 'SICK', icon: 'medication_liquid', icon_color: 'light-blue-6' },
// { label: 'timesheet.shift.types.BANKING', value: 'BANKING', icon: 'savings', icon_color: 'pink-3' },
{ label: 'timesheet.shift.types.WITHDRAW_BANKED', value: 'WITHDRAW_BANKED', icon: 'attach_money', icon_color: 'yellow-4' },
];
{ label: 'timesheet.shift.types.REGULAR', value: 'REGULAR', icon: 'wb_sunny', icon_color: 'accent' },
{ label: 'timesheet.shift.types.EVENING', value: 'EVENING', icon: 'bedtime', icon_color: 'orange-5' },
{ label: 'timesheet.shift.types.EMERGENCY', value: 'EMERGENCY', icon: 'ring_volume', icon_color: 'red-5' },
{ label: 'timesheet.shift.types.VACATION', value: 'VACATION', icon: 'beach_access', icon_color: 'deep-orange-5' },
{ label: 'timesheet.shift.types.HOLIDAY', value: 'HOLIDAY', icon: 'event', icon_color: 'purple-5' },
{ label: 'timesheet.shift.types.SICK', value: 'SICK', icon: 'medication_liquid', icon_color: 'light-blue-6' },
// { label: 'timesheet.shift.types.BANKING', value: 'BANKING', icon: 'savings', icon_color: 'pink-3' },
{ label: 'timesheet.shift.types.WITHDRAW_BANKED', value: 'WITHDRAW_BANKED', icon: 'attach_money', icon_color: 'yellow-4' },
];
export const getShiftOptions = (disablePTO: boolean, isNotUnique: boolean): ShiftOption[] => {
return [
{ label: 'timesheet.shift.types.REGULAR', value: 'REGULAR', icon: 'wb_sunny', icon_color: 'accent' },
{ label: 'timesheet.shift.types.EVENING', value: 'EVENING', icon: 'bedtime', icon_color: 'orange-5' },
{ label: 'timesheet.shift.types.EMERGENCY', value: 'EMERGENCY', icon: 'ring_volume', icon_color: 'red-5' },
{ label: 'timesheet.shift.types.VACATION', value: 'VACATION', icon: 'beach_access', icon_color: 'deep-orange-5', disable: disablePTO },
{ label: 'timesheet.shift.types.HOLIDAY', value: 'HOLIDAY', icon: 'event', icon_color: 'purple-5', disable: isNotUnique || disablePTO },
{ label: 'timesheet.shift.types.SICK', value: 'SICK', icon: 'medication_liquid', icon_color: 'light-blue-6', disable: disablePTO },
// { label: 'timesheet.shift.types.BANKING', value: 'BANKING', icon: 'savings', icon_color: 'pink-3' },
// { label: 'timesheet.shift.types.WITHDRAW_BANKED', value: 'WITHDRAW_BANKED', icon: 'attach_money', icon_color: 'yellow-4' },
];
}

View File

@ -20,14 +20,27 @@ export const getMinutes = (hours: number) => {
return minutes > 1 ? minutes.toString() : '0';
}
export const getHoursMinutesStringFromHoursFloat = (hours: number): string => {
let flat_hours = Math.floor(hours);
let minutes = Math.round((hours - flat_hours) * 60);
export const getHoursMinutesStringFromHoursFloat = (hours: number, minutes?: number): string => {
let flatHours = Math.floor(hours);
let flatMinutes = minutes ?? Math.round((hours - flatHours) * 60);
if (minutes === 60) {
flat_hours += 1;
minutes = 0;
if (flatMinutes === 60) {
flatHours += 1;
flatMinutes = 0;
}
return `${flat_hours}h${minutes > 1 ? ' ' + minutes : ''}`
return `${flatHours}h${flatMinutes > 1 ? ' ' + flatMinutes : ''}`
}
export const getHoursMinutesBetweenTwoHHmm = (startTime: string, endTime: string): {
hours: number,
minutes: number,
} => {
const [startHours, startMinutes] = startTime.split(':');
const [endHours, endMinutes] = endTime.split(':');
return {
hours: Number(endHours) - Number(startHours),
minutes: Number(endMinutes) - Number(startMinutes),
}
}