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

Reviewed-on: Targo/targo_frontend#16
This commit is contained in:
matthieuh 2025-09-18 14:39:07 -04:00
commit 3c30f44eb1
17 changed files with 1257 additions and 316 deletions

View File

@ -250,7 +250,8 @@ export default {
timeSheetValidations: 'Time sheet approvals',
},
timesheet: {
//employee's timesheet page
title:'Timesheet',
date_ranges_to:'to',
days: ['Sunday','Monday','Tuesday','Wednesday','Thursday','Friday','Saturday'],
nav_button: {
calendar_date_picker:'Calendar',
@ -262,76 +263,71 @@ export default {
cancel_button:'Cancel',
remote_button: 'Remote work',
delete_button: 'Delete',
delete_confirmation_msg: 'Do you want to delete this shift completly?',
add_shift:'Add Shift',
edit_shift: 'Edit shift',
delete_shift: 'Delete shift',
shift_types_label: 'Shift`s Type',
shift_types: {
EMERGENCY: 'Emergency',
EVENING: 'Evening',
HOLIDAY: 'Holiday',
OVERTIME: 'Overtime',
REGULAR: 'Regular',
SICK: 'Sick Leave',
VACATION: 'Vacation',
REMOTE: 'Remote work',
shift: {
actions: {
add:'Add Shift',
edit: 'Edit shift',
delete: 'Delete shift',
delete_confirmation_msg: 'Do you want to delete this shift completly?',
},
types: {
label: 'Shift`s Type',
EMERGENCY: 'Emergency',
EVENING: 'Evening',
HOLIDAY: 'Holiday',
OVERTIME: 'Overtime',
REGULAR: 'Regular',
SICK: 'Sick Leave',
VACATION: 'Vacation',
REMOTE: 'Remote work',
},
errors: {
not_found:'Shift not found',
overlap:'An overlaps occured between 2 or more shifts',
invalid:'Invalid shift`s entry',
unknown:'Unknown error',
comment_required:'A comment is required',
comment_too_long:'Your comment is too long',
},
fields: {
start:'Start (HH:mm)',
end:'End (HH:mm)',
header_comment:'Shift`s comment',
textarea_comment: 'Leave a comment here',
},
},
fields: {
start:'Start (HH:mm)',
end:'End (HH:mm)',
header_comment:'Shift`s comment',
textarea_comment: 'Leave a comment here',
expense: {
add_expense:'Add Expense',
amount:'Amount',
date:'Date',
empty_list:'No registered expenses',
errors: {
date_required_or_invalid:'the date is missing or invalid',
comment_required:'A comment required',
comment_too_long:'Your comment is too long',
amount_must_be_positive:'the amount cannot be under 0$',
mileave_must_be_positive:'the mileage cannot be under 0',
amount_xor_mileage:'you cannot enter an amount and a mileage for the same expense',
mileage_required_for_type:'you need to enter a value for mileage when you enter an expense of that type',
amount_required_for_type:'you need to enter a value for amount when you enter an expense of that type',
},
hints: {
amount_or_mileage:'Either amount or mileage, not both',
comment_required:'A comment required',
},
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:'Per Diem',
EXPENSES:'expense',
MILEAGE:'mileage',
PRIME_GARDE:'on-call allowance',
},
},
//rest
timeSheetTab_1: 'Shifts',
timeSheetTab_2: 'Expenses',
templateButton: 'Apply Templates',
shiftTemplateTitle: 'Set up your day schedule',
shiftType: 'Type',
remoteShift: 'Remote',
shiftStartTime: 'Start time',
shiftEndTime: 'End time',
shiftComment: 'Comment',
overTimeTitle: 'Overtime regular hours: ',
totalPayedHours: 'Total hours worked: ',
// shift options
shiftRegular: 'regular',
shiftEvening: 'evening',
shiftEmergency: 'emergency',
shiftSick: 'sick',
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',
},
timeSheetValidations: {
tableColumnLabelFullname: 'Full name',

View File

@ -300,7 +300,8 @@ export default {
noDataLabel: 'Je nai rien trouvé pour toi',
},
timesheet: {
//employee's timesheet page
title:'Carte de temps',
date_ranges_to:'au',
days: ['Dimanche','Lundi','Mardi','Mercredi','Jeudi','Vendredi','Samedi'],
nav_button: {
calendar_date_picker:'Calendrier',
@ -312,76 +313,71 @@ export default {
cancel_button:'Annuler',
remote_button: 'Télétravail',
delete_button: 'Supprimer',
delete_confirmation_msg: 'Voulez-vous supprimer complètement ce quart?',
add_shift:'Ajouter une quart',
edit_shift: 'Modifier un quart',
delete_shift: 'Supprimer un quart',
shift_types_label: 'Type de quart',
shift_types: {
EMERGENCY: 'Urgence',
EVENING: 'Soir',
HOLIDAY: 'Férier',
OVERTIME: 'Supplémentaire',
SICK: 'Absence',
REGULAR: 'Régulier',
VACATION: 'Vacance',
REMOTE: 'Télétravail',
shift: {
actions: {
add:'Ajouter un Quart',
edit: 'Modifier un Quart',
delete: 'Supprimer un Quart',
delete_confirmation_msg: 'Voulez-vous complètement supprimer ce quart?',
},
types: {
label: 'Type de Quart',
EMERGENCY: 'Urgence',
EVENING: 'Soir',
HOLIDAY: 'Férié',
OVERTIME: 'Supplémentaire',
REGULAR: 'Régulier',
SICK: 'Maladie',
VACATION: 'Vacance',
REMOTE: 'Télétravail',
},
errors: {
not_found:'Aucun quart trouvé',
overlap:'Il y a un chevauchement entre deux ou plusieurs quarts',
invalid:'Entrée du quart invalide',
unknown:'Erreur inconnue',
comment_required:'un commentaire est requis',
comment_too_long:'votre commentaire est trop long',
},
fields: {
start:'Début (HH:mm)',
end:'Fin (HH:mm)',
header_comment:'Commentaire du Quart',
textarea_comment: 'Laissez votre commentaire ici',
},
},
fields: {
start:'Entrée (HH:mm)',
end:'Sortie (HH:mm)',
header_comment:'Commentaire du Quart',
textarea_comment:'Laissez votre commentaire',
expense: {
add_expense:'Ajouter une dépense',
amount:'Montant',
date:'Date',
empty_list:'Aucun dépense enregistrée',
errors: {
date_required_or_invalid:'La date est manquante ou invalide',
comment_required:'un commentaire est requis',
comment_too_long:'votre commentaire est trop long',
amount_must_be_positive:'le montant doit être suppérieur à 0$',
mileave_must_be_positive:'le kilométrage doit être suppérieur à 0',
amount_xor_mileage:'Vous ne pouvez pas saisir un montant et un kilométrage pour une même dépense',
mileage_required_for_type:'Vous devez entrer une valeur en kilométrage pour ce type de dépense',
amount_required_for_type:'Vous devez entrer une valeur en montant $ pour ce type de dépense',
},
hints: {
amount_or_mileage:'Soit dépense ou kilométrage, pas les deux',
comment_required:'un commentaire est requis',
},
mileage:'Kilométrage',
open_btn:'Liste des Dépenses',
title:'Liste des dépenses',
total_amount:'Montant total',
total_mileage:'Kilométrage total',
type:'Type',
types: {
PER_DIEM:'Per diem',
EXPENSES:'dépense',
MILEAGE:'kilométrage',
PRIME_GARDE:'Prime de garde',
},
},
//rest
timeSheetTab_1: 'Quarts de travail',
timeSheetTab_2: 'Dépenses',
templateButton: 'Appliquer le modèle',
shiftTemplateTitle: 'Mettre en place votre horaire de jour',
shiftType: 'Type',
remoteShift: 'Télétravail',
shiftStartTime: 'Entrée',
shiftEndTime: 'Sortie',
shiftComment: 'Commentaire',
overTimeTitle: 'Heures régulières supplémentaires: ',
totalPayedHours: 'Total des heures travaillées: ',
// shift options
shiftRegular: 'régulier',
shiftEvening: 'soir',
shiftEmergency: 'urgence',
shiftSick: 'maladie',
shiftVacation: 'vacances',
shiftHoliday: 'férié',
dateRangesFrom: 'du',
dateRangesTo: 'au',
shiftBankedHours: 'Totale dheures à banquer',
bankedHoursHint_1: ' sur',
bankedHoursHint_2: ' heures daccumulé',
qTimeClose: 'Fermer',
saveButton: 'Enregistrer',
//shift validations
timeSheetValidated: 'Semaine validée',
timeSheetBlocked: 'Semaine bloquée',
shiftTypeValidation: 'Le type doit être rempli.',
shiftStartTimeValidation: 'Entrée doit être rempli.',
shiftEndTimeValidation: 'Sortie doit être rempli.',
endTimeValidation:'Lheure de fin doit suivre lheure 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: {
tableColumnLabelFullname: 'nom complet',

View File

@ -0,0 +1,345 @@
<script setup lang="ts">
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 { 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 = ref<Partial<TimesheetExpense>>({
date:'',
type: 'EXPENSES',
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;
});
//actions
const reset_draft = () => {
draft.value.date = '';
draft.value.type = 'EXPENSES';
delete draft.value.amount;
delete draft.value.mileage;
draft.value.comment = '';
};
const add_draft_as_item = () => {
const candidate: TimesheetExpense = normalize_expense({
date: String(draft.value.date ?? '').trim(),
type: String(draft.value.type ?? '').trim(),
...(typeof draft.value.amount === 'number' ? { amount: draft.value.amount }: {}),
...(typeof draft.value.mileage === 'number' ? { mileage: draft.value.mileage }: {}),
comment: String(draft.value.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.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);
}
};
</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>

View File

@ -0,0 +1,242 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { upsert_shifts_by_date, type UpsertShiftsBody, type ShiftPayload } from '../../composables/use-shift-api';
import { useI18n } from 'vue-i18n';
/* eslint-disable */
type Option = { value: string; label: string };
const { t } = useI18n();
const props = defineProps<{
mode: 'create' | 'edit' | 'delete';
dateIso: string;
initialShift?: ShiftPayload | null;
shiftOptions: Option[];
email: string;
}>();
const emit = defineEmits<{
(e: 'close' ): void;
(e: 'saved'): void;
}>();
const isSubmitting = ref(false);
const errorBanner = ref<string | null>(null);
const conflicts = ref<Array<{start_time: string; end_time: string; type: string}>>([]);
const opened = defineModel<boolean>( { default: false });
const startTime = defineModel<string> ('startTime', { default: '' });
const endTime = defineModel<string> ('endTime' , { default: '' });
const type = defineModel<string> ('type' , { default: '' });
const isRemote = defineModel<boolean>('isRemote' , { default: false });
const comment = defineModel<string> ('comment' , { default: '' });
const buildNewShiftPayload = (): ShiftPayload => {
const trimmed = (comment.value ?? '').trim();
return {
start_time: startTime.value,
end_time: endTime.value,
type: type.value,
is_remote: isRemote.value,
...(trimmed ? { comment: trimmed } : {}),
};
};
const onSubmit = async () => {
errorBanner.value = null;
conflicts.value = [];
isSubmitting.value = true;
try{
let body: UpsertShiftsBody;
if(props.mode === 'create') {
body = { new_shift: buildNewShiftPayload() };
} else if (props.mode === 'edit') {
if(!props.initialShift) throw new Error('Missing initial Shift for edit');
body = { old_shift: props.initialShift, new_shift: buildNewShiftPayload() };
} else {
if (!props.initialShift) throw new Error('Missing initial Shift for delete.');
body = { old_shift: props.initialShift };
}
await upsert_shifts_by_date(props.email, props.dateIso, body);
opened.value = false;
emit('saved');
} catch (error: any) {
const status = error?.status_code ?? error.response?.status ?? 500;
const apiConflicts = error?.response?.data?.conflicts ?? error?.data?.conflicts;
if(Array.isArray(apiConflicts)){
conflicts.value = apiConflicts.map((c:any)=> ({
start_time: String(c.start_time ?? ''),
end_time: String(c.end_time ?? ''),
type: String(c.type ?? ''),
}));
} else {
conflicts.value = [];
}
if (status === 404) errorBanner.value = t('timesheet.shift.errors.not_found')
else if (status === 409) errorBanner.value = t('timesheet.shift.errors.overlap')
else if (status === 422) errorBanner.value = t('timesheet.shift.errors.invalid')
else errorBanner.value = t('timesheet.shift.errors.unknown')
//add conflicts.value error management
} finally {
isSubmitting.value = false;
}
}
const hydrateFromProps = () => {
if(props.mode === 'edit' || props.mode === 'delete') {
const shift = props.initialShift;
startTime.value = shift?.start_time ?? '';
endTime.value = shift?.end_time ?? '';
type.value = shift?.type ?? '';
isRemote.value = !!shift?.is_remote;
comment.value = (shift as any)?.comment ?? '';
} else {
startTime.value = '';
endTime.value = '';
type.value = '';
isRemote.value = false;
comment.value = '';
}
};
const canSubmit = computed(() =>
props.mode === 'delete' ||
(startTime.value.trim().length === 5 &&
endTime.value.trim().length === 5 &&
type.value.trim().length > 0)
);
watch(
()=> [opened.value, props.mode, props.initialShift, props.dateIso],
()=> { if (opened.value) hydrateFromProps();},
{ immediate: true }
);
</script>
<!-- create/edit/delete shifts dialog -->
<template>
<q-dialog
v-model="opened"
persistent
transition-show="fade"
transition-hide="fade"
>
<q-card class="q-pa-md">
<div class="row items-center q-mb-sm">
<q-icon name="schedule" size="24px" class="q-mr-sm"/>
<div class="text-h6">
{{
props.mode === 'create'
? $t('timesheet.shift.actions.add')
: props.mode === 'edit'
? $t('timesheet.shift.actions.edit')
: $t('timesheet.shift.actions.delete')
}}
</div>
<q-space/>
<q-badge outline color="primary">{{ props.dateIso }}</q-badge>
</div>
<q-separator spaced/>
<div v-if="props.mode !== 'delete'" class="column q-gutter-md">
<div class="row ">
<div class="col">
<q-input
v-model="startTime"
:label="$t('timesheet.shift.fields.start')"
filled dense
inputmode="numeric"
mask="##:##"
/>
</div>
<div class="col">
<q-input
v-model="endTime"
:label="$t('timesheet.shift.fields.end')"
filled dense
inputmode="numeric"
mask="##:##"
/>
</div>
</div>
<div class="row items-center">
<q-select
v-model="type"
options-dense
:options="props.shiftOptions"
:label="$t('timesheet.shift.types.label')"
class="col"
color="primary"
filled dense
hide-dropdown-icon
emit-value
map-options
/>
<q-toggle
v-model="isRemote"
:label="$t('timesheet.shift.types.REMOTE')"
class="col-auto" />
</div>
<q-input
v-model="comment"
type="textarea"
autogrow filled dense
:label="$t('timesheet.shift.fields.header_comment')"
:counter="true" :maxlength="512"
/>
</div>
<div v-else class="q-pa-md">
{{ $t('timesheet.shift.actions.delete_confirmation_msg') }}
</div>
<div v-if="errorBanner" class="q-mt-md">
<q-banner dense class="bg-red-2 text-negative">{{ errorBanner }}</q-banner>
<div v-if="conflicts.length" class="q-mt-xs">
<div class="text-caption">Conflits :</div>
<ul class="q-pl-md q-mt-xs">
<li v-for="(c, i) in conflicts" :key="i">
{{ c.start_time }}{{ c.end_time }} ({{ c.type }})
</li>
</ul>
</div>
</div>
<q-separator spaced />
<div class="row justify-end q-gutter-sm">
<q-btn
flat
color="grey-8"
:label="$t('timesheet.cancel_button')"
@click="() => { opened = false; emit('close');}"
/>
<q-btn
v-if="props.mode === 'delete'"
outline color="negative"
icon="cancel"
:label="$t('timesheet.delete_button')"
:loading="isSubmitting"
:disable="!canSubmit"
@click="onSubmit"
/>
<q-btn
v-else
color="primary"
icon="save_alt"
:label="$t('timesheet.save_button')"
:loading="isSubmitting"
:disable="!canSubmit"
@click="onSubmit"
/>
</div>
</q-card>
</q-dialog>
</template>

View File

@ -13,13 +13,13 @@ type ShiftLegendItem = {
};
const legend: ShiftLegendItem[] = [
{type:'REGULAR' , color: 'secondary', label_key: 'timesheet.shift_types.REGULAR', text_color: 'grey-8'},
{type:'EVENING' , color: 'warning' , label_key: 'timesheet.shift_types.EVENING'},
{type:'EMERGENCY', color: 'amber-10' , label_key: 'timesheet.shift_types.EMERGENCY'},
{type:'OVERTIME' , color: 'negative' , label_key: 'timesheet.shift_types.OVERTIME'},
{type:'VACATION' , color: 'purple-10', label_key: 'timesheet.shift_types.VACATION'},
{type:'HOLIDAY' , color: 'purple-8' , label_key: 'timesheet.shift_types.HOLIDAY'},
{type:'SICK' , color: 'grey-8' , label_key: 'timesheet.shift_types.SICK'},
{type:'REGULAR' , color: 'secondary', label_key: 'timesheet.shift.types.REGULAR', text_color: 'grey-8'},
{type:'EVENING' , color: 'warning' , label_key: 'timesheet.shift.types.EVENING'},
{type:'EMERGENCY', color: 'amber-10' , label_key: 'timesheet.shift.types.EMERGENCY'},
{type:'OVERTIME' , color: 'negative' , label_key: 'timesheet.shift.types.OVERTIME'},
{type:'VACATION' , color: 'purple-10', label_key: 'timesheet.shift.types.VACATION'},
{type:'HOLIDAY' , color: 'purple-8' , label_key: 'timesheet.shift.types.HOLIDAY'},
{type:'SICK' , color: 'grey-8' , label_key: 'timesheet.shift.types.SICK'},
]
const shift_type_legend = computed(()=>

View File

@ -37,11 +37,13 @@ import { computed } from 'vue';
const get_text_color = (type: string): string => {
switch(type) {
case 'REGULAR': return 'grey-8';
case '': return 'transparent';
case '': return 'grey-5';
default: return 'white';
}
}
const on_click_edit = () => emit('request-edit', { shift: props.shift });
const on_click_edit = (type: string) => {
if(type !== '') { emit('request-edit', { shift: props.shift })};
}
const on_click_delete = () => emit('request-delete', { shift: props.shift });
</script>
@ -49,9 +51,10 @@ const on_click_delete = () => emit('request-delete', { shift: props.shift });
<template>
<q-card-section
horizontal
class="q-pa-none text-uppercase text-center items-center cursor-pointer rounded-10"
class="q-pa-none text-uppercase text-center items-center rounded-10"
:class="props.shift.type === '' ? '': 'cursor-pointer'"
style="line-height: 1;"
@click.stop="on_click_edit"
@click.stop="on_click_edit(props.shift.type)"
>
<!-- punch-in timestamps -->
<q-card-section class="q-pa-none col">

View 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,
});
}
};

View File

@ -29,7 +29,7 @@ export interface UpsertShiftsResponse {
day: DayShift[];
}
export const TIME_FORMAT_PATTERN = /^([01]\d|2[0-3]):([0-5]\d)$/;
export const TIME_FORMAT_PATTERN = /^\d{2}:\d{2}$/;
export const DATE_FORMAT_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
export const COMMENT_MAX_LENGTH = 512 as const;

View File

@ -2,20 +2,93 @@
import { useTimesheetStore } from 'src/stores/timesheet-store';
import { useAuthStore } from 'src/stores/auth-store';
import { useTimesheetApi } from '../composables/use-timesheet-api';
import { computed, onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { computed, onMounted, ref } from 'vue';
import { date } from 'quasar';
import TimesheetNavigation from '../components/timesheet/timesheet-navigation.vue';
import ShiftsLegend from '../components/shift/shifts-legend.vue';
import TimesheetDetailsShifts from '../components/timesheet/timesheet-details-shifts.vue';
import { upsert_shifts_by_date, type ShiftPayload, type UpsertShiftsBody } from '../composables/use-shift-api';
import TimesheetDetailsShifts from '../components/shift/timesheet-details-shifts.vue';
import { type ShiftPayload } 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';
import TimesheetDetailsExpenses from '../components/expenses/timesheet-details-expenses.vue';
import ShiftCrudDialog from '../components/shift/shift-crud-dialog.vue';
/* eslint-disable */
const { locale, tm, t } = useI18n();
const { locale, t } = useI18n();
const timesheet_store = useTimesheetStore();
const auth_store = useAuthStore();
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;
expenses_error.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;
expenses_error.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;
expenses_error.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 = {
day: 'numeric',
month: 'long',
@ -58,10 +131,9 @@ onMounted( async () => {
await loadByDate(date.formatDate(new Date(), 'YYYY-MM-DD' ));
});
const reload_current_period = async () => {
await timesheet_store.getPayPeriodByDate(date.formatDate(new Date(), 'YYYY-MM-DD'));
const onShiftSaved = async () => {
await timesheet_store.getTimesheetsByPayPeriodAndEmail(auth_store.user.email);
};
}
type FormMode = 'create' | 'edit' | 'delete';
@ -70,32 +142,16 @@ const form_mode = ref<FormMode>('create');
const selected_date = ref<string>('');
const old_shift_ref = ref<ShiftPayload | undefined>(undefined);
const start_time = ref<string>('');
const end_time = ref<string>('');
const type = ref<string>('');
const is_remote = ref<boolean>(false);
const comment = ref<string>('');
const open_create_dialog = (iso_date: string) => {
form_mode.value = 'create';
selected_date.value = iso_date;
old_shift_ref.value = undefined;
start_time.value = '';
end_time.value = '';
type.value = '';
is_remote.value = false;
comment.value = '';
is_dialog_open.value = true;
};
const open_edit_dialog = (iso_date: string, shift: any) => {
form_mode.value = 'edit';
selected_date.value = iso_date;
start_time.value = shift.start_time,
end_time.value = shift.end_time,
type.value = shift.type,
is_remote.value = shift.is_remote,
comment.value = shift.comment,
is_dialog_open.value = true;
old_shift_ref.value = {
start_time: shift.start_time,
@ -119,63 +175,15 @@ const open_delete_dialog = (iso_date: string, shift: any) => {
is_dialog_open.value = true;
};
const build_new_shift_payload = () => {
const base = {
start_time: start_time.value,
end_time: end_time.value,
type: type.value,
is_remote: !!is_remote.value,
};
const trimmed_comment = (comment.value ?? '').trim();
return {
...base,
...(trimmed_comment.length > 0 ? { comment: trimmed_comment }: {}),
};
};
const is_submitting = ref<boolean>(false);
const error_banner = ref<string|null>(null);
const conflicts = ref<Array<{start_time: string; end_time: string; type: string }>>([]);
const submit_dialog = async () => {
error_banner.value = null;
conflicts.value = [];
is_submitting.value = true;
try {
const email = auth_store.user.email;
const date_iso = selected_date.value;
let body: UpsertShiftsBody;
const expenses_error = ref<string|null>(null);
if(form_mode.value === 'create') {
body = { new_shift: build_new_shift_payload() };
} else if (form_mode.value === 'edit') {
body = { old_shift: old_shift_ref.value!, new_shift: build_new_shift_payload() };
} else {
body = { old_shift: old_shift_ref.value! };
}
await upsert_shifts_by_date(email, date_iso, body);
await timesheet_store.getTimesheetsByPayPeriodAndEmail(email);
is_dialog_open.value = false;
} catch (e:any) {
const status = e?.status_code ?? e?.response?.status ?? 500;
if (status === 404) {
error_banner.value = 'Ce quart a été modifié ou supprimé. Rafraichissez la page.';
} else if (status === 409) {
error_banner.value = 'Chevauchement détecté avec un autre quart';
} else if (status === 422) {
error_banner.value = 'Certains champs sont invalides. Vérifiez le formulaire.';
} else {
error_banner.value = e?.message || 'Erreur inconnue';
}
} finally {
is_submitting.value = false;
}
};
const close_dialog = () => { is_dialog_open.value = false; };
const close_dialog = () => {
expenses_error.value = null;
is_dialog_open.value = false;
};
const on_request_add = ({ date }: { date: string }) => open_create_dialog(date);
const on_request_edit = ({ date, shift }: { date: string; shift: any }) => open_edit_dialog(date, shift);
@ -187,7 +195,7 @@ const on_request_delete = async ({ date, shift }: { date: string; shift: any })
<q-page padding class="q-pa-md bg-secondary" >
<!-- title and dates -->
<div class="text-h4 row justify-center text-center q-mt-lg text-uppercase text-weight-bolder text-grey-8">
{{ $t('pageTitles.timeSheets') }}
{{ $t('timesheet.title') }}
</div>
<div class="row items-center justify-center q-py-none q-my-none">
<div
@ -200,7 +208,7 @@ const on_request_delete = async ({ date, shift }: { date: string; shift: any })
class="text-grey-8 text-uppercase q-mx-md"
:class="$q.screen.lt.md ? 'text-weight-medium text-caption' : 'text-weight-bold'"
>
{{ $t('timesheet.dateRangesTo') }}
{{ $t('timesheet.date_ranges_to') }}
</div>
<div
class="text-primary text-uppercase text-center text-weight-bold"
@ -210,8 +218,18 @@ const on_request_delete = async ({ date, shift }: { date: string; shift: any })
</div>
</div>
<div>
<q-card flat class="q-mt-md bg-secondary">
<q-card flat class=" col q-mt-md bg-secondary">
<!-- 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
:is-disabled="timesheet_store.is_loading"
:is-previous-limit="is_calendar_limit"
@ -219,10 +237,11 @@ const on_request_delete = async ({ date, shift }: { date: string; shift: any })
@pressed-previous-button="timesheet_api.getPreviousPayPeriod()"
@pressed-next-button="timesheet_api.getNextPayPeriod()"
/>
<!-- shift's colored legend -->
<ShiftsLegend
:is-loading="false"
/>
</q-card-section>
<!-- shift's colored legend -->
<ShiftsLegend
:is-loading="false"
/>
<q-card-section horizontal>
<!-- display of shifts for 2 timesheets -->
<TimesheetDetailsShifts
@ -231,96 +250,55 @@ const on_request_delete = async ({ date, shift }: { date: string; shift: any })
@request-add="on_request_add"
@request-edit="on_request_edit"
@request-delete="on_request_delete"
/>
<q-inner-loading :showing="timesheet_store.is_loading" color="primary"/>
</q-card-section>
</q-card>
</div>
<!-- create/edit/delete dialog -->
<q-dialog
v-model="is_dialog_open"
persistent
transition-show="fade"
transition-hide="fade"
<!-- read/edit/create/delete expense dialog -->
<q-dialog
v-model="show_expenses_dialog"
persistent
>
<q-card class="q-pa-md">
<div class="row items-center q-mb-sm">
<q-icon name="schedule" size="24px" class="q-mr-sm"/>
<div class="text-h6">
{{ form_mode === 'create' ? $t('timesheet.add_shift') : form_mode === 'edit' ? $t('timesheet.edit_shift') : $t('timesheet.delete_shift') }}
</div>
<q-space/>
<q-badge outline color="primary"> {{ selected_date }}</q-badge>
</div>
<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>
<q-banner
v-if="expenses_error"
dense
class="bg-red-2 text-negative q-mt-sm"
>
{{ expenses_error }}
</q-banner>
<q-separator spaced/>
<div v-if="form_mode !== 'delete'" class="column q-gutter-md">
<div class="row ">
<div class="col">
<q-input
v-model="start_time"
:label="$t('timesheet.fields.start')" filled dense inputmode="numeric" mask="##:##" />
</div>
<div class="col">
<q-input v-model="end_time" :label="$t('timesheet.fields.end')" filled dense inputmode="numeric" mask="##:##" />
</div>
</div>
<div class="row items-center">
<q-select
v-model="type"
options-dense
:options="shift_options"
:label="$t('timesheet.shift_types_label')"
class="col"
color="primary"
filled
dense
hide-dropdown-icon
emit-value
map-options
/>
<q-toggle v-model="is_remote" :label="$t('timesheet.shift_types.REMOTE')" class="col-auto" />
</div>
<q-input v-model="comment" type="textarea" autogrow filled dense :label="$t('timesheet.fields.header_comment')" :counter="true" :maxlength="512" />
</div>
<div v-else class="q-pa-md">
{{ $t('timesheet.delete_confirmation_msg') }}
</div>
<div v-if="error_banner" class="q-mt-md">
<q-banner dense class="bg-red-2 text-negative">{{ error_banner }}</q-banner>
<div v-if="conflicts.length" class="q-mt-xs">
<div class="text-caption">Conflits :</div>
<ul class="q-pl-md q-mt-xs">
<li v-for="(c, i) in conflicts" :key="i">
{{ c.start_time }}{{ c.end_time }} ({{ c.type }})
</li>
</ul>
</div>
</div>
<q-separator spaced />
<div class="row justify-end q-gutter-sm">
<q-btn flat color="grey-8" :label="$t('timesheet.cancel_button')" @click="close_dialog" />
<q-btn
v-if="form_mode === 'delete'"
outline color="negative" icon="cancel" :label="$t('timesheet.delete_button')"
:loading="is_submitting"
@click="submit_dialog"
/>
<q-btn
v-else
color="primary" icon="save_alt" :label="$t('timesheet.save_button')"
:loading="is_submitting"
@click="submit_dialog"
/>
</div>
<TimesheetDetailsExpenses
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>
</q-dialog>
<!-- shift crud dialog -->
<ShiftCrudDialog
v-model="is_dialog_open"
:mode="form_mode"
:date-iso="selected_date"
:email="auth_store.user.email"
:initial-shift="old_shift_ref || null"
:shift-options="shift_options"
@close="close_dialog"
@saved="onShiftSaved"
/>
</q-page>
</template>

View 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']

View File

@ -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;
};
}

View 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 }
);