- {{ $t('pageTitles.timeSheets') }}
+ {{ $t('timesheet.title') }}
- {{ $t('timesheet.dateRangesTo') }}
+ {{ $t('timesheet.date_ranges_to') }}
-
+
+
+
+
+
-
-
+
+
+
-
-
-
+
-
-
-
-
- {{ form_mode === 'create' ? $t('timesheet.add_shift') : form_mode === 'edit' ? $t('timesheet.edit_shift') : $t('timesheet.delete_shift') }}
-
-
-
{{ selected_date }}
-
+
+
+
+
+
+ {{ expenses_error }}
+
-
-
-
-
-
- {{ $t('timesheet.delete_confirmation_msg') }}
-
-
-
-
{{ error_banner }}
-
-
Conflits :
-
- -
- {{ c.start_time }}–{{ c.end_time }} ({{ c.type }})
-
-
-
-
-
-
-
-
-
-
-
-
+
-
+
+
+
+
\ No newline at end of file
diff --git a/src/modules/timesheets/types/timesheet-expenses-interface.ts b/src/modules/timesheets/types/timesheet-expenses-interface.ts
new file mode 100644
index 0000000..130183d
--- /dev/null
+++ b/src/modules/timesheets/types/timesheet-expenses-interface.ts
@@ -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
= ['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 }
+);