From b488848ac37bbab32be403dd37ce74ba3df933c2 Mon Sep 17 00:00:00 2001 From: Matthieu Haineault Date: Wed, 17 Sep 2025 16:47:57 -0400 Subject: [PATCH] feat(timesheet): added expenses list with create/update/delete expenses --- src/i18n/en-ca/index.ts | 77 ++-- src/i18n/fr-ca/index.ts | 80 ++-- .../expenses/timesheet-details-expenses.vue | 344 ++++++++++++++++++ .../expenses/timesheet-expenses-list-row.vue | 0 .../expenses/timesheet-expenses-list.vue | 0 .../expenses/timesheet-expenses.vue | 0 .../timesheet-details-shifts-row-header.vue | 0 .../timesheet-details-shifts-row.vue | 0 .../timesheet-details-shifts.vue | 0 .../timesheets/composables/use-expense-api.ts | 198 ++++++++++ .../pages/timesheet-details-overview.vue | 135 ++++++- .../types/timesheet-expenses-interface.ts | 21 ++ .../timesheet-expenses-list-interface.ts | 14 + .../utils/timesheet-expenses-validators.ts | 148 ++++++++ 14 files changed, 908 insertions(+), 109 deletions(-) create mode 100644 src/modules/timesheets/components/expenses/timesheet-details-expenses.vue delete mode 100644 src/modules/timesheets/components/expenses/timesheet-expenses-list-row.vue delete mode 100644 src/modules/timesheets/components/expenses/timesheet-expenses-list.vue delete mode 100644 src/modules/timesheets/components/expenses/timesheet-expenses.vue rename src/modules/timesheets/components/{timesheet => shift}/timesheet-details-shifts-row-header.vue (100%) rename src/modules/timesheets/components/{timesheet => shift}/timesheet-details-shifts-row.vue (100%) rename src/modules/timesheets/components/{timesheet => shift}/timesheet-details-shifts.vue (100%) create mode 100644 src/modules/timesheets/composables/use-expense-api.ts create mode 100644 src/modules/timesheets/types/timesheet-expenses-interface.ts create mode 100644 src/modules/timesheets/types/timesheet-expenses-list-interface.ts create mode 100644 src/modules/timesheets/utils/timesheet-expenses-validators.ts diff --git a/src/i18n/en-ca/index.ts b/src/i18n/en-ca/index.ts index 67a4577..c733aa2 100644 --- a/src/i18n/en-ca/index.ts +++ b/src/i18n/en-ca/index.ts @@ -286,52 +286,37 @@ export default { header_comment:'Shift`s comment', textarea_comment: 'Leave a comment here', }, - //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', + expense: { + add_expense:'Add Expense', + amount:'Amount', + date:'Date', + empty_list:'No registered expenses', + errors: { + date_required_or_invalid:'', + comment_required:'', + comment_too_long:'', + amount_must_be_positive:'', + mileave_must_be_positive:'', + amount_xor_mileage:'', + mileage_required_for_type:'', + amount_required_for_type:'', + }, + hints: { + amount_or_mileage:'Either amount or mileage, not both', + }, + 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: { tableColumnLabelFullname: 'Full name', diff --git a/src/i18n/fr-ca/index.ts b/src/i18n/fr-ca/index.ts index 4c76adf..1bdd198 100644 --- a/src/i18n/fr-ca/index.ts +++ b/src/i18n/fr-ca/index.ts @@ -312,13 +312,10 @@ 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', @@ -336,52 +333,37 @@ export default { header_comment:'Commentaire du Quart', textarea_comment:'Laissez votre commentaire', }, - //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 d’heures à banquer', - bankedHoursHint_1: ' sur', - bankedHoursHint_2: ' heures d’accumulé', - 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:'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', + expense: { + add_expense:'Ajouter une dépense', + amount:'Montant', + date:'Date', + empty_list:'Aucun dépense enregistrée', + errors: { + date_required_or_invalid:'', + comment_required:'', + comment_too_long:'', + amount_must_be_positive:'', + mileave_must_be_positive:'', + amount_xor_mileage:'', + mileage_required_for_type:'', + amount_required_for_type:'', + }, + hints: { + amount_or_mileage:'Soit dépense ou kilométrage, pas les deux', + }, + 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:'', + EXPENSES:'', + MILEAGE:'', + PRIME_GARDE:'', + }, + }, }, timeSheetValidations: { tableColumnLabelFullname: 'nom complet', diff --git a/src/modules/timesheets/components/expenses/timesheet-details-expenses.vue b/src/modules/timesheets/components/expenses/timesheet-details-expenses.vue new file mode 100644 index 0000000..8ba65c3 --- /dev/null +++ b/src/modules/timesheets/components/expenses/timesheet-details-expenses.vue @@ -0,0 +1,344 @@ + + + + \ No newline at end of file diff --git a/src/modules/timesheets/components/expenses/timesheet-expenses-list-row.vue b/src/modules/timesheets/components/expenses/timesheet-expenses-list-row.vue deleted file mode 100644 index e69de29..0000000 diff --git a/src/modules/timesheets/components/expenses/timesheet-expenses-list.vue b/src/modules/timesheets/components/expenses/timesheet-expenses-list.vue deleted file mode 100644 index e69de29..0000000 diff --git a/src/modules/timesheets/components/expenses/timesheet-expenses.vue b/src/modules/timesheets/components/expenses/timesheet-expenses.vue deleted file mode 100644 index e69de29..0000000 diff --git a/src/modules/timesheets/components/timesheet/timesheet-details-shifts-row-header.vue b/src/modules/timesheets/components/shift/timesheet-details-shifts-row-header.vue similarity index 100% rename from src/modules/timesheets/components/timesheet/timesheet-details-shifts-row-header.vue rename to src/modules/timesheets/components/shift/timesheet-details-shifts-row-header.vue diff --git a/src/modules/timesheets/components/timesheet/timesheet-details-shifts-row.vue b/src/modules/timesheets/components/shift/timesheet-details-shifts-row.vue similarity index 100% rename from src/modules/timesheets/components/timesheet/timesheet-details-shifts-row.vue rename to src/modules/timesheets/components/shift/timesheet-details-shifts-row.vue diff --git a/src/modules/timesheets/components/timesheet/timesheet-details-shifts.vue b/src/modules/timesheets/components/shift/timesheet-details-shifts.vue similarity index 100% rename from src/modules/timesheets/components/timesheet/timesheet-details-shifts.vue rename to src/modules/timesheets/components/shift/timesheet-details-shifts.vue diff --git a/src/modules/timesheets/composables/use-expense-api.ts b/src/modules/timesheets/composables/use-expense-api.ts new file mode 100644 index 0000000..e8beb38 --- /dev/null +++ b/src/modules/timesheets/composables/use-expense-api.ts @@ -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; +} + +export class ExpensesApiError extends Error { + status_code: number; + error_code?: string; + context?: Record; + 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 = (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 => { + 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(`/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 => { + 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( + `/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 => { + 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( + `/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, + }); + } +}; + diff --git a/src/modules/timesheets/pages/timesheet-details-overview.vue b/src/modules/timesheets/pages/timesheet-details-overview.vue index 4ce0b1c..341842d 100644 --- a/src/modules/timesheets/pages/timesheet-details-overview.vue +++ b/src/modules/timesheets/pages/timesheet-details-overview.vue @@ -7,15 +7,86 @@ 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 TimesheetDetailsShifts from '../components/shift/timesheet-details-shifts.vue'; 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 */ -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(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 = { day: 'numeric', month: 'long', @@ -209,19 +280,30 @@ const on_request_delete = async ({ date, shift }: { date: string; shift: any })
- + - - - + + + + + + +
+ + + + + + - + + + + = ['MILEAGE']; +export const TYPES_WITH_AMOUNT_ONLY: Readonly = ['PER_DIEM', 'EXPENSES', 'PRIME_DISPO'] \ No newline at end of file diff --git a/src/modules/timesheets/types/timesheet-expenses-list-interface.ts b/src/modules/timesheets/types/timesheet-expenses-list-interface.ts new file mode 100644 index 0000000..5eb86b2 --- /dev/null +++ b/src/modules/timesheets/types/timesheet-expenses-list-interface.ts @@ -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; + }; +} \ No newline at end of file diff --git a/src/modules/timesheets/utils/timesheet-expenses-validators.ts b/src/modules/timesheets/utils/timesheet-expenses-validators.ts new file mode 100644 index 0000000..b159800 --- /dev/null +++ b/src/modules/timesheets/utils/timesheet-expenses-validators.ts @@ -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; +} + +export class ExpensesValidationError extends Error { + status_code: number; + error_code?: string | undefined; + context?: Record | 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 } +);