refactor(timesheet): More UI/UX adjustments to timesheet approval filters, mostly work on timesheets UI/UX for mobile

This commit is contained in:
Nicolas Drolet 2025-12-18 17:14:31 -05:00
parent fdbc563a0e
commit 4231b51c11
18 changed files with 576 additions and 188 deletions

View File

@ -176,6 +176,10 @@ export default {
timesheet: {
page_header: "Timesheet",
week: "week",
total_hours: "total hours: ",
current_shifts: "shifts worked",
apply_preset: "auto-fill",
apply_preset_day: "Apply schedule to day",
apply_preset_week: "Apply schedule to week",
nav_button: {
@ -254,15 +258,6 @@ export default {
timesheet_approvals: {
page_title: "Validation cartes de temps",
table: {
full_name: "full name",
email: "email address",
is_approved: "approval",
expenses: "expenses",
mileage: "mileage",
verified: "approved",
unverified: "pending",
},
chart: {
hours_worked_title: "hours worked",
expenses_title: "expenses accrued",
@ -275,6 +270,15 @@ export default {
expenses: "expenses",
options: "options",
},
table: {
full_name: "full name",
email: "email address",
is_approved: "approval",
expenses: "expenses",
mileage: "mileage",
verified: "approved",
unverified: "pending",
},
tooltip: {
button_detailed_view: "detailed view",
},

View File

@ -177,6 +177,10 @@ export default {
timesheet: {
page_header: "Carte de temps",
week: "semaine",
total_hours: "heures totales: ",
current_shifts: "quarts entrées",
apply_preset: "auto-remplir",
apply_preset_day: "Appliquer horaire pour la journée",
apply_preset_week: "Appliquer horaire pour la semaine",
nav_button: {
@ -255,15 +259,6 @@ export default {
timesheet_approvals: {
page_title: "Validation cartes de temps",
table: {
full_name: "nom complet",
email: "courriel",
is_approved: "approuvé",
expenses: "dépenses",
mileage: "kilométrage",
verified: "approuvé",
unverified: "à vérifier",
},
chart: {
hours_worked_title: "heures travaillées",
expenses_title: "dépenses encourues"
@ -276,6 +271,15 @@ export default {
expenses: "dépenses",
options: "options",
},
table: {
full_name: "nom complet",
email: "courriel",
is_approved: "approuvé",
expenses: "dépenses",
mileage: "kilométrage",
verified: "approuvé",
unverified: "à vérifier",
},
tooltip: {
button_detailed_view: "vue détaillée",
},

View File

@ -3,7 +3,7 @@
setup
>
import { useUiStore } from 'src/stores/ui-store';
import HeaderBarNotification from './main-layout-header-bar-notification.vue';
// import HeaderBarNotification from './main-layout-header-bar-notification.vue';
const uiStore = useUiStore();
</script>
@ -29,7 +29,7 @@
</q-btn>
</q-toolbar-title>
<q-item class="q-pa-none">
<HeaderBarNotification />
<!-- <HeaderBarNotification /> -->
</q-item>
</q-toolbar>
</q-header>

View File

@ -2,16 +2,15 @@
lang="ts"
setup
>
import { RouterView } from 'vue-router';
import HeaderBar from 'src/layouts/components/main-layout-header-bar.vue';
import FooterBar from 'src/layouts/components/main-layout-footer-bar.vue';
import LeftDrawer from 'src/layouts/components/main-layout-left-drawer.vue';
import { useUiStore } from 'src/stores/ui-store';
import { onMounted, watch, ref } from 'vue';
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
import { RouterView } from 'vue-router';
import { useUiStore } from 'src/stores/ui-store';
const ui_store = useUiStore();
const user_preferences = ref(ui_store.user_preferences);
@ -23,7 +22,7 @@
watch(user_preferences, async () => {
if (ui_store.user_preferences.id !== -1) {
await ui_store.updateUserPreferences(t);
await ui_store.updateUserPreferences();
return
}
await ui_store.getUserPreferences();

View File

@ -25,7 +25,7 @@
<div>
<q-card
flat
class="rounded-5 bg-transparent q-pa-none"
class="rounded-5 bg-transparent q-pa-none fit"
>
<MenuTemplate
:first-name="employee_profile.first_name === '' ? auth_store.user?.first_name ?? '' : employee_profile.first_name"

View File

@ -11,18 +11,15 @@
</script>
<template>
<div class="q-pa-md column fit">
<div
class="col-auto"
style="transform: translate(10px, 12px);"
>
<div class="q-px-md column no-wrap">
<div style="transform: translate(10px, 12px);">
<span class="text-uppercase text-weight-bold text-accent bg-dark q-px-sm">
{{ $t('profile.preferences.display_options') }}
</span>
</div>
<div
class="col-auto justify-center content-center q-mb-sm q-pa-sm rounded-5"
class="col-auto justify-center content-center q-mb-sm q-pa-md rounded-5"
:class="ui_store.is_mobile_mode ? 'column' : 'row'"
style="border: 1px solid var(--q-accent);"
>
@ -32,12 +29,12 @@
clickable
dense
v-ripple
class="col rounded-5 q-ma-sm shadow-4"
:class="mode.quasar_value === $q.dark.mode ? 'bg-accent text-white text-weight-bolder' : ''"
class="col row rounded-5 q-ma-sm shadow-4"
:class="(mode.quasar_value === $q.dark.mode ? 'bg-accent text-white text-weight-bolder' : '') + ($q.platform.is.mobile ? ' full-width q-py-xs' : '')"
@click="ui_store.user_preferences.is_dark_mode = mode.value"
>
<q-item-section avatar>
<q-icon
<q-icon
:name="mode.icon"
size="md"
:color="mode.quasar_value === $q.dark.mode ? 'white' : ''"

View File

@ -16,7 +16,7 @@
<template>
<div
class="column flex-center"
class="column flex-center fit"
>
<MenuHeader
:user-first-name="firstName"
@ -41,7 +41,7 @@
<q-card
class="col"
:class="$q.screen.lt.sm ? 'full-width' : 'q-ml-sm'"
:class="$q.platform.is.mobile ? 'fit' : 'q-ml-sm'"
>
<q-tab-panels
v-model="current_menu"
@ -49,8 +49,8 @@
vertical
transition-prev="jump-up"
transition-next="jump-up"
class="rounded-5"
style="height: 50vh;"
class="rounded-5 q-py-sm"
:style="$q.platform.is.mobile ? '' : 'height: 50vh;'"
>
<slot name="panels"></slot>
</q-tab-panels>

View File

@ -1,12 +1,37 @@
<script setup lang="ts">
<script
setup
lang="ts"
>
import { ref } from 'vue';
const boolean_filters = ref([])
</script>
<template>
<div class="column bg-accent q-pa-sm">
<span class="col">test</span>
<span class="col">test</span>
<span class="col">test</span>
<span class="col">test</span>
<div class="column text-weight-medium">
<q-separator
color="accent"
size="5px"
class="col-auto"
/>
<div class="col row">
<q-checkbox
v-model="boolean_filters"
size="lg"
val="inactive"
color="accent"
label="show inactive"
class="col"
/>
<q-checkbox
v-model="boolean_filters"
size="lg"
val="team"
color="accent"
label="show team only"
class="col"
/>
</div>
</div>
</template>

View File

@ -0,0 +1,295 @@
<script
setup
lang="ts"
>
import { computed, onMounted, ref } from 'vue';
import { QSelect, QInput } from 'quasar';
import { useUiStore } from 'src/stores/ui-store';
import { SHIFT_OPTIONS } from 'src/modules/timesheets/utils/shift.util';
import type { Shift } from 'src/modules/timesheets/models/shift.models';
const ui_store = useUiStore();
const COMMENT_LENGTH_MAX = 280;
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 { dense = false, hasShiftAfter = false, isTimesheetApproved = false } = defineProps<{
dense?: boolean;
hasShiftAfter?: boolean;
isTimesheetApproved?: boolean;
}>();
const emit = defineEmits<{
'requestDelete': [void];
'onTimeFieldBlur': [void];
}>();
const onBlurShiftTypeSelect = () => {
if (shift_type_selected.value === undefined) {
shift.value.type = 'REGULAR';
shift.value.id = 0;
emit('requestDelete');
}
};
const getCommentCounterColor = (comment_length: number) => {
if (comment_length < 200) return 'primary';
if (comment_length < 250) return 'warning';
return 'negative';
};
onMounted(() => {
if (ui_store.focus_next_component) {
select_ref.value?.focus();
select_ref.value?.showPopup();
shift_type_selected.value = undefined;
ui_store.focus_next_component = false;
}
});
</script>
<template>
<div class="row q-px-xs">
<div class="col column">
<div class="col row items-center text-uppercase q-px-xs rounded-5">
<!-- comment button -->
<q-btn
v-if="ui_store.is_mobile_mode && !dense"
:icon="shift.comment ? 'chat' : 'chat_bubble_outline'"
:text-color="shift.comment ? ((shift.is_approved && isTimesheetApproved) ? 'white' : 'accent') : 'grey-5'"
class="col-auto full-height q-mx-xs rounded-5 shadow-1"
@click="is_showing_comment_popup = true"
>
<q-dialog v-model="is_showing_comment_popup">
<q-input
color="white"
v-model="shift.comment"
dense
:readonly="(shift.is_approved || isTimesheetApproved)"
autofocus
counter
bottom-slots
stack-label
:label="$t('timesheet.shift.fields.header_comment')"
:maxlength="COMMENT_LENGTH_MAX"
:class="(shift.is_approved || isTimesheetApproved) ? 'cursor-not-allowed' : ''"
>
<template #append>
<q-icon name="edit" />
</template>
<template #counter>
<div class="row flex-center">
<q-space />
<q-knob
v-model="comment_length"
readonly
:max="COMMENT_LENGTH_MAX"
size="1.6em"
:thickness="0.4"
:color="getCommentCounterColor(comment_length)"
track-color="grey-4"
class="col-auto q-mr-xs"
/>
<span
:class="'col-auto text-weight-bolder text-' + getCommentCounterColor(comment_length)"
>{{ 280 - comment_length }}</span>
</div>
</template>
</q-input>
</q-dialog>
</q-btn>
<!-- shift type -->
<q-select
ref="select"
v-model="shift_type_selected"
:standout="$q.dark.isActive ? 'bg-blue-grey-3' : 'bg-blue-grey-9'"
dense
:borderless="(shift.is_approved && isTimesheetApproved)"
:readonly="(shift.is_approved && isTimesheetApproved)"
options-dense
hide-dropdown-icon
:menu-offset="[0, 10]"
menu-anchor="bottom middle"
menu-self="top middle"
:options="SHIFT_OPTIONS"
class="col rounded-5 bg-dark"
:class="!shift.is_approved && !isTimesheetApproved ? '' : 'inset-shadow'"
popup-content-class="text-uppercase text-weight-bold text-center rounded-5"
:style="shift.is_approved ? 'background-color: #0a7d32 !important;' : ''"
popup-content-style="border: 2px solid var(--q-accent)"
@blur="onBlurShiftTypeSelect"
@update:model-value="option => shift.type = option.value"
>
<template #selected-item="scope">
<div
class="row items-center text-weight-bold q-ma-none q-pa-none no-wrap ellipsis full-width"
:class="ui_store.is_mobile_mode ? 'full-height' : ''"
:tabindex="scope.tabindex"
>
<q-icon
:name="scope.opt.icon"
:color="scope.opt.icon_color"
size="sm"
class="col-auto"
:class="shift.is_approved ? 'q-mx-xs' : 'q-mr-xs'"
/>
<span
style="line-height: 1.2em;"
class="col-auto ellipsis"
:class="!shift.is_approved ? '' : 'text-white'"
>
{{ $t(scope.opt.label) }}
</span>
</div>
</template>
<template #after>
<q-icon
v-if="shift.is_approved"
:name="shift.is_remote ? 'las la-laptop' : 'las la-building'"
size="1.2em"
color="white"
class="q-mr-sm"
>
<q-tooltip
anchor="top middle"
self="bottom middle"
:offset="[0, 10]"
class="text-uppercase text-weight-bold text-white bg-primary"
>
{{ shift.is_remote ? $t('timesheet.shift.types.REMOTE') :
$t('timesheet.shift.types.OFFICE') }}
</q-tooltip>
</q-icon>
<q-toggle
v-else
v-model="shift.is_remote"
:disable="shift.is_approved"
dense
keep-color
size="3em"
color="accent"
icon="las la-building"
checked-icon="las la-laptop"
>
<q-tooltip
anchor="top middle"
self="bottom middle"
:offset="[0, 10]"
class="text-uppercase text-weight-medium text-white bg-accent"
>
{{ shift.is_remote ? $t('timesheet.shift.types.REMOTE') :
$t('timesheet.shift.types.OFFICE') }}
</q-tooltip>
</q-toggle>
</template>
</q-select>
</div>
<div class="col row items-start text-uppercase rounded-5 q-pa-xs">
<!-- punch in field -->
<div class="col q-pr-xs">
<q-input
v-model="shift.start_time"
dense
:borderless="(shift.is_approved && isTimesheetApproved)"
:readonly="(shift.is_approved && isTimesheetApproved)"
type="time"
:standout="$q.dark.isActive ? 'bg-blue-grey-3' : 'bg-blue-grey-9'"
label-slot
lazy-rules
no-error-icon
hide-bottom-space
:label-color="!shift.is_approved ? 'accent' : 'white'"
class="rounded-5 bg-dark"
:class="(shift.id === -2 ? 'bg-negative ' : ' ') + (!shift.is_approved && !isTimesheetApproved ? '' : 'cursor-not-allowed inset-shadow')"
:input-class="'text-weight-medium ' + (shift.id === -2 ? 'text-white ' : ' ') + (shift.is_approved ? 'text-white cursor-not-allowed q-px-sm' : '')"
input-style="font-size: 1.2em;"
:style="shift.is_approved ? 'background-color: #0a7d32 !important;' : ''"
@blur="emit('onTimeFieldBlur')"
>
<template #label>
<span
class="text-weight-bolder"
:class="shift.is_approved ? ' q-ml-md' : ''"
style="font-size: 0.95em;"
>{{ $t('shared.misc.in') }}</span>
</template>
</q-input>
</div>
<!-- punch out field -->
<div class="col">
<q-input
v-model="shift.end_time"
standout
dense
:borderless="(shift.is_approved && isTimesheetApproved)"
:readonly="(shift.is_approved && isTimesheetApproved)"
type="time"
label-slot
no-error-icon
hide-bottom-space
:label-color="!shift.is_approved ? 'accent' : 'white'"
:input-class="'text-weight-medium ' + (shift.id === -2 ? 'text-white ' : ' ') + (shift.is_approved ? 'text-white cursor-not-allowed q-px-sm' : '')"
input-style="font-size: 1.2em;"
class="rounded-5 bg-dark"
:class="(shift.id === -2 ? 'bg-negative ' : ' ') + (shift.is_approved ? 'cursor-not-allowed q-px-xs transparent inset-shadow' : (isTimesheetApproved ? 'inset-shadow' : ''))"
:style="shift.is_approved ? 'background-color: #0a7d32 !important;' : ''"
@blur="emit('onTimeFieldBlur')"
>
<template #label>
<span
class="text-weight-bolder"
:class="shift.is_approved ? ' q-ml-md' : ''"
style="font-size: 0.95em;"
>{{ $t('shared.misc.out') }}</span>
</template>
</q-input>
</div>
</div>
</div>
<div class="col-auto">
<q-btn
v-if="!shift.is_approved"
flat
dense
color="negative"
icon="las la-trash"
size="lg"
class="full-height"
@click="$emit('requestDelete')"
/>
</div>
<q-separator
v-if="hasShiftAfter"
spaced
class="q-mx-md col-12"
/>
</div>
</template>
<style scoped>
:deep(.q-field--error) {
background-color: var(--q-negative) !important;
}
:deep(.q-field--error .q-field__bottom) {
color: white;
font-weight: 900;
border-radius: 0 0 5px 5px;
padding-top: 0;
align-items: center;
}
</style>

View File

@ -3,12 +3,12 @@
lang="ts"
>
/* eslint-disable*/
import { onBeforeUnmount, onMounted, ref, useTemplateRef, watch, nextTick } from 'vue';
import { onBeforeUnmount, onMounted, ref, useTemplateRef, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { QSelect, QInput } from 'quasar';
import { Shift, type ShiftOption } from 'src/modules/timesheets/models/shift.models';
import { Shift } from 'src/modules/timesheets/models/shift.models';
import { useUiStore } from 'src/stores/ui-store';
import { useShiftRules, SHIFT_OPTIONS } from 'src/modules/timesheets/utils/shift.util';
import { SHIFT_OPTIONS } from 'src/modules/timesheets/utils/shift.util';
let timer: NodeJS.Timeout;
const { t } = useI18n();
@ -22,15 +22,12 @@
const start_time_ref = useTemplateRef<QInput>('start_time');
const end_time_ref = useTemplateRef<QInput>('end_time');
const { dayShifts = [], dense = false, hasShiftAfter = false, isTimesheetApproved = false } = defineProps<{
dayShifts: Shift[];
const { dense = false, hasShiftAfter = false, isTimesheetApproved = false } = defineProps<{
dense?: boolean;
hasShiftAfter?: boolean;
isTimesheetApproved?: boolean;
}>();
const shift_rules = useShiftRules(t('timesheet.errors.SHIFT_TIME_REQUIRED'), t('timesheet.errors.SHIFT_OVERLAP_SHORT'), dayShifts);
const emit = defineEmits<{
'saveComment': [comment: string, shift_id: number];
'requestDelete': [void];
@ -251,7 +248,6 @@
lazy-rules
no-error-icon
hide-bottom-space
:rules="[shift_rules.isTimeRequiredRule, shift_rules.isShiftOverlapRule]"
:label-color="!shift.is_approved ? 'accent' : 'white'"
class="col rounded-5 bg-dark"
:class="(shift.id === -2 ? 'bg-negative ' : ' ') + (ui_store.is_mobile_mode ? 'q-mr-xs ' : 'q-mx-xs ') + (!shift.is_approved && !isTimesheetApproved ? '' : 'cursor-not-allowed inset-shadow')"
@ -279,10 +275,8 @@
:readonly="(shift.is_approved && isTimesheetApproved)"
type="time"
label-slot
lazy-rules
no-error-icon
hide-bottom-space
:rules="[shift_rules.isTimeRequiredRule, shift_rules.isShiftOverlapRule]"
:label-color="!shift.is_approved ? 'accent' : 'white'"
:input-class="'text-weight-medium ' + (shift.id === -2 ? 'text-white ' : ' ') + (shift.is_approved ? 'text-white cursor-not-allowed q-px-sm' : '')"
input-style="font-size: 1.2em;"

View File

@ -3,6 +3,7 @@
lang="ts"
>
import ShiftListDayRow from 'src/modules/timesheets/components/shift-list-day-row.vue';
import ShiftListDayRowMobile from 'src/modules/timesheets/components/mobile/shift-list-day-row-mobile.vue';
import { ref } from 'vue';
import { useShiftApi } from 'src/modules/timesheets/composables/use-shift-api';
@ -69,15 +70,29 @@
</q-btn>
</transition>
<ShiftListDayRow
<div
v-for="shift, shift_index in day.shifts"
:key="shift_index"
v-model:shift="day.shifts[shift_index]!"
:day-shifts="day.shifts"
:is-timesheet-approved="approved"
:dense="dense"
:has-shift-after="shift_index < day.shifts.length - 1"
@request-delete="deleteCurrentShift(shift)"
/>
class="col"
>
<ShiftListDayRowMobile
v-if="$q.platform.is.mobile"
v-model:shift="day.shifts[shift_index]!"
:is-timesheet-approved="approved"
:dense="dense"
:has-shift-after="shift_index < day.shifts.length - 1"
@request-delete="deleteCurrentShift(shift)"
/>
<ShiftListDayRow
v-else
v-model:shift="day.shifts[shift_index]!"
:is-timesheet-approved="approved"
:dense="dense"
:has-shift-after="shift_index < day.shifts.length - 1"
@request-delete="deleteCurrentShift(shift)"
/>
</div>
</div>
</template>

View File

@ -47,20 +47,24 @@
return day.shifts.every(shift => shift.is_approved === true);
}
const handleSwipe = async (direction: 'left' | 'up' | 'down' | 'right' | undefined, distance: {x?: number, y?: number}) => {
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 )
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})">
<div
class="col column fit relative-position"
:style="$q.platform.is.mobile ? 'margin-bottom: 40px' : ''"
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"
class="absolute-full hide-scrollbar"
:thumb-style="{ opacity: '0' }"
:bar-style="{ opacity: '0' }"
style="min-height: 50vh;"
@ -123,21 +127,20 @@
<div
v-for="day, day_index in timesheet.days"
:key="day.date"
class="col-auto row rounded-10 q-ma-sm shadow-10"
class="col-auto row q-ma-sm"
: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"
class="col column full-width q-px-md q-py-sm"
>
<q-card
class="rounded-10"
class="rounded-5 shadow-12"
: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="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;"
>
@ -145,6 +148,15 @@
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
@ -166,26 +178,14 @@
<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 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>
@ -194,13 +194,6 @@
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' : ''"
@ -234,7 +227,7 @@
/>
<q-btn
v-else
:dense="!ui_store.is_mobile_mode"
:dense="!$q.platform.is.mobile"
icon="more_time"
size="lg"
color="accent"
@ -250,29 +243,29 @@
</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>
<q-page-sticky
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

@ -39,7 +39,7 @@
<LoadingOverlay v-model="timesheet_store.is_loading" />
<div
class="col-auto row items-center full-width"
class="col-auto row items-center full-width q-px-lg"
:class="$q.platform.is.mobile && ($q.screen.width < $q.screen.height) ? 'justify-between' : 'q-mt-md q-px-md'"
>
<!-- navigation btn -->
@ -63,20 +63,7 @@
@click="expenses_store.open"
/>
<q-space v-if="!$q.platform.is.mobile" />
<!-- desktop save timesheet changes button -->
<q-btn
v-if="mode === 'normal' && !is_timesheets_approved && !$q.platform.is.mobile"
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-mr-md'"
@click="shift_api.saveShiftChanges"
/>
<q-space v-if="$q.screen.width > $q.screen.height" />
<!-- desktop expenses button -->
<q-btn
@ -88,22 +75,108 @@
:label="$t('timesheet.expense.open_btn')"
@click="expenses_store.open"
/>
<!-- desktop save timesheet changes button -->
<q-btn
v-if="mode === 'normal' && !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="shift_api.saveShiftChanges"
/>
</div>
<TimesheetErrorWidget class="col-auto" />
<!-- mobile weekly overview widget -->
<div
v-if="$q.platform.is.mobile && $q.screen.width < $q.screen.height"
class="col-auto row items-start q-px-sm q-pt-sm full-width"
>
<!-- per timesheet -->
<div
v-for="timesheet, timesheet_index in timesheet_store.timesheets"
:key="timesheet_index"
class="col column flex-center q-pa-sm"
>
<!-- container -->
<div
class="rounded-5 relative-position q-px-sm q-pt-sm q-pb-xs full-width shadow-4"
style="border: 1px solid var(--q-accent);"
>
<!-- label for week number -->
<div
class="self-start text-uppercase text-weight-bolder text-accent bg-secondary absolute-top-left q-px-xs"
style="font-size: 0.8em; top: -7px; left: 10px; line-height: 1em;"
>{{ $t('timesheet.week') + ` ${timesheet_index + 1}` }}</div>
<!-- hours worked in the week -->
<div class="col-auto row">
<span class="text-weight-bolder text-uppercase text-accent text-caption q-mr-sm">{{
$t('timesheet.total_hours') }}</span>
<span>{{
(timesheet.weekly_hours.regular +
timesheet.weekly_hours.evening +
timesheet.weekly_hours.emergency +
timesheet.weekly_hours.overtime).toFixed(2)
}}</span>
</div>
<!-- label for current shifts preview -->
<div
class="col-auto full-width text-center text-weight-medium text-caption text-uppercase q-mt-xs"
style="font-size: 0.65em; line-height: 1.2em;"
> {{ $t('timesheet.current_shifts') }}</div>
<!-- preview of current number of shifts -->
<div
class="col row flex-center"
style="height: 20px;"
>
<div
v-for="day, day_index in timesheet.days"
:key="day_index"
class="col row flex-center"
>
<q-badge :color="day.shifts.length > 0 ? 'accent' : 'blue-grey-4'">
<span class="text-weight-bolder">{{ day.shifts.length > 0 ? day.shifts.length : ''
}}</span>
</q-badge>
</div>
</div>
</div>
<!-- button to apply weekly schedule preset -->
<div class="col-auto flex-center row q-pt-xs full-width">
<q-btn
v-if="timesheet.days.every(day => day.shifts.length < 1)"
push
dense
color="accent"
:label="$t('timesheet.apply_preset')"
class="full-width"
@click="timesheet_api.applyPreset(timesheet.timesheet_id)"
/>
</div>
</div>
</div>
<TimesheetErrorWidget class="col-auto"/>
<ShiftList />
<q-btn
v-if="mode === 'approval' || $q.platform.is.mobile && $q.screen.width < $q.screen.height"
push
rounded
v-if="$q.platform.is.mobile && $q.screen.width < $q.screen.height"
square
:disable="timesheet_store.is_loading"
size="lg"
color="accent"
icon="upload"
:label="$t('shared.label.save')"
class="col-auto"
:class="$q.platform.is.mobile && $q.screen.width < $q.screen.height ? 'full-width q-mt-sm' : 'q-mr-md'"
class="col-auto absolute-bottom shadow-up-10 z-top"
style="height: 50px;"
@click="shift_api.saveShiftChanges"
/>

View File

@ -1,4 +1,4 @@
import { date, patterns, type ValidationRule } from "quasar";
import { date } from "quasar";
import type { SchedulePresetShift } from "src/modules/employee-list/models/schedule-presets.models";
import type { Shift, ShiftOption } from "src/modules/timesheets/models/shift.models";
@ -26,16 +26,6 @@ export const isShiftOverlap = (shifts: Shift[] | SchedulePresetShift[]): boolean
return false;
};
export const useShiftRules = (time_required_error: string, overlap_error_string: string, day_shifts: Shift[]) => {
const isTimeRequiredRule: ValidationRule<string> = (time_string: string) => (!!time_string && patterns.testPattern.time(time_string)) || time_required_error;
const isShiftOverlapRule: ValidationRule<string> = (_time_string: string) => !isShiftOverlap(day_shifts) || overlap_error_string;
return {
isTimeRequiredRule,
isShiftOverlapRule
};
};
export const SHIFT_OPTIONS: ShiftOption[] = [
{ label: 'timesheet.shift.types.REGULAR', value: 'REGULAR', icon: 'wb_sunny', icon_color: 'accent' },
{ label: 'timesheet.shift.types.EVENING', value: 'EVENING', icon: 'bedtime', icon_color: 'orange-5' },

View File

@ -21,10 +21,11 @@ import { onMounted } from 'vue';
</script>
<template>
<q-page class="bg-secondary row items-center justify-center">
<q-page class="bg-secondary row items-center justify-center fit">
<MenuEmployee
v-if="employee_roles.includes(auth_store.user?.role.toUpperCase() ?? 'GUEST')"
class="col-xs-12 col-md-10 col-lg-7 col-xl-5"
:class="$q.platform.is.mobile ? 'self-stretch' : ''"
/>
</q-page>
</template>

View File

@ -16,8 +16,8 @@
import { useTimesheetApprovalApi } from 'src/modules/timesheet-approval/composables/use-timesheet-approval-api';
import { useTimesheetStore } from 'src/stores/timesheet-store';
const timesheet_approval_api = useTimesheetApprovalApi();
const timesheet_store = useTimesheetStore();
const timesheet_approval_api = useTimesheetApprovalApi();
const timesheet_store = useTimesheetStore();
const is_showing_filters = ref(false);
@ -58,34 +58,24 @@ const timesheet_store = useTimesheetStore();
<q-space />
<q-btn-toggle
v-model="timesheet_store.is_approval_grid_mode"
push
rounded
color="white"
text-color="accent"
toggle-color="accent"
class="col-auto"
:class="$q.platform.is.mobile ? 'q-mb-sm' : 'q-mr-md'"
:options="[
{ icon: 'grid_view', value: true },
{ icon: 'view_list', value: false },
]"
style="height: 40px;"
/>
<div
class="col-auto row no-wrap items-start"
:class="$q.platform.is.mobile ? 'q-mb-md' : ''"
>
<q-btn
flat
icon="filter_alt"
<q-btn-toggle
v-model="timesheet_store.is_approval_grid_mode"
push
rounded
color="white"
:label="$q.platform.is.mobile ? '' : $t('shared.label.filter')"
class="col q-mr-sm self-stretch bg-accent"
style="border-radius: 10px 10px 0 0;"
@click="is_showing_filters = !is_showing_filters"
text-color="accent"
toggle-color="accent"
class="col-auto"
:class="$q.platform.is.mobile ? 'q-mb-sm' : 'q-mr-sm'"
:options="[
{ icon: 'grid_view', value: true },
{ icon: 'view_list', value: false },
]"
style="height: 40px;"
/>
<q-btn
@ -95,6 +85,7 @@ const timesheet_store = useTimesheetStore();
color="accent"
:label="$q.screen.lt.md ? '' : $t('shared.label.download')"
class="col-auto q-mr-sm"
style="height: 40px;"
@click="timesheet_store.is_report_dialog_open = true"
/>
@ -102,22 +93,31 @@ const timesheet_store = useTimesheetStore();
v-model:search="timesheet_store.search_filter"
class="col-auto q-mb-xs"
/>
<q-btn
flat
icon="filter_alt"
color="white"
:label="$q.platform.is.mobile ? '' : $t('shared.label.filter')"
class="col q-ml-sm self-stretch bg-accent"
style="border-radius: 5px 5px 0 0;"
@click="is_showing_filters = !is_showing_filters"
/>
</div>
</div>
<q-slide-transition>
<q-slide-transition class="col-auto">
<OverviewListFilters
v-if="is_showing_filters"
class="q-mx-lg"
class="q-mx-lg col-auto"
/>
</q-slide-transition>
<q-separator
color="accent"
size="4px"
size="5px"
class="q-mx-lg"
/>
<OverviewReport />
<OverviewList class="col" />

View File

@ -16,7 +16,7 @@
<template>
<q-page
padding
class="column q-pa-md bg-secondary items-center"
class="column bg-secondary items-center"
>
<PageHeaderTemplate
:title="'timesheet.page_header'"

View File

@ -1,9 +1,9 @@
import { useI18n } from 'vue-i18n';
import { defineStore } from 'pinia';
import { Notify, LocalStorage, useQuasar, Dark } from 'quasar';
import { computed, ref } from 'vue';
import { LocalStorage, useQuasar, Dark } from 'quasar';
import { Preferences } from 'src/modules/profile/models/preferences.models';
import { ProfileService } from 'src/modules/profile/services/profile-service';
import { useI18n, type ComposerTranslation } from 'vue-i18n';
export const useUiStore = defineStore('ui', () => {
@ -44,7 +44,7 @@ export const useUiStore = defineStore('ui', () => {
}
};
const updateUserPreferences = async (t: ComposerTranslation) => {
const updateUserPreferences = async () => {
try {
if (user_preferences.value.id === -1) return;
@ -53,13 +53,11 @@ export const useUiStore = defineStore('ui', () => {
Object.assign(user_preferences.value, response.data);
LocalStorage.setItem('user_preferences', response.data);
setPreferences();
Notify.create({ message: t('profile.preferences.update_successful'), color: 'accent' });
return;
}
} catch (error) {
console.error('Could not update user preferences: ', error);
}
Notify.create({ message: t('profile.preferences.update_failed'), color: 'negative' })
};
const setPreferences = () => {