targo-frontend/src/modules/timesheets/components/expense-dialog-form.vue
Nic D e37ec79827 fix(many): refactor timesheet approval download menu, details window fixes, store refactor
- Timesheet Approval's download menu has had its UI overhauled and the script has been streamlined to better match backend structure and logic

- Details window in timesheet approval has a few bug and oversight fixes.

- Refactored UI store to work with camelCase instead of snake_case
2026-03-18 09:18:06 -04:00

299 lines
13 KiB
Vue

<script
setup
lang="ts"
>
import TargoInput from 'src/modules/shared/components/targo-input.vue';
import { useI18n } from 'vue-i18n';
import { computed, onMounted, ref } from 'vue';
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 { getExpenseIcon, useExpenseRules } from 'src/modules/timesheets/utils/expense.util';
import { Expense, type ExpenseOption, TYPES_WITH_AMOUNT_ONLY } from 'src/modules/timesheets/models/expense.models';
// ================= state ======================
const COMMENT_MAX_LENGTH = 280;
const expense = defineModel<Expense>({ default: new Expense(new Date().toISOString().slice(0, 10)) })
const file = defineModel<File>('file');
const { email } = defineProps<{
email?: string | undefined;
mode?: 'normal' | 'approval';
}>();
const emit = defineEmits<{
'clickSave': [void];
}>();
const { t } = useI18n();
const timesheetStore = useTimesheetStore();
const expenseStore = useExpensesStore();
const expensesApi = useExpensesApi();
const isNavigatorOpen = ref(false);
const rules = useExpenseRules(t);
const expenseOptions: ExpenseOption[] = [
{ 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.ON_CALL'), value: 'ON_CALL', icon: getExpenseIcon('ON_CALL') },
]
const expenseSelected = ref<ExpenseOption | undefined>();
// ================== computed ===================
const period_start_date = computed(() => timesheetStore.pay_period?.period_start.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 =======================
const openDatePicker = () => {
isNavigatorOpen.value = true;
if (expenseStore.current_expense.date === undefined) {
expenseStore.current_expense.date = timesheetStore.pay_period?.period_start ?? '';
}
};
const closeDatePicker = (date: string) => {
isNavigatorOpen.value = false;
expenseStore.current_expense.date = date;
}
const requestExpenseCreationOrUpdate = async () => {
const success = await expensesApi.upsertExpense(expenseStore.current_expense, email, file.value);
if (success) {
expenseStore.is_showing_create_form = false;
emit('clickSave');
}
};
onMounted(() => {
if (expense.value)
expenseSelected.value = expenseOptions.find(expense_option => expense_option.value === expense.value.type);
else
expenseSelected.value = expenseOptions[0];
})
</script>
<template>
<div
v-if="!expenseStore.current_expense.is_approved"
class="full-width q-mt-md q-px-md"
>
<q-form
flat
@submit.prevent="requestExpenseCreationOrUpdate"
>
<div class="column rounded-5 q-pb-sm">
<div class="row">
<!-- date selection input -->
<div class="row col items-center q-pl-sm">
<q-btn
push
dense
icon="event"
color="accent"
class="col-auto"
@click="openDatePicker"
/>
<q-dialog
v-model="isNavigatorOpen"
transition-show="jump-right"
transition-hide="jump-right"
class="z-top"
>
<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>
<TargoInput
v-model="expenseStore.current_expense.date"
no-top-padding
:label="$t('timesheet.expense.date')"
background-color="bg-dark"
class="col"
/>
</div>
<!-- expenses type selection -->
<div class="col">
<q-select
v-model="expenseSelected"
dense
borderless
color="accent"
label-color="white"
stack-label
label-slot
:options="expenseOptions"
hide-dropdown-icon
lazy-rules
no-error-icon
hide-bottom-space
options-selected-class="text-white text-bold bg-accent"
class="q-px-md rounded-5 inset-shadow"
:class="$q.dark.isActive ? 'bg-primary' : 'bg-dark'"
popup-content-class="text-uppercase text-weight-medium rounded-5 shadow-12 z-top"
popup-content-style="border: 1px solid var(--q-primary);"
menu-anchor="bottom middle"
menu-self="top middle"
:menu-offset="[0, 5]"
:style="`border: 1px solid var(${$q.dark.isActive ? '--q-secondary' : '--q-primary'});`"
:rules="[rules.typeRequired]"
@update:model-value="option => expenseStore.current_expense.type = option.value"
>
<template #label>
<span
class="text-weight-medium text-uppercase q-px-sm no-pointer-events"
:class="$q.dark.isActive ? 'bg-secondary' : 'bg-primary'"
>
{{ $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"
: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">
<TargoInput
v-if="TYPES_WITH_AMOUNT_ONLY.includes(expenseStore.current_expense?.type ?? 'EXPENSES')"
v-model.number="expenseStore.current_expense.amount"
no-top-padding
background-color="bg-dark"
type="number"
input-class="text-right"
append-content=" $"
:label="$t('timesheet.expense.amount')"
/>
<TargoInput
v-else
v-model.number="expenseStore.current_expense.mileage"
no-top-padding
background-color="bg-dark"
type="number"
input-class="text-right"
append-content=" km"
:label="$t('timesheet.expense.mileage')"
/>
</div>
</div>
<div class="row q-pt-md">
<!-- employee comment input -->
<div class="col">
<TargoInput
v-model="expenseStore.current_expense.comment"
no-top-padding
background-color="bg-dark"
:max-length="COMMENT_MAX_LENGTH"
:label="$t('timesheet.expense.employee_comment')"
/>
</div>
<div
v-if="mode === 'approval'"
class="col"
>
<TargoInput
v-model="expenseStore.current_expense.supervisor_comment"
no-top-padding
background-color="bg-dark"
:max-length="COMMENT_MAX_LENGTH"
:label="$t('timesheet.expense.supervisor_comment')"
/>
</div>
<!-- import attach file section -->
<div class="col-3 q-px-sm">
<q-file
v-model="file"
dense
borderless
color="accent"
label-color="white"
stack-label
label-slot
type="file"
accept="image/*"
class="q-px-md rounded-5 inset-shadow"
:class="$q.dark.isActive ? 'bg-primary' : 'bg-dark'"
:style="`border: 1px solid var(${$q.dark.isActive ? '--q-secondary' : '--q-primary'});`"
>
<template #append>
<q-icon
name="attach_file"
size="sm"
color="accent"
/>
</template>
<template #label>
<span
class="text-weight-medium text-uppercase q-px-sm no-pointer-events"
:class="$q.dark.isActive ? 'bg-secondary' : 'bg-primary'"
>
{{ $t('timesheet.expense.hints.attach_file') }}
</span>
</template>
</q-file>
</div>
</div>
</div>
<div class="col row full-width items-center">
<q-space />
<q-btn
push
:disable="isSaveDisabled"
:color="isSaveDisabled ? 'grey-5' : 'accent'"
:icon="expenseStore.mode === 'update' ? 'save' : 'upload'"
:label="expenseStore.mode === 'update' ? $t('shared.label.update') : $t('shared.label.add')"
class="q-px-sm "
:class="expenseStore.mode === 'create' ? 'q-mr-lg q-mb-md' : 'q-mb-sm q-ml-lg'"
type="submit"
/>
</div>
</q-form>
</div>
</template>
<style
scoped
lang="css"
>
:deep(.q-field--dense.q-field--float .q-field__label) {
transform: translate(-17px, -60%) scale(0.75) !important;
border-radius: 10px 10px 10px 0px;
}
</style>