fix(approvals): fix sizing issue, multiple ui bugs in details window, separate scrollable timesheet from static one

This commit is contained in:
Nicolas Drolet 2026-01-06 15:10:27 -05:00
parent 07b52c854f
commit ec466bf6f2
4 changed files with 397 additions and 331 deletions

View File

@ -0,0 +1,102 @@
<script
setup
lang="ts"
>
import ShiftList from 'src/modules/timesheets/components/shift-list.vue';
import { useQuasar } from 'quasar';
import { computed, ref, watch } from 'vue';
import { useTimesheetStore } from 'src/stores/timesheet-store';
import { useTimesheetApi } from 'src/modules/timesheets/composables/use-timesheet-api';
import type { QScrollArea, TouchSwipeValue } from 'quasar';
const q = useQuasar();
const timesheet_store = useTimesheetStore();
const timesheet_api = useTimesheetApi();
const { mode = 'normal' } = defineProps<{
mode: 'normal' | 'approval';
}>();
const mobile_animation_direction = ref('fadeInLeft');
const timesheet_page = ref<QScrollArea | null>(null);
const currentDayComponent = ref<HTMLElement[] | null>(null);
const currentDayComponentWatcher = ref(currentDayComponent);
const scroll_y = computed(() => timesheet_page.value?.getScrollPosition().top ?? 0);
const handleSwipe: TouchSwipeValue = (details) => {
mobile_animation_direction.value = details.direction === 'left' ? 'fadeInRight' : 'fadeInLeft';
if (details.distance && details.distance.x && Math.abs(details.distance.x) > 10) {
timesheet_api.getTimesheetsBySwiping(details.direction === 'left' ? 1 : -1).catch(error => console.error(error));
}
};
watch(currentDayComponentWatcher, () => {
if (currentDayComponent.value && timesheet_page.value && q.platform.is.mobile) {
timesheet_page.value.setScrollPosition('vertical', currentDayComponent.value[0]!.offsetTop, 800);
return;
}
})
</script>
<template>
<div
class="column fit relative-position"
:style="$q.platform.is.mobile && $q.screen.width < $q.screen.height ? 'margin-bottom: 40px' : ''"
v-touch-swipe="handleSwipe"
>
<q-scroll-area
ref="timesheet_page"
:horizontal-offset="[0, 3]"
class="col absolute-full hide-scrollbar"
:thumb-style="{ opacity: '0' }"
:bar-style="{ opacity: '0' }"
>
<!-- Show if no timesheets found (further than one month from present) -->
<div
v-if="timesheet_store.timesheets.length < 1 && !timesheet_store.is_loading"
class="col-auto column flex-center fit q-py-lg"
style="min-height: 20vh;"
>
<span class="text-uppercase text-weight-bolder text-center">{{ $t('shared.error.no_data_found')
}}</span>
<q-icon
name="las la-calendar"
color="accent"
size="10em"
class="absolute"
style="opacity: 0.2;"
/>
</div>
<!-- Else show timesheets if found -->
<ShiftList />
</q-scroll-area>
<q-page-sticky
v-if="mode === 'normal'"
position="bottom-right"
:offset="$q.screen.width > $q.screen.height ? [15, 15] : [15, 65]"
class="z-top"
>
<transition
appear
enter-active-class="animated zoomIn"
leave-active-class="animated zoomOut"
>
<q-btn
v-if="scroll_y > 400"
fab
icon="las la-chevron-up"
color="white"
text-color="accent"
class="shadow-12"
@click="timesheet_page!.setScrollPosition('vertical', 0, 300)"
/>
</transition>
</q-page-sticky>
</div>
</template>

View File

@ -5,39 +5,27 @@
import ShiftListDay from 'src/modules/timesheets/components/shift-list-day.vue';
import ShiftListDateWidget from 'src/modules/timesheets/components/shift-list-date-widget.vue';
import { date, useQuasar } from 'quasar';
import { computed, ref, watch } from 'vue';
import { ref, computed } from 'vue';
import { useUiStore } from 'src/stores/ui-store';
import { useTimesheetStore } from 'src/stores/timesheet-store';
import { Shift } from 'src/modules/timesheets/models/shift.models';
import { useTimesheetApi } from 'src/modules/timesheets/composables/use-timesheet-api';
import type { QScrollArea, TouchSwipeValue } from 'quasar';
import type { TimesheetDay } from 'src/modules/timesheets/models/timesheet.models';
import { date } from 'quasar';
const CURRENT_DATE_STRING = new Date().toISOString().slice(0, 10);
const { extractDate } = date;
const q = useQuasar();
const ui_store = useUiStore();
const timesheet_store = useTimesheetStore();
const timesheet_api = useTimesheetApi();
const { mode = 'normal' } = defineProps<{
mode: 'normal' | 'approval';
}>();
const mobile_animation_direction = ref('fadeInLeft');
const animation_style = computed(() => ui_store.is_mobile_mode ? mobile_animation_direction.value : 'fadeInDown');
const timesheet_page = ref<QScrollArea | null>(null);
const currentDayComponent = ref<HTMLElement[] | null>(null);
const currentDayComponentWatcher = ref(currentDayComponent);
const scroll_y = computed(() => timesheet_page.value?.getScrollPosition().top ?? 0);
const timesheet_container = ref<HTMLElement | null>(null);
const scroll_area_height = ref(0);
const addNewShift = (day_shifts: Shift[], date: string, timesheet_id: number) => {
ui_store.focus_next_component = true;
const new_shift = new Shift;
@ -59,248 +47,170 @@
return day.shifts.every(shift => shift.is_approved === true);
};
const handleSwipe: TouchSwipeValue = (details) => {
mobile_animation_direction.value = details.direction === 'left' ? 'fadeInRight' : 'fadeInLeft';
if (details.distance && details.distance.x && Math.abs(details.distance.x) > 10) {
timesheet_api.getTimesheetsBySwiping(details.direction === 'left' ? 1 : -1).catch(error => console.error(error));
}
};
const getMobileDayRef = (iso_date_string: string): string => {
return iso_date_string === CURRENT_DATE_STRING ? 'currentDayComponent' : '';
};
watch(currentDayComponentWatcher, () => {
if (currentDayComponent.value && timesheet_page.value && q.platform.is.mobile) {
console.log('setting scroll position to offsetTop of currentDayComponent: ', currentDayComponent.value[0]!.offsetTop);
timesheet_page.value.setScrollPosition('vertical', currentDayComponent.value[0]!.offsetTop, 800);
return;
}
if (timesheet_container.value !== null && mode === 'approval') {
scroll_area_height.value = timesheet_container.value.offsetHeight
}
})
</script>
<template>
<div
class="column fit relative-position"
:style="$q.platform.is.mobile && $q.screen.width < $q.screen.height ? 'margin-bottom: 40px' : ''"
v-touch-swipe="handleSwipe"
class="fit"
:class="$q.platform.is.mobile ? 'column' : 'row'"
>
<q-scroll-area
ref="timesheet_page"
:horizontal-offset="[0, 3]"
class="col absolute-full hide-scrollbar"
:style="mode === 'approval' ? `height: ${scroll_area_height}px;` : ''"
:thumb-style="{ opacity: '0' }"
:bar-style="{ opacity: '0' }"
>
<!-- Show if no timesheets found (further than one month from present) -->
<div
v-if="timesheet_store.timesheets.length < 1 && !timesheet_store.is_loading"
class="col-auto column flex-center fit q-py-lg"
style="min-height: 20vh;"
>
<span class="text-uppercase text-weight-bolder text-center">{{ $t('shared.error.no_data_found')
}}</span>
<q-icon
name="las la-calendar"
color="accent"
size="10em"
class="absolute"
style="opacity: 0.2;"
/>
</div>
<!-- Else show timesheets if found -->
<div
v-else
ref="timesheet_container"
class="col fit"
:class="$q.platform.is.mobile ? 'column' : 'row'"
>
<div
v-for="timesheet, timesheet_index of timesheet_store.timesheets"
:key="timesheet.timesheet_id"
class="col column fit flex-center"
>
<transition
appear
enter-active-class="animated fadeInDown"
leave-active-class="animated fadeOutUp"
>
<q-btn
v-if="!$q.platform.is.mobile && timesheet.days.every(day => day.shifts.length < 1) && timesheet_store.has_timesheet_preset"
:disable="!timesheet.days.every(day => day.shifts.length < 1)"
flat
dense
:label="$t('timesheet.apply_preset_week')"
class="col-auto text-uppercase text-weight-bold text-accent q-mx-lg q-py-none rounded-5"
@click="timesheet_api.applyPreset(timesheet.timesheet_id)"
>
<q-icon
name="las la-calendar-week"
color="accent"
size="md"
/>
</q-btn>
</transition>
<transition-group
appear
:enter-active-class="`animated ${animation_style}`"
>
<div
v-for="day, day_index in timesheet.days"
:key="day.date"
:ref="getMobileDayRef(day.date)"
class="col-auto row q-pa-sm fit"
:style="`animation-delay: ${day_index / 15}s;`"
>
<!-- mobile version in portrait mode -->
<div
v-if="$q.platform.is.mobile && ($q.screen.width < $q.screen.height)"
class="col column full-width q-px-md q-py-sm"
>
<q-card
class="mobile-rounded-10 shadow-12"
:class="(getDayApproval(day) || timesheet.is_approved) ? 'bg-accent' : 'bg-dark'"
>
<q-card-section
class="text-weight-bolder text-uppercase text-h6 q-py-sm text-center relative-position"
:class="(getDayApproval(day) || timesheet.is_approved) ? 'bg-accent text-white' : 'bg-primary text-white'"
style="line-height: 1em;"
>
<span> {{ $d(extractDate(day.date, 'YYYY-MM-DD'), {
weekday: 'long', day: 'numeric', month:
'long'
}) }}</span>
<q-icon
v-if="(getDayApproval(day) || timesheet.is_approved)"
name="verified"
size="3em"
color="white"
class="absolute-top-left z-top"
style="top: -0.2em; left: 0px;"
/>
</q-card-section>
<q-card-section
v-if="day.shifts.filter(shift => shift.id !== 0).length > 0"
class="q-pa-none transparent"
>
<ShiftListDay
outlined
:timesheet-id="timesheet.timesheet_id"
:week-day-index="day_index"
:animation-delay-multiplier="day_index"
:approved="(getDayApproval(day) || timesheet.is_approved)"
:day="day"
@delete-unsaved-shift="deleteUnsavedShift(timesheet_index, day_index)"
/>
</q-card-section>
<q-card-section class="q-pa-none">
<q-btn
v-if="!(getDayApproval(day) || timesheet.is_approved)"
square
dense
size="xl"
color="accent"
icon="more_time"
class="full-width"
style="border-radius: 0 0 10px 10px;"
@click="addNewShift(day.shifts, day.date, timesheet.timesheet_id)"
/>
</q-card-section>
</q-card>
</div>
<!-- desktop version -->
<div
v-else
class="col row full-width rounded-10 ellipsis shadow-10"
>
<div
class="col row"
:class="(getDayApproval(day) || timesheet.is_approved) ? 'bg-accent' : 'bg-dark'"
>
<!-- Date block -->
<ShiftListDateWidget
:display-date="day.date"
:approved="(getDayApproval(day) || timesheet.is_approved)"
class="col-auto"
/>
<ShiftListDay
:timesheet-id="timesheet.timesheet_id"
:week-day-index="day_index"
:day="day"
:approved="getDayApproval(day) || timesheet.is_approved"
class="col"
@delete-unsaved-shift="deleteUnsavedShift(timesheet_index, day_index)"
/>
</div>
<div class="col-auto self-stretch">
<q-icon
v-if="(getDayApproval(day) || timesheet.is_approved)"
name="verified"
color="white"
size="xl"
class="full-height"
:class="(getDayApproval(day) || timesheet.is_approved) ? 'bg-accent' : ''"
/>
<q-btn
v-else
:dense="!$q.platform.is.mobile"
square
icon="more_time"
size="lg"
color="accent"
text-color="white"
class="full-height"
:class="$q.platform.is.mobile ? 'q-px-xs' : ''"
@click="addNewShift(day.shifts, day.date, timesheet.timesheet_id)"
/>
</div>
</div>
</div>
</transition-group>
</div>
</div>
</q-scroll-area>
<q-page-sticky
v-if="mode === 'normal'"
position="bottom-right"
:offset="$q.screen.width > $q.screen.height ? [15, 15] : [15, 65]"
class="z-top"
<div
v-for="timesheet, timesheet_index of timesheet_store.timesheets"
:key="timesheet.timesheet_id"
class="col column fit items-center"
>
<transition
appear
enter-active-class="animated zoomIn"
leave-active-class="animated zoomOut"
enter-active-class="animated fadeInDown"
leave-active-class="animated fadeOutUp"
>
<q-btn
v-if="scroll_y > 400"
fab
icon="las la-chevron-up"
color="white"
text-color="accent"
class="shadow-12"
@click="timesheet_page!.setScrollPosition('vertical', 0, 300)"
/>
v-if="!$q.platform.is.mobile && timesheet.days.every(day => day.shifts.length < 1) && timesheet_store.has_timesheet_preset"
:disable="!timesheet.days.every(day => day.shifts.length < 1)"
flat
dense
:label="$t('timesheet.apply_preset_week')"
class="col-auto text-uppercase text-weight-bold text-accent q-mx-lg q-py-none rounded-5"
@click="timesheet_api.applyPreset(timesheet.timesheet_id)"
>
<q-icon
name="las la-calendar-week"
color="accent"
size="md"
/>
</q-btn>
</transition>
</q-page-sticky>
</div>
<transition-group
appear
:enter-active-class="`animated ${animation_style}`"
>
<div
v-for="day, day_index in timesheet.days"
:key="day.date"
:ref="getMobileDayRef(day.date)"
class="col-auto row q-pa-sm full-width"
:style="`animation-delay: ${day_index / 15}s;`"
>
<!-- mobile version in portrait mode -->
<div
v-if="$q.platform.is.mobile && ($q.screen.width < $q.screen.height)"
class="col column full-width q-px-md q-py-sm"
>
<q-card
class="shadow-12"
:class="(getDayApproval(day) || timesheet.is_approved) ? 'bg-accent rounded-10' : 'bg-dark mobile-rounded-10'"
>
<q-card-section
class="text-weight-bolder text-uppercase text-h6 q-py-sm text-center relative-position"
:class="(getDayApproval(day) || timesheet.is_approved) ? 'bg-accent text-white' : 'bg-primary text-white'"
style="line-height: 1em;"
>
<span> {{ $d(extractDate(day.date, 'YYYY-MM-DD'), {
weekday: 'long', day: 'numeric', month:
'long'
}) }}</span>
<q-icon
v-if="(getDayApproval(day) || timesheet.is_approved)"
name="verified"
size="3em"
color="white"
class="absolute-top-left z-top"
style="top: -0.2em; left: 0px;"
/>
</q-card-section>
<q-card-section
v-if="day.shifts.filter(shift => shift.id !== 0).length > 0"
class="q-pa-none transparent"
>
<ShiftListDay
outlined
:timesheet-id="timesheet.timesheet_id"
:week-day-index="day_index"
:animation-delay-multiplier="day_index"
:approved="(getDayApproval(day) || timesheet.is_approved)"
:day="day"
@delete-unsaved-shift="deleteUnsavedShift(timesheet_index, day_index)"
/>
</q-card-section>
<q-card-section class="q-pa-none">
<q-btn
v-if="!(getDayApproval(day) || timesheet.is_approved)"
square
dense
size="xl"
color="accent"
icon="more_time"
class="full-width"
style="border-radius: 0 0 10px 10px;"
@click="addNewShift(day.shifts, day.date, timesheet.timesheet_id)"
/>
</q-card-section>
</q-card>
</div>
<!-- desktop version -->
<div
v-else
class="col row full-width rounded-10 ellipsis shadow-10"
>
<div
class="col row"
:class="(getDayApproval(day) || timesheet.is_approved) ? 'bg-accent' : 'bg-dark'"
>
<!-- Date block -->
<ShiftListDateWidget
:display-date="day.date"
:approved="(getDayApproval(day) || timesheet.is_approved)"
class="col-auto"
/>
<ShiftListDay
:timesheet-id="timesheet.timesheet_id"
:week-day-index="day_index"
:day="day"
:approved="getDayApproval(day) || timesheet.is_approved"
class="col"
@delete-unsaved-shift="deleteUnsavedShift(timesheet_index, day_index)"
/>
</div>
<div class="col-auto self-stretch">
<q-icon
v-if="(getDayApproval(day) || timesheet.is_approved)"
name="verified"
color="white"
size="xl"
class="full-height"
:class="(getDayApproval(day) || timesheet.is_approved) ? 'bg-accent' : ''"
/>
<q-btn
v-else
:dense="!$q.platform.is.mobile"
square
icon="more_time"
size="lg"
color="accent"
text-color="white"
class="full-height"
:class="$q.platform.is.mobile ? 'q-px-xs' : ''"
@click="addNewShift(day.shifts, day.date, timesheet.timesheet_id)"
/>
</div>
</div>
</div>
</transition-group>
</div>
</div>
</template>
<style

View File

@ -4,6 +4,7 @@
>
/* eslint-disable */
import ShiftList from 'src/modules/timesheets/components/shift-list.vue';
import ShiftListScrollable from 'src/modules/timesheets/components/shift-list-scrollable.vue';
import LoadingOverlay from 'src/modules/shared/components/loading-overlay.vue';
import ExpenseDialog from 'src/modules/timesheets/components/expense-dialog.vue';
import PageHeaderTemplate from 'src/modules/shared/components/page-header-template.vue';
@ -28,6 +29,21 @@
const is_timesheets_approved = computed(() => timesheet_store.timesheets.every(timesheet => timesheet.is_approved))
const total_hours = computed(() => timesheet_store.timesheets.reduce((sum, timesheet) =>
sum + timesheet.weekly_hours.regular
+ timesheet.weekly_hours.evening
+ timesheet.weekly_hours.emergency
+ timesheet.weekly_hours.overtime,
0) //initial value
);
const total_expenses = computed(() => timesheet_store.timesheets.reduce((sum, timesheet) =>
sum + timesheet.weekly_expenses.expenses
+ timesheet.weekly_expenses.on_call
+ timesheet.weekly_expenses.per_diem,
0) //initial value
);
const { mode = 'normal' } = defineProps<{
mode?: 'approval' | 'normal';
}>();
@ -54,8 +70,15 @@
<!-- weekly overview -->
<div class="col-auto row q-px-lg full-width">
<!-- supervisor weekly overview -->
<div class="col-xs-6 col-md-4 col-xl-3 q-pa-md">
<ShiftListWeeklyOverview mode="total-hours" />
<div
v-if="!$q.platform.is.mobile"
class="col-xs-6 col-md-4 col-xl-3 q-pa-md"
>
<ShiftListWeeklyOverview
mode="total-hours"
:total-hours="total_hours"
:total-expenses="total_expenses"
/>
</div>
<PageHeaderTemplate
@ -66,10 +89,13 @@
class="col"
/>
<q-space v-else />
<q-space v-if="!$q.platform.is.mobile && mode === 'approval'" />
<!-- employee weekly overview -->
<div class="col-xs-6 col-md-4 col-xl-3 q-pa-md">
<div
v-if="!$q.platform.is.mobile"
class="col-xs-6 col-md-4 col-xl-3 q-pa-md"
>
<ShiftListWeeklyOverview mode="off-hours" />
</div>
</div>
@ -77,7 +103,7 @@
<!-- top menu -->
<div
class="col-auto row items-center full-width"
:class="$q.platform.is.mobile && ($q.screen.width < $q.screen.height) ? 'justify-between' : 'q-pb-sm q-px-md'"
:class="$q.platform.is.mobile && ($q.screen.width < $q.screen.height) ? 'justify-between q-px-md' : 'q-pb-sm q-px-xl'"
>
<!-- navigation btn -->
<PayPeriodNavigator
@ -133,12 +159,40 @@
<!-- mobile weekly overview widget -->
<ShiftListWeeklyOverviewMobile class="col-auto" />
<ShiftList
<!-- standard scrollable shift list for user input -->
<ShiftListScrollable
v-if="mode === 'normal'"
:mode="mode"
:class="mode === 'normal' ? 'col' : 'col-auto'"
:style="mode === 'normal' ? '' : 'min-height: 100vh'"
/>
<!-- full shift list for timesheet approval details dialog -->
<div
v-else
class="col-auto column full-width"
: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) -->
<div
v-if="timesheet_store.timesheets.length < 1 && !timesheet_store.is_loading"
class="col-auto column flex-center fit q-py-lg"
style="min-height: 20vh;"
>
<span class="text-uppercase text-weight-bolder text-center">{{ $t('shared.error.no_data_found')
}}</span>
<q-icon
name="las la-calendar"
color="accent"
size="10em"
class="absolute"
style="opacity: 0.2;"
/>
</div>
<!-- Else show timesheets if found -->
<ShiftList class="col" />
</div>
<q-btn
v-if="$q.platform.is.mobile && $q.screen.width < $q.screen.height"
square

View File

@ -20,7 +20,7 @@
const headerComponent = ref<HTMLElement | null>(null);
const table_max_height = computed(() => {
const height = page_height.value - (headerComponent.value?.clientHeight ?? 0);
const height = page_height.value - Math.min(headerComponent.value?.clientHeight ?? 0, headerComponent.value?.offsetHeight ?? 0);
return height;
});