fix(timesheet): fix issue with expense not updating properly in approval module

Also rework expense item appearance in list to better divide space between components for visual clarity.
This commit is contained in:
Nic D 2026-03-10 15:52:40 -04:00
parent c6187305d9
commit 1271d1eb61
11 changed files with 411 additions and 377 deletions

View File

@ -66,10 +66,13 @@ export default defineConfig((ctx) => {
// polyfillModulePreload: true, // polyfillModulePreload: true,
// distDir // distDir
extendViteConf: (_config) => ({ extendViteConf: (config) => ({
optimizeDeps: { optimizeDeps: {
exclude: ['tesseract.js'] exclude: ['tesseract.js']
} },
define: {
__VUE_PROD_DEVTOOLS__: config.mode !== 'production'
},
}), }),
// viteVuePluginOptions: {}, // viteVuePluginOptions: {},

View File

@ -2,20 +2,21 @@
setup setup
lang="ts" lang="ts"
> >
import { useI18n } from 'vue-i18n';
import { computed, ref } from 'vue';
import { useTimesheetStore } from 'src/stores/timesheet-store';
import DetailsDialogChartHoursWorked from 'src/modules/timesheet-approval/components/details-dialog-chart-hours-worked.vue'; import DetailsDialogChartHoursWorked from 'src/modules/timesheet-approval/components/details-dialog-chart-hours-worked.vue';
import DetailsDialogChartShiftTypes from 'src/modules/timesheet-approval/components/details-dialog-chart-shift-types.vue'; import DetailsDialogChartShiftTypes from 'src/modules/timesheet-approval/components/details-dialog-chart-shift-types.vue';
import DetailsDialogChartExpenses from 'src/modules/timesheet-approval/components/details-dialog-chart-expenses.vue'; import DetailsDialogChartExpenses from 'src/modules/timesheet-approval/components/details-dialog-chart-expenses.vue';
import TimesheetWrapper from 'src/modules/timesheets/components/timesheet-wrapper.vue'; import TimesheetWrapper from 'src/modules/timesheets/components/timesheet-wrapper.vue';
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 { date } from 'quasar';
import { useI18n } from 'vue-i18n';
import { computed, ref } from 'vue';
import { useTimesheetStore } from 'src/stores/timesheet-store';
import { useTimesheetApprovalApi } from '../composables/use-timesheet-approval-api'; import { useTimesheetApprovalApi } from '../composables/use-timesheet-approval-api';
import { useShiftApi } from 'src/modules/timesheets/composables/use-shift-api'; import { useShiftApi } from 'src/modules/timesheets/composables/use-shift-api';
import { useExpensesStore } from 'src/stores/expense-store'; import { useExpensesStore } from 'src/stores/expense-store';
import { Expense } from 'src/modules/timesheets/models/expense.models'; import { Expense } from 'src/modules/timesheets/models/expense.models';
import { date } from 'quasar';
// ========== state ======================================== // ========== state ========================================
@ -25,6 +26,7 @@
const timesheetApprovalApi = useTimesheetApprovalApi(); const timesheetApprovalApi = useTimesheetApprovalApi();
const shiftApi = useShiftApi(); const shiftApi = useShiftApi();
const isDialogOpen = ref(false); const isDialogOpen = ref(false);
const refreshKey = ref(0);
// ========== computed ======================================== // ========== computed ========================================
@ -34,9 +36,9 @@
t('shared.label.unlock') : t('shared.label.unlock') :
t('shared.label.lock') t('shared.label.lock')
); );
const approveButtonIcon = computed(() => isApproved.value ? 'las la-lock' : 'las la-unlock'); const approveButtonIcon = computed(() => isApproved.value ? 'las la-lock' : 'las la-unlock');
const hasExpenses = computed(() => timesheetStore.timesheets.some(timesheet => const hasExpenses = computed(() => timesheetStore.timesheets.some(timesheet =>
Object.values(timesheet.weekly_expenses).some(hours => hours > 0)) Object.values(timesheet.weekly_expenses).some(hours => hours > 0))
); );
@ -60,13 +62,22 @@
expenseStore.is_showing_create_form = false; expenseStore.is_showing_create_form = false;
} }
const onClickExpenseCreate = () => { const onClickNewExpense = () => {
expenseStore.mode = 'create'; expenseStore.mode = 'create';
if (timesheetStore.pay_period) if (timesheetStore.pay_period)
expenseStore.current_expense = new Expense(timesheetStore.pay_period.period_start); expenseStore.current_expense = new Expense(timesheetStore.pay_period.period_start);
else else
expenseStore.current_expense = new Expense(date.formatDate(new Date(), 'YYYY-MM-DD')); expenseStore.current_expense = new Expense(date.formatDate(new Date(), 'YYYY-MM-DD'));
} }
const onClickSaveNewExpense = () => {
expenseStore.is_showing_create_form = false;
}
const onShowDetailsDialog = () => {
isDialogOpen.value = true;
expenseStore.is_showing_create_form = false;
}
</script> </script>
<template> <template>
@ -77,7 +88,7 @@
transition-show="jump-down" transition-show="jump-down"
transition-hide="jump-down" transition-hide="jump-down"
backdrop-filter="blur(6px)" backdrop-filter="blur(6px)"
@show="isDialogOpen = true" @show="onShowDetailsDialog"
@hide="isDialogOpen = false" @hide="isDialogOpen = false"
@before-hide="timesheetStore.getTimesheetOverviews" @before-hide="timesheetStore.getTimesheetOverviews"
> >
@ -154,7 +165,10 @@
class="q-mx-md" class="q-mx-md"
/> />
<ExpenseDialogList mode="approval" /> <ExpenseDialogList
mode="approval"
:key="refreshKey + 1"
/>
<q-expansion-item <q-expansion-item
v-if="!isApproved" v-if="!isApproved"
@ -162,7 +176,7 @@
hide-expand-icon hide-expand-icon
:dense="!$q.platform.is.mobile" :dense="!$q.platform.is.mobile"
group="expenses" group="expenses"
@show="onClickExpenseCreate()" @show="onClickNewExpense()"
header-class="bg-accent text-white q-mx-md rounded-5" header-class="bg-accent text-white q-mx-md rounded-5"
> >
<template #header> <template #header>
@ -180,7 +194,11 @@
</div> </div>
</template> </template>
<ExpenseDialogForm :email="timesheetStore.current_pay_period_overview?.email"/> <ExpenseDialogForm
:email="timesheetStore.current_pay_period_overview?.email"
:key="refreshKey"
@click-save="onClickSaveNewExpense"
/>
</q-expansion-item> </q-expansion-item>
<q-separator <q-separator

View File

@ -116,7 +116,7 @@ watch(selected_company, (company) => {
left-label left-label
color="white" color="white"
dense dense
:label="$t(company.label)" :label="company.label"
:val="company.value" :val="company.value"
checked-icon="radio_button_checked" checked-icon="radio_button_checked"
unchecked-icon="radio_button_unchecked" unchecked-icon="radio_button_unchecked"

View File

@ -2,16 +2,14 @@
setup setup
lang="ts" lang="ts"
> >
import { date } from 'quasar';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { computed, inject, onMounted, ref } from 'vue'; import { computed, onMounted, ref } from 'vue';
import { useUiStore } from 'src/stores/ui-store'; import { useUiStore } from 'src/stores/ui-store';
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 { useExpensesApi } from 'src/modules/timesheets/composables/use-expense-api'; import { useExpensesApi } from 'src/modules/timesheets/composables/use-expense-api';
import { getExpenseIcon, useExpenseRules } from 'src/modules/timesheets/utils/expense.util'; import { getExpenseIcon, useExpenseRules } from 'src/modules/timesheets/utils/expense.util';
import { Expense, type ExpenseOption, TYPES_WITH_AMOUNT_ONLY } from 'src/modules/timesheets/models/expense.models'; import { Expense, type ExpenseOption, TYPES_WITH_AMOUNT_ONLY } from 'src/modules/timesheets/models/expense.models';
import { useAuthStore } from 'src/stores/auth-store';
// ================= state ====================== // ================= state ======================
@ -19,293 +17,290 @@
const expense = defineModel<Expense>({ default: new Expense(new Date().toISOString().slice(0, 10)) }) const expense = defineModel<Expense>({ default: new Expense(new Date().toISOString().slice(0, 10)) })
const file = defineModel<File>('file'); const file = defineModel<File>('file');
const { email } = defineProps<{
const {email} = defineProps<{
email?: string | undefined; email?: string | undefined;
}>(); }>();
const emit = defineEmits<{
'clickSave': [void];
}>();
const { t } = useI18n(); const { t } = useI18n();
const ui_store = useUiStore(); const ui_store = useUiStore();
const timesheet_store = useTimesheetStore(); const timesheetStore = useTimesheetStore();
const expenses_store = useExpensesStore(); const expenseStore = useExpensesStore();
const auth_store = useAuthStore(); const expensesApi = useExpensesApi();
const expenses_api = useExpensesApi(); const isNavigatorOpen = ref(false);
const is_navigator_open = ref(false);
const rules = useExpenseRules(t); const rules = useExpenseRules(t);
const expense_options: ExpenseOption[] = [ const expenseOptions: 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.EXPENSES'), value: 'EXPENSES', icon: getExpenseIcon('EXPENSES') },
{ label: t('timesheet.expense.types.PER_DIEM'), value: 'PER_DIEM', icon: getExpenseIcon('PER_DIEM') },
{ 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<ExpenseOption | undefined>(); const expenseSelected = ref<ExpenseOption | undefined>();
const employeeEmail = inject<string>('employeeEmail');
// ================== computed =================== // ================== computed ===================
const period_start_date = computed(() => timesheet_store.pay_period?.period_start.replaceAll('-', '/') ?? ''); const period_start_date = computed(() => timesheetStore.pay_period?.period_start.replaceAll('-', '/') ?? '');
const period_end_date = computed(() => timesheet_store.pay_period?.period_end.replaceAll('-', '/') ?? ''); const period_end_date = computed(() => timesheetStore.pay_period?.period_end.replaceAll('-', '/') ?? '');
const isSaveDisabled = computed(() =>
JSON.stringify(expenseStore.current_expense) === JSON.stringify(expenseStore.initial_expense)
);
// ==================== method ======================= // ==================== method =======================
const openDatePicker = () => { const openDatePicker = () => {
is_navigator_open.value = true; isNavigatorOpen.value = true;
if (expenses_store.current_expense.date === undefined) { if (expenseStore.current_expense.date === undefined) {
expenses_store.current_expense.date = timesheet_store.pay_period?.period_start ?? ''; expenseStore.current_expense.date = timesheetStore.pay_period?.period_start ?? '';
} }
}; };
const closeDatePicker = (date: string) => { const closeDatePicker = (date: string) => {
is_navigator_open.value = false; isNavigatorOpen.value = false;
expenses_store.current_expense.date = date; expenseStore.current_expense.date = date;
} }
const requestExpenseCreationOrUpdate = async () => { const requestExpenseCreationOrUpdate = async () => {
if (file.value) const success = await expensesApi.upsertExpense(expenseStore.current_expense, email, file.value);
await expenses_api.upsertExpense(
expenses_store.current_expense, if (success) {
email ?? employeeEmail ?? auth_store.user?.email ?? 'MISSING_EMAIL', expenseStore.is_showing_create_form = false;
file.value emit('clickSave');
); }
else
await expenses_api.upsertExpense(
expenses_store.current_expense,
email ?? employeeEmail ?? auth_store.user?.email ?? 'MISSING_EMAIL'
);
expenses_store.is_showing_create_form = true;
expenses_store.mode = 'create';
expenses_store.current_expense = new Expense(date.formatDate(new Date(), 'YYYY-MM-DD'));
}; };
onMounted(() => { onMounted(() => {
if (expense.value) if (expense.value)
expense_selected.value = expense_options.find(expense_option => expense_option.value === expense.value.type); expenseSelected.value = expenseOptions.find(expense_option => expense_option.value === expense.value.type);
else else
expense_selected.value = expense_options[1]; expenseSelected.value = expenseOptions[0];
}) })
</script> </script>
<template> <template>
<q-form <div
v-if="!expenses_store.current_expense.is_approved" v-if="!expenseStore.current_expense.is_approved"
flat
@submit.prevent="requestExpenseCreationOrUpdate"
class="full-width q-mt-md q-px-md" class="full-width q-mt-md q-px-md"
> >
<div <q-form
class="row justify-between items-start rounded-5 q-pb-sm" flat
:class="expenses_store.mode === 'create' ? 'q-px-lg' : ''" @submit.prevent="requestExpenseCreationOrUpdate"
> >
<!-- date selection input --> <div
<div class="col q-px-xs"> class="row justify-between items-start rounded-5 q-pb-sm"
<q-input :class="expenseStore.mode === 'create' ? 'q-px-lg' : ''"
v-model="expenses_store.current_expense.date" >
dense <!-- date selection input -->
standout <div class="col q-px-xs">
readonly <q-input
stack-label v-model="expenseStore.current_expense.date"
color="primary" dense
input-class="text-weight-medium" standout
input-style="font-size: 1em;" readonly
:label="$t('timesheet.expense.date')" stack-label
> color="primary"
<template #prepend> input-class="text-weight-medium"
<q-btn input-style="font-size: 1em;"
push :label="$t('timesheet.expense.date')"
dense >
icon="event" <template #prepend>
color="accent" <q-btn
class="q-mr-sm" push
@click="openDatePicker" dense
/> icon="event"
color="accent"
<q-dialog class="q-mr-sm"
v-model="is_navigator_open" @click="openDatePicker"
transition-show="jump-right"
transition-hide="jump-right"
class="z-top"
>
<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="closeDatePicker"
/> />
</q-dialog>
</template>
<template #label> <q-dialog
<span class="text-weight-bold text-accent text-uppercase text-caption"> v-model="isNavigatorOpen"
{{ $t('timesheet.expense.date') }} transition-show="jump-right"
</span> transition-hide="jump-right"
</template> class="z-top"
</q-input> >
</div> <q-date
v-model="expenseStore.current_expense.date"
mask="YYYY-MM-DD"
event-color="accent"
:options="date => date >= period_start_date && date <= period_end_date"
@update:model-value="closeDatePicker"
/>
</q-dialog>
</template>
<!-- expenses type selection --> <template #label>
<div class="col q-px-xs"> <span class="text-weight-bold text-accent text-uppercase text-caption">
<q-select {{ $t('timesheet.expense.date') }}
v-model="expense_selected" </span>
standout </template>
dense </q-input>
:options="expense_options" </div>
hide-dropdown-icon
stack-label
label-slot
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 z-top"
options-selected-class="text-weight-bolder text-white bg-accent"
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"> <!-- expenses type selection -->
<div <div class="col q-px-xs">
class="row items-center text-weight-bold q-ma-none q-pa-none no-wrap ellipsis full-width" <q-select
:class="ui_store.is_mobile_mode ? 'full-height' : ''" v-model="expenseSelected"
:tabindex="scope.tabindex" standout
> dense
:options="expenseOptions"
hide-dropdown-icon
stack-label
label-slot
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 z-top"
options-selected-class="text-weight-bolder text-white bg-accent"
popup-content-style="border: 2px solid var(--q-accent)"
:rules="[rules.typeRequired]"
@update:model-value="option => expenseStore.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 no-wrap ellipsis full-width"
:class="ui_store.is_mobile_mode ? 'full-height' : ''"
:tabindex="scope.tabindex"
>
<q-icon
:name="scope.opt.icon"
size="xs"
class="col-auto q-mx-xs"
/>
<span
style="line-height: 1em;"
class="col-auto ellipsis text-uppercase"
>{{ scope.opt.label }}</span>
</div>
</template>
</q-select>
</div>
<!-- amount input -->
<div class="col q-px-xs">
<q-input
v-if="TYPES_WITH_AMOUNT_ONLY.includes(expenseStore.current_expense?.type ?? 'EXPENSES')"
v-model.number="expenseStore.current_expense.amount"
standout
dense
label-slot
stack-label
suffix="$"
type="number"
color="primary"
input-class="text-right text-weight-medium"
input-style="font-size: 1.3em;"
lazy-rules="ondemand"
:rules="[rules.amountRequired]"
>
<template #label>
<span class="text-weight-bold text-accent text-uppercase text-caption">
{{ $t('timesheet.expense.amount') }}
</span>
</template>
</q-input>
<q-input
v-else
v-model="expenseStore.current_expense.mileage"
standout
dense
label-slot
stack-label
suffix="km"
type="number"
input-class="text-right text-weight-medium"
input-style="font-size: 1.3em;"
color="primary"
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 -->
<div class="col q-px-xs">
<q-input
v-model="expenseStore.current_expense.comment"
standout
dense
stack-label
label-slot
color="primary"
input-class="text-weight-medium"
input-style="font-size: 1.3em;"
:maxlength="COMMENT_MAX_LENGTH"
lazy-rules="ondemand"
:rules="[rules.commentRequired]"
>
<template #label>
<span class="text-weight-bold text-accent text-uppercase text-caption">
{{ $t('timesheet.expense.employee_comment') }}
</span>
</template>
</q-input>
</div>
<!-- import attach file section -->
<div class="col q-px-xs">
<q-file
v-model="file"
standout
dense
stack-label
label-slot
type="file"
accept="image/*"
>
<template #prepend>
<q-icon <q-icon
:name="scope.opt.icon" name="attach_file"
size="xs" size="sm"
class="col-auto q-mx-xs" color="accent"
/> />
<span </template>
style="line-height: 1em;"
class="col-auto ellipsis text-uppercase" <template #label>
>{{ scope.opt.label }}</span> <span class="text-weight-bold text-accent text-uppercase text-caption">
</div> {{ $t('timesheet.expense.hints.attach_file') }}
</template> </span>
</q-select> </template>
</q-file>
</div>
</div> </div>
<!-- amount input --> <div class="col row full-width items-center">
<div class="col q-px-xs"> <q-space />
<q-input
v-if="TYPES_WITH_AMOUNT_ONLY.includes(expenses_store.current_expense?.type ?? 'EXPENSES')"
v-model="expenses_store.current_expense.amount"
standout
dense
label-slot
stack-label
suffix="$"
type="number"
color="primary"
input-class="text-right text-weight-medium"
input-style="font-size: 1.3em;"
lazy-rules="ondemand"
:rules="[rules.amountRequired]"
>
<template #label>
<span class="text-weight-bold text-accent text-uppercase text-caption">
{{ $t('timesheet.expense.amount') }}
</span>
</template>
</q-input>
<q-input <q-btn
v-else push
v-model="expenses_store.current_expense.mileage" :disable="isSaveDisabled"
standout :color="isSaveDisabled ? 'grey-5' : 'accent'"
dense :icon="expenseStore.mode === 'update' ? 'save' : 'upload'"
label-slot :label="expenseStore.mode === 'update' ? $t('shared.label.update') : $t('shared.label.add')"
stack-label class="q-px-sm "
suffix="km" :class="expenseStore.mode === 'create' ? 'q-mr-lg q-mb-md' : 'q-mb-sm q-ml-lg'"
type="number" type="submit"
input-class="text-right text-weight-medium" />
input-style="font-size: 1.3em;"
color="primary"
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> </div>
</q-form>
<!-- employee comment input --> </div>
<div class="col q-px-xs">
<q-input
v-model="expenses_store.current_expense.comment"
standout
dense
stack-label
label-slot
color="primary"
input-class="text-weight-medium"
input-style="font-size: 1.3em;"
:maxlength="COMMENT_MAX_LENGTH"
lazy-rules="ondemand"
:rules="[rules.commentRequired]"
>
<template #label>
<span class="text-weight-bold text-accent text-uppercase text-caption">
{{ $t('timesheet.expense.employee_comment') }}
</span>
</template>
</q-input>
</div>
<!-- import attach file section -->
<div class="col q-px-xs">
<q-file
v-model="file"
standout
dense
stack-label
label-slot
type="file"
accept="image/*"
>
<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>
<div class="col row full-width items-center">
<q-space />
<q-btn
push
:disable="expenses_store.is_save_disabled"
:color="expenses_store.is_save_disabled ? 'grey-5' : '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-lg q-mb-md' : 'q-mb-sm q-ml-lg'"
type="submit"
/>
</div>
</q-form>
</template> </template>
<style scoped> <style scoped>

View File

@ -4,10 +4,9 @@
> >
import ExpenseDialogForm from 'src/modules/timesheets/components/expense-dialog-form.vue'; import ExpenseDialogForm from 'src/modules/timesheets/components/expense-dialog-form.vue';
import { computed, ref } from 'vue'; import { computed, ref, toRaw } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { date, Notify } from 'quasar'; import { date, Notify } from 'quasar';
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 { 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';
@ -23,44 +22,44 @@
}>(); }>();
const { t } = useI18n(); const { t } = useI18n();
const expenses_api = useExpensesApi(); const expensesApi = useExpensesApi();
const expenses_store = useExpensesStore(); const expenseStore = useExpensesStore();
const timesheet_store = useTimesheetStore(); const timesheetStore = useTimesheetStore();
const is_showing_update_form = ref(false); const isShowingUpdateForm = ref(false);
// ========== computed =================================== // ========== computed ===================================
const attachmentButtonColor = computed(() => expense.value.attachment_name ? const attachmentButtonColor = computed(() => expense.value.attachment_name ?
(expense.value.is_approved ? 'white' : 'accent') : (expense.value.is_approved ? 'white' : 'accent') :
'grey-5'); 'grey-5');
// ===================== methods ========================= // ===================== methods =========================
const requestExpenseDeletion = async () => { const requestExpenseDeletion = async () => {
await expenses_api.deleteExpenseById(expense.value.id); await expensesApi.deleteExpenseById(expense.value.id);
} }
const onClickExpenseUpdate = () => { const onClickExpenseUpdate = () => {
if (expense.value.is_approved) return; if (expense.value.is_approved) return;
expenses_store.mode = 'update'; expenseStore.mode = 'update';
expenses_store.current_expense = expense.value; expenseStore.current_expense = structuredClone(toRaw(expense.value));
expenses_store.initial_expense = unwrapAndClone(expense.value); expenseStore.initial_expense = structuredClone(toRaw(expense.value));
} }
const onClickApproval = async () => { const onClickApproval = async () => {
expenses_store.current_expense = unwrapAndClone(expense.value); expenseStore.current_expense = structuredClone(toRaw(expense.value));
expenses_store.current_expense.is_approved = !expenses_store.current_expense.is_approved; expenseStore.current_expense.is_approved = !expenseStore.current_expense.is_approved;
const success = await expenses_store.upsertExpense( const success = await expenseStore.upsertExpense(
expenses_store.current_expense, expenseStore.current_expense,
timesheet_store.current_pay_period_overview?.email timesheetStore.current_pay_period_overview?.email
); );
if (success) { if (success) {
expense.value.is_approved = !expense.value.is_approved; expense.value.is_approved = !expense.value.is_approved;
} else { } else {
expenses_store.current_expense.is_approved = !expenses_store.current_expense.is_approved; expenseStore.current_expense.is_approved = !expenseStore.current_expense.is_approved;
Notify.create({ Notify.create({
message: t('timesheet.errors.UPDATE_ERROR'), message: t('timesheet.errors.UPDATE_ERROR'),
color: "negative" color: "negative"
@ -68,16 +67,25 @@
} }
} }
const getEmployeeEmail = () => {
if (mode === 'approval')
return timesheetStore.current_pay_period_overview?.email;
}
const onClickAttachment = async () => { const onClickAttachment = async () => {
expenses_store.isShowingAttachmentDialog = true; expenseStore.isShowingAttachmentDialog = true;
await expenses_store.getAttachmentURL(expense.value.attachment_key); await expenseStore.getAttachmentURL(expense.value.attachment_key);
console.log('image url: ', expenses_store.attachmentURL); console.log('image url: ', expenseStore.attachmentURL);
}
const hideUpdateForm = () => {
isShowingUpdateForm.value = false;
} }
</script> </script>
<template> <template>
<q-expansion-item <q-expansion-item
v-model="is_showing_update_form" v-model="isShowingUpdateForm"
hide-expand-icon hide-expand-icon
dense dense
group="expenses" group="expenses"
@ -106,7 +114,7 @@
</div> </div>
<!-- amount or mileage section --> <!-- amount or mileage section -->
<div class="col column"> <div class="col-auto column q-pr-md">
<span <span
class="text-weight-bolder" class="text-weight-bolder"
:class="expense.is_approved ? ' bg-accent text-white' : ''" :class="expense.is_approved ? ' bg-accent text-white' : ''"
@ -122,81 +130,74 @@
:class="expense.is_approved ? ' bg-accent text-white' : ''" :class="expense.is_approved ? ' bg-accent text-white' : ''"
> >
{{ $d(date.extractDate(expense.date, 'YYYY-MM-DD'), { {{ $d(date.extractDate(expense.date, 'YYYY-MM-DD'), {
month: 'short', day: 'numeric', weekday: month: 'long', day: 'numeric', weekday: 'long'
'long'
}) }} }) }}
</span> </span>
</div> </div>
<q-separator vertical spaced class="q-my-xs"/>
<!-- attachment file icon --> <!-- comments section -->
<div class="col row items-center justify-start"> <div class="col column">
<q-btn <div class="col row items-center">
push <span
:disable="expense.attachment_name === undefined" class="col-auto text-weight-medium text-accent text-uppercase q-pr-md"
:color="attachmentButtonColor" style="font-size: 1.2em;"
:text-color="expense.is_approved ? 'accent' : 'white'" >
class="col-auto q-px-sm q-mr-sm" {{ $t('timesheet.expense.employee_comment') }} :
icon="attach_file"
@click.stop="onClickAttachment"
/>
<q-item-label class="col">
<span v-if="expense.attachment_name">
{{ expense.attachment_name }}
</span> </span>
<span <span
v-else class="col"
class="text-italic text-blue-grey-5 text-uppercase" :class="expense.is_approved ? ' bg-accent text-white' : ''"
> >
{{ $t('timesheet.expense.no_attachment') }} {{ expense.comment }}
</span> </span>
</q-item-label> </div>
<q-separator class="q-mr-md"/>
<div class="col row items-center">
<span
class="col-auto text-weight-medium text-accent text-uppercase q-pr-md"
style="font-size: 1.2em; "
:style="expense.supervisor_comment ? '' : 'filter: grayscale(1);'"
>
{{ $t('timesheet.expense.supervisor_comment') }} :
</span>
<span
class="col"
:class="expense.is_approved ? ' bg-accent text-white' : ''"
>
{{ expense.supervisor_comment }}
</span>
</div>
</div> </div>
<!-- comment section --> <!-- attachment -->
<div class="col column no-wrap"> <div class="col-auto">
<span class="col-auto text-weight-bold text-accent text-uppercase text-caption"> <q-btn
{{ $t('timesheet.expense.employee_comment') }} flat
</span> size="lg"
:disable="expense.attachment_name === undefined"
<span :color="attachmentButtonColor"
class="col ellipsis" class="col-auto q-px-sm q-mr-sm"
:class="expense.is_approved ? ' bg-accent text-white' : ''" :icon="expense.attachment_key ? 'image' : 'hide_image'"
style="font-size: 1em;" @click.stop="onClickAttachment"
> >
{{ expense.comment }} <q-tooltip
</span> anchor="top middle"
self="center middle"
<q-tooltip :offset="[0, 20]"
anchor="top middle" class="bg-accent text-uppercase text-weight-bold"
self="center middle" >
:offset="[0, 20]" {{ expense.attachment_name ?? $t('timesheet.expense.no_attachment') }}
class="bg-accent text-uppercase text-weight-bold" </q-tooltip>
> </q-btn>
{{ expense.comment }}
</q-tooltip>
</div> </div>
<!-- supervisor comment section --> <!-- buttons -->
<div
v-if="expense.supervisor_comment"
class="col column"
>
<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 }}
</span>
</div>
<div class="col-auto"> <div class="col-auto">
<q-btn <q-btn
v-if="mode === 'approval'" v-if="mode === 'approval'"
@ -240,6 +241,10 @@
</div> </div>
</template> </template>
<ExpenseDialogForm v-model="expense" /> <ExpenseDialogForm
v-model="expense"
:email="getEmployeeEmail()"
@click-save="hideUpdateForm"
/>
</q-expansion-item> </q-expansion-item>
</template> </template>

View File

@ -2,7 +2,7 @@
setup setup
lang="ts" lang="ts"
> >
import { computed, inject } 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'; import ExpenseDialogListItemMobile from 'src/modules/timesheets/components/mobile/expense-dialog-list-item-mobile.vue';
@ -23,10 +23,6 @@
} }
return []; return [];
}) })
// ==================== methods ========================
inject( 'employeeEmail', mode === 'approval' ? timesheet_store.current_pay_period_overview?.email : undefined);
</script> </script>
<template> <template>
@ -45,7 +41,7 @@
</q-item-label> </q-item-label>
<div <div
v-for="(expense, index) in expenses_list" v-for="(_expense, index) in expenses_list"
:key="index" :key="index"
> >
<ExpenseDialogListItemMobile <ExpenseDialogListItemMobile

View File

@ -11,10 +11,12 @@
import { date } from 'quasar'; import { date } from 'quasar';
import { useExpensesStore } from 'src/stores/expense-store'; import { useExpensesStore } from 'src/stores/expense-store';
import { Expense } from 'src/modules/timesheets/models/expense.models'; import { Expense } from 'src/modules/timesheets/models/expense.models';
import { ref } from 'vue';
const expense_store = useExpensesStore(); const expense_store = useExpensesStore();
const refreshKey = ref(0);
const { isApproved = false} = defineProps<{ const { isApproved = false } = defineProps<{
isApproved?: boolean; isApproved?: boolean;
}>(); }>();
@ -47,7 +49,7 @@
<q-card-section class="q-pa-none"> <q-card-section class="q-pa-none">
<ExpenseDialogHeader /> <ExpenseDialogHeader />
<ExpenseDialogList /> <ExpenseDialogList :key="refreshKey + 1" />
<q-expansion-item <q-expansion-item
v-if="!isApproved" v-if="!isApproved"
@ -56,6 +58,7 @@
:dense="!$q.platform.is.mobile" :dense="!$q.platform.is.mobile"
group="expenses" group="expenses"
@show="onClickExpenseCreate()" @show="onClickExpenseCreate()"
@after-hide="refreshKey += 1"
header-class="bg-accent text-white" header-class="bg-accent text-white"
> >
<template #header> <template #header>
@ -66,7 +69,7 @@
class="col-auto" class="col-auto"
:class="expense_store.is_showing_create_form ? 'invisible' : ''" :class="expense_store.is_showing_create_form ? 'invisible' : ''"
/> />
<span class="col-auto text-uppercase text-weight-bold text-h6 q-ml-xs q-mr-sm"> <span class="col-auto text-uppercase text-weight-bold text-h6 q-ml-xs q-mr-sm">
{{ $t('timesheet.expense.add_expense') }} {{ $t('timesheet.expense.add_expense') }}
</span> </span>
@ -74,8 +77,10 @@
</template> </template>
<ExpenseDialogFormMobile v-if="$q.platform.is.mobile" /> <ExpenseDialogFormMobile v-if="$q.platform.is.mobile" />
<ExpenseDialogForm
<ExpenseDialogForm v-else /> v-else
:key="refreshKey"
/>
</q-expansion-item> </q-expansion-item>
</q-card-section> </q-card-section>
</q-card> </q-card>

View File

@ -2,13 +2,13 @@
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 { Expense } from "src/modules/timesheets/models/expense.models"; import { Expense } from "src/modules/timesheets/models/expense.models";
import { date } from "quasar"; import { date, Notify } from "quasar";
export const useExpensesApi = () => { export const useExpensesApi = () => {
const expenses_store = useExpensesStore(); const expenses_store = useExpensesStore();
const timesheet_store = useTimesheetStore(); const timesheet_store = useTimesheetStore();
const upsertExpense = async (expense: Expense, employee_email: string, file?: File): Promise<string> => { const upsertExpense = async (expense: Expense, employee_email?: string, file?: File): Promise<boolean> => {
if (file) { if (file) {
const attachmentKey = await expenses_store.uploadAttachment(file); const attachmentKey = await expenses_store.uploadAttachment(file);
@ -19,23 +19,24 @@ export const useExpensesApi = () => {
expense.attachment_name = file.name; expense.attachment_name = file.name;
} }
} }
console.log('employee email provided for expense: ', employee_email)
const success = await expenses_store.upsertExpense(expense, employee_email);
if (success) {
expenses_store.current_expense = new Expense(date.formatDate( new Date(), 'YYYY-MM-DD'));
timesheet_store.getTimesheetsByOptionalEmployeeEmail(employee_email);
return 'SUCCESS';
}
return 'INVALID_EXPENSE'; const success = await expenses_store.upsertExpense(expense, employee_email);
if (success) {
expenses_store.current_expense = new Expense(date.formatDate(new Date(), 'YYYY-MM-DD'));
await timesheet_store.getTimesheetsByOptionalEmployeeEmail(employee_email);
return true;
}
return false;
}; };
const deleteExpenseById = async (expense_id: number, employee_email?: string): Promise<void> => { const deleteExpenseById = async (expense_id: number, employee_email?: string): Promise<void> => {
const success = await expenses_store.deleteExpenseById(expense_id); const success = await expenses_store.deleteExpenseById(expense_id);
if (success) {
timesheet_store.getTimesheetsByOptionalEmployeeEmail(employee_email); if (success)
} await timesheet_store.getTimesheetsByOptionalEmployeeEmail(employee_email);
}; };
return { return {

View File

@ -1,5 +1,5 @@
import { date } from "quasar"; import { date } from "quasar";
import { computed, ref } from "vue"; import { ref } from "vue";
import { defineStore } from "pinia"; import { defineStore } from "pinia";
import { useTimesheetStore } from "src/stores/timesheet-store"; import { useTimesheetStore } from "src/stores/timesheet-store";
import { Expense } from "src/modules/timesheets/models/expense.models"; import { Expense } from "src/modules/timesheets/models/expense.models";
@ -16,7 +16,6 @@ export const useExpensesStore = defineStore('expenses', () => {
const current_expense = ref<Expense>(new Expense(date.formatDate(new Date(), 'YYYY-MM-DD'))); const current_expense = ref<Expense>(new Expense(date.formatDate(new Date(), 'YYYY-MM-DD')));
const initial_expense = ref<Expense>(new Expense(date.formatDate(new Date(), 'YYYY-MM-DD'))); const initial_expense = ref<Expense>(new Expense(date.formatDate(new Date(), 'YYYY-MM-DD')));
const isShowingAttachmentDialog = ref(false); const isShowingAttachmentDialog = ref(false);
const is_save_disabled = computed(() => JSON.stringify(current_expense.value) === JSON.stringify(initial_expense.value))
const open = (): void => { const open = (): void => {
is_open.value = true; is_open.value = true;
@ -103,7 +102,6 @@ export const useExpensesStore = defineStore('expenses', () => {
current_expense, current_expense,
initial_expense, initial_expense,
isShowingAttachmentDialog, isShowingAttachmentDialog,
is_save_disabled,
attachmentURL, attachmentURL,
open, open,
upsertExpense, upsertExpense,

View File

@ -1,4 +1,4 @@
import { Notify, date } from 'quasar'; import { Notify } from 'quasar';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
import { defineStore } from 'pinia'; import { defineStore } from 'pinia';
@ -14,6 +14,7 @@ import type { TimesheetApprovalCSVReportFilters } from 'src/modules/timesheet-ap
import { type FederalHoliday, TARGO_HOLIDAY_NAMES_FR } from 'src/modules/timesheets/models/federal-holidays.models'; import { type FederalHoliday, TARGO_HOLIDAY_NAMES_FR } from 'src/modules/timesheets/models/federal-holidays.models';
import type { RouteNames } from 'src/router/router-constants'; import type { RouteNames } from 'src/router/router-constants';
import type { RouteRecordNameGeneric } from 'vue-router'; import type { RouteRecordNameGeneric } from 'vue-router';
import { isBetweenDateStrings } from 'src/utils/date-and-time-utils';
export const useTimesheetStore = defineStore('timesheet', () => { export const useTimesheetStore = defineStore('timesheet', () => {
const { t } = useI18n(); const { t } = useI18n();
@ -223,11 +224,7 @@ export const useTimesheetStore = defineStore('timesheet', () => {
const pay_period_event: PayPeriodEvent = JSON.parse(event.data); const pay_period_event: PayPeriodEvent = JSON.parse(event.data);
// abort notification if event date is not within pay period being currently viewed // abort notification if event date is not within pay period being currently viewed
const eventDate = date.extractDate(pay_period_event.date, 'YYYY-MM-DD'); if (!isBetweenDateStrings(pay_period_event.date, pay_period.value!.period_start, pay_period.value!.period_end))
const startDate = date.extractDate(pay_period.value!.period_start, 'YYYY-MM-DD');
const endDate = date.extractDate(pay_period.value!.period_end, 'YYYY-MM-DD');
if (!date.isBetweenDates(eventDate, startDate, endDate, { inclusiveFrom: true, inclusiveTo: true, onlyDate: true }))
return; return;
const overview = pay_period_overviews.value.find(overview => overview.email === pay_period_event.employee_email); const overview = pay_period_overviews.value.find(overview => overview.email === pay_period_event.employee_email);

View File

@ -28,7 +28,7 @@ export const getHoursMinutesStringFromHoursFloat = (hours: number, minutes?: num
flatHours += 1; flatHours += 1;
flatMinutes = 0; flatMinutes = 0;
} }
return `${flatHours}h${flatMinutes > 1 ? ' ' + flatMinutes : ''}` return `${flatHours}h${flatMinutes > 1 ? ' ' + flatMinutes : ''}`
} }
@ -39,8 +39,24 @@ export const getHoursMinutesBetweenTwoHHmm = (startTime: string, endTime: string
const [startHours, startMinutes] = startTime.split(':'); const [startHours, startMinutes] = startTime.split(':');
const [endHours, endMinutes] = endTime.split(':'); const [endHours, endMinutes] = endTime.split(':');
return { return {
hours: Number(endHours) - Number(startHours), hours: Number(endHours) - Number(startHours),
minutes: Number(endMinutes) - Number(startMinutes), minutes: Number(endMinutes) - Number(startMinutes),
} }
}
export const isBetweenDateStrings = (evDate: string, start: string, end: string, inclusive: boolean = true) => {
const eventDate = date.extractDate(evDate, 'YYYY-MM-DD');
const startDate = date.extractDate(start, 'YYYY-MM-DD');
const endDate = date.extractDate(end, 'YYYY-MM-DD');
return date.isBetweenDates(
eventDate,
startDate,
endDate,
{
inclusiveFrom: inclusive,
inclusiveTo: inclusive,
onlyDate: true
});
} }