Merge pull request 'fix(all): fix scroll issue in timesheet mobile, add approval for individual expenses in approvals' (#39) from dev/nicolas/staging-prep into main

Reviewed-on: Targo/targo_frontend#39
This commit is contained in:
Nicolas 2026-01-07 09:49:54 -05:00
commit 00bc56ea61
10 changed files with 121 additions and 36 deletions

View File

@ -123,6 +123,8 @@ export default {
page_header: "account login", page_header: "account login",
email: "e-mail", email: "e-mail",
password: "password", password: "password",
connected: "Connected",
redirecting: "Redirecting...",
button: { button: {
connect: "connect", connect: "connect",
employee: "employee", employee: "employee",
@ -323,6 +325,7 @@ export default {
TIMESHEET_NOT_FOUND: "No timesheet found with provided data", TIMESHEET_NOT_FOUND: "No timesheet found with provided data",
INVALID_EXPENSE: "An expense contains missing or corrupted data", INVALID_EXPENSE: "An expense contains missing or corrupted data",
EXPENSE_NOT_FOUND: "No expense found with provided data", EXPENSE_NOT_FOUND: "No expense found with provided data",
UPDATE_ERROR: "Error while updating data",
}, },
}, },
@ -355,6 +358,8 @@ export default {
}, },
tooltip: { tooltip: {
button_detailed_view: "detailed view", button_detailed_view: "detailed view",
approve: "Approve",
unapprove: "remove approval",
}, },
}, },
descriptions: { descriptions: {

View File

@ -123,6 +123,8 @@ export default {
page_header: "connexion au compte", page_header: "connexion au compte",
email: "courriel", email: "courriel",
password: "mot de passe", password: "mot de passe",
connected: "Connecté",
redirecting: "redirection en cours...",
button: { button: {
connect: "connecter", connect: "connecter",
employee: "employé", employee: "employé",
@ -324,6 +326,7 @@ export default {
TIMESHEET_NOT_FOUND: "Aucune feuille de temps ne correspond au détails fournis", TIMESHEET_NOT_FOUND: "Aucune feuille de temps ne correspond au détails fournis",
INVALID_EXPENSE: "Une dépense contient des données manquantes ou corrompues", INVALID_EXPENSE: "Une dépense contient des données manquantes ou corrompues",
EXPENSE_NOT_FOUND: "Aucune dépense ne correspond aux détails fournis", EXPENSE_NOT_FOUND: "Aucune dépense ne correspond aux détails fournis",
UPDATE_ERROR: "Une erreur est survenu lors de la mise à jour",
}, },
}, },
@ -356,6 +359,8 @@ export default {
}, },
tooltip: { tooltip: {
button_detailed_view: "vue détaillée", button_detailed_view: "vue détaillée",
approve: "mettre status approuvé",
unapprove: "enlever status approuvé",
}, },
}, },
descriptions: { descriptions: {

View File

@ -1,4 +1,7 @@
<script setup lang="ts"> <script
setup
lang="ts"
>
import { onMounted } from 'vue'; import { onMounted } from 'vue';
onMounted(() => { onMounted(() => {
@ -6,7 +9,7 @@
setTimeout(() => { setTimeout(() => {
window.opener.postMessage({ type: 'authSuccess' }); window.opener.postMessage({ type: 'authSuccess' });
window.close(); window.close();
}, 1500); }, 2000);
} }
}); });
</script> </script>
@ -15,14 +18,31 @@
<q-layout class="bg-secondary"> <q-layout class="bg-secondary">
<q-page-container> <q-page-container>
<q-page class="column items-center justify-center q-pa-xl"> <q-page class="column items-center justify-center q-pa-xl">
<transition appear enter-active-class="animated slow flipInX" leave-active-class="animated flipOutX"> <transition
<q-card class="col-3 items-center"> appear
<q-card-section class="row justify-center "> enter-active-class="animated flipInX"
<q-icon name="check_circle" color="accent" size="xl" /> >
<q-card class="col-3 items-center rounded-10 q-px-lg">
<q-card-section class="row justify-center ">
<q-icon
name="check_circle"
color="accent"
size="xl"
/>
</q-card-section> </q-card-section>
<q-separator inset color="accent" /> <q-separator
<q-card-section class="row justify-center"> inset
<span class="row text-h3">Login Successful!</span> color="accent"
/>
<q-card-section class="column items-center">
<span class="col-auto text-h4 text-uppercase">{{ $t('login.connected') }}</span>
<span class="col-auto text-h6 text-uppercase">{{ $t('login.redirecting') }}</span>
<q-spinner
color="accent"
size="5em"
:thickness="4"
class="col-auto"
/>
</q-card-section> </q-card-section>
</q-card> </q-card>
</transition> </transition>

View File

@ -46,7 +46,7 @@
</div> </div>
<div class="col-auto"> <div class="col-auto">
<ExpenseDialogList /> <ExpenseDialogList mode="approval" />
</div> </div>
<!-- list of shifts --> <!-- list of shifts -->

View File

@ -3,7 +3,7 @@
lang="ts" lang="ts"
> >
import type { TimesheetApprovalOverview } from 'src/modules/timesheet-approval/models/timesheet-overview.models'; import type { TimesheetApprovalOverview } from 'src/modules/timesheet-approval/models/timesheet-overview.models';
import { getHoursMinutesStringFromHoursFloat, getMinutes } from 'src/utils/date-and-time-utils'; import { getHoursMinutesStringFromHoursFloat, getMinutes } from 'src/utils/date-and-time-utils';
const modelApproval = defineModel<boolean>(); const modelApproval = defineModel<boolean>();
@ -14,7 +14,7 @@ import { getHoursMinutesStringFromHoursFloat, getMinutes } from 'src/utils/date-
const emit = defineEmits<{ const emit = defineEmits<{
'clickDetails': [overview: TimesheetApprovalOverview]; 'clickDetails': [overview: TimesheetApprovalOverview];
'clickApprovalAll' : [is_approved: boolean]; 'clickApprovalAll': [is_approved: boolean];
}>(); }>();
</script> </script>
@ -45,11 +45,10 @@ import { getHoursMinutesStringFromHoursFloat, getMinutes } from 'src/utils/date-
<!-- Buttons to view detailed shifts or view employee timesheet --> <!-- Buttons to view detailed shifts or view employee timesheet -->
<q-btn <q-btn
flat
dense dense
square square
unelevated unelevated
class="col-auto q-pa-none q-ma-none" class="col-auto q-ma-none rounded-5"
color="accent" color="accent"
icon="las la-chart-pie" icon="las la-chart-pie"
@click="emit('clickDetails', row)" @click="emit('clickDetails', row)"
@ -57,6 +56,7 @@ import { getHoursMinutesStringFromHoursFloat, getMinutes } from 'src/utils/date-
<q-tooltip <q-tooltip
anchor="top middle" anchor="top middle"
self="center middle" self="center middle"
:offset="[0, 20]"
class="bg-accent text-uppercase text-weight-bold" class="bg-accent text-uppercase text-weight-bold"
> >
{{ $t('timesheet_approvals.tooltip.button_detailed_view') }} {{ $t('timesheet_approvals.tooltip.button_detailed_view') }}
@ -64,7 +64,7 @@ import { getHoursMinutesStringFromHoursFloat, getMinutes } from 'src/utils/date-
<q-icon <q-icon
name="las la-chart-bar" name="las la-chart-bar"
color="accent" color="white"
/> />
</q-btn> </q-btn>
</q-card-section> </q-card-section>
@ -152,9 +152,7 @@ import { getHoursMinutesStringFromHoursFloat, getMinutes } from 'src/utils/date-
v-if="row.is_active" v-if="row.is_active"
class="col row full-width" class="col row full-width"
> >
<div <div class="col text-uppercase">
class="col text-uppercase"
>
<span class="text-h6 q-ml-sm text-weight-bolder">{{ 'Total : ' + Math.floor(row.total_hours) <span class="text-h6 q-ml-sm text-weight-bolder">{{ 'Total : ' + Math.floor(row.total_hours)
}}</span> }}</span>
<span class="text-uppercase text-weight-medium text-caption">H</span> <span class="text-uppercase text-weight-medium text-caption">H</span>
@ -176,7 +174,17 @@ import { getHoursMinutesStringFromHoursFloat, getMinutes } from 'src/utils/date-
class="text-uppercase" class="text-uppercase"
:class="row.is_approved ? '' : 'text-accent'" :class="row.is_approved ? '' : 'text-accent'"
@update:model-value="value => $emit('clickApprovalAll', value)" @update:model-value="value => $emit('clickApprovalAll', value)"
/> >
<q-tooltip
anchor="top middle"
self="center middle"
:offset="[0, 20]"
class="bg-accent text-uppercase text-weight-bold"
>
{{ row.is_approved ? $t('timesheet_approvals.tooltip.unapprove') :
$t('timesheet_approvals.tooltip.approve') }}
</q-tooltip>
</q-checkbox>
</div> </div>
</div> </div>
@ -190,8 +198,10 @@ import { getHoursMinutesStringFromHoursFloat, getMinutes } from 'src/utils/date-
class="col-auto" class="col-auto"
size="lg" size="lg"
/> />
<span class="col q-pl-sm text-uppercase text-weight-bold text-h5">{{
$t('timesheet_approvals.table.inactive') }}</span> <span class="col q-pl-sm text-uppercase text-weight-bold text-h5">
{{ $t('timesheet_approvals.table.inactive') }}
</span>
</div> </div>
</q-card-section> </q-card-section>
</q-card> </q-card>

View File

@ -4,21 +4,30 @@
> >
import ExpenseDialogForm from 'src/modules/timesheets/components/expense-dialog-form.vue'; import ExpenseDialogForm from 'src/modules/timesheets/components/expense-dialog-form.vue';
import { date } from 'quasar';
import { ref } from 'vue'; import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { date, Notify } from 'quasar';
import { unwrapAndClone } from 'src/utils/unwrap-and-clone'; import { unwrapAndClone } from 'src/utils/unwrap-and-clone';
import { useExpensesStore } from 'src/stores/expense-store'; import { useExpensesStore } from 'src/stores/expense-store';
import { useTimesheetStore } from 'src/stores/timesheet-store';
import { useExpensesApi } from 'src/modules/timesheets/composables/use-expense-api'; import { useExpensesApi } from 'src/modules/timesheets/composables/use-expense-api';
import { getExpenseIcon } from 'src/modules/timesheets/utils/expense.util'; import { getExpenseIcon } from 'src/modules/timesheets/utils/expense.util';
import type { Expense } from 'src/modules/timesheets/models/expense.models'; import type { Expense } from 'src/modules/timesheets/models/expense.models';
const { t } = useI18n();
const expense = defineModel<Expense>({ required: true }); const expense = defineModel<Expense>({ required: true });
const expenses_store = useExpensesStore();
const expenses_api = useExpensesApi(); const expenses_api = useExpensesApi();
const expenses_store = useExpensesStore();
const timesheet_store = useTimesheetStore();
const is_showing_update_form = ref(false); const is_showing_update_form = ref(false);
const { mode = 'normal' } = defineProps<{
mode?: 'approval' | 'normal';
}>();
const requestExpenseDeletion = async () => { const requestExpenseDeletion = async () => {
await expenses_api.deleteExpenseById(expense.value.id); await expenses_api.deleteExpenseById(expense.value.id);
} }
@ -30,6 +39,22 @@
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);
} }
const onClickApproval = async () => {
expenses_store.current_expense = unwrapAndClone(expense.value);
expenses_store.current_expense.is_approved = !expenses_store.current_expense.is_approved;
const success = await expenses_store.upsertExpense(expenses_store.current_expense, timesheet_store.current_pay_period_overview?.email);
if (success) {
expense.value.is_approved = !expense.value.is_approved;
} else {
expenses_store.current_expense.is_approved = !expenses_store.current_expense.is_approved;
Notify.create({
message: t('timesheet.errors.UPDATE_ERROR'),
color: "negative"
});
}
}
</script> </script>
<template> <template>
@ -127,11 +152,31 @@
<div class="col-auto"> <div class="col-auto">
<q-btn
v-if="mode === 'approval'"
flat
size="lg"
:icon="expense.is_approved ? 'lock' : 'lock_open'"
:color="expense.is_approved ? 'white' : 'accent'"
class="relative-position"
@click.stop="onClickApproval"
>
<q-tooltip
anchor="top middle"
self="center middle"
:offset="[0, 20]"
class="bg-accent text-uppercase text-weight-bold"
>
{{ expense.is_approved ? $t('timesheet_approvals.tooltip.unapprove') : $t('timesheet_approvals.tooltip.approve') }}
</q-tooltip>
</q-btn>
<q-icon <q-icon
v-if="expense.is_approved" v-if="expense.is_approved"
name="verified" name="verified"
color="white" color="white"
size="lg" size="2.5em"
class="q-px-sm"
/> />
<q-btn <q-btn
@ -141,7 +186,7 @@
size="lg" size="lg"
icon="close" icon="close"
color="negative" color="negative"
class="q-py-none q-my-xs" class="q-py-xs q-px-sm"
@click.stop="requestExpenseDeletion" @click.stop="requestExpenseDeletion"
/> />
</div> </div>

View File

@ -9,8 +9,8 @@
const timesheet_store = useTimesheetStore(); const timesheet_store = useTimesheetStore();
const { horizontal = false } = defineProps<{ const { mode = 'normal' } = defineProps<{
horizontal?: boolean; mode?: 'approval' | 'normal';
}>(); }>();
const expenses_list = computed(() => { const expenses_list = computed(() => {
@ -26,7 +26,6 @@
<q-list <q-list
padding padding
class="q-px-lg" class="q-px-lg"
:class="horizontal ? 'row flex-center' : ''"
> >
<q-item-label <q-item-label
v-if="expenses_list.length < 1" v-if="expenses_list.length < 1"
@ -50,6 +49,7 @@
v-else v-else
v-model="expenses_list[index]!" v-model="expenses_list[index]!"
:index="index" :index="index"
:mode="mode"
/> />
</div> </div>
</q-list> </q-list>

View File

@ -29,7 +29,7 @@
const handleSwipe: TouchSwipeValue = (details) => { const handleSwipe: TouchSwipeValue = (details) => {
mobile_animation_direction.value = details.direction === 'left' ? 'fadeInRight' : 'fadeInLeft'; mobile_animation_direction.value = details.direction === 'left' ? 'fadeInRight' : 'fadeInLeft';
if (details.distance && details.distance.x && Math.abs(details.distance.x) > 10) { if (details.distance && details.distance.x && Math.abs(details.distance.x) > 30) {
timesheet_api.getTimesheetsBySwiping(details.direction === 'left' ? 1 : -1).catch(error => console.error(error)); timesheet_api.getTimesheetsBySwiping(details.direction === 'left' ? 1 : -1).catch(error => console.error(error));
} }
}; };
@ -46,7 +46,7 @@
<div <div
class="column fit relative-position" class="column fit relative-position"
: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' : ''"
v-touch-swipe="handleSwipe" v-touch-swipe.horizontal="handleSwipe"
> >
<q-scroll-area <q-scroll-area
ref="timesheet_page" ref="timesheet_page"

View File

@ -7,8 +7,8 @@ export const ExpenseService = {
return response.data; return response.data;
}, },
updateExpense: async (expense: Expense): Promise<{success: boolean, data: Expense, error?: unknown}> => { updateExpense: async (expense: Expense, email?: string): Promise<{success: boolean, data: Expense, error?: unknown}> => {
const response = await api.patch(`expense/update`, expense); const response = await api.patch(`expense/update${email ? '?employee_email=' + email : ''}`, expense);
return response.data; return response.data;
}, },

View File

@ -29,13 +29,13 @@ export const useExpensesStore = defineStore('expenses', () => {
is_showing_create_form.value = true; is_showing_create_form.value = true;
}; };
const upsertExpense = async (expense: Expense): Promise<boolean> => { const upsertExpense = async (expense: Expense, email?: string): Promise<boolean> => {
try { try {
if (expense.id < 0) { if (expense.id < 0) {
const data = await ExpenseService.createExpense(expense); const data = await ExpenseService.createExpense(expense);
return data.success; return data.success;
} }
const data = await ExpenseService.updateExpense(expense); const data = await ExpenseService.updateExpense(expense, email);
return data.success; return data.success;
} catch (err) { } catch (err) {
// setErrorFrom(err); // setErrorFrom(err);