feat(expenses): expense's dialog 1st iteration

This commit is contained in:
Matthieu Haineault 2025-09-22 14:17:07 -04:00
parent 3c30f44eb1
commit d1fc596b62
5 changed files with 265 additions and 198 deletions

View File

@ -301,6 +301,8 @@ export default {
amount:'Amount',
date:'Date',
empty_list:'No registered expenses',
employee_comment:'Comment',
supervisor_comment:'Supervisor note',
errors: {
date_required_or_invalid:'the date is missing or invalid',
comment_required:'A comment required',

View File

@ -351,6 +351,8 @@ export default {
amount:'Montant',
date:'Date',
empty_list:'Aucun dépense enregistrée',
employee_comment:'Commentaire',
supervisor_comment:'Note du Superviseur',
errors: {
date_required_or_invalid:'La date est manquante ou invalide',
comment_required:'un commentaire est requis',

View File

@ -3,11 +3,10 @@
import { computed, ref } from 'vue';
import { EXPENSE_TYPE, type ExpenseType, type TimesheetExpense } from '../../types/timesheet-expenses-interface';
import { compute_expense_totals, ExpensesValidationError, normalize_expense, validate_expense_UI } from '../../utils/timesheet-expenses-validators';
import { useI18n } from 'vue-i18n';
// import { date } from 'quasar';
import { COMMENT_MAX_LENGTH } from '../../composables/use-shift-api';
/* eslint-disable */
const { t } = useI18n();
//props
const props = defineProps<{
@ -31,27 +30,28 @@ const emit = defineEmits<{
}>();
//q-select mapper
const type_options = computed(()=> EXPENSE_TYPE.map((val)=> ({
label: t(`timesheet.expense.types.${val}`, val),
value: val,
})));
const type_options = computed(()=> EXPENSE_TYPE.map( val => ({ label: val, value: val })));
//refs & states
const items = ref<TimesheetExpense[]>(Array.isArray(props.initial_expenses) ? props.initial_expenses.map(normalize_expense): []);
const formRef = ref<InstanceType<any> | null>(null);
const triedSubmit = ref(false);
const DEFAULT_TYPE: ExpenseType = 'EXPENSES'
const draft = ref<Partial<TimesheetExpense>>({
date:'',
type: 'EXPENSES',
type: DEFAULT_TYPE,
comment:'',
});
// computeds
const totals = computed(()=> compute_expense_totals(items.value));
const remaining_comment_chars = computed(()=> {
const comment = String(draft.value.comment ?? '');
return COMMENT_MAX_LENGTH - comment.length;
});
const is_readonly = computed(()=> !!props.is_approved);
const showMileage = computed(()=> (draft.value.type as string) === 'MILEAGE');
const showAmount = computed(()=> !showMileage.value);
//actions
//helpers
const reset_draft = () => {
draft.value.date = '';
draft.value.type = 'EXPENSES';
@ -60,10 +60,20 @@ const reset_draft = () => {
draft.value.comment = '';
};
const set_draft_type = (value: ExpenseType) => {
draft.value.type = value;
if (value === 'MILEAGE') {
delete draft.value.amount;
} else {
delete draft.value.mileage;
}
};
//actions
const add_draft_as_item = () => {
const candidate: TimesheetExpense = normalize_expense({
date: String(draft.value.date ?? '').trim(),
type: String(draft.value.type ?? '').trim(),
date: draft.value.date,
type: normType(draft.value.type),
...(typeof draft.value.amount === 'number' ? { amount: draft.value.amount }: {}),
...(typeof draft.value.mileage === 'number' ? { mileage: draft.value.mileage }: {}),
comment: String(draft.value.comment ?? '').trim(),
@ -117,79 +127,157 @@ const on_save = () => {
}
};
const on_form_submit = async () => {
triedSubmit.value = true;
const ok = await formRef.value?.validate(true);
if(!ok) return;
add_draft_as_item();
};
const on_close = () => emit('close');
//read-only guard for supervisor comment and approved expenses
const is_readonly = computed(()=> !!props.is_approved);
const set_draft_type = (value: ExpenseType) => (draft.value.type = value);
const set_draft_amount = (value: number | null) => {
if(value === null || value === undefined || Number.isNaN(Number(value))) {
delete draft.value.amount;
} else {
draft.value.amount = Number(value);
}
};
const set_draft_mileage = (value: number | null) => {
if(value === null || value === undefined || Number.isNaN(Number(value))) {
delete draft.value.mileage;
} else {
draft.value.mileage = Number(value);
}
//icons managament
type ExpensesType = 'MILEAGE' | 'EXPENSES' | 'PER_DIEM' | 'PRIME_GARDE' | string;
const normType = (type: unknown) => String(type ?? '').trim().toUpperCase();
const expenseTypeIcon = (type: ExpensesType) => {
const t = normType(type);
const map: Record<string, string> = {
MILEAGE: 'time_to_leave',
EXPENSES: 'receipt_long',
PER_DIEM: 'hotel',
PRIME_GARDE: 'admin_panel_settings',
};
return map[String(t)] ?? 'help_outline';
};
</script>
<template>
<q-card class="q-pa-md q-gutter-md" flat bordered>
<div >
<!-- header (title with totals)-->
<div class="row items-center justify-between">
<div class="text-h6"> {{ $t('timesheet.expense.title') }}</div>
<div class="row items-center q-gutter-sm">
<q-badge :label="$t('timesheet.expense.total_amount') + ': ' + totals.amount.toFixed(2)"/>
<q-badge :label="$t('timesheet.expense.total_mileage') + ': ' + totals.mileage.toFixed(2)"/>
</div>
</div>
<q-separator/>
<q-item class="row justify-between">
<q-item-label header class="text-h6 col-auto">
{{ $t('timesheet.expense.title') }}
</q-item-label>
<q-item-section class="items-center col-auto">
<q-badge lines="1" class="q-pa-sm q-px-md" :label="$t('timesheet.expense.total_amount') + ': ' + totals.amount.toFixed(2)"/>
<q-separator spaced/>
<q-badge lines="2" class="q-pa-sm q-px-md" :label="$t('timesheet.expense.total_mileage') + ': ' + totals.mileage.toFixed(1)"/>
</q-item-section>
</q-item>
<!-- liste des dépenses pré existantes -->
<div class="column q-gutter-sm">
<div
v-if="items.length === 0"
class="text-italic text-secondary"
>{{ $t('timesheet.expense.empty_list') }}
</div>
<q-card
v-for="(expense, index) in items"
:key="index"
flat
<q-list
padding
bordered
class="q-pa-sm"
class="rounded-borders"
>
<!-- date section -->
<div class="row items-start q-col-gutter-md">
<div class="col-12 col-md-2">
<div class="text-caption text-secondary">{{ $t('timesheet.expense.date') }} </div>
<div class="text-body2"> {{ expense.date }}</div>
<q-item-label v-if="items.length === 0" class="text-italic q-px-sm">
{{ $t('timesheet.expense.empty_list') }}
</q-item-label>
<q-item
style="border: solid 1px lightgrey; border-radius: 7px;"
v-for="(expense, index) in items" :key="index"
class="q-my-xs shadow-1"
>
<!-- avatar type icon section -->
<q-item-section avatar>
<q-icon :name="expenseTypeIcon(expense.type)" color="primary"/>
</q-item-section>
<!-- amount or mileage section -->
<q-item-section top>
<q-item-label v-if="String(expense.type).trim().toUpperCase() === 'MILEAGE'">
{{ expense.mileage?.toFixed(1) }} km
</q-item-label>
<q-item-label v-else>
{{ expense.amount?.toFixed(2) }} $
</q-item-label>
<!-- date label -->
<q-item-label caption lines="2">
{{ $d(new Date(expense.date + 'T00:00:00'), { year:'numeric', month:'short', day: 'numeric', weekday: 'short'}) }}
</q-item-label>
</q-item-section>
<!-- attachment file icon -->
<q-item-section side>
<q-btn
push
dense
size="md"
color="primary"
class="q-mx-lg"
icon="attach_file"
/>
</q-item-section>
<!-- comment section -->
<q-item-section top>
<q-item-label lines="1">
{{ $t('timesheet.expense.employee_comment') }}
</q-item-label>
<q-item-label caption lines="2">
{{ expense.comment }}
</q-item-label>
</q-item-section>
<!-- supervisor comment section -->
<q-item-section top>
<q-item-label lines="1">
{{ $t('timesheet.expense.supervisor_comment') }}
</q-item-label>
<q-item-label v-if="expense.supervisor_comment" caption lines="2">
{{ expense.supervisor_comment }}
</q-item-label>
</q-item-section>
<!-- delete btn -->
<q-item-section side>
<q-btn
v-if="!is_readonly"
push
dense
size="xs"
color="negative"
icon="close"
@click="remove_item_at(index)"
/>
</q-item-section>
</q-item>
</q-list>
<q-form
ref="formRef"
flat
v-if="!is_readonly"
@submit.prevent="on_form_submit"
>
<div class="text-subtitle2 q-py-sm">
{{ $t('timesheet.expense.add_expense')}}
</div>
</div>
<!-- expense type section -->
<div class="row items-start q-col-gutter-md">
<div class="col-12 col-md-2">
<div class="text-body2"> {{ $t('timesheet.expense.types') + expense.type, expense.type }} </div>
<div class="text-body2">
{{ $t('timesheet.expense.types') + expense.type, expense.type }}
</div>
</div>
<div class="col-12 col-sm-3">
<div class="row justify-between">
<!-- date selection input -->
<q-input
v-model="draft.date"
type="date"
dense
filled
class="col- q-px-xs"
color="primary"
:label="$t('timesheet.expense.date')"
clearable
>
</q-input>
<!-- expenses type selection -->
<q-select
v-model="draft.type"
:options="type_options"
:option-label="opt => $t(`timesheet.expense.types.${opt.label}`)"
filled
dense
class="col-auto q-px-xs"
color="primary"
emit-value
map-options
@ -197,134 +285,109 @@ const set_draft_mileage = (value: number | null) => {
:rules="[ val => !! val || $t('timesheet.expense.errors.type_required')]"
@update:model-value="val => set_draft_type(val as ExpenseType)"
/>
</div>
</div>
<!-- amount section -->
<div class="col-6 col-md-2">
<div class="text-caption text-secondary"> {{ $t('timesheet.expense.amount') }}</div>
<div class="text-body2">
<span v-if="typeof expense.amount === 'number'">{{ expense.amount.toFixed(2) }}</span>
<span v-else class="text-grey-6">-</span>
</div>
</div>
<!-- comment section -->
<div class="col-12 col-md">
<div class="text-caption text-secondary">{{ $t('timesheet.expense.employee_comment') }}</div>
<div class="text-body2">{{ expense.comment }}</div>
</div>
<!-- supervisor comment section -->
<div class="col-12 col-md">
<div class="text-caption text-secondary">{{ $t('timesheet.expense.supervisor_comment') }}</div>
<div class="text-body2">
<span v-if="expense.supervisor_comment">{{ expense.supervisor_comment }}</span>
<span v-else class="text-grey-6">-</span>
</div>
</div>
<!-- delete btn -->
<div class="col-auto q-ml-auto">
<q-btn
v-if="!is_readonly"
flat
round
size="sm"
color="negative"
icon="delete"
:aria-label="$t('timesheet.delete_button')"
@click="remove_item_at(index)"
/>
</div>
</q-card>
</div>
<q-separator spaced/>
<div v-if="!is_readonly" class="column q-gutter-sm">
<div class="text-subtitle2">{{ $t('timesheet.expense.add_expense')}}</div>
<!-- add a new expense btn -->
<div class="row q-col-gutter-md">
<div class="col-12 col-sm-3">
<!-- date selection input -->
<q-input
v-model="draft.date"
filled
color="primary"
type="date"
:label="$t('timesheet.expense.date')"
:rules="[ value =>!!value || $t('timesheet.expense.errors.date_required_or_invalid')]"
/>
</div>
<div class="col-12 col-sm-3">
<!-- amount input -->
<template v-if="showAmount">
<q-input
:model-value="draft.amount"
@update:model-value="val => set_draft_amount(val as any)"
key="amount"
v-model.number="draft.amount"
filled
input-class="text-right"
dense
clearable
color="primary"
type="number"
step="0.01"
min="0"
:disable="(draft.type as string) === 'MILEAGE'"
class="col-auto q-px-xs"
:label="$t('timesheet.expense.amount')"
:hint="$t('timesheet.expense.hints.amount_or_mileage')"
suffix="$"
lazy-rules="ondemand"
:rules="[
value => (value !== undefined && value !== null && value !== '')
|| $t('timesheet.expense.errors.amount_required_for_type')
]"
/>
</div>
</template>
<div class="col-12 col-sm-3">
<!-- mileage input -->
<template v-else>
<q-input
:model-value="draft.mileage"
@update:model-value="val => set_draft_mileage(val as any)"
key="mileage"
v-model.number="draft.mileage"
filled
input-class="text-right"
dense
clearable
color="primary"
type="number"
step="0.1"
min="0"
:disable="(draft.type as string) !== 'MILEAGE'"
class="col-auto q-px-xs"
:label="$t('timesheet.expense.mileage')"
:hint="$t('timesheet.expense.hints.amount_or_mileage')"
suffix="km"
lazy-rules="ondemand"
:rules="[
value => (value !== undefined && value !== null && value !== '')
|| $t('timesheet.expense.errors.mileage_required_for_type')
]"
/>
</div>
</template>
<div class="col-12">
<!-- employee comment input -->
<q-input
v-model="draft.comment"
filled
color="primary"
type="textarea"
type="text"
class="col q-px-sm"
dense
clearable
:label="$t('timesheet.expense.employee_comment')"
:counter="true"
:maxlength="COMMENT_MAX_LENGTH"
lazy-rules="ondemand"
:rules="[
value => (value && String(value).trim().length) || $t('timesheet.expense.errors.comment_required'),
value => String(value || '').length <= COMMENT_MAX_LENGTH || $t('timesheet.expense.errors.comment_too_long')
]"
:hint="$t('timesheet.expense.hints.comment_required')"
/>
<div class="text-right text-caption text-secondary q-mt-xs">
{{ remaining_comment_chars }} {{ $t('general.chars_left') }}
<q-btn
flat
dense
color="primary"
class="q-px-sm row"
>
<div class="row column">
<q-input
v-model="draft.comment"
type="text"
readonly
filled
class="col-auto justify-end"
>
<q-icon
name="attach_file"
size="sm"
class="col-auto justify-start"
/>
</q-input>
</div>
</q-btn>
<!-- add btn section -->
<div>
<q-btn
push
dense
color="primary"
icon="add"
size="sm"
class="q-mt-sm q-ml-sm"
type="submit"
/>
</div>
</div>
<div class="col-12">
<!-- add expense btn -->
<q-btn
color="primary"
unelevated
:label="$t('timesheet.expense.add_expense')"
icon="add"
@click="add_draft_as_item"
/>
</div>
</div>
</div>
</q-form>
<q-separator spaced/>
<div class="row justify-end q-gutter-sm">
<div class="row col-auto justify-end">
<!-- close btn -->
<q-btn
flat
@ -341,5 +404,5 @@ const set_draft_mileage = (value: number | null) => {
@click="on_save"
/>
</div>
</q-card>
</div>
</template>

View File

@ -261,19 +261,19 @@ const on_request_delete = async ({ date, shift }: { date: string; shift: any })
persistent
>
<q-card
class="q-pa=md"
style="min-width:900px; max-width: 95vw;"
class="q-pa-md column"
style=" min-width: 70vw;"
>
<q-inner-loading :showing="is_loading_expenses">
<q-spinner size="32px"/>
</q-inner-loading>
<q-banner
<!-- <q-banner
v-if="expenses_error"
dense
class="bg-red-2 text-negative q-mt-sm"
class="bg-red-2 col-auto text-negative q-mt-sm"
>
{{ expenses_error }}
</q-banner>
</q-banner> -->
<TimesheetDetailsExpenses
v-if="expenses_data"

View File

@ -12,10 +12,10 @@ export const EXPENSE_TYPE = [
'PER_DIEM',
'MILEAGE',
'EXPENSES',
'PRIME_DISPO',
'PRIME_GARDE',
] as const;
export type ExpenseType = typeof EXPENSE_TYPE[number];
export const TYPES_WITH_MILEAGE_ONLY: Readonly<ExpenseType[]> = ['MILEAGE'];
export const TYPES_WITH_AMOUNT_ONLY: Readonly<ExpenseType[]> = ['PER_DIEM', 'EXPENSES', 'PRIME_DISPO']
export const TYPES_WITH_AMOUNT_ONLY: Readonly<ExpenseType[]> = ['PER_DIEM', 'EXPENSES', 'PRIME_GARDE']