refactor(timesheet): add buttons to apply weekly or daily presets, fix mobile UI/UX to please sup.

This commit is contained in:
Nicolas Drolet 2025-12-17 13:27:15 -05:00
parent 35d36873e4
commit db821d1d13
17 changed files with 425 additions and 370 deletions

View File

@ -175,16 +175,14 @@ export default {
timesheet: { timesheet: {
page_header: "Timesheet", page_header: "Timesheet",
apply_preset_day: "Apply schedule to day",
apply_preset_week: "Apply schedule to week",
nav_button: { nav_button: {
calendar_date_picker: "Calendar", calendar_date_picker: "Calendar",
current_week: "This week", current_week: "This week",
next_week: "Next period", next_week: "Next period",
previous_week: "Previous period", previous_week: "Previous period",
}, },
save_button: "Save",
cancel_button: "Cancel",
remote_button: "Remote work",
delete_button: "Delete",
shift: { shift: {
actions: { actions: {
add: "Add Shift", add: "Add Shift",

View File

@ -176,16 +176,14 @@ export default {
timesheet: { timesheet: {
page_header: "Carte de temps", page_header: "Carte de temps",
apply_preset_day: "Appliquer horaire pour la journée",
apply_preset_week: "Appliquer horaire pour la semaine",
nav_button: { nav_button: {
calendar_date_picker: "Calendrier", calendar_date_picker: "Calendrier",
current_week: "Semaine actuelle", current_week: "Semaine actuelle",
next_week: "Prochaine période", next_week: "Prochaine période",
previous_week: "Période précédente", previous_week: "Période précédente",
}, },
save_button: "Enregistrer",
cancel_button: "Annuler",
remote_button: "Télétravail",
delete_button: "Supprimer",
shift: { shift: {
actions: { actions: {
add: "Ajouter un Quart", add: "Ajouter un Quart",

View File

@ -114,7 +114,7 @@
<q-input <q-input
v-model="filters.search_bar_string" v-model="filters.search_bar_string"
standout outlined
dense dense
rounded rounded
color="accent" color="accent"

View File

@ -2,7 +2,8 @@
setup setup
lang="ts" lang="ts"
> >
import { date } from 'quasar'; import { date, useQuasar } from 'quasar';
import { computed } from 'vue';
const { title, startDate = "", endDate = "" } = defineProps<{ const { title, startDate = "", endDate = "" } = defineProps<{
title: string; title: string;
@ -10,13 +11,17 @@
endDate?: string; endDate?: string;
}>(); }>();
const date_format_options = { day: 'numeric', month: 'long', year: 'numeric', }; const q = useQuasar();
const date_format_options = computed(() => q.platform.is.mobile ? { day: 'numeric', month: 'short', year: 'numeric' } : { day: 'numeric', month: 'long', year: 'numeric', });
</script> </script>
<template> <template>
<div class="column q-mt-lg text-uppercase text-center text-weight-bolder text-h4"> <div class="column text-uppercase text-center text-weight-bolder text-h4">
<span class="col">{{ $t(title) }}</span> <span
v-if="!$q.platform.is.mobile"
class="col q-mt-lg"
>{{ $t(title) }}</span>
<transition <transition
enter-active-class="animated fadeInDown" enter-active-class="animated fadeInDown"
@ -27,6 +32,7 @@
:key="startDate" :key="startDate"
v-if="startDate.length > 0" v-if="startDate.length > 0"
class="col row flex-center full-width q-py-none q-my-none" class="col row flex-center full-width q-py-none q-my-none"
:class="$q.platform.is.mobile ? 'q-mb-md' : ''"
> >
<div class="text-accent text-weight-bold text-h6"> <div class="text-accent text-weight-bold text-h6">
{{ $d(date.extractDate(startDate, 'YYYY-MM-DD'), date_format_options) }} {{ $d(date.extractDate(startDate, 'YYYY-MM-DD'), date_format_options) }}

View File

@ -66,7 +66,6 @@
<template> <template>
<q-form <q-form
v-if="!timesheet_store.timesheets?.every(timesheet => timesheet.is_approved)" v-if="!timesheet_store.timesheets?.every(timesheet => timesheet.is_approved)"
:key="expenses_store.current_expense.id"
flat flat
@submit.prevent="requestExpenseCreationOrUpdate" @submit.prevent="requestExpenseCreationOrUpdate"
class="full-width q-mt-md q-px-md" class="full-width q-mt-md q-px-md"

View File

@ -24,6 +24,8 @@
} }
const onClickExpenseUpdate = () => { const onClickExpenseUpdate = () => {
if (expense.value.is_approved) return;
expenses_store.mode = 'update'; expenses_store.mode = 'update';
expenses_store.current_expense = expense.value; expenses_store.current_expense = expense.value;
expenses_store.initial_expense = unwrapAndClone(expense.value); expenses_store.initial_expense = unwrapAndClone(expense.value);

View File

@ -42,11 +42,8 @@
:key="index" :key="index"
> >
<ExpenseDialogListItemMobile <ExpenseDialogListItemMobile
v-if="$q.screen.lt.md" v-if="$q.platform.is.mobile"
v-model="expenses_list[index]!" v-model="expenses_list[index]!"
:index="index"
:expense="expense"
:horizontal="horizontal"
/> />
<ExpenseDialogListItem <ExpenseDialogListItem

View File

@ -27,12 +27,12 @@
<q-dialog <q-dialog
v-model="expense_store.is_open" v-model="expense_store.is_open"
persistent persistent
:full-width="$q.platform.is.mobile"
transition-show="jump-down" transition-show="jump-down"
transition-hide="jump-down" transition-hide="jump-down"
> >
<q-card <q-card
class="q-pa-none rounded-10 shadow-10" class="q-pa-none rounded-10 shadow-24 bg-secondary"
:class="$q.screen.lt.md ? ' bg-primary' : 'bg-secondary'"
style=" min-width: 70vw;" style=" min-width: 70vw;"
:style="$q.dark.isActive ? 'border: solid 2px var(--q-accent);' : ''" :style="$q.dark.isActive ? 'border: solid 2px var(--q-accent);' : ''"
> >
@ -45,22 +45,13 @@
<ExpenseDialogList /> <ExpenseDialogList />
<q-separator
v-if="$q.screen.lt.md"
spaced
color="accent"
size="2px"
class="q-mx-md"
/>
<q-expansion-item <q-expansion-item
v-if="!isApproved" v-if="!isApproved"
v-model="expense_store.is_showing_create_form" v-model="expense_store.is_showing_create_form"
hide-expand-icon hide-expand-icon
dense :dense="!$q.platform.is.mobile"
group="expenses" group="expenses"
@before-show="onClickExpenseCreate()" @show="onClickExpenseCreate()"
@hide="expense_store.mode = 'update'"
header-class="bg-accent text-white" header-class="bg-accent text-white"
> >
<template #header> <template #header>
@ -77,7 +68,7 @@
</div> </div>
</template> </template>
<ExpenseDialogFormMobile v-if="$q.screen.lt.md" /> <ExpenseDialogFormMobile v-if="$q.platform.is.mobile" />
<ExpenseDialogForm v-else /> <ExpenseDialogForm v-else />
</q-expansion-item> </q-expansion-item>

View File

@ -51,31 +51,18 @@
const requestExpenseCreationOrUpdate = async () => { const requestExpenseCreationOrUpdate = async () => {
await expenses_api.upsertExpense(expenses_store.current_expense); await expenses_api.upsertExpense(expenses_store.current_expense);
}; };
defineEmits<{
'onClickUpdateCancel': [void];
}>();
</script> </script>
<template> <template>
<q-form <q-form
v-if="!timesheet_store.timesheets?.every(timesheet => timesheet.is_approved)" v-if="!timesheet_store.timesheets?.every(timesheet => timesheet.is_approved)"
:key="expenses_store.current_expense.id"
flat flat
@submit.prevent="requestExpenseCreationOrUpdate" @submit.prevent="requestExpenseCreationOrUpdate"
class="column full-width" class="column full-width"
> >
<!-- header -->
<div
class="col text-uppercase text-weight-medium text-h6 q-ma-xs"
:class="expenses_store.mode === 'create' ? 'q-px-md' : 'invisible'"
>
{{ $t('timesheet.expense.add_expense') }}
</div>
<div <div
class="col column items-start rounded-5 q-pb-sm" class="col column items-start rounded-5 q-pb-sm"
:class="expenses_store.mode === 'create' ? 'q-px-md' : ''" :class="expenses_store.is_showing_create_form ? 'q-px-md' : 'q-px-sm'"
> >
<!-- date and type row --> <!-- date and type row -->
<div class="col row q-my-xs full-width"> <div class="col row q-my-xs full-width">
@ -83,7 +70,6 @@
<q-input <q-input
v-model="expenses_store.current_expense.date" v-model="expenses_store.current_expense.date"
dense dense
type="date"
outlined outlined
readonly readonly
stack-label stack-label
@ -286,27 +272,15 @@
</template> </template>
</q-file> </q-file>
</div> </div>
<div class="col row full-width items-center">
<q-space />
<q-btn
v-if="expenses_store.mode === 'update'"
flat
dense
class="col-auto"
icon="clear"
color="negative"
:label="$q.screen.gt.sm ? $t('shared.label.cancel') : ''"
@click="$emit('onClickUpdateCancel')"
/>
<div class="col row full-width items-center" :class="expenses_store.mode === 'create' ? 'q-px-md q-py-xs' : ''">
<q-btn <q-btn
push push
color="accent" color="accent"
:icon="expenses_store.mode === 'update' ? 'save' : 'upload'" :icon="expenses_store.mode === 'update' ? 'save' : 'upload'"
:label="expenses_store.mode === 'update' ? $t('shared.label.update') : $t('shared.label.add')" :label="expenses_store.mode === 'update' ? $t('shared.label.update') : $t('shared.label.add')"
class="q-px-sm" class="q-px-sm full-width"
:class="expenses_store.mode === 'create' ? 'q-mr-md q-mb-md' : 'q-mb-sm q-ml-lg'" :class="expenses_store.mode === 'create' ? '' : 'q-mb-sm'"
type="submit" type="submit"
/> />
</div> </div>

View File

@ -2,70 +2,48 @@
setup setup
lang="ts" lang="ts"
> >
/* eslint-disable */
import { date } from 'quasar'; import { date } from 'quasar';
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
import { unwrapAndClone } from 'src/utils/unwrap-and-clone'; import { unwrapAndClone } from 'src/utils/unwrap-and-clone';
import { useExpensesApi } from 'src/modules/timesheets/composables/use-expense-api'; import { useExpensesApi } from 'src/modules/timesheets/composables/use-expense-api';
import { useExpensesStore } from 'src/stores/expense-store'; import { useExpensesStore } from 'src/stores/expense-store';
import { getExpenseIcon } from 'src/modules/timesheets/utils/expense.util'; import { getExpenseIcon } from 'src/modules/timesheets/utils/expense.util';
import { Expense } from 'src/modules/timesheets/models/expense.models'; import type { Expense } from 'src/modules/timesheets/models/expense.models';
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';
const { expense, horizontal = false } = defineProps<{ const expense = defineModel<Expense>({ required: true })
expense: Expense;
index: number;
horizontal?: boolean;
}>();
const expenses_store = useExpensesStore(); const expenses_store = useExpensesStore();
const expenses_api = useExpensesApi(); const expenses_api = useExpensesApi();
const refresh_key = ref(1); const approved_class = computed(() => expense.value.is_approved ? ' bg-accent text-white' : '')
const approved_class = computed(() => expense.is_approved ? ' bg-accent text-white' : '')
const is_showing_update_form = ref(false); const is_showing_update_form = ref(false);
const requestExpenseDeletion = async () => { const requestExpenseDeletion = async () => {
await expenses_api.deleteExpenseById(expense.id); await expenses_api.deleteExpenseById(expense.value.id);
} }
const onUpdateClicked = () => { const onUpdateClicked = () => {
if (expense.is_approved) return; if (expense.value.is_approved) return;
if (JSON.stringify(expense) === JSON.stringify(expenses_store.current_expense)) {
expenses_store.mode = 'create';
expenses_store.current_expense = new Expense(date.formatDate(new Date(), 'YYYY-MM-DD'));
is_showing_update_form.value = false;
return;
}
expenses_store.mode = 'update'; expenses_store.mode = 'update';
expenses_store.current_expense = expense; expenses_store.current_expense = expense.value;
expenses_store.initial_expense = unwrapAndClone(expense); expenses_store.initial_expense = unwrapAndClone(expense.value);
is_showing_update_form.value = true;
} }
</script> </script>
<template> <template>
<div class="column bg-dark rounded-5 q-my-sm full-width"> <div class="column bg-dark shadow-5 rounded-5 q-my-sm full-width">
<q-slide-item <q-expansion-item
right-color="negative" v-model="is_showing_update_form"
class="rounded-5 bg-dark full-width" hide-expand-icon
@right="requestExpenseDeletion" dense
> group="expenses"
<template :class="expense.is_approved ? ' bg-accent text-white' : ''"
#right @before-show="onUpdateClicked()"
v-if="$q.screen.lt.md && expenses_store.is_showing_create_form && !expense.is_approved"
>
<q-icon name="delete" />
</template>
<q-item
:key="refresh_key"
clickable
class="row q-py-none q-pa-xs rounded-5 full-width"
:class="approved_class"
@click="onUpdateClicked"
> >
<template #header>
<div class="column col"> <div class="column col">
<!-- date label --> <!-- date label -->
<div class="col-auto row items-center q-pl-xs"> <div class="col-auto row items-center q-pl-xs">
@ -92,22 +70,21 @@
:name="getExpenseIcon(expense.type)" :name="getExpenseIcon(expense.type)"
:color="expense.is_approved ? 'white' : ($q.dark.isActive ? 'white' : 'primary')" :color="expense.is_approved ? 'white' : ($q.dark.isActive ? 'white' : 'primary')"
size="lg" size="lg"
class="col-auto"
/> />
<!-- amount or mileage section --> <!-- amount or mileage section -->
<q-item-section class="col text-weight-bold text-h6"> <div class="col text-weight-bold text-h6">
<q-item-label v-if="expense.type === 'MILEAGE'"> <q-item-label v-if="expense.type === 'MILEAGE'">
{{ expense.mileage?.toFixed(1) }} km {{ expense.mileage?.toFixed(1) }} km
</q-item-label> </q-item-label>
<q-item-label v-else> <q-item-label v-else>
$ {{ expense.amount.toFixed(2) }} $ {{ expense.amount.toFixed(2) }}
</q-item-label> </q-item-label>
</q-item-section> </div>
<q-space v-if="horizontal" />
<!-- attachment file icon --> <!-- attachment file icon -->
<q-item-section avatar> <div class="col-auto q-px-xs">
<q-btn <q-btn
push push
:color="expense.is_approved ? 'white' : 'accent'" :color="expense.is_approved ? 'white' : 'accent'"
@ -115,14 +92,9 @@
class="col-auto q-mx-sm q-px-sm q-pb-sm" class="col-auto q-mx-sm q-px-sm q-pb-sm"
icon="attach_file" icon="attach_file"
/> />
</q-item-section>
</div>
</div> </div>
<div <div class="col-auto">
class="col-auto q-px-sm"
:class="expense.is_approved ? '' : 'invisible'"
>
<q-icon <q-icon
v-if="expense.is_approved" v-if="expense.is_approved"
name="verified" name="verified"
@ -131,18 +103,13 @@
class="full-height" class="full-height"
/> />
</div> </div>
</q-item> </div>
</q-slide-item> </div>
</template>
<q-slide-transition
@hide="expenses_store.is_showing_create_form = true" <div class="q-px-sm">
:duration="200" <ExpenseDialogFormMobile />
> </div>
<ExpenseDialogFormMobile </q-expansion-item>
v-if="is_showing_update_form && !expenses_store.is_showing_create_form"
class="q-mt-sm q-pa-sm"
@on-click-update-cancel="onUpdateClicked"
/>
</q-slide-transition>
</div> </div>
</template> </template>

View File

@ -381,12 +381,6 @@
:class="shift.is_approved ? 'invisible' : ''" :class="shift.is_approved ? 'invisible' : ''"
@click="$emit('requestDelete')" @click="$emit('requestDelete')"
> >
<q-badge
v-if="!shift.is_approved"
color="white"
class="absolute"
style="z-index: -1;"
/>
</q-btn> </q-btn>
</div> </div>
</div> </div>

View File

@ -3,18 +3,26 @@
lang="ts" lang="ts"
> >
import ShiftListDayRow from 'src/modules/timesheets/components/shift-list-day-row.vue'; import ShiftListDayRow from 'src/modules/timesheets/components/shift-list-day-row.vue';
import { ref } 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 type { Shift } from 'src/modules/timesheets/models/shift.models'; import type { Shift } from 'src/modules/timesheets/models/shift.models';
import type { TimesheetDay } from 'src/modules/timesheets/models/timesheet.models'; import type { TimesheetDay } from 'src/modules/timesheets/models/timesheet.models';
const shift_api = useShiftApi(); const shift_api = useShiftApi();
const timesheet_api = useTimesheetApi();
const { day, dense = false, approved = false } = defineProps<{ const { day, dense = false, approved = false } = defineProps<{
timesheetId: number;
weekDayIndex: number;
day: TimesheetDay; day: TimesheetDay;
dense?: boolean; dense?: boolean;
approved?: boolean; approved?: boolean;
}>(); }>();
const preset_mouseover = ref(false);
const emit = defineEmits<{ const emit = defineEmits<{
'deleteUnsavedShift': [void]; 'deleteUnsavedShift': [void];
}>(); }>();
@ -30,7 +38,37 @@
</script> </script>
<template> <template>
<div class="column justify-center q-py-xs" :class="approved ? '' : ''"> <div
class="column justify-center q-py-xs"
:class="approved ? '' : ''"
@mouseenter="preset_mouseover = true"
@mouseleave="preset_mouseover = false"
>
<!-- Button to apply preset to day -->
<transition
appear
enter-active-class="animated zoomIn fast"
leave-active-class="animated zoomOut fast"
>
<q-btn
v-if="!$q.platform.is.mobile && day.shifts.length < 1 && preset_mouseover"
:disable="day.shifts.length > 0"
flat
dense
size="lg"
:label="$t('timesheet.apply_preset_day')"
class="text-uppercase text-weight-bold text-accent q-mx-lg q-py-none rounded-5"
style="opacity: 0.6;"
@click.stop="timesheet_api.applyPreset(timesheetId, weekDayIndex, day.date)"
>
<q-icon
name="las la-calendar-day"
color="accent"
size="md"
/>
</q-btn>
</transition>
<ShiftListDayRow <ShiftListDayRow
v-for="shift, shift_index in day.shifts" v-for="shift, shift_index in day.shifts"
:key="shift_index" :key="shift_index"

View File

@ -2,22 +2,29 @@
setup setup
lang="ts" lang="ts"
> >
import { computed } from 'vue'; 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 { date } from 'quasar';
import { computed, ref } from 'vue';
import { useUiStore } from 'src/stores/ui-store'; import { useUiStore } from 'src/stores/ui-store';
import { useTimesheetStore } from 'src/stores/timesheet-store'; import { useTimesheetStore } from 'src/stores/timesheet-store';
import { Shift } from 'src/modules/timesheets/models/shift.models'; import { Shift } from 'src/modules/timesheets/models/shift.models';
import ShiftListDay from 'src/modules/timesheets/components/shift-list-day.vue'; import { useTimesheetApi } from 'src/modules/timesheets/composables/use-timesheet-api';
import ShiftListDateWidget from 'src/modules/timesheets/components/shift-list-date-widget.vue'; import type { QScrollArea } from 'quasar';
import type { TimesheetDay } from 'src/modules/timesheets/models/timesheet.models'; import type { TimesheetDay } from 'src/modules/timesheets/models/timesheet.models';
const { extractDate } = date; const { extractDate } = date;
const ui_store = useUiStore(); const ui_store = useUiStore();
const timesheet_store = useTimesheetStore(); const timesheet_store = useTimesheetStore();
const timesheet_api = useTimesheetApi();
const animation_style = computed(() => ui_store.is_mobile_mode ? 'fadeInLeft' : 'fadeInDown'); const animation_style = computed(() => ui_store.is_mobile_mode ? 'fadeInLeft' : '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) => { const addNewShift = (day_shifts: Shift[], date: string, timesheet_id: number) => {
ui_store.focus_next_component = true; ui_store.focus_next_component = true;
const new_shift = new Shift; const new_shift = new Shift;
@ -41,14 +48,44 @@
</script> </script>
<template> <template>
<div <div class="col column fit">
:class="$q.screen.lt.md ? 'column full-width' : 'row'" <q-scroll-area
ref="timesheet_page"
:horizontal-offset="[0, 3]"
class="col hide-scrollbar q-mt-sm"
:thumb-style="{ opacity: '0' }"
:bar-style="{ opacity: '0' }"
> >
<div :class="$q.platform.is.mobile ? 'column' : 'row'">
<div <div
v-for="timesheet, timesheet_index in timesheet_store.timesheets" v-for="timesheet, timesheet_index in timesheet_store.timesheets"
:key="timesheet.timesheet_id" :key="timesheet.timesheet_id"
class="col column" class="col"
> >
<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 <transition-group
appear appear
:enter-active-class="`animated ${animation_style}`" :enter-active-class="`animated ${animation_style}`"
@ -60,7 +97,7 @@
:style="`animation-delay: ${day_index / 15}s;`" :style="`animation-delay: ${day_index / 15}s;`"
> >
<div <div
v-if="ui_store.is_mobile_mode" v-if="$q.platform.is.mobile && ($q.screen.width < $q.screen.height)"
class="col column full-width" class="col column full-width"
> >
<q-card <q-card
@ -86,6 +123,8 @@
> >
<ShiftListDay <ShiftListDay
outlined outlined
:timesheet-id="timesheet.timesheet_id"
:week-day-index="day_index"
:animation-delay-multiplier="day_index" :animation-delay-multiplier="day_index"
:approved="(getDayApproval(day) || timesheet.is_approved)" :approved="(getDayApproval(day) || timesheet.is_approved)"
:day="day" :day="day"
@ -125,8 +164,13 @@
class="col row full-width" class="col row full-width"
:class="(getDayApproval(day) || timesheet.is_approved) ? 'rounded-10 bg-accent' : ''" :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 --> <!-- List of shifts -->
<div <div
class="col row bg-dark" class="col row bg-dark"
:class="(getDayApproval(day) || timesheet.is_approved) ? 'bg-transparent' : ''" :class="(getDayApproval(day) || timesheet.is_approved) ? 'bg-transparent' : ''"
@ -139,8 +183,9 @@
class="col-auto" class="col-auto"
/> />
<ShiftListDay <ShiftListDay
:timesheet-id="timesheet.timesheet_id"
:week-day-index="day_index"
:day="day" :day="day"
:approved="getDayApproval(day) || timesheet.is_approved" :approved="getDayApproval(day) || timesheet.is_approved"
class="col" class="col"
@ -175,4 +220,28 @@
</transition-group> </transition-group>
</div> </div>
</div> </div>
</q-scroll-area>
</div>
<q-page-sticky
position="bottom-right"
:offset="[15, 15]"
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>
</template> </template>

View File

@ -9,11 +9,12 @@
import TimesheetErrorWidget from 'src/modules/timesheets/components/timesheet-error-widget.vue'; import TimesheetErrorWidget from 'src/modules/timesheets/components/timesheet-error-widget.vue';
import LoadingOverlay from 'src/modules/shared/components/loading-overlay.vue'; import LoadingOverlay from 'src/modules/shared/components/loading-overlay.vue';
import { computed } from 'vue'; import { computed, onMounted } 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 { date } from 'quasar';
const expenses_store = useExpensesStore(); const expenses_store = useExpensesStore();
@ -28,24 +29,23 @@
mode?: 'approval' | 'normal'; mode?: 'approval' | 'normal';
}>(); }>();
onMounted(async () => {
await timesheet_api.getTimesheetsByDate(date.formatDate(new Date(), 'YYYY-MM-DD'));
});
</script> </script>
<template> <template>
<div class="column flex-center full-width"> <div class="column items-center full-height">
<LoadingOverlay v-model="timesheet_store.is_loading" /> <LoadingOverlay v-model="timesheet_store.is_loading" />
<q-card <div
flat class="col-auto row items-center full-width"
class="transparent full-width" :class="$q.platform.is.mobile && ($q.screen.width < $q.screen.height) ? 'justify-between' : 'q-mt-md q-px-md'"
>
<q-card-section
:horizontal="$q.screen.gt.sm"
class="q-px-md items-center q-mb-md"
:class="$q.screen.lt.md ? 'column' : ''"
> >
<!-- navigation btn --> <!-- navigation btn -->
<PayPeriodNavigator <PayPeriodNavigator
v-if="mode === 'normal'" v-if="mode === 'normal'"
class="col-auto"
@date-selected="date_value => timesheet_api.getTimesheetsByDate(date_value)" @date-selected="date_value => timesheet_api.getTimesheetsByDate(date_value)"
@pressed-previous-button="timesheet_api.getTimesheetsByCurrentPayPeriod" @pressed-previous-button="timesheet_api.getTimesheetsByCurrentPayPeriod"
@pressed-next-button="timesheet_api.getTimesheetsByCurrentPayPeriod" @pressed-next-button="timesheet_api.getTimesheetsByCurrentPayPeriod"
@ -53,34 +53,34 @@
<!-- mobile expenses button --> <!-- mobile expenses button -->
<q-btn <q-btn
v-if="$q.screen.lt.md && mode === 'normal'" v-if="($q.platform.is.mobile && ($q.screen.width < $q.screen.height))"
push push
rounded rounded
color="accent" color="accent"
icon="receipt_long" icon="receipt_long"
:label="$t('timesheet.expense.open_btn')" :label="$t('timesheet_approvals.table.expenses')"
class="q-mt-sm" class="col-auto"
@click="expenses_store.open" @click="expenses_store.open"
/> />
<q-space /> <q-space v-if="!$q.platform.is.mobile" />
<!-- save timesheet changes button --> <!-- desktop save timesheet changes button -->
<q-btn <q-btn
v-if="mode === 'normal' && !is_timesheets_approved" v-if="mode === 'normal' && !is_timesheets_approved && !$q.platform.is.mobile"
push push
rounded rounded
:disable="timesheet_store.is_loading || has_shift_errors" :disable="timesheet_store.is_loading || has_shift_errors"
:color="timesheet_store.is_loading || has_shift_errors ? 'grey-5' : 'accent'" :color="timesheet_store.is_loading || has_shift_errors ? 'grey-5' : 'accent'"
icon="upload" icon="upload"
:label="$t('shared.label.save')" :label="$t('shared.label.save')"
class="q-mr-md" :class="$q.platform.is.mobile && ($q.screen.width < $q.screen.height) ? 'full-width' : 'q-mr-md'"
@click="shift_api.saveShiftChanges" @click="shift_api.saveShiftChanges"
/> />
<!-- desktop expenses button --> <!-- desktop expenses button -->
<q-btn <q-btn
v-if="mode === 'normal'" v-if="mode === 'normal' && $q.screen.width > $q.screen.height"
push push
rounded rounded
color="accent" color="accent"
@ -88,29 +88,25 @@
:label="$t('timesheet.expense.open_btn')" :label="$t('timesheet.expense.open_btn')"
@click="expenses_store.open" @click="expenses_store.open"
/> />
</div>
</q-card-section> <TimesheetErrorWidget class="col-auto"/>
<q-card-section class="q-pa-none">
<TimesheetErrorWidget />
</q-card-section>
<ShiftList :mode="mode" /> <ShiftList :mode="mode" />
<q-card-actions align="right">
<q-btn <q-btn
v-if="mode === 'approval'" v-if="mode === 'approval' || $q.platform.is.mobile && $q.screen.width < $q.screen.height"
push push
rounded rounded
:disable="timesheet_store.is_loading" :disable="timesheet_store.is_loading"
color="accent" color="accent"
icon="upload" icon="upload"
:label="$t('shared.label.save')" :label="$t('shared.label.save')"
class="q-mr-md" class="col-auto"
:class="$q.platform.is.mobile && $q.screen.width < $q.screen.height ? 'full-width q-mt-sm' : 'q-mr-md'"
@click="shift_api.saveShiftChanges" @click="shift_api.saveShiftChanges"
/> />
</q-card-actions>
</q-card>
<ExpenseDialog :is-approved="is_timesheets_approved" /> <ExpenseDialog :is-approved="is_timesheets_approved" />
</div> </div>
</template> </template>

View File

@ -1,4 +1,5 @@
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 timesheet_store = useTimesheetStore();
@ -29,8 +30,30 @@ export const useTimesheetApi = () => {
timesheet_store.is_loading = false; timesheet_store.is_loading = false;
}; };
const applyPreset = async (timesheet_id: number, week_day_index?: number, date?: string) => {
if (timesheet_store.timesheets.map(timesheet => timesheet.timesheet_id).includes(timesheet_id)) {
timesheet_store.is_loading = true;
try {
let response;
if (week_day_index && date)
response = await timesheetService.applyPresetToDay(timesheet_id, week_day_index, date);
else
response = await timesheetService.applyPresetToWeek(timesheet_id);
if (response.success)
await timesheet_store.getTimesheetsByOptionalEmployeeEmail();
} catch (error) {
console.error('Error applying weekly timesheet: ', error);
}
timesheet_store.is_loading = false;
}
}
return { return {
getTimesheetsByDate, getTimesheetsByDate,
getTimesheetsByCurrentPayPeriod, getTimesheetsByCurrentPayPeriod,
applyPreset,
}; };
}; };

View File

@ -2,6 +2,7 @@ import { api } from "src/boot/axios";
import type { PayPeriod } from "src/modules/shared/models/pay-period.models"; import type { PayPeriod } from "src/modules/shared/models/pay-period.models";
import type { TimesheetResponse } from "src/modules/timesheets/models/timesheet.models"; import type { TimesheetResponse } from "src/modules/timesheets/models/timesheet.models";
import type { TimesheetOverview } from "src/modules/timesheet-approval/models/timesheet-overview.models"; import type { TimesheetOverview } from "src/modules/timesheet-approval/models/timesheet-overview.models";
import type { BackendResponse } from "src/modules/shared/models/backend-response.models";
export const timesheetService = { export const timesheetService = {
getPayPeriodByDate: async (date_string: string): Promise<PayPeriod> => { getPayPeriodByDate: async (date_string: string): Promise<PayPeriod> => {
@ -28,4 +29,14 @@ export const timesheetService = {
return response.data.data; return response.data.data;
} }
}, },
applyPresetToWeek: async (timesheet_id: number): Promise<BackendResponse<boolean>> => {
const response = await api.post<BackendResponse<boolean>>(`schedule-presets/apply-preset`, { timesheet_id });
return response.data;
},
applyPresetToDay: async (timesheet_id: number, week_day_index: number, date: string): Promise<BackendResponse<boolean>> => {
const response = await api.post<BackendResponse<boolean>>('schedule-presets/apply-day-preset', { timesheet_id, week_day_index, date });
return response.data;
}
}; };

View File

@ -2,21 +2,14 @@
setup setup
lang="ts" lang="ts"
> >
import { date } from 'quasar';
import { onMounted } from 'vue';
import { useAuthStore } from 'src/stores/auth-store';
import { useTimesheetApi } from 'src/modules/timesheets/composables/use-timesheet-api';
import { useTimesheetStore } from 'src/stores/timesheet-store';
import PageHeaderTemplate from 'src/modules/shared/components/page-header-template.vue'; import PageHeaderTemplate from 'src/modules/shared/components/page-header-template.vue';
import TimesheetWrapper from 'src/modules/timesheets/components/timesheet-wrapper.vue'; import TimesheetWrapper from 'src/modules/timesheets/components/timesheet-wrapper.vue';
import { useAuthStore } from 'src/stores/auth-store';
import { useTimesheetStore } from 'src/stores/timesheet-store';
const { user } = useAuthStore(); const { user } = useAuthStore();
const timesheet_store = useTimesheetStore(); const timesheet_store = useTimesheetStore();
const timesheet_api = useTimesheetApi();
onMounted(async () => {
await timesheet_api.getTimesheetsByDate(date.formatDate(new Date(), 'YYYY-MM-DD'));
});
</script> </script>
@ -33,11 +26,10 @@
/> />
<div <div
class="col" class="col column fit"
:style="$q.screen.gt.sm ? 'width: 90vw' : ''" :style="$q.platform.is.mobile && ($q.screen.width < $q.screen.height) ? '' : 'width: 90vw'"
> >
<TimesheetWrapper :employee-email="user?.email ?? ''" /> <TimesheetWrapper :employee-email="user?.email ?? ''" class="col"/>
</div> </div>
</q-page> </q-page>
</template> </template>