Merge pull request 'dev/matthieu/timesheet-form' (#18) from dev/matthieu/timesheet-form into main

Reviewed-on: Targo/targo_frontend#18
This commit is contained in:
matthieuh 2025-09-22 15:16:47 -04:00
commit 4470c855cf
5 changed files with 273 additions and 206 deletions

View File

@ -160,10 +160,12 @@ export default {
}, },
}, },
expense: { expense: {
add_expense:"Add Expense", add_expense:'Add Expense',
amount:"Amount", amount:'Amount',
date:"Date", date:'Date',
empty_list:"No registered expenses", empty_list:'No registered expenses',
employee_comment:'Comment',
supervisor_comment:'Supervisor note',
errors: { errors: {
date_required_or_invalid:"the date is missing or invalid", date_required_or_invalid:"the date is missing or invalid",
comment_required:"A comment required", comment_required:"A comment required",

View File

@ -160,10 +160,12 @@ export default {
}, },
}, },
expense: { expense: {
add_expense:"Ajouter une dépense", add_expense:'Ajouter une dépense',
amount:"Montant", amount:'Montant',
date:"Date", date:'Date',
empty_list:"Aucun dépense enregistrée", empty_list:'Aucun dépense enregistrée',
employee_comment:'Commentaire',
supervisor_comment:'Note du Superviseur',
errors: { errors: {
date_required_or_invalid:"La date est manquante ou invalide", date_required_or_invalid:"La date est manquante ou invalide",
comment_required:"un commentaire est requis", comment_required:"un commentaire est requis",

View File

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

View File

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

View File

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