feat(timesheet): added expenses list with create/update/delete expenses
This commit is contained in:
parent
10a62219ef
commit
b488848ac3
|
|
@ -286,52 +286,37 @@ export default {
|
||||||
header_comment:'Shift`s comment',
|
header_comment:'Shift`s comment',
|
||||||
textarea_comment: 'Leave a comment here',
|
textarea_comment: 'Leave a comment here',
|
||||||
},
|
},
|
||||||
//rest
|
expense: {
|
||||||
timeSheetTab_1: 'Shifts',
|
add_expense:'Add Expense',
|
||||||
timeSheetTab_2: 'Expenses',
|
amount:'Amount',
|
||||||
templateButton: 'Apply Templates',
|
date:'Date',
|
||||||
shiftTemplateTitle: 'Set up your day schedule',
|
empty_list:'No registered expenses',
|
||||||
shiftType: 'Type',
|
errors: {
|
||||||
remoteShift: 'Remote',
|
date_required_or_invalid:'',
|
||||||
shiftStartTime: 'Start time',
|
comment_required:'',
|
||||||
shiftEndTime: 'End time',
|
comment_too_long:'',
|
||||||
shiftComment: 'Comment',
|
amount_must_be_positive:'',
|
||||||
overTimeTitle: 'Overtime regular hours: ',
|
mileave_must_be_positive:'',
|
||||||
totalPayedHours: 'Total hours worked: ',
|
amount_xor_mileage:'',
|
||||||
// shift options
|
mileage_required_for_type:'',
|
||||||
shiftRegular: 'regular',
|
amount_required_for_type:'',
|
||||||
shiftEvening: 'evening',
|
},
|
||||||
shiftEmergency: 'emergency',
|
hints: {
|
||||||
shiftSick: 'sick',
|
amount_or_mileage:'Either amount or mileage, not both',
|
||||||
shiftVacation: 'vacation',
|
},
|
||||||
shiftHoliday: 'holiday',
|
|
||||||
dateRangesFrom: 'from',
|
|
||||||
dateRangesTo: 'to',
|
|
||||||
shiftBankedHours: 'Total hours to bank',
|
|
||||||
bankedHoursHint_1: ' on',
|
|
||||||
bankedHoursHint_2: ' accumulated hours',
|
|
||||||
qTimeClose: 'Close',
|
|
||||||
saveButton: 'Save',
|
|
||||||
//shift validations
|
|
||||||
timeSheetValidated: 'Validated week',
|
|
||||||
timeSheetBlocked: 'Blocked week',
|
|
||||||
shiftTypeValidation: 'Type must be filled in.',
|
|
||||||
shiftStartTimeValidation: 'Start time must be filled in.',
|
|
||||||
shiftEndTimeValidation: 'End time must be filled in.',
|
|
||||||
endTimeValidation: 'The end time cannot be before or equal the start time',
|
|
||||||
expensesTile: 'daily expenses',
|
|
||||||
expensesType: 'Type',
|
|
||||||
expensesValue: 'amount',
|
|
||||||
expensesDescription: 'Description',
|
|
||||||
expensesEvidence: 'attachment',
|
|
||||||
//expenses validations
|
|
||||||
expensesTypeValidation: 'Type must be filled in.',
|
|
||||||
expensesValueValidation: 'Amount must be filled in.',
|
|
||||||
//expensesOptions
|
|
||||||
refund: 'Refund',
|
|
||||||
garde: 'Garde',
|
|
||||||
perdiem: 'Perdiem',
|
|
||||||
mileage:'Mileage',
|
mileage:'Mileage',
|
||||||
|
open_btn:'List of expenses',
|
||||||
|
title:'List of all expenses',
|
||||||
|
total_amount:'Total amount',
|
||||||
|
total_mileage:'Total mileage',
|
||||||
|
type:'Type',
|
||||||
|
types: {
|
||||||
|
PER_DIEM:'',
|
||||||
|
EXPENSES:'',
|
||||||
|
MILEAGE:'',
|
||||||
|
PRIME_GARDE:'',
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
timeSheetValidations: {
|
timeSheetValidations: {
|
||||||
tableColumnLabelFullname: 'Full name',
|
tableColumnLabelFullname: 'Full name',
|
||||||
|
|
|
||||||
|
|
@ -312,13 +312,10 @@ export default {
|
||||||
cancel_button:'Annuler',
|
cancel_button:'Annuler',
|
||||||
remote_button: 'Télétravail',
|
remote_button: 'Télétravail',
|
||||||
delete_button: 'Supprimer',
|
delete_button: 'Supprimer',
|
||||||
|
|
||||||
delete_confirmation_msg: 'Voulez-vous supprimer complètement ce quart?',
|
delete_confirmation_msg: 'Voulez-vous supprimer complètement ce quart?',
|
||||||
|
|
||||||
add_shift:'Ajouter une quart',
|
add_shift:'Ajouter une quart',
|
||||||
edit_shift: 'Modifier un quart',
|
edit_shift: 'Modifier un quart',
|
||||||
delete_shift: 'Supprimer un quart',
|
delete_shift: 'Supprimer un quart',
|
||||||
|
|
||||||
shift_types_label: 'Type de quart',
|
shift_types_label: 'Type de quart',
|
||||||
shift_types: {
|
shift_types: {
|
||||||
EMERGENCY: 'Urgence',
|
EMERGENCY: 'Urgence',
|
||||||
|
|
@ -336,52 +333,37 @@ export default {
|
||||||
header_comment:'Commentaire du Quart',
|
header_comment:'Commentaire du Quart',
|
||||||
textarea_comment:'Laissez votre commentaire',
|
textarea_comment:'Laissez votre commentaire',
|
||||||
},
|
},
|
||||||
//rest
|
expense: {
|
||||||
timeSheetTab_1: 'Quarts de travail',
|
add_expense:'Ajouter une dépense',
|
||||||
timeSheetTab_2: 'Dépenses',
|
amount:'Montant',
|
||||||
templateButton: 'Appliquer le modèle',
|
date:'Date',
|
||||||
shiftTemplateTitle: 'Mettre en place votre horaire de jour',
|
empty_list:'Aucun dépense enregistrée',
|
||||||
shiftType: 'Type',
|
errors: {
|
||||||
remoteShift: 'Télétravail',
|
date_required_or_invalid:'',
|
||||||
shiftStartTime: 'Entrée',
|
comment_required:'',
|
||||||
shiftEndTime: 'Sortie',
|
comment_too_long:'',
|
||||||
shiftComment: 'Commentaire',
|
amount_must_be_positive:'',
|
||||||
overTimeTitle: 'Heures régulières supplémentaires: ',
|
mileave_must_be_positive:'',
|
||||||
totalPayedHours: 'Total des heures travaillées: ',
|
amount_xor_mileage:'',
|
||||||
// shift options
|
mileage_required_for_type:'',
|
||||||
shiftRegular: 'régulier',
|
amount_required_for_type:'',
|
||||||
shiftEvening: 'soir',
|
},
|
||||||
shiftEmergency: 'urgence',
|
hints: {
|
||||||
shiftSick: 'maladie',
|
amount_or_mileage:'Soit dépense ou kilométrage, pas les deux',
|
||||||
shiftVacation: 'vacances',
|
},
|
||||||
shiftHoliday: 'férié',
|
mileage:'Kilométrage',
|
||||||
dateRangesFrom: 'du',
|
open_btn:'Liste des Dépenses',
|
||||||
dateRangesTo: 'au',
|
title:'Liste des dépenses',
|
||||||
shiftBankedHours: 'Totale d’heures à banquer',
|
total_amount:'Montant total',
|
||||||
bankedHoursHint_1: ' sur',
|
total_mileage:'Kilométrage total',
|
||||||
bankedHoursHint_2: ' heures d’accumulé',
|
type:'Type',
|
||||||
qTimeClose: 'Fermer',
|
types: {
|
||||||
saveButton: 'Enregistrer',
|
PER_DIEM:'',
|
||||||
//shift validations
|
EXPENSES:'',
|
||||||
timeSheetValidated: 'Semaine validée',
|
MILEAGE:'',
|
||||||
timeSheetBlocked: 'Semaine bloquée',
|
PRIME_GARDE:'',
|
||||||
shiftTypeValidation: 'Le type doit être rempli.',
|
},
|
||||||
shiftStartTimeValidation: 'Entrée doit être rempli.',
|
},
|
||||||
shiftEndTimeValidation: 'Sortie doit être rempli.',
|
|
||||||
endTimeValidation:'L’heure de fin doit suivre l’heure de début.',
|
|
||||||
expensesTile: 'Dépenses du jour',
|
|
||||||
expensesType: 'Type',
|
|
||||||
expensesValue: 'Montant',
|
|
||||||
expensesDescription: 'Description',
|
|
||||||
expensesEvidence: 'Attachement',
|
|
||||||
//expenses validations
|
|
||||||
expensesTypeValidation: 'Type doit être rempli.',
|
|
||||||
expensesValueValidation: 'Montant doit être rempli.',
|
|
||||||
//expensesOptions
|
|
||||||
refund: 'Remboursement ',
|
|
||||||
garde: 'Garde',
|
|
||||||
perdiem: 'Perdiem',
|
|
||||||
mileage: 'Kilometrage',
|
|
||||||
},
|
},
|
||||||
timeSheetValidations: {
|
timeSheetValidations: {
|
||||||
tableColumnLabelFullname: 'nom complet',
|
tableColumnLabelFullname: 'nom complet',
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,344 @@
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, reactive, 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 { COMMENT_MAX_LENGTH } from '../../composables/use-shift-api';
|
||||||
|
/* eslint-disable */
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
//props
|
||||||
|
const props = defineProps<{
|
||||||
|
pay_period_no: number;
|
||||||
|
pay_year: number;
|
||||||
|
email: string;
|
||||||
|
is_approved?: boolean;
|
||||||
|
initial_expenses?: TimesheetExpense[];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
//emits
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e:'close'): void;
|
||||||
|
(e: 'save', payload: {
|
||||||
|
pay_period_no: number;
|
||||||
|
pay_year: number;
|
||||||
|
email: string;
|
||||||
|
expenses: TimesheetExpense[];
|
||||||
|
}): void;
|
||||||
|
(e: 'error', err: ExpensesValidationError): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
//q-select mapper
|
||||||
|
const type_options = computed(()=> EXPENSE_TYPE.map((val)=> ({
|
||||||
|
label: t(`timesheet.expense.types.${val}`, val),
|
||||||
|
value: val,
|
||||||
|
})));
|
||||||
|
|
||||||
|
//refs & states
|
||||||
|
const items = ref<TimesheetExpense[]>(Array.isArray(props.initial_expenses) ? props.initial_expenses.map(normalize_expense): []);
|
||||||
|
const draft = reactive<Partial<TimesheetExpense>>({
|
||||||
|
date:'',
|
||||||
|
type: 'EXPENSES',
|
||||||
|
comment:'',
|
||||||
|
});
|
||||||
|
|
||||||
|
// computeds
|
||||||
|
const totals = computed(()=> compute_expense_totals(items.value));
|
||||||
|
const remaining_comment_chars = computed(()=> {
|
||||||
|
const comment = String(draft.comment ?? '');
|
||||||
|
return COMMENT_MAX_LENGTH - comment.length;
|
||||||
|
});
|
||||||
|
|
||||||
|
//actions
|
||||||
|
const reset_draft = () => {
|
||||||
|
draft.date = '';
|
||||||
|
draft.type = 'EXPENSES';
|
||||||
|
delete draft.amount;
|
||||||
|
delete draft.mileage;
|
||||||
|
draft.comment = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const add_draft_as_item = () => {
|
||||||
|
const candidate: TimesheetExpense = normalize_expense({
|
||||||
|
date: String(draft.date ?? '').trim(),
|
||||||
|
type: String(draft.type ?? '').trim(),
|
||||||
|
...(typeof draft.amount === 'number' ? { amount: draft.amount }: {}),
|
||||||
|
...(typeof draft.mileage === 'number' ? { mileage: draft.mileage }: {}),
|
||||||
|
comment: String(draft.comment ?? '').trim(),
|
||||||
|
} as TimesheetExpense);
|
||||||
|
|
||||||
|
try {
|
||||||
|
validate_expense_UI(candidate, 'expense_draft');
|
||||||
|
items.value = [ ...items.value, candidate];
|
||||||
|
reset_draft();
|
||||||
|
} catch (err: any) {
|
||||||
|
const e = err instanceof ExpensesValidationError
|
||||||
|
? err : new ExpensesValidationError({
|
||||||
|
status_code: 400,
|
||||||
|
message: String(err?.message || err)
|
||||||
|
});
|
||||||
|
emit('error', e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const remove_item_at = (index: number) => {
|
||||||
|
if(props.is_approved) return;
|
||||||
|
if(index < 0 || index >= items.value.length) return;
|
||||||
|
items.value = items.value.filter((_,i) => i !== index);
|
||||||
|
};
|
||||||
|
|
||||||
|
const validate_all = () => {
|
||||||
|
for(const expense of items.value) {
|
||||||
|
validate_expense_UI(expense, 'expense_item');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const on_save = () => {
|
||||||
|
try {
|
||||||
|
validate_all();
|
||||||
|
const payload = items.value.map(normalize_expense);
|
||||||
|
|
||||||
|
emit('save', {
|
||||||
|
pay_period_no: props.pay_period_no,
|
||||||
|
pay_year: props.pay_year,
|
||||||
|
email: props.email,
|
||||||
|
expenses: payload,
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch(err: any) {
|
||||||
|
const e = err instanceof ExpensesValidationError
|
||||||
|
? err: new ExpensesValidationError({
|
||||||
|
status_code: 400,
|
||||||
|
message: String(err?.message || err)
|
||||||
|
});
|
||||||
|
emit('error', e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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.type = value);
|
||||||
|
const set_draft_amount = (value: number | null) => {
|
||||||
|
if(value === null || value === undefined || Number.isNaN(Number(value))) {
|
||||||
|
delete draft.amount;
|
||||||
|
} else {
|
||||||
|
draft.amount = Number(value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const set_draft_mileage = (value: number | null) => {
|
||||||
|
if(value === null || value === undefined || Number.isNaN(Number(value))) {
|
||||||
|
delete draft.mileage;
|
||||||
|
} else {
|
||||||
|
draft.mileage = Number(value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<q-card class="q-pa-md q-gutter-md" flat bordered>
|
||||||
|
<!-- 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/>
|
||||||
|
|
||||||
|
<!-- 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
|
||||||
|
bordered
|
||||||
|
class="q-pa-sm"
|
||||||
|
>
|
||||||
|
<!-- 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>
|
||||||
|
</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">
|
||||||
|
<q-select
|
||||||
|
v-model="draft.type"
|
||||||
|
:options="type_options"
|
||||||
|
filled
|
||||||
|
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)"
|
||||||
|
/>
|
||||||
|
</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 -->
|
||||||
|
<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')"
|
||||||
|
:hint="$t('timesheet.expense.hints.amount_or_mileage')"
|
||||||
|
/>
|
||||||
|
</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="[
|
||||||
|
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') }}
|
||||||
|
</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-separator spaced/>
|
||||||
|
|
||||||
|
<div class="row justify-end q-gutter-sm">
|
||||||
|
<!-- close btn -->
|
||||||
|
<q-btn
|
||||||
|
flat
|
||||||
|
color="primary"
|
||||||
|
:label="$t('timesheet.cancel_button')"
|
||||||
|
@click="on_close"
|
||||||
|
/>
|
||||||
|
<!-- save btn -->
|
||||||
|
<q-btn
|
||||||
|
color="primary"
|
||||||
|
unelevated
|
||||||
|
:disable="is_readonly || items.length === 0"
|
||||||
|
:label="$t('timesheet.save_button')"
|
||||||
|
@click="on_save"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</q-card>
|
||||||
|
</template>
|
||||||
198
src/modules/timesheets/composables/use-expense-api.ts
Normal file
198
src/modules/timesheets/composables/use-expense-api.ts
Normal file
|
|
@ -0,0 +1,198 @@
|
||||||
|
import { isProxy, toRaw } from "vue";
|
||||||
|
import { type ExpenseType, type TimesheetExpense } from "../types/timesheet-expenses-interface";
|
||||||
|
import { type PayPeriodExpenses } from "../types/timesheet-expenses-list-interface";
|
||||||
|
import { normalize_expense, validate_expense_UI } from "../utils/timesheet-expenses-validators";
|
||||||
|
import { api } from "src/boot/axios";
|
||||||
|
/* eslint-disable */
|
||||||
|
export interface ExpensePayload{
|
||||||
|
date: string;
|
||||||
|
type: ExpenseType;
|
||||||
|
amount?: number;
|
||||||
|
mileage?: number;
|
||||||
|
comment: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpsertExpensesBody {
|
||||||
|
expenses: ExpensePayload[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpsertExpensesResponse {
|
||||||
|
data: PayPeriodExpenses;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiErrorPayload {
|
||||||
|
status_code: number;
|
||||||
|
error_code?: string;
|
||||||
|
message?: string;
|
||||||
|
context?: Record<string,unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ExpensesApiError extends Error {
|
||||||
|
status_code: number;
|
||||||
|
error_code?: string;
|
||||||
|
context?: Record<string, unknown>;
|
||||||
|
constructor(payload: ApiErrorPayload) {
|
||||||
|
super(payload.message || 'Request failed');
|
||||||
|
this.name = 'ExpensesApiError';
|
||||||
|
this.status_code = payload.status_code;
|
||||||
|
|
||||||
|
if(payload.error_code !== undefined) this.error_code = payload.error_code;
|
||||||
|
if(payload.context !== undefined) this.context = payload.context;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const to_plain = <T extends object>(obj:T): T => {
|
||||||
|
const raw = isProxy(obj) ? toRaw(obj) : obj;
|
||||||
|
if(typeof (globalThis as any).structuredClone === 'function') {
|
||||||
|
return (globalThis as any).structuredClone(raw);
|
||||||
|
}
|
||||||
|
return JSON.parse(JSON.stringify(raw));
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalize_payload = (expense: ExpensePayload): ExpensePayload => {
|
||||||
|
const exp = normalize_expense(expense as unknown as TimesheetExpense);
|
||||||
|
const out: ExpensePayload = {
|
||||||
|
date: exp.date,
|
||||||
|
type: exp.type as ExpenseType,
|
||||||
|
comment: exp.comment || '',
|
||||||
|
};
|
||||||
|
if(typeof exp.amount === 'number') out.amount = exp.amount;
|
||||||
|
if(typeof exp.mileage === 'number') out.mileage = exp.mileage;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
//GET by email, year and period no
|
||||||
|
export const get_pay_period_expenses = async (
|
||||||
|
email: string,
|
||||||
|
pay_year: number,
|
||||||
|
pay_period_no: number
|
||||||
|
) : Promise<PayPeriodExpenses> => {
|
||||||
|
const encoded_email = encodeURIComponent(email);
|
||||||
|
const encoded_year = encodeURIComponent(String(pay_year));
|
||||||
|
const encoded_pay_period_no = encodeURIComponent(String(pay_period_no));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { data } = await api.get<PayPeriodExpenses>(`/expenses/${encoded_email}/${encoded_year}/${encoded_pay_period_no}`);
|
||||||
|
|
||||||
|
const items = Array.isArray(data.expenses) ? data.expenses.map(normalize_expense) : [];
|
||||||
|
return {
|
||||||
|
...data,
|
||||||
|
expenses: items,
|
||||||
|
};
|
||||||
|
} catch(err:any) {
|
||||||
|
const status_code: number = err?.response?.status ?? 500;
|
||||||
|
const data = err?.response?.data ?? {};
|
||||||
|
throw new ExpensesApiError({
|
||||||
|
status_code,
|
||||||
|
error_code: data.error_code,
|
||||||
|
message: data.message || data.error || err.message,
|
||||||
|
context: data.context,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
//PUT by email, year and period no
|
||||||
|
export const put_pay_period_expenses = async (
|
||||||
|
email: string,
|
||||||
|
pay_year: number,
|
||||||
|
pay_period_no: number,
|
||||||
|
expenses: TimesheetExpense[]
|
||||||
|
): Promise<PayPeriodExpenses> => {
|
||||||
|
const encoded_email = encodeURIComponent(email);
|
||||||
|
const encoded_year = encodeURIComponent(String(pay_year));
|
||||||
|
const encoded_pay_period_no = encodeURIComponent(String(pay_period_no));
|
||||||
|
|
||||||
|
const plain = Array.isArray(expenses) ? expenses.map(to_plain): [];
|
||||||
|
|
||||||
|
const normalized: ExpensePayload[] = plain.map((exp) => {
|
||||||
|
const norm = normalize_expense(exp as TimesheetExpense);
|
||||||
|
validate_expense_UI(norm, 'expense_item');
|
||||||
|
return normalize_payload(norm as unknown as ExpensePayload);
|
||||||
|
});
|
||||||
|
|
||||||
|
const body: UpsertExpensesBody = {expenses: normalized};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { data } = await api.put<UpsertExpensesResponse>(
|
||||||
|
`/expenses/${encoded_email}/${encoded_year}/${encoded_pay_period_no}`,
|
||||||
|
body,
|
||||||
|
{ headers: {'Content-Type': 'application/json'}}
|
||||||
|
);
|
||||||
|
|
||||||
|
const items = Array.isArray(data?.data?.expenses)
|
||||||
|
? data.data.expenses.map(normalize_expense)
|
||||||
|
: [];
|
||||||
|
return {
|
||||||
|
...(data?.data ?? {
|
||||||
|
pay_period_no,
|
||||||
|
pay_year,
|
||||||
|
employee_email: email,
|
||||||
|
is_approved: false,
|
||||||
|
expenses: [],
|
||||||
|
totals: {amount: 0, mileage: 0},
|
||||||
|
}),
|
||||||
|
expenses: items,
|
||||||
|
};
|
||||||
|
} catch (err: any) {
|
||||||
|
const status_code: number = err?.response?.status ?? 500;
|
||||||
|
const data = err?.response?.data ?? {};
|
||||||
|
throw new ExpensesApiError({
|
||||||
|
status_code,
|
||||||
|
error_code: data.error_code,
|
||||||
|
message: data.message || data.error || err.message,
|
||||||
|
context: data.context,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const post_pay_period_expenses = async (
|
||||||
|
email: string,
|
||||||
|
pay_year: number,
|
||||||
|
pay_period_no: number,
|
||||||
|
new_expenses: TimesheetExpense[]
|
||||||
|
): Promise<PayPeriodExpenses> => {
|
||||||
|
const encoded_email = encodeURIComponent(email);
|
||||||
|
const encoded_year = encodeURIComponent(String(pay_year));
|
||||||
|
const encoded_pp = encodeURIComponent(String(pay_period_no));
|
||||||
|
|
||||||
|
const plain = Array.isArray(new_expenses) ? new_expenses.map(to_plain) : [];
|
||||||
|
const normalized: ExpensePayload[] = plain.map((exp) => {
|
||||||
|
const norm = normalize_expense(exp as TimesheetExpense);
|
||||||
|
validate_expense_UI(norm, 'expense_item');
|
||||||
|
return normalize_payload(norm as unknown as ExpensePayload);
|
||||||
|
});
|
||||||
|
|
||||||
|
const body: UpsertExpensesBody = { expenses: normalized };
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { data } = await api.post<UpsertExpensesResponse>(
|
||||||
|
`/expenses/${encoded_email}/${encoded_year}/${encoded_pp}`,
|
||||||
|
body,
|
||||||
|
{ headers: { 'content-type': 'application/json' } }
|
||||||
|
);
|
||||||
|
const items = Array.isArray(data?.data?.expenses)
|
||||||
|
? data.data.expenses.map(normalize_expense)
|
||||||
|
: [];
|
||||||
|
return {
|
||||||
|
...(data?.data ?? {
|
||||||
|
pay_period_no,
|
||||||
|
pay_year,
|
||||||
|
employee_email: email,
|
||||||
|
is_approved: false,
|
||||||
|
expenses: [],
|
||||||
|
totals: { amount: 0, mileage: 0 },
|
||||||
|
}),
|
||||||
|
expenses: items,
|
||||||
|
};
|
||||||
|
} catch (err: any) {
|
||||||
|
const status_code: number = err?.response?.status ?? 500;
|
||||||
|
const data = err?.response?.data ?? {};
|
||||||
|
throw new ExpensesApiError({
|
||||||
|
status_code,
|
||||||
|
error_code: data.error_code,
|
||||||
|
message: data.message || data.error || err.message,
|
||||||
|
context: data.context,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
@ -7,15 +7,86 @@ import { computed, onMounted, ref } from 'vue';
|
||||||
import { date } from 'quasar';
|
import { date } from 'quasar';
|
||||||
import TimesheetNavigation from '../components/timesheet/timesheet-navigation.vue';
|
import TimesheetNavigation from '../components/timesheet/timesheet-navigation.vue';
|
||||||
import ShiftsLegend from '../components/shift/shifts-legend.vue';
|
import ShiftsLegend from '../components/shift/shifts-legend.vue';
|
||||||
import TimesheetDetailsShifts from '../components/timesheet/timesheet-details-shifts.vue';
|
import TimesheetDetailsShifts from '../components/shift/timesheet-details-shifts.vue';
|
||||||
import { upsert_shifts_by_date, type ShiftPayload, type UpsertShiftsBody } from '../composables/use-shift-api';
|
import { upsert_shifts_by_date, type ShiftPayload, type UpsertShiftsBody } from '../composables/use-shift-api';
|
||||||
|
import { ExpensesApiError, get_pay_period_expenses, put_pay_period_expenses } from '../composables/use-expense-api';
|
||||||
|
import type { PayPeriodExpenses } from '../types/timesheet-expenses-list-interface';
|
||||||
|
import type { TimesheetExpense } from '../types/timesheet-expenses-interface';
|
||||||
|
|
||||||
/* eslint-disable */
|
/* eslint-disable */
|
||||||
const { locale, tm, t } = useI18n();
|
const { locale, t } = useI18n();
|
||||||
const timesheet_store = useTimesheetStore();
|
const timesheet_store = useTimesheetStore();
|
||||||
const auth_store = useAuthStore();
|
const auth_store = useAuthStore();
|
||||||
const timesheet_api = useTimesheetApi();
|
const timesheet_api = useTimesheetApi();
|
||||||
|
|
||||||
|
//expenses refs
|
||||||
|
const show_expenses_dialog = ref(false);
|
||||||
|
const is_loading_expenses = ref(false);
|
||||||
|
const expenses_data = ref<PayPeriodExpenses | null>(null);
|
||||||
|
|
||||||
|
const notify_error = (err: number) => {
|
||||||
|
const e = err as any;
|
||||||
|
error_banner.value = (e instanceof ExpensesApiError && t(e.message)) || e?.message || 'Unknown error';
|
||||||
|
};
|
||||||
|
|
||||||
|
const open_expenses_dialog = async () => {
|
||||||
|
show_expenses_dialog.value = true;
|
||||||
|
is_loading_expenses.value = true;
|
||||||
|
error_banner.value = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await get_pay_period_expenses(
|
||||||
|
auth_store.user.email,
|
||||||
|
timesheet_store.current_pay_period.pay_year,
|
||||||
|
timesheet_store.current_pay_period.pay_period_no,
|
||||||
|
);
|
||||||
|
} catch(err) {
|
||||||
|
notify_error(err as any);
|
||||||
|
expenses_data.value = {
|
||||||
|
pay_period_no: timesheet_store.current_pay_period.pay_period_no,
|
||||||
|
pay_year: timesheet_store.current_pay_period.pay_year,
|
||||||
|
employee_email: auth_store.user.email,
|
||||||
|
is_approved: false,
|
||||||
|
expenses: [],
|
||||||
|
totals: {amount:0, mileage:0},
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
is_loading_expenses.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const on_save_expenses = async (payload: {
|
||||||
|
pay_period_no: number;
|
||||||
|
pay_year: number;
|
||||||
|
email: string;
|
||||||
|
expenses: TimesheetExpense[];
|
||||||
|
}) => {
|
||||||
|
is_loading_expenses.value = true;
|
||||||
|
error_banner.value = null;
|
||||||
|
|
||||||
|
try{
|
||||||
|
const updated = await put_pay_period_expenses(
|
||||||
|
payload.email,
|
||||||
|
payload.pay_year,
|
||||||
|
payload.pay_period_no,
|
||||||
|
payload.expenses
|
||||||
|
);
|
||||||
|
expenses_data.value = updated;
|
||||||
|
|
||||||
|
await timesheet_store.getTimesheetsByPayPeriodAndEmail(auth_store.user.email);
|
||||||
|
|
||||||
|
show_expenses_dialog.value = false;
|
||||||
|
} catch(err) {
|
||||||
|
notify_error(err as any);
|
||||||
|
} finally {
|
||||||
|
is_loading_expenses.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const on_close_expenses = () => {
|
||||||
|
show_expenses_dialog.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
const date_options: Intl.DateTimeFormatOptions = {
|
const date_options: Intl.DateTimeFormatOptions = {
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
month: 'long',
|
month: 'long',
|
||||||
|
|
@ -209,8 +280,18 @@ const on_request_delete = async ({ date, shift }: { date: string; shift: any })
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<q-card flat class="q-mt-md bg-secondary">
|
<q-card flat class=" col q-mt-md bg-secondary">
|
||||||
<!-- navigation btn -->
|
<!-- navigation btn -->
|
||||||
|
<q-card-section horizontal>
|
||||||
|
<q-btn
|
||||||
|
color="primary"
|
||||||
|
unelevated
|
||||||
|
icon="receipt_long"
|
||||||
|
:label="$t('timesheet.expense.open_btn')"
|
||||||
|
@click="open_expenses_dialog"
|
||||||
|
/>
|
||||||
|
</q-card-section>
|
||||||
|
<q-card-section class="row items-center justify-between q-px-md q-pb-none">
|
||||||
<TimesheetNavigation
|
<TimesheetNavigation
|
||||||
:is-disabled="timesheet_store.is_loading"
|
:is-disabled="timesheet_store.is_loading"
|
||||||
:is-previous-limit="is_calendar_limit"
|
:is-previous-limit="is_calendar_limit"
|
||||||
|
|
@ -218,6 +299,7 @@ const on_request_delete = async ({ date, shift }: { date: string; shift: any })
|
||||||
@pressed-previous-button="timesheet_api.getPreviousPayPeriod()"
|
@pressed-previous-button="timesheet_api.getPreviousPayPeriod()"
|
||||||
@pressed-next-button="timesheet_api.getNextPayPeriod()"
|
@pressed-next-button="timesheet_api.getNextPayPeriod()"
|
||||||
/>
|
/>
|
||||||
|
</q-card-section>
|
||||||
<!-- shift's colored legend -->
|
<!-- shift's colored legend -->
|
||||||
<ShiftsLegend
|
<ShiftsLegend
|
||||||
:is-loading="false"
|
:is-loading="false"
|
||||||
|
|
@ -236,8 +318,33 @@ const on_request_delete = async ({ date, shift }: { date: string; shift: any })
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
</q-card>
|
</q-card>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- read/edit/create/delete expense dialog -->
|
||||||
|
<q-dialog
|
||||||
|
v-model="show_expenses_dialog"
|
||||||
|
persistent
|
||||||
|
>
|
||||||
|
<q-card
|
||||||
|
class="q-pa=md"
|
||||||
|
style="min-width:900px; max-width: 95vw;"
|
||||||
|
>
|
||||||
|
<q-inner-loading :showing="is_loading_expenses">
|
||||||
|
<q-spinner size="32px"/>
|
||||||
|
</q-inner-loading>
|
||||||
|
|
||||||
<!-- create/edit/delete dialog -->
|
<TimesheetExpense
|
||||||
|
v-if="expenses_data"
|
||||||
|
:pay_period_no="expenses_data.pay_period_no"
|
||||||
|
:pay_year="expenses_data.pay_year"
|
||||||
|
:email="expenses_data.employee_email"
|
||||||
|
:is_approved="expenses_data.is_approved"
|
||||||
|
:initial_expenses="expenses_data.expenses"
|
||||||
|
@save="on_save_expenses"
|
||||||
|
@close="on_close_expenses"
|
||||||
|
@error=" "
|
||||||
|
/>
|
||||||
|
</q-card>
|
||||||
|
</q-dialog>
|
||||||
|
<!-- create/edit/delete shifts dialog -->
|
||||||
<q-dialog
|
<q-dialog
|
||||||
v-model="is_dialog_open"
|
v-model="is_dialog_open"
|
||||||
persistent
|
persistent
|
||||||
|
|
|
||||||
21
src/modules/timesheets/types/timesheet-expenses-interface.ts
Normal file
21
src/modules/timesheets/types/timesheet-expenses-interface.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
export interface TimesheetExpense {
|
||||||
|
date: string;
|
||||||
|
amount?: number;
|
||||||
|
mileage?: number;
|
||||||
|
comment?: string;
|
||||||
|
supervisor_comment?: string;
|
||||||
|
is_approved?: boolean;
|
||||||
|
type: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EXPENSE_TYPE = [
|
||||||
|
'PER_DIEM',
|
||||||
|
'MILEAGE',
|
||||||
|
'EXPENSES',
|
||||||
|
'PRIME_DISPO',
|
||||||
|
] 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']
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
import type { TimesheetExpense } from "./timesheet-expenses-interface";
|
||||||
|
|
||||||
|
export interface PayPeriodExpenses {
|
||||||
|
pay_period_no: number;
|
||||||
|
pay_year: number;
|
||||||
|
employee_email: string;
|
||||||
|
is_approved: boolean;
|
||||||
|
expenses: TimesheetExpense[];
|
||||||
|
totals: {
|
||||||
|
amount: number;
|
||||||
|
mileage: number;
|
||||||
|
reimbursable_total?: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
148
src/modules/timesheets/utils/timesheet-expenses-validators.ts
Normal file
148
src/modules/timesheets/utils/timesheet-expenses-validators.ts
Normal file
|
|
@ -0,0 +1,148 @@
|
||||||
|
import { type ExpenseType, type TimesheetExpense, TYPES_WITH_AMOUNT_ONLY, TYPES_WITH_MILEAGE_ONLY } from "../types/timesheet-expenses-interface";
|
||||||
|
|
||||||
|
|
||||||
|
export const DATE_FORMAT_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
|
||||||
|
export const COMMENT_MAX_LENGTH = 512 as const;
|
||||||
|
|
||||||
|
|
||||||
|
//errors handling
|
||||||
|
export interface ApiErrorPayload {
|
||||||
|
status_code: number;
|
||||||
|
error_code?: string;
|
||||||
|
message?: string;
|
||||||
|
context?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ExpensesValidationError extends Error {
|
||||||
|
status_code: number;
|
||||||
|
error_code?: string | undefined;
|
||||||
|
context?: Record<string, unknown> | undefined;
|
||||||
|
|
||||||
|
constructor(payload: ApiErrorPayload) {
|
||||||
|
super(payload.message || 'Invalid expense payload');
|
||||||
|
this.name = 'ExpensesValidationError';
|
||||||
|
this.status_code = payload.status_code;
|
||||||
|
this.error_code = payload.error_code;
|
||||||
|
this.context = payload.context;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//normalization helpers
|
||||||
|
export const toNumOrUndefined = (value: unknown): number | undefined => {
|
||||||
|
if(value === undefined || value === null || (typeof value === 'string' && value.trim() === '')) return undefined;
|
||||||
|
const num = Number(value);
|
||||||
|
return Number.isFinite(num) ? num : undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const normalize_comment = (input?: string): string | undefined => {
|
||||||
|
if(typeof input === 'undefined' || input === null) return undefined;
|
||||||
|
const trimmed = String(input).trim();
|
||||||
|
return trimmed.length ? trimmed : undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const normalize_type = (input: string): string => (input ?? '').trim().toUpperCase();
|
||||||
|
|
||||||
|
export const normalize_expense = (expense: TimesheetExpense): TimesheetExpense => {
|
||||||
|
const comment = normalize_comment(expense.comment);
|
||||||
|
const amount = toNumOrUndefined(expense.amount);
|
||||||
|
const mileage = toNumOrUndefined(expense.mileage);
|
||||||
|
return {
|
||||||
|
date: (expense.date ?? '').trim(),
|
||||||
|
type: normalize_type(expense.type),
|
||||||
|
...(amount !== undefined ? { amount } : {}),
|
||||||
|
...(mileage !== undefined ? { mileage } : {}),
|
||||||
|
...(comment !== undefined ? { comment } : {}),
|
||||||
|
...(typeof expense.supervisor_comment === 'string' && expense.supervisor_comment.trim().length
|
||||||
|
? { supervisor_comment: expense.supervisor_comment.trim() }
|
||||||
|
: {}),
|
||||||
|
...(typeof expense.is_approved === 'boolean' ? { is_approved: expense.is_approved }: {} ),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
//UI validation error messages
|
||||||
|
export const validate_expense_UI = (raw: TimesheetExpense, label: string = 'expense'): void => {
|
||||||
|
const expense = normalize_expense(raw);
|
||||||
|
|
||||||
|
//Date input validation
|
||||||
|
if(!DATE_FORMAT_PATTERN.test(expense.date)) {
|
||||||
|
throw new ExpensesValidationError({
|
||||||
|
status_code: 400,
|
||||||
|
message: 'timesheet.expense.errors.date_required_or_invalid',
|
||||||
|
context: { [label]: expense },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
//comment input validation
|
||||||
|
if(!expense.comment) {
|
||||||
|
throw new ExpensesValidationError({
|
||||||
|
status_code: 400,
|
||||||
|
message: 'timesheet.expense.errors.comment_required',
|
||||||
|
context: { [label]: expense },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if((expense.comment.length ?? 0) > COMMENT_MAX_LENGTH) {
|
||||||
|
throw new ExpensesValidationError({
|
||||||
|
status_code: 400,
|
||||||
|
message: 'timesheet.expense.errors.comment_too_long',
|
||||||
|
context: { [label]: { ...expense, comment_length: expense.comment?.length } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
//amount input validation
|
||||||
|
if(expense.amount !== undefined && expense.amount <= 0) {
|
||||||
|
throw new ExpensesValidationError({
|
||||||
|
status_code: 400,
|
||||||
|
message: 'timesheet.expense.errors.amount_must_be_positive',
|
||||||
|
context: { [label]: expense },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
//mileage input validation
|
||||||
|
if(expense.mileage !== undefined && expense.mileage <= 0) {
|
||||||
|
throw new ExpensesValidationError({
|
||||||
|
status_code: 400,
|
||||||
|
message: 'timesheet.expense.errors.mileage_must_be_positive',
|
||||||
|
context: { [label]: expense },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
//cross origin amount/mileage validation
|
||||||
|
const has_amount = typeof expense.amount === 'number' && expense.amount > 0;
|
||||||
|
const has_mileage = typeof expense.mileage === 'number' && expense.mileage > 0;
|
||||||
|
|
||||||
|
if(has_amount === has_mileage) {
|
||||||
|
throw new ExpensesValidationError({
|
||||||
|
status_code: 400,
|
||||||
|
message: 'timesheet.expense.errors.amount_xor_mileage',
|
||||||
|
context: { [label]: expense },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
//type constraint validation
|
||||||
|
const type = expense.type as ExpenseType;
|
||||||
|
if(TYPES_WITH_MILEAGE_ONLY.includes(type) && !has_mileage) {
|
||||||
|
throw new ExpensesValidationError({
|
||||||
|
status_code: 400,
|
||||||
|
message: 'timesheet.expense.errors.mileage_required_for_type',
|
||||||
|
context: { [label]: expense },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if(TYPES_WITH_AMOUNT_ONLY.includes(type) && !has_amount) {
|
||||||
|
throw new ExpensesValidationError({
|
||||||
|
status_code: 400,
|
||||||
|
message: 'timesheet.expense.errors.amount_required_for_type',
|
||||||
|
context: { [label]: expense },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
//totals per pay-period
|
||||||
|
export const compute_expense_totals = (items: TimesheetExpense[]) => items.reduce(
|
||||||
|
(acc, raw) => {
|
||||||
|
const expense = normalize_expense(raw);
|
||||||
|
if(typeof expense.amount === 'number' && expense.amount > 0) acc.amount += expense.amount;
|
||||||
|
if(typeof expense.mileage === 'number' && expense.mileage > 0) acc.mileage += expense.mileage;
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{ amount: 0, mileage: 0 }
|
||||||
|
);
|
||||||
Loading…
Reference in New Issue
Block a user