Merge pull request 'release/nicolas/v1.1' (#81) from release/nicolas/v1.1 into main

Reviewed-on: Targo/targo_frontend#81
This commit is contained in:
Nicolas 2026-02-23 14:43:45 -05:00
commit 64ae14edba
26 changed files with 710 additions and 233 deletions

View File

@ -245,6 +245,8 @@ export default {
day: "day", day: "day",
empty: "empty", empty: "empty",
name: "name", name: "name",
lock: "",
unlock: "",
}, },
misc: { misc: {
or: "or", or: "or",
@ -290,6 +292,8 @@ export default {
apply_preset_day: "Apply schedule to day", apply_preset_day: "Apply schedule to day",
apply_preset_week: "Apply schedule to week", apply_preset_week: "Apply schedule to week",
save_successful: "timesheets saved", save_successful: "timesheets saved",
unsaved_changes_title: "You have unsaved changes",
unsaved_changes_caption: "Save before leaving?",
nav_button: { nav_button: {
calendar_date_picker: "Calendar", calendar_date_picker: "Calendar",
current_week: "This week", current_week: "This week",
@ -349,9 +353,10 @@ export default {
type: "Type", type: "Type",
types: { types: {
PER_DIEM: "Per Diem", PER_DIEM: "Per Diem",
EXPENSES: "expense", EXPENSES: "reimbursement",
MILEAGE: "mileage", MILEAGE: "mileage",
ON_CALL: "on-call allowance", ON_CALL: "on-call allowance",
COMMISSION: "Commission",
}, },
}, },
errors: { errors: {

View File

@ -245,6 +245,8 @@ export default {
day: "jour", day: "jour",
empty: "vide", empty: "vide",
name: "nom", name: "nom",
lock: "verrouiller",
unlock: "déverrouiller",
}, },
misc: { misc: {
or: "ou", or: "ou",
@ -290,6 +292,8 @@ export default {
apply_preset_day: "Appliquer horaire pour la journée", apply_preset_day: "Appliquer horaire pour la journée",
apply_preset_week: "Appliquer horaire pour la semaine", apply_preset_week: "Appliquer horaire pour la semaine",
save_successful: "feuilles de temps enregistrées", save_successful: "feuilles de temps enregistrées",
unsaved_changes_title: "Vous avez des changements non-enregistrés",
unsaved_changes_caption: "Sauvegardez avant de quitter?",
nav_button: { nav_button: {
calendar_date_picker: "Calendrier", calendar_date_picker: "Calendrier",
current_week: "Semaine actuelle", current_week: "Semaine actuelle",
@ -349,9 +353,10 @@ export default {
type: "Type", type: "Type",
types: { types: {
PER_DIEM: "Per diem", PER_DIEM: "Per diem",
EXPENSES: "dépense", EXPENSES: "remboursement",
MILEAGE: "kilométrage", MILEAGE: "kilométrage",
ON_CALL: "Prime de garde", ON_CALL: "Prime de garde",
COMMISSION: "Commission",
}, },
}, },
errors: { errors: {

View File

@ -11,25 +11,33 @@
import TimesheetWrapper from 'src/modules/timesheets/components/timesheet-wrapper.vue'; import TimesheetWrapper from 'src/modules/timesheets/components/timesheet-wrapper.vue';
import ExpenseDialogList from 'src/modules/timesheets/components/expense-dialog-list.vue'; import ExpenseDialogList from 'src/modules/timesheets/components/expense-dialog-list.vue';
import { useTimesheetApprovalApi } from '../composables/use-timesheet-approval-api'; import { useTimesheetApprovalApi } from '../composables/use-timesheet-approval-api';
import { useShiftApi } from 'src/modules/timesheets/composables/use-shift-api';
// ========== state ========================================
const { t } = useI18n(); const { t } = useI18n();
const timesheet_store = useTimesheetStore(); const timesheetStore = useTimesheetStore();
const timesheetApprovalApi = useTimesheetApprovalApi(); const timesheetApprovalApi = useTimesheetApprovalApi();
const is_dialog_open = ref(false); const shiftApi = useShiftApi();
const isDialogOpen = ref(false);
const isApproved = computed(() => timesheet_store.timesheets.every(timesheet => timesheet.is_approved)); // ========== computed ========================================
const isApproved = computed(() => timesheetStore.timesheets.every(timesheet => timesheet.is_approved));
const approveButtonLabel = computed(() => isApproved.value ? const approveButtonLabel = computed(() => isApproved.value ?
t('timesheet_approvals.table.verified') : t('shared.label.unlock') :
t('timesheet_approvals.table.unverified') t('shared.label.lock')
); );
const approveButtonIcon = computed(() => isApproved.value ? 'lock' : 'lock_open'); const approveButtonIcon = computed(() => isApproved.value ? 'las la-lock' : 'las la-unlock');
const hasExpenses = computed(() => timesheet_store.timesheets.some(timesheet => const hasExpenses = computed(() => timesheetStore.timesheets.some(timesheet =>
Object.values(timesheet.weekly_expenses).some(hours => hours > 0)) Object.values(timesheet.weekly_expenses).some(hours => hours > 0))
); );
// ========== methods ========================================
const onClickApproveAll = async () => { const onClickApproveAll = async () => {
const employeeEmail = timesheet_store.current_pay_period_overview?.email; const employeeEmail = timesheetStore.current_pay_period_overview?.email;
const isApproved = timesheet_store.timesheets.every(timesheet => timesheet.is_approved); const isApproved = timesheetStore.timesheets.every(timesheet => timesheet.is_approved);
if (employeeEmail !== undefined && isApproved !== undefined) { if (employeeEmail !== undefined && isApproved !== undefined) {
await timesheetApprovalApi.toggleTimesheetsApprovalByEmployeeEmail( await timesheetApprovalApi.toggleTimesheetsApprovalByEmployeeEmail(
@ -38,19 +46,23 @@
); );
} }
} }
const onClickSaveTimesheets = async () => {
await shiftApi.saveShiftChanges(timesheetStore.current_pay_period_overview?.email);
}
</script> </script>
<template> <template>
<q-dialog <q-dialog
v-model="timesheet_store.is_details_dialog_open" v-model="timesheetStore.is_details_dialog_open"
full-width full-width
full-height full-height
transition-show="jump-down" transition-show="jump-down"
transition-hide="jump-down" transition-hide="jump-down"
backdrop-filter="blur(6px)" backdrop-filter="blur(6px)"
@show="is_dialog_open = true" @show="isDialogOpen = true"
@hide="is_dialog_open = false" @hide="isDialogOpen = false"
@before-hide="timesheet_store.getTimesheetOverviews" @before-hide="timesheetStore.getTimesheetOverviews"
> >
<div <div
class="column bg-secondary hide-scrollbar shadow-12 rounded-15 q-pb-sm no-wrap" class="column bg-secondary hide-scrollbar shadow-12 rounded-15 q-pb-sm no-wrap"
@ -58,13 +70,14 @@
> >
<!-- employee name --> <!-- employee name -->
<div class="col-auto row flex-center q-px-none q-py-sm sticky-top bg-secondary full-width shadow-4"> <div class="col-auto row flex-center q-px-none q-py-sm sticky-top bg-secondary full-width shadow-4">
<span class="col text-h4 text-weight-bolder text-uppercase q-px-lg"> <span class="col-auto text-h4 text-weight-bolder text-uppercase q-px-lg">
{{ timesheet_store.selected_employee_name }} {{ timesheetStore.selected_employee_name }}
</span> </span>
<div class="col-auto q-px-lg"> <div class="col-auto q-px-lg">
<q-btn <q-btn
push :push="isApproved"
:outline="!isApproved"
dense dense
size="lg" size="lg"
color="accent" color="accent"
@ -84,11 +97,27 @@
</transition> </transition>
</q-btn> </q-btn>
</div> </div>
<q-space />
<div class="col-auto q-px-md">
<q-btn
push
dense
size="lg"
:disable="timesheetStore.is_loading || !timesheetStore.canSaveTimesheets"
:color="timesheetStore.is_loading || !timesheetStore.canSaveTimesheets ? 'grey-5' : 'accent'"
icon="upload"
:label="$t('shared.label.save')"
:class="$q.platform.is.mobile && ($q.screen.width < $q.screen.height) ? 'full-width' : 'q-ml-md'"
@click="onClickSaveTimesheets"
/>
</div>
</div> </div>
<!-- employee pay period details using chart --> <!-- employee pay period details using chart -->
<div <div
v-if="is_dialog_open" v-if="isDialogOpen"
class="col-auto q-px-md no-wrap" class="col-auto q-px-md no-wrap"
:class="$q.platform.is.mobile ? 'column' : 'row'" :class="$q.platform.is.mobile ? 'column' : 'row'"
> >
@ -108,7 +137,7 @@
<div class="col-auto"> <div class="col-auto">
<TimesheetWrapper <TimesheetWrapper
mode="approval" mode="approval"
:employee-email="timesheet_store.current_pay_period_overview?.email" :employee-email="timesheetStore.current_pay_period_overview?.email"
class="col-auto" class="col-auto"
/> />
</div> </div>

View File

@ -16,6 +16,7 @@
import { type OverviewColumns, pay_period_overview_columns, PayPeriodOverviewFilters, type TimesheetApprovalOverview } from 'src/modules/timesheet-approval/models/timesheet-overview.models'; import { type OverviewColumns, pay_period_overview_columns, PayPeriodOverviewFilters, type TimesheetApprovalOverview } from 'src/modules/timesheet-approval/models/timesheet-overview.models';
import { getHoursMinutesStringFromHoursFloat } from 'src/utils/date-and-time-utils'; import { getHoursMinutesStringFromHoursFloat } from 'src/utils/date-and-time-utils';
import { useUiStore } from 'src/stores/ui-store'; import { useUiStore } from 'src/stores/ui-store';
import { useTimesheetApi } from 'src/modules/timesheets/composables/use-timesheet-api';
// ========== constants ======================================== // ========== constants ========================================
@ -46,11 +47,12 @@
const q = useQuasar(); const q = useQuasar();
const uiStore = useUiStore(); const uiStore = useUiStore();
const auth_store = useAuthStore(); const authStore = useAuthStore();
const timesheet_store = useTimesheetStore(); const timesheetApi = useTimesheetApi();
const timesheet_approval_api = useTimesheetApprovalApi(); const timesheetStore = useTimesheetStore();
const timesheetApprovalApi = useTimesheetApprovalApi();
const overview_filters = ref<PayPeriodOverviewFilters>({ const overviewFilters = ref<PayPeriodOverviewFilters>({
is_showing_inactive: false, is_showing_inactive: false,
is_showing_team_only: true, is_showing_team_only: true,
supervisors: [], supervisors: [],
@ -63,21 +65,21 @@
uiStore.user_preferences.is_timesheet_approval_grid uiStore.user_preferences.is_timesheet_approval_grid
); );
const overview_rows = computed(() => const overviewRows = computed(() =>
timesheet_store.pay_period_overviews.filter(overview => overview) timesheetStore.pay_period_overviews.filter(overview => overview)
); );
// ========== methods ======================================== // ========== methods ========================================
const onClickedDetails = async (row: TimesheetApprovalOverview) => { const onClickedDetails = async (row: TimesheetApprovalOverview) => {
timesheet_store.current_pay_period_overview = row; timesheetStore.current_pay_period_overview = row;
await timesheet_store.getTimesheetsByOptionalEmployeeEmail(row.email); await timesheetApi.getTimesheetsByCurrentPayPeriod(row.email);
timesheet_store.is_details_dialog_open = true; timesheetStore.is_details_dialog_open = true;
}; };
const onClickApproveAll = async (email: string, is_approved: boolean) => { const onClickApproveAll = async (email: string, is_approved: boolean) => {
await timesheet_approval_api.toggleTimesheetsApprovalByEmployeeEmail(email, is_approved); await timesheetApprovalApi.toggleTimesheetsApprovalByEmployeeEmail(email, is_approved);
} }
const filterEmployeeRows = (rows: readonly TimesheetApprovalOverview[], terms: PayPeriodOverviewFilters): TimesheetApprovalOverview[] => { const filterEmployeeRows = (rows: readonly TimesheetApprovalOverview[], terms: PayPeriodOverviewFilters): TimesheetApprovalOverview[] => {
@ -88,7 +90,7 @@
} }
if (terms.is_showing_team_only) { if (terms.is_showing_team_only) {
result = result.filter(row => row.supervisor !== null && row.supervisor.email === (auth_store.user ? auth_store.user.email : '')); result = result.filter(row => row.supervisor !== null && row.supervisor.email === (authStore.user ? authStore.user.email : ''));
} }
if (terms.name_search_string.length > 0) { if (terms.name_search_string.length > 0) {
@ -114,20 +116,20 @@
<template> <template>
<div class="full-width"> <div class="full-width">
<LoadingOverlay v-model="timesheet_store.is_loading" /> <LoadingOverlay v-model="timesheetStore.is_loading" />
<q-table <q-table
dense dense
row-key="email" row-key="email"
color="accent" color="accent"
separator="none" separator="none"
hide-pagination hide-pagination
:rows="overview_rows" :rows="overviewRows"
:columns="pay_period_overview_columns" :columns="pay_period_overview_columns"
:table-colspan="pay_period_overview_columns.length" :table-colspan="pay_period_overview_columns.length"
:visible-columns="VISIBLE_COLUMNS" :visible-columns="VISIBLE_COLUMNS"
:grid="isGridMode" :grid="isGridMode"
:pagination="{ sortBy: 'is_active' }" :pagination="{ sortBy: 'is_active' }"
:filter="overview_filters" :filter="overviewFilters"
:filter-method="filterEmployeeRows" :filter-method="filterEmployeeRows"
:rows-per-page-options="[0]" :rows-per-page-options="[0]"
class="bg-transparent" class="bg-transparent"
@ -138,20 +140,20 @@
:no-results-label="$t('shared.error.no_search_results')" :no-results-label="$t('shared.error.no_search_results')"
:loading-label="$t('shared.label.loading')" :loading-label="$t('shared.label.loading')"
table-header-style="min-width: 80xp; max-width: 80px;" table-header-style="min-width: 80xp; max-width: 80px;"
:style="overview_rows.length > 0 ? `max-height: ${maxHeight}px;` : ''" :style="overviewRows.length > 0 ? `max-height: ${maxHeight}px;` : ''"
:table-style="{ tableLayout: 'fixed' }" :table-style="{ tableLayout: 'fixed' }"
@row-click="(_evt, row: TimesheetApprovalOverview) => onClickedDetails(row)" @row-click="(_evt, row: TimesheetApprovalOverview) => onClickedDetails(row)"
> >
<template #top> <template #top>
<OverviewListTopMobile <OverviewListTopMobile
v-if="$q.platform.is.mobile" v-if="$q.platform.is.mobile"
v-model:filters="overview_filters" v-model:filters="overviewFilters"
v-model:visible-columns="VISIBLE_COLUMNS" v-model:visible-columns="VISIBLE_COLUMNS"
/> />
<OverviewListTop <OverviewListTop
v-else v-else
v-model:filters="overview_filters" v-model:filters="overviewFilters"
v-model:visible-columns="VISIBLE_COLUMNS" v-model:visible-columns="VISIBLE_COLUMNS"
/> />
</template> </template>
@ -192,7 +194,7 @@
mode="out-in" mode="out-in"
> >
<div <div
:key="props.rowIndex + (timesheet_store.pay_period?.pay_period_no ?? 0)" :key="props.rowIndex + (timesheetStore.pay_period?.pay_period_no ?? 0)"
class="rounded-5" class="rounded-5"
:style="`animation-delay: ${props.rowIndex / 15}s; opacity: ${props.row.is_active ? '1' : '0.5'};`" :style="`animation-delay: ${props.rowIndex / 15}s; opacity: ${props.row.is_active ? '1' : '0.5'};`"
> >
@ -265,7 +267,7 @@
<template #item="props: { row: TimesheetApprovalOverview, rowIndex: number }"> <template #item="props: { row: TimesheetApprovalOverview, rowIndex: number }">
<OverviewListItem <OverviewListItem
v-model="props.row.is_approved" v-model="props.row.is_approved"
:key="props.row.email + timesheet_store.pay_period?.pay_period_no" :key="props.row.email + timesheetStore.pay_period?.pay_period_no"
:row="props.row" :row="props.row"
@click-details="onClickedDetails" @click-details="onClickedDetails"
@click-approval-all="is_approved => onClickApproveAll(props.row.email, is_approved)" @click-approval-all="is_approved => onClickApproveAll(props.row.email, is_approved)"
@ -275,7 +277,7 @@
<!-- Template for custome failed-to-load state --> <!-- Template for custome failed-to-load state -->
<template #no-data="{ message, filter }"> <template #no-data="{ message, filter }">
<div <div
v-if="!timesheet_store.is_loading" v-if="!timesheetStore.is_loading"
class="full-width column items-center text-accent" class="full-width column items-center text-accent"
> >
<q-icon <q-icon

View File

@ -0,0 +1,45 @@
<script
setup
lang="ts"
>
import { ref } from 'vue';
import { useExpensesStore } from 'src/stores/expense-store';
const expenseStore = useExpensesStore();
const isMaximized = ref(false);
const imageDimension = ref('400px');
const onClickMaximizeButton = () => {
isMaximized.value = !isMaximized.value;
imageDimension.value = isMaximized.value ? '100%' : '400px';
}
</script>
<template>
<q-dialog
v-model="expenseStore.isShowingAttachmentDialog"
backdrop-filter="blur(4px)"
:full-height="isMaximized"
:full-width="isMaximized"
>
<q-card class="q-pa-md flex-center relative-position">
<q-img
spinner-color="accent"
fit="contain"
:height="imageDimension"
:width="imageDimension"
:src="expenseStore.attachmentURL"
/>
<q-btn
dense
size="lg"
color="accent"
:icon="isMaximized ? 'zoom_in_map' : 'zoom_out_map'"
class="absolute-bottom-right q-ma-sm rounded-5"
style="opacity: 0.5;"
@click="onClickMaximizeButton"
/>
</q-card>
</q-dialog>
</template>

View File

@ -67,6 +67,12 @@
}); });
} }
} }
const onClickAttachment = async () => {
expenses_store.isShowingAttachmentDialog = true;
await expenses_store.getAttachmentURL(expense.value.attachment_key);
console.log('image url: ', expenses_store.attachmentURL);
}
</script> </script>
<template> <template>
@ -131,6 +137,7 @@
:text-color="expense.is_approved ? 'accent' : 'white'" :text-color="expense.is_approved ? 'accent' : 'white'"
class="col-auto q-px-sm q-mr-sm" class="col-auto q-px-sm q-mr-sm"
icon="attach_file" icon="attach_file"
@click.stop="onClickAttachment"
/> />
<q-item-label class="col"> <q-item-label class="col">

View File

@ -6,6 +6,7 @@
import ExpenseDialogForm from 'src/modules/timesheets/components/expense-dialog-form.vue'; import ExpenseDialogForm from 'src/modules/timesheets/components/expense-dialog-form.vue';
import ExpenseDialogHeader from 'src/modules/timesheets/components/expense-dialog-header.vue'; import ExpenseDialogHeader from 'src/modules/timesheets/components/expense-dialog-header.vue';
import ExpenseDialogFormMobile from 'src/modules/timesheets/components/mobile/expense-dialog-form-mobile.vue'; import ExpenseDialogFormMobile from 'src/modules/timesheets/components/mobile/expense-dialog-form-mobile.vue';
import ExpenseDialogAttachmentViewer from 'src/modules/timesheets/components/expense-dialog-attachment-viewer.vue';
import { date } from 'quasar'; import { date } from 'quasar';
import { useExpensesStore } from 'src/stores/expense-store'; import { useExpensesStore } from 'src/stores/expense-store';
@ -32,6 +33,8 @@
transition-show="jump-down" transition-show="jump-down"
transition-hide="jump-down" transition-hide="jump-down"
> >
<ExpenseDialogAttachmentViewer class="z-top" />
<q-card <q-card
class="q-pa-none rounded-10 shadow-24 bg-secondary" class="q-pa-none rounded-10 shadow-24 bg-secondary"
style=" min-width: 70vw;" style=" min-width: 70vw;"

View File

@ -61,6 +61,11 @@
employeeEmail ?? auth_store.user?.email ?? 'MISSING_EMAIL', employeeEmail ?? auth_store.user?.email ?? 'MISSING_EMAIL',
file.value file.value
); );
else
await expenses_api.upsertExpense(
expenses_store.current_expense,
employeeEmail ?? auth_store.user?.email ?? 'MISSING_EMAIL'
);
emit('onUpdateClicked'); emit('onUpdateClicked');
}; };

View File

@ -5,25 +5,31 @@
import { computed, onMounted, ref } from 'vue'; import { computed, onMounted, ref } from 'vue';
import { QSelect, QInput } from 'quasar'; import { QSelect, QInput } from 'quasar';
import { useUiStore } from 'src/stores/ui-store'; import { useUiStore } from 'src/stores/ui-store';
import { SHIFT_OPTIONS } from 'src/modules/timesheets/utils/shift.util'; import { getCurrentDailyMinutesWorked, getTimeStringFromMinutes, SHIFT_OPTIONS } from 'src/modules/timesheets/utils/shift.util';
import type { Shift } from 'src/modules/timesheets/models/shift.models'; import type { Shift, ShiftOption, ShiftType } from 'src/modules/timesheets/models/shift.models';
import { getHoursMinutesStringFromHoursFloat } from 'src/utils/date-and-time-utils';
const ui_store = useUiStore(); // ========== state ========================================
const SHIFT_TYPES_WITH_PREDEFINED_TIMES: ShiftType[] = ['HOLIDAY', 'SICK', 'VACATION'];
const COMMENT_LENGTH_MAX = 280; const COMMENT_LENGTH_MAX = 280;
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 = 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 { errorMessage = undefined, dense = false, hasShiftAfter = false, isTimesheetApproved = false } = defineProps<{ const {
dense = false,
hasShiftAfter = false,
isTimesheetApproved = false,
errorMessage = undefined,
expectedDailyHours = 8,
currentShifts,
} = defineProps<{
dense?: boolean; dense?: boolean;
hasShiftAfter?: boolean; hasShiftAfter?: boolean;
isTimesheetApproved?: boolean; isTimesheetApproved?: boolean;
errorMessage?: string | undefined; errorMessage?: string | undefined;
expectedDailyHours?: number;
currentShifts: Shift[];
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
@ -31,8 +37,23 @@
'onTimeFieldBlur': [void]; 'onTimeFieldBlur': [void];
}>(); }>();
const ui_store = useUiStore();
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 ========================================
const comment_length = computed(() => shift.value.comment?.length ?? 0);
// ========== methods =========================================
const onBlurShiftTypeSelect = () => { const onBlurShiftTypeSelect = () => {
if (shift_type_selected.value === undefined) { if (shiftTypeSelected.value === undefined) {
shift.value.type = 'REGULAR'; shift.value.type = 'REGULAR';
shift.value.id = 0; shift.value.id = 0;
emit('requestDelete'); emit('requestDelete');
@ -56,11 +77,35 @@
return 'negative'; 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(() => { onMounted(() => {
if (ui_store.focus_next_component) { if (ui_store.focus_next_component) {
select_ref.value?.focus(); select_ref.value?.focus();
select_ref.value?.showPopup(); select_ref.value?.showPopup();
shift_type_selected.value = undefined; shiftTypeSelected.value = undefined;
ui_store.focus_next_component = false; ui_store.focus_next_component = false;
} }
@ -124,7 +169,7 @@
<!-- shift type --> <!-- shift type -->
<q-select <q-select
ref="select" ref="select"
v-model="shift_type_selected" v-model="shiftTypeSelected"
: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
:borderless="(shift.is_approved && isTimesheetApproved)" :borderless="(shift.is_approved && isTimesheetApproved)"
@ -141,7 +186,7 @@
:style="shift.is_approved ? 'background-color: #0a7d32 !important;' : ''" :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="onShiftTypeChange"
> >
<template #selected-item="scope"> <template #selected-item="scope">
<div <div
@ -227,7 +272,25 @@
</q-select> </q-select>
</div> </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 --> <!-- punch in field -->
<div class="col q-pr-xs"> <div class="col q-pr-xs">
<q-input <q-input

View File

@ -6,15 +6,29 @@
import { computed, inject, onMounted, ref } from 'vue'; import { computed, inject, onMounted, ref } from 'vue';
import { QSelect, QInput, useQuasar, type QSelectProps, QPopupProxy } from 'quasar'; import { QSelect, QInput, useQuasar, type QSelectProps, QPopupProxy } from 'quasar';
import { useUiStore } from 'src/stores/ui-store'; 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 { useAuthStore } from 'src/stores/auth-store'; import { useAuthStore } from 'src/stores/auth-store';
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';
// ================== State ================== // ========== Constants ========================================
const SHIFT_TYPES_WITH_PREDEFINED_TIMES: ShiftType[] = ['HOLIDAY', 'SICK', 'VACATION'];
const COMMENT_LENGTH_MAX = 280;
// ========== State ========================================
const shift = defineModel<Shift>('shift', { required: true }); const shift = defineModel<Shift>('shift', { required: true });
const { errorMessage = undefined, isTimesheetApproved = false, holiday = false } = defineProps<{ const {
errorMessage = undefined,
isTimesheetApproved = false,
currentShifts,
holiday = false,
expectedDailyHours = 8,
} = defineProps<{
currentShifts: Shift[];
expectedDailyHours?: number;
isTimesheetApproved?: boolean; isTimesheetApproved?: boolean;
errorMessage?: string | undefined; errorMessage?: string | undefined;
holiday?: boolean | undefined; holiday?: boolean | undefined;
@ -25,9 +39,6 @@
'onTimeFieldBlur': [void]; 'onTimeFieldBlur': [void];
}>(); }>();
const COMMENT_LENGTH_MAX = 280;
const q = useQuasar(); const q = useQuasar();
const { t } = useI18n(); const { t } = useI18n();
const ui_store = useUiStore(); const ui_store = useUiStore();
@ -39,10 +50,15 @@
const selectRef = ref<QSelect | null>(null); const selectRef = ref<QSelect | null>(null);
const shiftErrorMessage = ref<string | undefined>(); const shiftErrorMessage = ref<string | undefined>();
const is_showing_delete_confirm = ref(false); const is_showing_delete_confirm = ref(false);
const isShowingPredefinedTime = ref(shift.value.type === 'HOLIDAY');
const popupProxyRef = ref<QPopupProxy | null>(null); const popupProxyRef = ref<QPopupProxy | null>(null);
const predefinedHoursString = ref('');
const predefinedHoursBgColor = ref(`bg-${shiftTypeSelected.value?.icon_color ?? ''}`);
// ================== Computed ================== // ================== 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 rightClickMenuIcon = computed(() => shift.value.is_approved ? 'lock_open' : 'lock');
const rightClickMenuLabel = computed(() => shift.value.is_approved ? const rightClickMenuLabel = computed(() => shift.value.is_approved ?
@ -77,7 +93,7 @@
menuOffset: [0, 10], menuOffset: [0, 10],
menuAnchor: "bottom middle", menuAnchor: "bottom middle",
menuSelf: "top 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'}`, 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", popupContentClass: "text-uppercase text-weight-bold text-center rounded-5",
style: shift.value.is_approved ? (holiday ? 'background-color: #7b1fa2 !important' : 'background-color: #0a7d32 !important;') : '', style: shift.value.is_approved ? (holiday ? 'background-color: #7b1fa2 !important' : 'background-color: #0a7d32 !important;') : '',
@ -128,6 +144,30 @@
popupProxyRef.value.hide(); popupProxyRef.value.hide();
} }
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);
} else {
isShowingPredefinedTime.value = true;
predefinedHoursString.value = getHoursMinutesStringFromHoursFloat(expectedDailyHours);
shift.value.start_time = '00:00';
shift.value.end_time = `${expectedDailyHours}:00`;
}
} else
isShowingPredefinedTime.value = false;
}
onMounted(() => { onMounted(() => {
if (ui_store.focus_next_component) { if (ui_store.focus_next_component) {
selectRef.value?.focus(); selectRef.value?.focus();
@ -206,7 +246,7 @@
v-model="shiftTypeSelected" v-model="shiftTypeSelected"
v-bind="shiftTypeSelectProps" v-bind="shiftTypeSelectProps"
@blur="onBlurShiftTypeSelect" @blur="onBlurShiftTypeSelect"
@update:model-value="option => shift.type = option.value" @update:model-value="onShiftTypeChange"
> >
<template #selected-item="scope"> <template #selected-item="scope">
<div <div
@ -291,8 +331,25 @@
</q-select> </q-select>
</div> </div>
<div class="col row items-start text-uppercase rounded-5 q-pa-xs"> <div class="col row">
<!-- punch in field --> <!-- If shift type has predefined timestamps -->
<div
v-if="isShowingPredefinedTime"
class="col row q-pa-xs relative-position flex-center"
>
<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>
<!-- Else show input fields for in-out timestamps -->
<div v-else class="col row items-start text-uppercase rounded-5 q-pa-xs">
<q-input <q-input
ref="start_time" ref="start_time"
v-model="shift.start_time" v-model="shift.start_time"
@ -325,6 +382,7 @@
>{{ $t('shared.misc.out') }}</span> >{{ $t('shared.misc.out') }}</span>
</template> </template>
</q-input> </q-input>
</div>
<div <div
class="row full-height" class="row full-height"

View File

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

View File

@ -23,6 +23,7 @@
const bankedHours = computed(() => timesheetStore.paid_time_off_totals.banked_hours); const bankedHours = computed(() => timesheetStore.paid_time_off_totals.banked_hours);
onMounted(async () => { onMounted(async () => {
if (timesheetMode === 'normal')
await timesheetStore.getPaidTimeOffTotalsWithOptionalEmployeeEmail(); await timesheetStore.getPaidTimeOffTotalsWithOptionalEmployeeEmail();
}) })
</script> </script>

View File

@ -10,14 +10,17 @@
import TimesheetErrorWidget from 'src/modules/timesheets/components/timesheet-error-widget.vue'; import TimesheetErrorWidget from 'src/modules/timesheets/components/timesheet-error-widget.vue';
import ShiftListWeeklyOverview from 'src/modules/timesheets/components/shift-list-weekly-overview.vue'; import ShiftListWeeklyOverview from 'src/modules/timesheets/components/shift-list-weekly-overview.vue';
import ShiftListWeeklyOverviewMobile from 'src/modules/timesheets/components/mobile/shift-list-weekly-overview-mobile.vue'; import ShiftListWeeklyOverviewMobile from 'src/modules/timesheets/components/mobile/shift-list-weekly-overview-mobile.vue';
import UnsavedChangesDialog from 'src/modules/timesheets/components/unsaved-changes-dialog.vue';
import { date, Notify } from 'quasar'; import { date, Notify } from 'quasar';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
import { computed, onMounted, provide } from 'vue'; import { computed, onMounted, provide } from 'vue';
import { useShiftApi } from 'src/modules/timesheets/composables/use-shift-api'; 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 { useTimesheetStore } from 'src/stores/timesheet-store'; import { useTimesheetStore } from 'src/stores/timesheet-store';
import { RouteNames } from 'src/router/router-constants';
// ================= state ==================== // ================= state ====================
@ -27,18 +30,25 @@
}>(); }>();
const { t } = useI18n(); const { t } = useI18n();
const expenses_store = useExpensesStore(); const router = useRouter();
const timesheet_store = useTimesheetStore(); const expenseStore = useExpensesStore();
const timesheet_api = useTimesheetApi(); const timesheetStore = useTimesheetStore();
const shift_api = useShiftApi(); const timesheetApi = useTimesheetApi();
const shiftApi = useShiftApi();
// ================== computed ==================== // ================== computed ====================
const has_shift_errors = computed(() => timesheet_store.all_current_shifts.filter(shift => shift.has_error === true).length > 0); const hasShiftErrors = computed(() => timesheetStore.all_current_shifts.filter(shift => shift.has_error === true).length > 0);
const isTimesheetsApproved = computed(() => timesheetStore.timesheets.every(timesheet => timesheet.is_approved));
// const timesheetStore.canSaveTimesheets = computed(() => {
// /* eslint-disable-next-line */
// const currentShifts = timesheetStore.timesheets.flatMap(timesheet => timesheet.days.flatMap(day => day.shifts.map(shift => { const { has_error, ...shft } = shift; return shft; })));
// const initialShifts = timesheetStore.initial_timesheets.flatMap(timesheet => timesheet.days.flatMap(day => day.shifts));
const is_timesheets_approved = computed(() => timesheet_store.timesheets.every(timesheet => timesheet.is_approved)) // return JSON.stringify(currentShifts) !== JSON.stringify(initialShifts);
// });
const total_hours = computed(() => timesheet_store.timesheets.reduce((sum, timesheet) => const totalHours = computed(() => timesheetStore.timesheets.reduce((sum, timesheet) =>
sum += timesheet.weekly_hours.regular sum += timesheet.weekly_hours.regular
+ timesheet.weekly_hours.evening + timesheet.weekly_hours.evening
+ timesheet.weekly_hours.emergency + timesheet.weekly_hours.emergency
@ -46,7 +56,7 @@
0) //initial value 0) //initial value
); );
const total_expenses = computed(() => timesheet_store.timesheets.reduce((sum, timesheet) => const totalExpenses = computed(() => timesheetStore.timesheets.reduce((sum, timesheet) =>
sum + timesheet.weekly_expenses.expenses sum + timesheet.weekly_expenses.expenses
+ timesheet.weekly_expenses.on_call + timesheet.weekly_expenses.on_call
+ timesheet.weekly_expenses.per_diem, + timesheet.weekly_expenses.per_diem,
@ -60,25 +70,43 @@
const onClickSaveTimesheets = async () => { const onClickSaveTimesheets = async () => {
if (mode === 'normal') { if (mode === 'normal') {
await shift_api.saveShiftChanges(); await shiftApi.saveShiftChanges();
Notify.create({ Notify.create({
message: t('timesheet.save_successful'), message: t('timesheet.save_successful'),
color: 'accent', color: 'accent',
}); });
} else { } else {
await shift_api.saveShiftChanges(timesheet_store.current_pay_period_overview?.email); await shiftApi.saveShiftChanges(timesheetStore.current_pay_period_overview?.email);
} }
} }
const onClickLeave = async () => {
timesheetStore.isShowingUnsavedWarning = false;
timesheetStore.timesheets = [];
timesheetStore.initial_timesheets = [];
await router.push({ name: timesheetStore.nextPageNameAfterUnsaveWarning ?? RouteNames.DASHBOARD});
}
const onClickSaveBeforeLeaving = async () => {
if (mode === 'approval') return;
timesheetStore.isShowingUnsavedWarning = false;
await onClickSaveTimesheets();
await onClickLeave();
}
onMounted(async () => { onMounted(async () => {
if (mode === 'normal') if (mode === 'normal')
await timesheet_api.getTimesheetsByDate(date.formatDate(new Date(), 'YYYY-MM-DD')); await timesheetApi.getTimesheetsByDate(date.formatDate(new Date(), 'YYYY-MM-DD'));
}); });
</script> </script>
<template> <template>
<div class="column items-center full-height" :class="mode === 'normal' ? 'relative-position' : ' no-wrap'"> <div
<LoadingOverlay v-model="timesheet_store.is_loading" /> class="column items-center full-height"
:class="mode === 'normal' ? 'relative-position' : ' no-wrap'"
>
<LoadingOverlay v-model="timesheetStore.is_loading" />
<!-- label for approval mode to delimit that this is the timesheet --> <!-- label for approval mode to delimit that this is the timesheet -->
<div <div
@ -90,19 +118,6 @@
</span> </span>
<q-space /> <q-space />
<!-- desktop save timesheet changes button -->
<q-btn
v-if="!is_timesheets_approved && $q.screen.width > $q.screen.height"
push
rounded
: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.platform.is.mobile && ($q.screen.width < $q.screen.height) ? 'full-width' : 'q-ml-md'"
@click="onClickSaveTimesheets"
/>
</div> </div>
@ -116,8 +131,8 @@
<ShiftListWeeklyOverview <ShiftListWeeklyOverview
mode="total-hours" mode="total-hours"
:timesheet-mode="mode" :timesheet-mode="mode"
:total-hours="total_hours" :total-hours="totalHours"
:total-expenses="total_expenses" :total-expenses="totalExpenses"
/> />
</div> </div>
@ -144,9 +159,9 @@
<!-- navigation btn --> <!-- navigation btn -->
<PayPeriodNavigator <PayPeriodNavigator
class="col-auto" class="col-auto"
@date-selected="timesheet_api.getTimesheetsByDate" @date-selected="timesheetApi.getTimesheetsByDate"
@pressed-previous-button="timesheet_api.getTimesheetsByCurrentPayPeriod" @pressed-previous-button="timesheetApi.getTimesheetsByCurrentPayPeriod"
@pressed-next-button="timesheet_api.getTimesheetsByCurrentPayPeriod" @pressed-next-button="timesheetApi.getTimesheetsByCurrentPayPeriod"
/> />
<!-- mobile expenses button --> <!-- mobile expenses button -->
@ -160,7 +175,7 @@
color="accent" color="accent"
icon="receipt_long" icon="receipt_long"
class="full-width" class="full-width"
@click="expenses_store.open" @click="expenseStore.open"
/> />
</div> </div>
@ -174,16 +189,16 @@
color="accent" color="accent"
icon="receipt_long" icon="receipt_long"
:label="$t('timesheet.expense.open_btn')" :label="$t('timesheet.expense.open_btn')"
@click="expenses_store.open" @click="expenseStore.open"
/> />
<!-- desktop save timesheet changes button --> <!-- desktop save timesheet changes button -->
<q-btn <q-btn
v-if="!is_timesheets_approved && $q.screen.width > $q.screen.height" v-if="!isTimesheetsApproved && $q.screen.width > $q.screen.height"
push push
rounded rounded
:disable="timesheet_store.is_loading || has_shift_errors" :disable="timesheetStore.is_loading || hasShiftErrors || !timesheetStore.canSaveTimesheets"
:color="timesheet_store.is_loading || has_shift_errors ? 'grey-5' : 'accent'" :color="timesheetStore.is_loading || hasShiftErrors || !timesheetStore.canSaveTimesheets ? 'grey-5' : 'accent'"
icon="upload" icon="upload"
:label="$t('shared.label.save')" :label="$t('shared.label.save')"
:class="$q.platform.is.mobile && ($q.screen.width < $q.screen.height) ? 'full-width' : 'q-ml-md'" :class="$q.platform.is.mobile && ($q.screen.width < $q.screen.height) ? 'full-width' : 'q-ml-md'"
@ -211,9 +226,9 @@
:class="$q.platform.is.mobile ? 'fit no-wrap' : 'full-width'" :class="$q.platform.is.mobile ? 'fit no-wrap' : 'full-width'"
:style="$q.platform.is.mobile && $q.screen.width < $q.screen.height ? 'margin-bottom: 40px' : ''" :style="$q.platform.is.mobile && $q.screen.width < $q.screen.height ? 'margin-bottom: 40px' : ''"
> >
<!-- Show if no timesheets found (further than one month from present) --> <!-- If no timesheets found -->
<div <div
v-if="timesheet_store.timesheets.length < 1 && !timesheet_store.is_loading" v-if="timesheetStore.timesheets.length < 1 && !timesheetStore.is_loading"
class="col-auto column flex-center fit q-py-lg" class="col-auto column flex-center fit q-py-lg"
style="min-height: 20vh;" style="min-height: 20vh;"
> >
@ -228,7 +243,7 @@
/> />
</div> </div>
<!-- Else show timesheets if found --> <!-- Else show timesheets -->
<ShiftList <ShiftList
v-else v-else
class="col-auto" class="col-auto"
@ -238,7 +253,7 @@
<q-btn <q-btn
v-if="$q.platform.is.mobile && $q.screen.width < $q.screen.height" v-if="$q.platform.is.mobile && $q.screen.width < $q.screen.height"
square square
:disable="timesheet_store.is_loading" :disable="timesheetStore.is_loading || hasShiftErrors || !timesheetStore.canSaveTimesheets"
size="lg" size="lg"
color="accent" color="accent"
icon="upload" icon="upload"
@ -249,8 +264,13 @@
/> />
<ExpenseDialog <ExpenseDialog
:is-approved="is_timesheets_approved" :is-approved="isTimesheetsApproved"
class="z-top" class="z-top"
/> />
<UnsavedChangesDialog
@click-save-no="onClickLeave"
@click-save-yes="onClickSaveBeforeLeaving"
/>
</div> </div>
</template> </template>

View File

@ -0,0 +1,74 @@
<script
setup
lang="ts"
>
import { useTimesheetStore } from 'src/stores/timesheet-store';
// ========== state ========================================
const emit = defineEmits<{
clickSaveNo: [];
clickSaveYes: [];
}>();
const timesheetStore = useTimesheetStore();
// ========== methods ========================================
const onClickSaveOptionButton = (option: 'cancel' | 'no' | 'yes') => {
switch(option) {
case 'cancel':
timesheetStore.isShowingUnsavedWarning = false;
break;
case 'no':
emit('clickSaveNo');
break;
case 'yes':
emit('clickSaveYes');
break;
}
}
</script>
<template>
<q-dialog
v-model="timesheetStore.isShowingUnsavedWarning"
backdrop-filter="blur(4px)"
>
<q-card
class="bg-dark shadow-12 flex-center"
style="border: 2px solid var(--q-accent);"
>
<div class="column flex-center q-py-sm q-px-md text-center">
<span class="col-auto text-bold text-uppercase" style="font-size: 1.2em;">
{{ $t('timesheet.unsaved_changes_title') }}
</span>
<span class="">{{ $t('timesheet.unsaved_changes_caption') }}</span>
</div>
<div class="col-auto row full-width">
<q-btn
flat
:label="$t('shared.label.cancel')"
class="col"
@click="onClickSaveOptionButton('cancel')"
/>
<q-btn
flat
color="negative"
:label="$t('shared.misc.no')"
class="col"
@click="onClickSaveOptionButton('no')"
/>
<q-btn
flat
color="accent"
:label="$t('shared.misc.yes')"
class="col"
@click="onClickSaveOptionButton('yes')"
/>
</div>
</q-card>
</q-dialog>
</template>

View File

@ -10,9 +10,14 @@ export const useExpensesApi = () => {
const upsertExpense = async (expense: Expense, employee_email: string, file?: File): Promise<string> => { const upsertExpense = async (expense: Expense, employee_email: string, file?: File): Promise<string> => {
if (file) { if (file) {
const presignedURL = expenses_store.uploadAttachment(file); const attachmentKey = await expenses_store.uploadAttachment(file);
if (!presignedURL) return 'PRESIGN_FAILED'; if (!attachmentKey)
console.error('failed to upload attachment');
else {
expense.attachment_key = attachmentKey;
expense.attachment_name = file.name;
}
} }
const success = await expenses_store.upsertExpense(expense, employee_email); const success = await expenses_store.upsertExpense(expense, employee_email);

View File

@ -10,7 +10,9 @@ export const useShiftApi = () => {
const success = await shift_store.deleteShiftById(shift_id, employee_email); const success = await shift_store.deleteShiftById(shift_id, employee_email);
if (success) { if (success) {
timesheetStore.timesheets = [];
await timesheetStore.getTimesheetsByOptionalEmployeeEmail(employee_email); await timesheetStore.getTimesheetsByOptionalEmployeeEmail(employee_email);
await timesheetStore.getPaidTimeOffTotalsWithOptionalEmployeeEmail(employee_email);
} }
timesheetStore.is_loading = false; timesheetStore.is_loading = false;
@ -23,8 +25,9 @@ export const useShiftApi = () => {
const create_success = await shift_store.createNewShifts(employee_email); const create_success = await shift_store.createNewShifts(employee_email);
if (create_success || update_success){ if (create_success || update_success){
timesheetStore.timesheets = [];
await timesheetStore.getTimesheetsByOptionalEmployeeEmail(employee_email); await timesheetStore.getTimesheetsByOptionalEmployeeEmail(employee_email);
await timesheetStore.getPaidTimeOffTotalsWithOptionalEmployeeEmail(); await timesheetStore.getPaidTimeOffTotalsWithOptionalEmployeeEmail(employee_email);
} }
timesheetStore.is_loading = false; timesheetStore.is_loading = false;

View File

@ -1,65 +1,70 @@
import { useShiftStore } from "src/stores/shift-store";
import { useTimesheetStore } from "src/stores/timesheet-store"; import { useTimesheetStore } from "src/stores/timesheet-store";
import { timesheetService } from "src/modules/timesheets/services/timesheet-service";
export const useTimesheetApi = () => { export const useTimesheetApi = () => {
const timesheet_store = useTimesheetStore(); const timesheetStore = useTimesheetStore();
const shiftStore = useShiftStore();
const getTimesheetsByDate = async (date_string: string, employee_email?: string) => { const getTimesheetsByDate = async (date_string: string, employeeEmail?: string) => {
timesheet_store.timesheets = []; timesheetStore.timesheets = [];
timesheet_store.is_loading = true; timesheetStore.is_loading = true;
const success = await timesheet_store.getPayPeriodByDateOrYearAndNumber(date_string); const success = await timesheetStore.getPayPeriodByDateOrYearAndNumber(date_string);
if (success) { if (success) {
await timesheet_store.getTimesheetsByOptionalEmployeeEmail(employee_email); await timesheetStore.getTimesheetsByOptionalEmployeeEmail(employeeEmail);
timesheet_store.is_loading = false; await timesheetStore.getPaidTimeOffTotalsWithOptionalEmployeeEmail(employeeEmail);
timesheetStore.is_loading = false;
} }
timesheet_store.is_loading = false; timesheetStore.is_loading = false;
} }
const getTimesheetsByCurrentPayPeriod = async (employee_email?: string) => { const getTimesheetsByCurrentPayPeriod = async (employeeEmail?: string) => {
if (timesheet_store.pay_period === undefined) return false; if (timesheetStore.pay_period === undefined) return false;
timesheet_store.is_loading = true; timesheetStore.is_loading = true;
const success = await timesheet_store.getPayPeriodByDateOrYearAndNumber(); const success = await timesheetStore.getPayPeriodByDateOrYearAndNumber();
if (success) { if (success) {
await timesheet_store.getTimesheetsByOptionalEmployeeEmail(employee_email); await timesheetStore.getTimesheetsByOptionalEmployeeEmail(employeeEmail);
timesheet_store.is_loading = false; await timesheetStore.getPaidTimeOffTotalsWithOptionalEmployeeEmail(employeeEmail);
timesheetStore.is_loading = false;
} }
timesheet_store.is_loading = false; timesheetStore.is_loading = false;
}; };
const applyPreset = async (timesheet_id: number, week_day_index?: number, date?: string, employeeEmail?: string) => { const applyPreset = async (timesheet_id: number, week_day_index?: number, date?: string, employeeEmail?: string) => {
if (timesheet_store.timesheets.map(timesheet => timesheet.timesheet_id).includes(timesheet_id)) { timesheetStore.is_loading = true;
timesheet_store.is_loading = true;
try {
let response;
if (week_day_index && date) const success = await timesheetStore.applyPreset(timesheet_id, week_day_index, date, employeeEmail);
response = await timesheetService.applyPresetToDay(timesheet_id, week_day_index, date, employeeEmail);
else
response = await timesheetService.applyPresetToWeek(timesheet_id, employeeEmail);
if (response.success) if (!success) {
await timesheet_store.getTimesheetsByOptionalEmployeeEmail(employeeEmail); timesheetStore.is_loading = false;
} catch (error) { return;
console.error('Error applying weekly timesheet: ', error);
} }
timesheet_store.is_loading = false; const timesheets = JSON.stringify(timesheetStore.timesheets);
} const initialTimesheets = JSON.stringify(timesheetStore.initial_timesheets);
if (timesheets !== initialTimesheets) {
await shiftStore.updateShifts(employeeEmail);
await shiftStore.createNewShifts(employeeEmail);
await timesheetStore.getTimesheetsByOptionalEmployeeEmail(employeeEmail);
await timesheetStore.getPaidTimeOffTotalsWithOptionalEmployeeEmail(employeeEmail);
} }
const getTimesheetsBySwiping = async( direction: number ) => { timesheetStore.is_loading = false;
timesheet_store.is_loading = true; }
timesheet_store.getNextOrPreviousPayPeriod(direction); const getTimesheetsBySwiping = async (direction: number) => {
await timesheet_store.getPayPeriodByDateOrYearAndNumber(); timesheetStore.is_loading = true;
await timesheet_store.getTimesheetsByOptionalEmployeeEmail();
timesheet_store.is_loading = false; timesheetStore.getNextOrPreviousPayPeriod(direction);
await timesheetStore.getPayPeriodByDateOrYearAndNumber();
await timesheetStore.getTimesheetsByOptionalEmployeeEmail();
timesheetStore.is_loading = false;
} }
return { return {

View File

@ -33,3 +33,8 @@ export interface ExpenseOption {
value: ExpenseType; value: ExpenseType;
icon: string; icon: string;
} }
export interface AttachmentPresignedURLResponse {
url: string;
key: string;
}

View File

@ -1,3 +1,5 @@
import type { QSelectOption } from "quasar";
export const SHIFT_TYPES: ShiftType[] = [ export const SHIFT_TYPES: ShiftType[] = [
'REGULAR', 'REGULAR',
'EVENING', 'EVENING',
@ -39,9 +41,10 @@ export class Shift {
} }
} }
export interface ShiftOption { export interface ShiftOption extends QSelectOption {
label: string; label: string;
value: ShiftType; value: ShiftType;
icon: string; icon: string;
icon_color: 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 { export interface TimesheetResponse {
has_preset_schedule: boolean; has_preset_schedule: boolean;
employee_fullname: string; employee_fullname: string;
daily_expected_hours: number;
timesheets: Timesheet[]; timesheets: Timesheet[];
} }

View File

@ -1,6 +1,6 @@
import { api } from "src/boot/axios"; import { api } from "src/boot/axios";
import type { BackendResponse } from "src/modules/shared/models/backend-response.models"; import type { BackendResponse } from "src/modules/shared/models/backend-response.models";
import type { Expense } from "src/modules/timesheets/models/expense.models"; import type { AttachmentPresignedURLResponse, Expense } from "src/modules/timesheets/models/expense.models";
export const ExpenseService = { export const ExpenseService = {
createExpense: async (expense: Expense): Promise<{ success: boolean, data: Expense, error?: unknown }> => { createExpense: async (expense: Expense): Promise<{ success: boolean, data: Expense, error?: unknown }> => {
@ -18,14 +18,19 @@ export const ExpenseService = {
return response.data; return response.data;
}, },
getPresignedUploadURL: async (file: File, checksum_crc32: string): Promise<BackendResponse<string>> => { getPresignedUploadURL: async (file: File, checksum_crc32: string): Promise<BackendResponse<AttachmentPresignedURLResponse>> => {
const [file_name, file_type] = file.name.split('.'); const [file_name, file_type] = file.name.split('.');
const response = await api.post(`attachments/s3/upload?file-name=${file_name}&file-type=${file_type}&checksumCRC32=${checksum_crc32}`); const response = await api.post(`attachments/s3/upload?file-name=${file_name}&file-type=${file_type}&checksumCRC32=${checksum_crc32}`);
return response.data; return response.data;
}, },
uploadAttachmentWithPresignedUrl: async (file: File, url: string) => { uploadAttachmentWithPresignedUrl: async (file: File, url: string): Promise<number> => {
const response = await api.put(url, file, { headers: { 'Content-Type': file.type, }, withCredentials: false }); const response = await api.put(url, file, { headers: { 'Content-Type': `image/${file.type}`, }, withCredentials: false });
console.log('response to upload: ', response); return response.status;
},
getPresignedDownloadURL: async (key: string): Promise<BackendResponse<string>> => {
const response = await api.get<BackendResponse<string>>(`attachments/s3/download?attachmentKey=${key}`);
return response.data;
} }
}; };

View File

@ -1,6 +1,6 @@
import { date } from "quasar"; import { date } from "quasar";
import type { SchedulePresetShift } from "src/modules/employee-list/models/schedule-presets.models"; 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 => { export const isShiftOverlap = (shifts: Shift[] | SchedulePresetShift[]): boolean => {
if (shifts.length < 2) return false; if (shifts.length < 2) return false;
@ -26,6 +26,37 @@ export const isShiftOverlap = (shifts: Shift[] | SchedulePresetShift[]): boolean
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[] = [ export const SHIFT_OPTIONS: ShiftOption[] = [
{ label: 'timesheet.shift.types.REGULAR', value: 'REGULAR', icon: 'wb_sunny', icon_color: 'accent' }, { 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.EVENING', value: 'EVENING', icon: 'bedtime', icon_color: 'orange-5' },
@ -35,4 +66,17 @@ export const SHIFT_OPTIONS: ShiftOption[] = [
{ label: 'timesheet.shift.types.SICK', value: 'SICK', icon: 'medication_liquid', icon_color: 'light-blue-6' }, { 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.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.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

@ -5,6 +5,7 @@ import { useAuthStore } from 'src/stores/auth-store';
import { RouteNames } from 'src/router/router-constants'; import { RouteNames } from 'src/router/router-constants';
import { useChatbotStore } from 'src/stores/chatbot-store'; import { useChatbotStore } from 'src/stores/chatbot-store';
import type { UserModuleAccess } from 'src/modules/shared/models/user.models'; import type { UserModuleAccess } from 'src/modules/shared/models/user.models';
import { useTimesheetStore } from 'src/stores/timesheet-store';
/* /*
* If not building with SSR mode, you can * If not building with SSR mode, you can
@ -30,19 +31,29 @@ export default defineRouter(function (/* { store, ssrContext } */) {
history: createHistory(process.env.VUE_ROUTER_BASE), history: createHistory(process.env.VUE_ROUTER_BASE),
}); });
Router.beforeEach(async (destination_page) => { Router.beforeEach(async (to, from) => {
const auth_store = useAuthStore(); const auth_store = useAuthStore();
const result = await auth_store.getProfile() ?? { status: 400, message: 'unknown error occured' }; const result = await auth_store.getProfile() ?? { status: 400, message: 'unknown error occured' };
if (destination_page.meta.requires_auth && !auth_store.user || (result.status >= 400 && destination_page.name !== RouteNames.LOGIN)) { if (to.meta.requires_auth && !auth_store.user || (result.status >= 400 && to.name !== RouteNames.LOGIN)) {
console.error('no user account found'); console.error('no user account found');
return { name: 'login' }; return { name: 'login' };
} }
if (destination_page.meta.required_module && auth_store.user) { if (to.meta.required_module && auth_store.user) {
if (!auth_store.user.user_module_access.includes(destination_page.meta.required_module as UserModuleAccess)) if (!auth_store.user.user_module_access.includes(to.meta.required_module as UserModuleAccess))
return {name: 'error'}; return {name: 'error'};
} }
if (from.name === RouteNames.TIMESHEET) {
const timesheetStore = useTimesheetStore();
if(timesheetStore.canSaveTimesheets) {
timesheetStore.nextPageNameAfterUnsaveWarning = to.name;
timesheetStore.isShowingUnsavedWarning = true;
return false;
}
}
}) })
Router.afterEach( (destination_page) => { Router.afterEach( (destination_page) => {

View File

@ -11,9 +11,11 @@ export const useExpensesStore = defineStore('expenses', () => {
const is_open = ref(false); const is_open = ref(false);
const is_loading = ref(false); const is_loading = ref(false);
const is_showing_create_form = ref(false); const is_showing_create_form = ref(false);
const attachmentURL = ref<string>('');
const mode = ref<'create' | 'update' | 'delete'>('create'); const mode = ref<'create' | 'update' | 'delete'>('create');
const current_expense = ref<Expense>(new Expense(date.formatDate(new Date(), 'YYYY-MM-DD'))); const current_expense = ref<Expense>(new Expense(date.formatDate(new Date(), 'YYYY-MM-DD')));
const initial_expense = ref<Expense>(new Expense(date.formatDate(new Date(), 'YYYY-MM-DD'))); const initial_expense = ref<Expense>(new Expense(date.formatDate(new Date(), 'YYYY-MM-DD')));
const isShowingAttachmentDialog = ref(false);
const is_save_disabled = computed(() => JSON.stringify(current_expense.value) === JSON.stringify(initial_expense.value)) const is_save_disabled = computed(() => JSON.stringify(current_expense.value) === JSON.stringify(initial_expense.value))
const open = (): void => { const open = (): void => {
@ -51,22 +53,48 @@ export const useExpensesStore = defineStore('expenses', () => {
return data.success; return data.success;
} }
const uploadAttachment = async (file: File) => { /**
* Attemps to upload the provided image file to the S3 storage bucket for attachments.
*
* @param file image file to be uploaded to the S3 storage
* @returns Key `string` associated with the uploaded image file if successful,
* `undefined` if it fails.
*/
const uploadAttachment = async (file: File): Promise<string | undefined> => {
try { try {
const checksum = await computeCRC32Base64(file); const checksum = await computeCRC32Base64(file);
const presignedUrlResponse = await ExpenseService.getPresignedUploadURL(file, checksum); const presignedUrlResponse = await ExpenseService.getPresignedUploadURL(file, checksum);
if (presignedUrlResponse.success && presignedUrlResponse.data) { if (!presignedUrlResponse.success || !presignedUrlResponse.data) {
const { url, key } = JSON.parse(presignedUrlResponse.data); console.error('failed to get presigned URL from server');
console.log('key: ', key); return;
await ExpenseService.uploadAttachmentWithPresignedUrl(file, url);
} }
const { url, key } = presignedUrlResponse.data;
const responseStatus = await ExpenseService.uploadAttachmentWithPresignedUrl(file, url);
if (responseStatus >= 400) {
console.error('an error occured during upload: error ', responseStatus);
return;
}
return key;
} catch (error) { } catch (error) {
console.error(error); console.error(error);
} }
} }
const getAttachmentURL = async (key?: string) => {
if (!key)
return;
const presignedAttachmentURL = await ExpenseService.getPresignedDownloadURL(key);
if (presignedAttachmentURL.success && presignedAttachmentURL.data)
attachmentURL.value = presignedAttachmentURL.data;
}
return { return {
is_open, is_open,
is_loading, is_loading,
@ -74,11 +102,14 @@ export const useExpensesStore = defineStore('expenses', () => {
mode, mode,
current_expense, current_expense,
initial_expense, initial_expense,
isShowingAttachmentDialog,
is_save_disabled, is_save_disabled,
attachmentURL,
open, open,
upsertExpense, upsertExpense,
deleteExpenseById, deleteExpenseById,
close, close,
uploadAttachment, uploadAttachment,
getAttachmentURL,
}; };
}); });

View File

@ -12,7 +12,8 @@ import type { PaidTimeOff } from 'src/modules/employee-list/models/employee-prof
import type { PayPeriodEvent } from 'src/modules/timesheet-approval/models/pay-period-event.models'; import type { PayPeriodEvent } from 'src/modules/timesheet-approval/models/pay-period-event.models';
import type { TimesheetApprovalCSVReportFilters } from 'src/modules/timesheet-approval/models/timesheet-approval-csv-report.models'; import type { TimesheetApprovalCSVReportFilters } from 'src/modules/timesheet-approval/models/timesheet-approval-csv-report.models';
import { type FederalHoliday, TARGO_HOLIDAY_NAMES_FR } from 'src/modules/timesheets/models/federal-holidays.models'; import { type FederalHoliday, TARGO_HOLIDAY_NAMES_FR } from 'src/modules/timesheets/models/federal-holidays.models';
import type { RouteNames } from 'src/router/router-constants';
import type { RouteRecordNameGeneric } from 'vue-router';
export const useTimesheetStore = defineStore('timesheet', () => { export const useTimesheetStore = defineStore('timesheet', () => {
const { t } = useI18n(); const { t } = useI18n();
@ -21,7 +22,16 @@ export const useTimesheetStore = defineStore('timesheet', () => {
const timesheets = ref<Timesheet[]>([]); const timesheets = ref<Timesheet[]>([]);
const all_current_shifts = computed(() => timesheets.value.flatMap(week => week.days.flatMap(day => day.shifts)) ?? []); 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 canSaveTimesheets = computed(() => {
/* eslint-disable-next-line */
const currentShifts = timesheets.value.flatMap(timesheet => timesheet.days.flatMap(day => day.shifts.map(shift => { const { has_error, ...shft } = shift; return shft; })));
const initialShifts = initial_timesheets.value.flatMap(timesheet => timesheet.days.flatMap(day => day.shifts));
return JSON.stringify(currentShifts) !== JSON.stringify(initialShifts);
});
const paid_time_off_totals = ref<PaidTimeOff>({ sick_hours: 0, vacation_hours: 0, banked_hours: 0 }); const paid_time_off_totals = ref<PaidTimeOff>({ sick_hours: 0, vacation_hours: 0, banked_hours: 0 });
const isShowingUnsavedWarning = ref(false);
const nextPageNameAfterUnsaveWarning = ref<RouteNames | RouteRecordNameGeneric>();
const pay_period_overviews = ref<TimesheetApprovalOverview[]>([]); const pay_period_overviews = ref<TimesheetApprovalOverview[]>([]);
const pay_period_infos = ref<PayPeriodOverviewResponse>(); const pay_period_infos = ref<PayPeriodOverviewResponse>();
@ -121,7 +131,7 @@ export const useTimesheetStore = defineStore('timesheet', () => {
has_timesheet_preset.value = response.data.has_preset_schedule; has_timesheet_preset.value = response.data.has_preset_schedule;
selected_employee_name.value = response.data.employee_fullname; selected_employee_name.value = response.data.employee_fullname;
timesheets.value = response.data.timesheets; timesheets.value = response.data.timesheets;
initial_timesheets.value = unwrapAndClone(timesheets.value); initial_timesheets.value = unwrapAndClone(response.data.timesheets);
} else { } else {
selected_employee_name.value = ''; selected_employee_name.value = '';
timesheets.value = []; timesheets.value = [];
@ -169,6 +179,24 @@ export const useTimesheetStore = defineStore('timesheet', () => {
return false; return false;
}; };
const applyPreset = async (timesheet_id: number, week_day_index?: number, date?: string, employeeEmail?: string): Promise<boolean> => {
if (timesheets.value.map(timesheet => timesheet.timesheet_id).includes(timesheet_id)) {
try {
let response;
if (week_day_index && date)
response = await timesheetService.applyPresetToDay(timesheet_id, week_day_index, date, employeeEmail);
else
response = await timesheetService.applyPresetToWeek(timesheet_id, employeeEmail);
return response.success ?? false;
} catch (error) {
console.error('Error applying weekly timesheet: ', error);
}
}
return false;
}
const getPayPeriodReport = async (report_filters: TimesheetApprovalCSVReportFilters) => { const getPayPeriodReport = async (report_filters: TimesheetApprovalCSVReportFilters) => {
try { try {
if (!pay_period.value) return false; if (!pay_period.value) return false;
@ -239,18 +267,22 @@ export const useTimesheetStore = defineStore('timesheet', () => {
current_pay_period_overview, current_pay_period_overview,
pay_period_infos, pay_period_infos,
selected_employee_name, selected_employee_name,
canSaveTimesheets,
has_timesheet_preset, has_timesheet_preset,
timesheets, timesheets,
all_current_shifts, all_current_shifts,
initial_timesheets, initial_timesheets,
federal_holidays, federal_holidays,
paid_time_off_totals, paid_time_off_totals,
isShowingUnsavedWarning,
nextPageNameAfterUnsaveWarning,
getCurrentFederalHolidays, getCurrentFederalHolidays,
getNextOrPreviousPayPeriod, getNextOrPreviousPayPeriod,
getPayPeriodByDateOrYearAndNumber, getPayPeriodByDateOrYearAndNumber,
getTimesheetOverviews, getTimesheetOverviews,
getTimesheetsByOptionalEmployeeEmail, getTimesheetsByOptionalEmployeeEmail,
toggleTimesheetsApprovalByEmployeeEmail, toggleTimesheetsApprovalByEmployeeEmail,
applyPreset,
getPayPeriodReport, getPayPeriodReport,
openReportDialog, openReportDialog,
closeReportDialog, closeReportDialog,

View File

@ -20,14 +20,27 @@ export const getMinutes = (hours: number) => {
return minutes > 1 ? minutes.toString() : '0'; return minutes > 1 ? minutes.toString() : '0';
} }
export const getHoursMinutesStringFromHoursFloat = (hours: number): string => { export const getHoursMinutesStringFromHoursFloat = (hours: number, minutes?: number): string => {
let flat_hours = Math.floor(hours); let flatHours = Math.floor(hours);
let minutes = Math.round((hours - flat_hours) * 60); let flatMinutes = minutes ?? Math.round((hours - flatHours) * 60);
if (minutes === 60) { if (flatMinutes === 60) {
flat_hours += 1; flatHours += 1;
minutes = 0; 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),
}
} }