feat(timesheet mobile): add interfaces for expense dialog in mobile format

Added mobile versions for expense form as well as expense items.
This commit is contained in:
Nicolas Drolet 2025-11-17 12:06:30 -05:00
parent b307f33ab0
commit 88cdb9e5ff
9 changed files with 524 additions and 33 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 MiB

After

Width:  |  Height:  |  Size: 242 KiB

View File

@ -232,7 +232,6 @@
color="primary" color="primary"
type="text" type="text"
class="col q-px-sm" class="col q-px-sm"
:counter="true"
:maxlength="COMMENT_MAX_LENGTH" :maxlength="COMMENT_MAX_LENGTH"
lazy-rules="ondemand" lazy-rules="ondemand"
:rules="[rules.commentRequired]" :rules="[rules.commentRequired]"
@ -289,7 +288,7 @@
push push
color="accent" color="accent"
:icon="expenses_store.mode === 'update' ? 'save' : 'upload'" :icon="expenses_store.mode === 'update' ? 'save' : 'upload'"
:label="$q.screen.gt.sm ? (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 "
:class="expenses_store.mode === 'create' ? 'q-mr-lg q-mb-md' : 'q-mb-sm q-ml-lg'" :class="expenses_store.mode === 'create' ? 'q-mr-lg q-mb-md' : 'q-mb-sm q-ml-lg'"
type="submit" type="submit"

View File

@ -48,20 +48,20 @@
{{ $t('timesheet.expense.total_amount') }} : {{ $t('timesheet.expense.total_amount') }} :
</span> </span>
<q-icon
v-else
name="payments"
size="sm"
color="accent"
class="col"
/>
<span <span
class="col-auto text-weight-light" class="col-auto text-weight-light"
style="font-size: 2.5em; line-height: 1em;" style="font-size: 2.5em; line-height: 1em;"
> >
{{ weekly_totals.expenses.toFixed(2) }} {{ weekly_totals.expenses.toFixed(2) }}
</span> </span>
<q-icon
v-if="$q.screen.lt.md"
name="attach_money"
size="md"
color="accent"
class="col q-ml-sm"
/>
</div> </div>
<div class="col-auto row items-center q-px-sm"> <div class="col-auto row items-center q-px-sm">
@ -72,20 +72,20 @@
{{ $t('timesheet.expense.total_mileage') }} : {{ $t('timesheet.expense.total_mileage') }} :
</span> </span>
<q-icon
v-else
name="drive_eta"
size="sm"
color="accent"
class="col"
/>
<span <span
class="col-auto text-weight-light" class="col-auto text-weight-light"
style="font-size: 2.5em; line-height: 1em;" style="font-size: 2.5em; line-height: 1em;"
> >
{{ weekly_totals.mileage.toFixed(1) }} {{ weekly_totals.mileage.toFixed(1) }}
</span> </span>
<q-icon
v-if="$q.screen.lt.md"
name="commute"
size="md"
color="accent"
class="col q-ml-sm"
/>
</div> </div>
</div> </div>
</div> </div>

View File

@ -96,7 +96,9 @@
class="text-uppercase text-weight-light" class="text-uppercase text-weight-light"
:class="approved_class" :class="approved_class"
> >
{{ $d(date.extractDate(expense.date, 'YYYY-MM-DD'), { month: 'short', day: 'numeric', weekday: 'long' }) }} {{ $d(date.extractDate(expense.date, 'YYYY-MM-DD'), {
month: 'short', day: 'numeric', weekday:
'long' }) }}
</q-item-label> </q-item-label>
</q-item-section> </q-item-section>
@ -194,7 +196,10 @@
@hide="expenses_store.is_hiding_create_form = false" @hide="expenses_store.is_hiding_create_form = false"
:duration="200" :duration="200"
> >
<ExpenseDialogForm v-if="is_showing_update_form && expenses_store.is_hiding_create_form" @on-click-update-cancel="onUpdateClicked"/> <ExpenseDialogForm
v-if="is_showing_update_form && expenses_store.is_hiding_create_form"
@on-click-update-cancel="onUpdateClicked"
/>
</q-slide-transition> </q-slide-transition>
</q-item> </q-item>
</template> </template>

View File

@ -5,6 +5,7 @@
import { computed } from 'vue'; import { computed } from 'vue';
import { useTimesheetStore } from 'src/stores/timesheet-store'; import { useTimesheetStore } from 'src/stores/timesheet-store';
import ExpenseDialogListItem from 'src/modules/timesheets/components/expense-dialog-list-item.vue'; import ExpenseDialogListItem from 'src/modules/timesheets/components/expense-dialog-list-item.vue';
import ExpenseDialogListItemMobile from 'src/modules/timesheets/components/mobile/expense-dialog-list-item-mobile.vue';
const timesheet_store = useTimesheetStore(); const timesheet_store = useTimesheetStore();
@ -38,14 +39,25 @@
<q-separator spaced /> <q-separator spaced />
</q-item-label> </q-item-label>
<ExpenseDialogListItem <div
v-for="(expense, index) in expenses_list" v-for="(expense, index) in expenses_list"
:key="index" :key="index"
v-model="expense.is_approved" >
:index="index" <ExpenseDialogListItemMobile
:expense="expense" v-if="$q.screen.lt.md"
:horizontal="horizontal" v-model="expense.is_approved"
/> :index="index"
</q-list> :expense="expense"
:horizontal="horizontal"
/>
<ExpenseDialogListItem
v-else
v-model="expense.is_approved"
:index="index"
:expense="expense"
:horizontal="horizontal"
/>
</div>
</q-list>
</template> </template>

View File

@ -5,6 +5,7 @@
import { useExpensesStore } from 'src/stores/expense-store'; import { useExpensesStore } from 'src/stores/expense-store';
import ExpenseDialogList from 'src/modules/timesheets/components/expense-dialog-list.vue'; import ExpenseDialogList from 'src/modules/timesheets/components/expense-dialog-list.vue';
import ExpenseDialogForm from 'src/modules/timesheets/components/expense-dialog-form.vue'; import ExpenseDialogForm from 'src/modules/timesheets/components/expense-dialog-form.vue';
import ExpenseDialogFormMobile from 'src/modules/timesheets/components/mobile/expense-dialog-form-mobile.vue';
import ExpenseDialogHeader from 'src/modules/timesheets/components/expense-dialog-header.vue'; import ExpenseDialogHeader from 'src/modules/timesheets/components/expense-dialog-header.vue';
const expense_store = useExpensesStore(); const expense_store = useExpensesStore();
@ -18,8 +19,10 @@
transition-hide="jump-down" transition-hide="jump-down"
> >
<q-card <q-card
class="q-pa-none rounded-10 shadow-10 bg-secondary" class="q-pa-none rounded-10 shadow-10"
:class="$q.screen.lt.md ? ' bg-primary' : 'bg-secondary'"
style=" min-width: 70vw;" style=" min-width: 70vw;"
:style="$q.screen.lt.md ? 'border: solid 2px var(--q-accent);' : ''"
> >
<q-inner-loading :showing="expense_store.is_loading"> <q-inner-loading :showing="expense_store.is_loading">
<q-spinner size="32px" /> <q-spinner size="32px" />
@ -38,8 +41,13 @@
<ExpenseDialogList /> <ExpenseDialogList />
<q-separator v-if="$q.screen.lt.md" spaced color="accent" size="2px" class="q-mx-md" />
<q-slide-transition @hide="expense_store.is_hiding_create_form = true" :duration="200"> <q-slide-transition @hide="expense_store.is_hiding_create_form = true" :duration="200">
<ExpenseDialogForm v-if="!expense_store.current_expense.is_approved && expense_store.mode !== 'update' && expense_store.is_hiding_create_form === false" /> <div v-if="!expense_store.current_expense.is_approved && expense_store.mode !== 'update' && expense_store.is_hiding_create_form === false">
<ExpenseDialogFormMobile v-if="$q.screen.lt.md" />
<ExpenseDialogForm v-else/>
</div>
</q-slide-transition> </q-slide-transition>
</q-card-section> </q-card-section>
</q-card> </q-card>

View File

@ -0,0 +1,317 @@
<script
setup
lang="ts"
>
import { computed, inject, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useUiStore } from 'src/stores/ui-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 { convertToMonetaryAmount, getExpenseIcon, useExpenseRules } from 'src/modules/timesheets/utils/expense.util';
import { type ExpenseType, TYPES_WITH_AMOUNT_ONLY } from 'src/modules/timesheets/models/expense.models';
interface ExpenseOption {
label: string;
value: ExpenseType;
icon: string;
}
const { t } = useI18n();
const ui_store = useUiStore();
const timesheet_store = useTimesheetStore();
const expenses_store = useExpensesStore();
const expenses_api = useExpensesApi();
const files = defineModel<File[] | null>('files');
const is_navigator_open = ref(false);
const is_showing_comment_dialog_mobile = ref(false);
const COMMENT_MAX_LENGTH = 280;
const employee_email = inject<string>('employeeEmail');
const rules = useExpenseRules(t);
const period_start_date = computed(() => timesheet_store.pay_period?.period_start.replaceAll('-', '/') ?? '');
const period_end_date = computed(() => timesheet_store.pay_period?.period_end.replaceAll('-', '/') ?? '');
const expense_options: ExpenseOption[] = [
{ label: t('timesheet.expense.types.PER_DIEM'), value: 'PER_DIEM', icon: getExpenseIcon('PER_DIEM') },
{ label: t('timesheet.expense.types.EXPENSES'), value: 'EXPENSES', icon: getExpenseIcon('EXPENSES') },
{ label: t('timesheet.expense.types.MILEAGE'), value: 'MILEAGE', icon: getExpenseIcon('MILEAGE') },
{ label: t('timesheet.expense.types.ON_CALL'), value: 'ON_CALL', icon: getExpenseIcon('ON_CALL') },
]
const expense_selected = ref(expense_options.find(expense => expense.value == expenses_store.current_expense.type));
const openDatePicker = () => {
is_navigator_open.value = true;
if (timesheet_store.pay_period !== undefined) {
expenses_store.current_expense.date = timesheet_store.pay_period.period_start;
}
};
const requestExpenseCreationOrUpdate = async () => {
if (expenses_store.mode === 'create') await expenses_api.createExpenseByEmployeeEmail(employee_email ?? '', expenses_store.current_expense?.date ?? '');
else await expenses_api.updateExpenseByEmployeeEmail(employee_email ?? '', expenses_store.current_expense?.date ?? '');
};
defineEmits<{
'onClickUpdateCancel': [void];
}>();
</script>
<template>
<q-form
v-if="!timesheet_store.timesheets?.every(timesheet => timesheet.is_approved)"
:key="expenses_store.current_expense.id"
flat
@submit.prevent="requestExpenseCreationOrUpdate"
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
class="col column items-start rounded-5 q-pb-sm"
:class="expenses_store.mode === 'create' ? 'q-px-md' : ''"
>
<!-- date and type row -->
<div class="col row q-my-xs full-width">
<!-- date selection input -->
<q-input
v-model="expenses_store.current_expense.date"
dense
type="date"
outlined
readonly
stack-label
hide-bottom-space
color="primary"
class="col-auto q-mr-sm"
input-class="text-weight-medium"
input-style="font-size: 1em;"
:label="$t('timesheet.expense.date')"
>
<template #prepend>
<q-btn
push
dense
icon="event"
color="accent"
class="q-mr-sm"
@click="openDatePicker"
/>
<q-dialog
v-model="is_navigator_open"
transition-show="jump-right"
transition-hide="jump-right"
>
<q-date
v-model="expenses_store.current_expense.date"
mask="YYYY-MM-DD"
event-color="accent"
:options="date => date >= period_start_date && date <= period_end_date"
@update:model-value="is_navigator_open = false"
/>
</q-dialog>
</template>
<template #label>
<span class="text-weight-bold text-accent text-uppercase text-caption">
{{ $t('timesheet.expense.date') }}
</span>
</template>
</q-input>
<!-- expenses type selection -->
<q-select
v-model="expense_selected"
standout="bg-blue-grey-9 text-white"
dense
:options="expense_options"
hide-dropdown-icon
stack-label
label-slot
hide-bottom-space
class="col"
color="primary"
:label="$t('timesheet.expense.type')"
:menu-offset="[0, 10]"
menu-anchor="bottom middle"
menu-self="top middle"
popup-content-class="text-uppercase text-weight-bold text-center rounded-5"
popup-content-style="border: 2px solid var(--q-accent)"
:rules="[rules.typeRequired]"
@update:model-value="option => expenses_store.current_expense.type = option.value"
>
<template #label>
<span class="text-weight-bold text-accent text-uppercase text-caption">
{{ $t('timesheet.expense.type') }}
</span>
</template>
<template #selected-item="scope">
<div
class="row items-center text-weight-bold q-ma-none q-pa-none full-width"
:class="ui_store.is_mobile_mode ? 'full-height' : ''"
:tabindex="scope.tabindex"
>
<q-icon
:name="scope.opt.icon"
size="sm"
class="col-auto q-mx-xs"
/>
<span class="col text-uppercase ellipsis">{{ scope.opt.label }}</span>
</div>
</template>
</q-select>
</div>
<!-- amount and comment row -->
<div class="col row q-my-xs full-width">
<!-- amount input -->
<div class="col q-mr-sm">
<q-input
v-if="TYPES_WITH_AMOUNT_ONLY.includes(expenses_store.current_expense?.type ?? 'EXPENSES')"
v-model.number="expenses_store.current_expense.amount"
key="amount"
standout="bg-blue-grey-9"
dense
label-slot
stack-label
hide-bottom-space
suffix="$"
color="primary"
input-class="text-right text-weight-bold"
:input-style="'font-size: 1.2em;'"
lazy-rules="ondemand"
:rules="[rules.amountRequired]"
@blur="expenses_store.current_expense.amount = convertToMonetaryAmount(expenses_store.current_expense.amount)"
>
<template #label>
<span class="text-weight-bold text-accent text-uppercase text-caption">
{{ $t('timesheet.expense.amount') }}
</span>
</template>
</q-input>
<!-- mileage input -->
<q-input
v-else
v-model.number="expenses_store.current_expense.mileage"
key="mileage"
standout="bg-blue-grey-9"
input-class="text-right"
dense
stack-label
clearable
hide-bottom-space
color="primary"
label-slot
suffix="km"
lazy-rules="ondemand"
:rules="[rules.mileageRequired]"
>
<template #label>
<span class="text-weight-bold text-accent text-uppercase text-caption">
{{ $t('timesheet.expense.mileage') }}
</span>
</template>
</q-input>
</div>
<!-- employee comment input -->
<q-btn
push
color="accent"
:icon="expenses_store.current_expense.comment ? 'chat' : 'chat_bubble_outline'"
@click="is_showing_comment_dialog_mobile = true"
class="col-auto"
/>
<q-dialog v-model="is_showing_comment_dialog_mobile">
<q-card class="full-width bg-secondary rounded-10">
<q-card-section class="q-pa-none">
<span
class="text-weight-bold text-accent text-uppercase text-caption"
style="font-size: 1.5em;"
>
{{ $t('timesheet.expense.employee_comment') }}
</span>
</q-card-section>
<q-card-section class="q-pa-none bg-primary rounded-10">
<q-input
v-model="expenses_store.current_expense.comment"
standout="bg-blue-grey-9"
dense
hide-bottom-space
color="primary"
type="textarea"
:maxlength="COMMENT_MAX_LENGTH"
lazy-rules="ondemand"
:rules="[rules.commentRequired]"
>
</q-input>
</q-card-section>
</q-card>
</q-dialog>
</div>
<!-- import attach file section -->
<q-file
v-model="files"
standout="bg-blue-grey-9"
dense
use-chips
multiple
stack-label
:label="$t('timesheet.expense.hints.attach_file')"
class="col full-width q-my-xs"
>
<template #prepend>
<q-icon
name="attach_file"
size="sm"
color="accent"
/>
</template>
<template #label>
<span class="text-weight-bold text-accent text-uppercase text-caption">
{{ $t('timesheet.expense.hints.attach_file') }}
</span>
</template>
</q-file>
</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')"
/>
<q-btn
push
color="accent"
:icon="expenses_store.mode === 'update' ? 'save' : 'upload'"
:label="expenses_store.mode === 'update' ? $t('shared.label.update') : $t('shared.label.add')"
class="q-px-sm"
:class="expenses_store.mode === 'create' ? 'q-mr-md q-mb-md' : 'q-mb-sm q-ml-lg'"
type="submit"
/>
</div>
</q-form>
</template>

View File

@ -0,0 +1,150 @@
<script
setup
lang="ts"
>
import { date } from 'quasar';
import { computed, ref } from 'vue';
import { unwrapAndClone } from 'src/utils/unwrap-and-clone';
import { deepEqual } from 'src/utils/deep-equal';
import { useExpensesApi } from 'src/modules/timesheets/composables/use-expense-api';
import { useExpensesStore } from 'src/stores/expense-store';
import { getExpenseIcon } from 'src/modules/timesheets/utils/expense.util';
import { Expense } from 'src/modules/timesheets/models/expense.models';
import ExpenseDialogFormMobile from 'src/modules/timesheets/components/mobile/expense-dialog-form-mobile.vue';
const { expense, horizontal = false } = defineProps<{
expense: Expense;
index: number;
horizontal?: boolean;
}>();
const expenses_store = useExpensesStore();
const expenses_api = useExpensesApi();
const refresh_key = ref(1);
const background_class = computed(() => deepEqual(expense, expenses_store.current_expense) ? '' : '');
const approved_class = computed(() => expense.is_approved ? ' bg-accent text-white' : '')
const is_showing_update_form = ref(false);
const requestExpenseDeletion = async () => {
await expenses_api.deleteExpenseById(expense.id);
}
const onUpdateClicked = () => {
if (expense.is_approved) return;
if (deepEqual(expense, 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.current_expense = expense;
expenses_store.initial_expense = unwrapAndClone(expense);
is_showing_update_form.value = true;
}
</script>
<template>
<div class="column bg-dark rounded-5 q-my-sm full-width">
<q-slide-item
right-color="negative"
class="rounded-5 bg-dark full-width"
@right="requestExpenseDeletion"
>
<template
#right
v-if="$q.screen.lt.md && !expenses_store.is_hiding_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="background_class + approved_class"
@click="onUpdateClicked"
>
<div class="column col">
<!-- date label -->
<div class="col-auto row items-center q-pl-xs">
<q-icon
name="calendar_month"
size="sm"
class="col-auto"
/>
<span
class="col text-uppercase text-weight-light full-width q-pl-sm text-h6"
:class="approved_class"
>
{{ $d(
date.extractDate(expense.date, 'YYYY-MM-DD'),
{ month: 'long', day: 'numeric' }
) }}
</span>
</div>
<div class="col row full-width items-center">
<!-- avatar type icon section -->
<q-icon
:name="getExpenseIcon(expense.type)"
:color="expense.is_approved ? 'white' : ($q.dark.isActive ? 'white' : 'primary')"
size="lg"
/>
<!-- amount or mileage section -->
<q-item-section class="col text-weight-bold text-h6">
<q-item-label v-if="expense.type === 'MILEAGE'">
{{ expense.mileage?.toFixed(1) }} km
</q-item-label>
<q-item-label v-else>
$ {{ expense.amount.toFixed(2) }}
</q-item-label>
</q-item-section>
<q-space v-if="horizontal" />
<!-- attachment file icon -->
<q-item-section avatar>
<q-btn
push
:color="expense.is_approved ? 'white' : 'accent'"
:text-color="expense.is_approved ? 'accent' : 'white'"
class="col-auto q-mx-sm q-px-sm q-pb-sm"
icon="attach_file"
/>
</q-item-section>
</div>
</div>
<div
class="col-auto q-px-sm"
:class="expense.is_approved ? '' : 'invisible'"
>
<q-icon
v-if="expense.is_approved"
name="verified"
color="white"
size="lg"
class="full-height"
/>
</div>
</q-item>
</q-slide-item>
<q-slide-transition
@hide="expenses_store.is_hiding_create_form = false"
:duration="200"
>
<ExpenseDialogFormMobile
v-if="is_showing_update_form && expenses_store.is_hiding_create_form"
class="q-mt-sm q-pa-sm"
@on-click-update-cancel="onUpdateClicked"
/>
</q-slide-transition>
</div>
</template>

View File

@ -4,10 +4,10 @@
<template> <template>
<q-layout view="hHh lpR fFf"> <q-layout view="hHh lpR fFf">
<q-page-container class="bg-dark"> <q-page-container class="bg-secondary">
<q-img src="src/assets/village.png" fit="cover" position="50% 100%" class="absolute-full" /> <q-page class="column">
<q-page class="flex flex-center"> <q-img src="src/assets/village.png" fit="contain" class="col absolute-bottom-right" style="opacity: 50%;" />
<transition appear slow enter-active-class="animated zoomIn" leave-active-class="animated zoomOut"> <transition appear slow enter-active-class="animated zoomIn" leave-active-class="animated zoomOut" class="col absolute-center">
<LoginConnectionPanel /> <LoginConnectionPanel />
</transition> </transition>
</q-page> </q-page>