targo-frontend/src/modules/timesheets/components/shift-list.vue
Nicolas Drolet 13c339953f feat(timesheet): add shift overlap verification to shift entries
Also refactor mobile UI/UX for timesheet: reduced header bloat, made only shifts scrollable, added left or right swipe to travel between pay periods, showing default 'no data' message when beyond 6-month-back 1-month-forward timesheet scope.
2025-12-18 10:05:31 -05:00

278 lines
13 KiB
Vue

<script
setup
lang="ts"
>
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 } from 'quasar';
import { computed, ref } 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 } from 'quasar';
import type { TimesheetDay } from 'src/modules/timesheets/models/timesheet.models';
const { extractDate } = date;
const ui_store = useUiStore();
const timesheet_store = useTimesheetStore();
const timesheet_api = useTimesheetApi();
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 scroll_y = computed(() => timesheet_page.value?.getScrollPosition().top ?? 0)
const addNewShift = (day_shifts: Shift[], date: string, timesheet_id: number) => {
ui_store.focus_next_component = true;
const new_shift = new Shift;
new_shift.date = date;
new_shift.timesheet_id = timesheet_id;
day_shifts.push(new_shift);
};
const deleteUnsavedShift = (timesheet_index: number, day_index: number) => {
if (timesheet_store.timesheets !== undefined) {
const day = timesheet_store.timesheets[timesheet_index]!.days[day_index]!;
const shifts_without_deleted_shift = day.shifts.filter(shift => shift.id !== 0);
day.shifts = shifts_without_deleted_shift;
}
}
const getDayApproval = (day: TimesheetDay) => {
if (day.shifts.length < 1) return false;
return day.shifts.every(shift => shift.is_approved === true);
}
const handleSwipe = async (direction: 'left' | 'up' | 'down' | 'right' | undefined, distance: {x?: number, y?: number}) => {
mobile_animation_direction.value = direction === 'left' ? 'fadeInRight' : 'fadeInLeft';
if (distance.x && Math.abs(distance.x) > 10 ) {
await timesheet_api.getTimesheetsBySwiping( direction === 'left' ? 1 : -1 )
}
}
</script>
<template>
<div class="col column fit relative-position" v-touch-swipe="value => handleSwipe(value.direction, value.distance ?? {x: 0, y: 0})">
<q-scroll-area
ref="timesheet_page"
:horizontal-offset="[0, 3]"
class="absolute-full hide-scrollbar q-mt-sm"
:thumb-style="{ opacity: '0' }"
:bar-style="{ opacity: '0' }"
style="min-height: 50vh;"
>
<!-- Show if no timesheets found (further than one month from present) -->
<div
v-if="timesheet_store.timesheets.length < 1"
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
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 fit"
>
<transition
appear
enter-active-class="animated fadeInDown"
leave-active-class="animated fadeOutUp"
>
<q-btn
v-if="!$q.platform.is.mobile"
:disable="!timesheet.days.every(day => day.shifts.length < 1)"
flat
dense
:label="$t('timesheet.apply_preset_week')"
class="text-uppercase text-weight-bold text-accent q-mx-lg q-py-none rounded-5"
:class="timesheet.days.every(day => day.shifts.length < 1) ? '' : 'invisible'"
@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"
class="col-auto row rounded-10 q-ma-sm shadow-10"
:style="`animation-delay: ${day_index / 15}s;`"
>
<div
v-if="$q.platform.is.mobile && ($q.screen.width < $q.screen.height)"
class="col column full-width"
>
<q-card
class="rounded-10"
:class="(getDayApproval(day) || timesheet.is_approved) ? 'bg-accent' : 'bg-dark'"
:style="ui_store.is_mobile_mode ? ((getDayApproval(day) || timesheet.is_approved) ? 'border: 6px inset var(--q-accent)' : 'border: 1px solid var(--q-accent);') : ''"
>
<q-card-section
class="text-weight-bolder text-uppercase text-h6 q-py-xs"
: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-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-actions class="q-pa-none">
<q-btn
v-if="!(getDayApproval(day) || timesheet.is_approved)"
square
color="accent"
icon="more_time"
class="full-width"
style="border-radius: 0 0 5px 5px;"
@click="addNewShift(day.shifts, day.date, timesheet.timesheet_id)"
/>
</q-card-actions>
<q-badge
v-if="(getDayApproval(day) || timesheet.is_approved)"
floating
class="transparent q-pa-none rounded-50"
style="transform: translate(15px, -5px);"
>
<q-icon
name="verified"
size="5em"
color="white"
/>
</q-badge>
</q-card>
</div>
<div
v-else
class="col row full-width"
:class="(getDayApproval(day) || timesheet.is_approved) ? 'rounded-10 bg-accent' : ''"
>
<transition
appear
enter-active-class="animated fadeInDown"
leave-active-class="animated fadeOutUp"
>
</transition>
<!-- List of shifts -->
<div
class="col row bg-dark"
:class="(getDayApproval(day) || timesheet.is_approved) ? 'bg-transparent' : ''"
style="border-radius: 10px 0 0 10px;"
>
<!-- 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"
/>
<q-btn
v-else
:dense="!ui_store.is_mobile_mode"
icon="more_time"
size="lg"
color="accent"
text-color="white"
class="full-height"
:class="$q.screen.lt.md ? 'q-px-xs ' : ' '"
style="border-radius: 0 10px 10px 0;"
@click="addNewShift(day.shifts, day.date, timesheet.timesheet_id)"
/>
</div>
</div>
</div>
</transition-group>
</div>
</div>
<q-page-sticky
position="bottom-right"
:offset="[0, -35]"
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>
</q-scroll-area>
</div>
</template>