refactor(timesheet): working on expense list, optimizing class usage

working to refactor expense list and form to instead be q-expansion-items that are part of the same group, will trim a lot of needless code and q-slide-transition use this way.
This commit is contained in:
Nicolas Drolet 2025-12-15 17:12:39 -05:00
parent c5cf6becda
commit 1b4e59b292
4 changed files with 295 additions and 339 deletions

View File

@ -35,13 +35,15 @@
const period_end_date = computed(() => timesheet_store.pay_period?.period_end.replaceAll('-', '/') ?? ''); const period_end_date = computed(() => timesheet_store.pay_period?.period_end.replaceAll('-', '/') ?? '');
const expense_options: ExpenseOption[] = [ const expense_options: ExpenseOption[] = [
{label: t('timesheet.expense.types.PER_DIEM'), value: 'PER_DIEM', icon: getExpenseIcon('PER_DIEM')}, { 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.EXPENSES'), value: 'EXPENSES', icon: getExpenseIcon('EXPENSES') },
{label: t('timesheet.expense.types.MILEAGE'), value: 'MILEAGE', icon: getExpenseIcon('MILEAGE')}, { 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')}, { 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 expense_selected = ref(expense_options.find(expense => expense.value == expenses_store.current_expense.type));
const expense_monetary_string = ref(expenses_store.current_expense.amount.toString());
const emit = defineEmits<{ const emit = defineEmits<{
'onClickUpdateCancel': [void]; 'onClickUpdateCancel': [void];
'onClickSaveUpdates': [void]; 'onClickSaveUpdates': [void];
@ -68,6 +70,12 @@
}; };
const saveAndConvert = () => {
expenses_store.current_expense.amount = convertToMonetaryAmount(expense_monetary_string.value);
expense_monetary_string.value = expenses_store.current_expense.amount.toString();
console.log('current expense amount: ', expenses_store.current_expense.amount);
}
watch(expenses_store.current_expense, () => { watch(expenses_store.current_expense, () => {
is_initial_expense.value = deepEqual(expenses_store.current_expense, expenses_store.initial_expense); is_initial_expense.value = deepEqual(expenses_store.current_expense, expenses_store.initial_expense);
}); });
@ -92,15 +100,14 @@
:class="expenses_store.mode === 'create' ? 'q-px-lg' : ''" :class="expenses_store.mode === 'create' ? 'q-px-lg' : ''"
> >
<!-- date selection input --> <!-- date selection input -->
<div class="col q-px-xs">
<q-input <q-input
v-model="expenses_store.current_expense.date" v-model="expenses_store.current_expense.date"
dense dense
type="date" standout
borderless
readonly readonly
stack-label stack-label
color="primary" color="primary"
class="col-auto q-px-xs"
input-class="text-weight-medium" input-class="text-weight-medium"
input-style="font-size: 1em;" input-style="font-size: 1em;"
:label="$t('timesheet.expense.date')" :label="$t('timesheet.expense.date')"
@ -136,17 +143,18 @@
</span> </span>
</template> </template>
</q-input> </q-input>
</div>
<!-- expenses type selection --> <!-- expenses type selection -->
<div class="col q-px-xs">
<q-select <q-select
v-model="expense_selected" v-model="expense_selected"
standout="bg-blue-grey-9" standout
dense dense
:options="expense_options" :options="expense_options"
hide-dropdown-icon hide-dropdown-icon
stack-label stack-label
label-slot label-slot
class="col q-px-xs"
color="primary" color="primary"
:label="$t('timesheet.expense.type')" :label="$t('timesheet.expense.type')"
:menu-offset="[0, 10]" :menu-offset="[0, 10]"
@ -181,24 +189,24 @@
</div> </div>
</template> </template>
</q-select> </q-select>
</div>
<!-- amount input --> <!-- amount input -->
<div v-if="TYPES_WITH_AMOUNT_ONLY.includes(expenses_store.current_expense?.type ?? 'EXPENSES')"> <div class="col q-px-xs">
<q-input <q-input
key="amount" v-if="TYPES_WITH_AMOUNT_ONLY.includes(expenses_store.current_expense?.type ?? 'EXPENSES')"
v-model.number="expenses_store.current_expense.amount" v-model="expense_monetary_string"
standout="bg-blue-grey-9" standout
dense dense
label-slot label-slot
stack-label stack-label
suffix="$" suffix="$"
color="primary" color="primary"
class="col-auto q-px-xs"
input-class="text-right text-weight-bold" input-class="text-right text-weight-bold"
:input-style="'font-size: 1.2em;'" :input-style="'font-size: 1.2em;'"
lazy-rules="ondemand" lazy-rules="ondemand"
:rules="[rules.amountRequired]" :rules="[rules.amountRequired]"
@blur="expenses_store.current_expense.amount = convertToMonetaryAmount(expenses_store.current_expense.amount)" @blur="saveAndConvert()"
> >
<template #label> <template #label>
<span class="text-weight-bold text-accent text-uppercase text-caption"> <span class="text-weight-bold text-accent text-uppercase text-caption">
@ -206,24 +214,22 @@
</span> </span>
</template> </template>
</q-input> </q-input>
</div>
<!-- mileage input -->
<div v-else>
<q-input <q-input
v-else
key="mileage" key="mileage"
v-model.number="expenses_store.current_expense.mileage" v-model="expenses_store.current_expense.mileage"
standout="bg-blue-grey-9" standout
input-class="text-right"
dense dense
stack-label stack-label
clearable clearable
color="primary"
class="col q-px-xs"
label-slot label-slot
input-class="text-right"
color="primary"
suffix="km" suffix="km"
lazy-rules="ondemand" lazy-rules="ondemand"
:rules="[rules.mileageRequired]" :rules="[rules.mileageRequired]"
@blur="expenses_store.current_expense.amount = convertToMonetaryAmount(expense_monetary_string)"
> >
<template #label> <template #label>
<span class="text-weight-bold text-accent text-uppercase text-caption"> <span class="text-weight-bold text-accent text-uppercase text-caption">
@ -234,15 +240,15 @@
</div> </div>
<!-- employee comment input --> <!-- employee comment input -->
<div class="col q-px-xs">
<q-input <q-input
v-model="expenses_store.current_expense.comment" v-model="expenses_store.current_expense.comment"
standout="bg-blue-grey-9" standout
dense dense
stack-label stack-label
label-slot label-slot
color="primary" color="primary"
type="text" type="text"
class="col q-px-sm"
:maxlength="COMMENT_MAX_LENGTH" :maxlength="COMMENT_MAX_LENGTH"
lazy-rules="ondemand" lazy-rules="ondemand"
:rules="[rules.commentRequired]" :rules="[rules.commentRequired]"
@ -253,18 +259,18 @@
</span> </span>
</template> </template>
</q-input> </q-input>
</div>
<!-- import attach file section --> <!-- import attach file section -->
<div class="col q-px-xs">
<q-file <q-file
v-model="files" v-model="files"
standout="bg-blue-grey-9" standout
dense dense
use-chips use-chips
multiple multiple
stack-label stack-label
:label="$t('timesheet.expense.hints.attach_file')" label-slot
class="col"
style="max-width: 300px;"
> >
<template #prepend> <template #prepend>
<q-icon <q-icon
@ -281,6 +287,8 @@
</template> </template>
</q-file> </q-file>
</div> </div>
</div>
<div class="col row full-width items-center"> <div class="col row full-width items-center">
<q-space /> <q-space />
@ -308,3 +316,9 @@
</div> </div>
</q-form> </q-form>
</template> </template>
<style scoped>
:deep(.q-field--standout.q-field--readonly .q-field__control::before) {
border: transparent;
}
</style>

View File

@ -2,60 +2,25 @@
setup setup
lang="ts" lang="ts"
> >
import { date } from 'quasar';
import { computed, ref, toRaw } 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 { useAuthStore } from 'src/stores/auth-store';
import { CAN_APPROVE_PAY_PERIODS } from 'src/modules/shared/models/user.models';
import { Expense } from 'src/modules/timesheets/models/expense.models';
import ExpenseDialogForm from 'src/modules/timesheets/components/expense-dialog-form.vue'; import ExpenseDialogForm from 'src/modules/timesheets/components/expense-dialog-form.vue';
const { expense, horizontal = false } = defineProps<{ import { date } from 'quasar';
expense: Expense; import { computed, ref } from 'vue';
index: number; import { useExpensesStore } from 'src/stores/expense-store';
horizontal?: boolean; import { useExpensesApi } from 'src/modules/timesheets/composables/use-expense-api';
}>(); import { getExpenseIcon } from 'src/modules/timesheets/utils/expense.util';
const is_approved = defineModel<boolean>({ required: true }); import { Expense } from 'src/modules/timesheets/models/expense.models';
const expense = defineModel<Expense>({ required: true });
const expenses_store = useExpensesStore(); const expenses_store = useExpensesStore();
const auth_store = useAuthStore();
const expenses_api = useExpensesApi(); 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_authorized_to_approve = computed(() => CAN_APPROVE_PAY_PERIODS.includes(auth_store.user?.role ?? 'GUEST'))
const is_showing_update_form = ref(false); const is_showing_update_form = ref(false);
const is_current_expense = computed(() => expense.id === expenses_store.current_expense.id); const is_current_expense = computed(() => expense.value.id === expenses_store.current_expense.id);
const requestExpenseDeletion = async () => { const requestExpenseDeletion = async () => {
await expenses_api.deleteExpenseById(expense.id); await expenses_api.deleteExpenseById(expense.value.id);
}
const onExpenseClicked = () => {
if (is_authorized_to_approve.value) {
is_approved.value = !is_approved.value;
refresh_key.value += 1;
}
}
const onUpdateClicked = () => {
if (deepEqual(expense, expenses_store.current_expense)) {
expenses_store.mode = 'create';
Object.assign(expense, toRaw(expenses_store.initial_expense))
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;
} }
const onSaveUpdatesClicked = () => { const onSaveUpdatesClicked = () => {
@ -66,124 +31,100 @@
</script> </script>
<template> <template>
<q-item <q-expansion-item
:key="refresh_key" v-model="is_showing_update_form"
:clickable="horizontal" hide-expand-icon
class="column col-4 items-center q-my-sm q-py-none shadow-3 rounded-5 bg-dark" dense
:class="background_class + approved_class" group="expenses"
@click="onExpenseClicked" class="shadow-3 rounded-5 bg-dark"
:class="expense.is_approved ? ' bg-accent text-white' : ''"
> >
<div class="col row fit items-center"> <template #header>
<div class="col row items-center full-width">
<!-- avatar type icon section --> <!-- avatar type icon section -->
<q-item-section avatar> <div class="col-auto">
<q-icon <q-icon
: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="q-px-sm"
/> />
</q-item-section> </div>
<!-- amount or mileage section --> <!-- amount or mileage section -->
<q-item-section class="col col-md-2 text-weight-bold"> <div class="col column">
<q-item-label v-if="expense.type === 'MILEAGE'"> <span
{{ expense.mileage?.toFixed(1) }} km class="text-weight-bolder"
</q-item-label> :class="expense.is_approved ? ' bg-accent text-white' : ''"
<q-item-label v-else> style="font-size: 1.3em;"
$ {{ expense.amount.toFixed(2) }} >
</q-item-label> {{ expense.type === 'MILEAGE' ? `${expense.mileage?.toFixed(1)} km` : `$
${expense.amount.toFixed(2)}` }}
</span>
<!-- date label --> <!-- date label -->
<q-item-label <span
caption class="text-uppercase text-weight-light text-caption"
lines="1" :class="expense.is_approved ? ' bg-accent text-white' : ''"
class="text-uppercase text-weight-light"
:class="approved_class"
> >
{{ $d(date.extractDate(expense.date, 'YYYY-MM-DD'), { {{ $d(date.extractDate(expense.date, 'YYYY-MM-DD'), {
month: 'short', day: 'numeric', weekday: month: 'short', day: 'numeric', weekday:
'long' 'long'
}) }} }) }}
</q-item-label> </span>
</q-item-section> </div>
<q-space v-if="horizontal" />
<!-- attachment file icon --> <!-- attachment file icon -->
<q-item-section avatar> <div class="col row items-center justify-start">
<q-btn <q-btn
push push
:color="expense.is_approved ? 'white' : 'accent'" :color="expense.is_approved ? 'white' : 'accent'"
:text-color="expense.is_approved ? 'accent' : 'white'" :text-color="expense.is_approved ? 'accent' : 'white'"
class="col-auto q-mx-sm q-px-sm q-pb-sm" class="col-auto q-px-sm q-mr-sm"
icon="attach_file" icon="attach_file"
/> />
</q-item-section>
<q-item-label class="col text-weight-light text-caption"> <q-item-label class="col">
<span>attachment_goes_here.jpg</span> attachment_name.jpg
</q-item-label> </q-item-label>
</div>
<!-- comment section --> <!-- comment section -->
<q-item-section <div class="col column">
v-if="!horizontal" <span class="col-auto text-weight-bold text-accent text-uppercase text-caption">
top
>
<q-item-label
lines="1"
class="text-weight-medium text-uppercase"
>
{{ $t('timesheet.expense.employee_comment') }} {{ $t('timesheet.expense.employee_comment') }}
</q-item-label> </span>
<q-item-label
caption <span
lines="1" class="col"
:class="approved_class" :class="expense.is_approved ? ' bg-accent text-white' : ''"
style="font-size: 1.3em;"
> >
{{ expense.comment }} {{ expense.comment }}
</q-item-label> </span>
</q-item-section> </div>
<!-- supervisor comment section --> <!-- supervisor comment section -->
<q-item-section <div
v-if="is_authorized_to_approve"
top
>
<q-item-label
lines="1"
class="text-weight-medium text-uppercase"
>
{{ $t('timesheet.expense.supervisor_comment') }}
</q-item-label>
<q-item-label
v-if="expense.supervisor_comment" v-if="expense.supervisor_comment"
caption class="col column"
lines="2" >
<span class="col-auto text-weight-bold text-accent text-uppercase text-caption">
{{ $t('timesheet.expense.supervisor_comment') }}
</span>
<span
class="col"
:class="expense.is_approved ? ' bg-accent text-white' : ''"
style="font-size: 1.3em;"
> >
{{ expense.supervisor_comment }} {{ expense.supervisor_comment }}
</q-item-label> </span>
</q-item-section> </div>
<q-item-section <div
:key="refresh_key" class="col-auto row"
side
:class="is_current_expense ? 'invisible' : ''"
>
<q-btn
flat
dense
size="lg"
icon="edit"
color="accent"
:disable="expense.is_approved"
class="q-py-none z-top"
:class="expense.is_approved ? 'invisible no-pointer' : ''"
@click.stop="onUpdateClicked"
/>
</q-item-section>
<q-item-section
side
:class="is_current_expense ? 'invisible' : ''" :class="is_current_expense ? 'invisible' : ''"
> >
<q-icon <q-icon
@ -203,18 +144,13 @@
class="q-py-none z-top q-my-xs" class="q-py-none z-top q-my-xs"
@click.stop="requestExpenseDeletion" @click.stop="requestExpenseDeletion"
/> />
</q-item-section>
</div> </div>
</div>
</template>
<q-slide-transition
@hide="expenses_store.mode === 'update' ? null : expenses_store.is_hiding_create_form = false"
:duration="200"
>
<ExpenseDialogForm <ExpenseDialogForm
v-if="is_current_expense && expenses_store.is_hiding_create_form" @on-click-update-cancel="is_showing_update_form = false"
@on-click-update-cancel="onUpdateClicked"
@on-click-save-updates="onSaveUpdatesClicked" @on-click-save-updates="onSaveUpdatesClicked"
/> />
</q-slide-transition> </q-expansion-item>
</q-item>
</template> </template>

View File

@ -51,10 +51,8 @@
<ExpenseDialogListItem <ExpenseDialogListItem
v-else v-else
v-model="expense.is_approved" v-model="expenses_list[index]!"
:index="index" :index="index"
:expense="expense"
:horizontal="horizontal"
/> />
</div> </div>
</q-list> </q-list>

View File

@ -30,11 +30,19 @@ export const convertToMonetaryAmount = (amount: number | string): number => {
if (typeof amount === 'string') { if (typeof amount === 'string') {
try { try {
const single_decimal_amount = amount.replace(/\.(?=.*\.)/g, ''); let cleaned_amount = amount.replace(/[^\d.]/g, '');
const numbers_only_decimal = single_decimal_amount.replace(/[^0-9.]/g, ''); const first_dot = cleaned_amount.indexOf('.');
return Number(numbers_only_decimal); if (first_dot !== -1) {
} catch(error) { cleaned_amount =
cleaned_amount.slice(0, first_dot + 1) +
cleaned_amount
.slice(first_dot + 1, first_dot + 3)
.replace(/\./g, '');
}
return Number(cleaned_amount);
} catch (error) {
console.error(error); console.error(error);
} }
} }