diff --git a/src/i18n/en-ca/index.ts b/src/i18n/en-ca/index.ts index 67a4577..e01b2c2 100644 --- a/src/i18n/en-ca/index.ts +++ b/src/i18n/en-ca/index.ts @@ -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', diff --git a/src/i18n/fr-ca/index.ts b/src/i18n/fr-ca/index.ts index 670c2be..ccf79e3 100644 --- a/src/i18n/fr-ca/index.ts +++ b/src/i18n/fr-ca/index.ts @@ -300,7 +300,8 @@ export default { noDataLabel: 'Je n’ai 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 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', }, 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..cda1de1 --- /dev/null +++ b/src/modules/timesheets/components/expenses/timesheet-details-expenses.vue @@ -0,0 +1,345 @@ + + + + \ 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/shift/shift-crud-dialog.vue b/src/modules/timesheets/components/shift/shift-crud-dialog.vue new file mode 100644 index 0000000..df27201 --- /dev/null +++ b/src/modules/timesheets/components/shift/shift-crud-dialog.vue @@ -0,0 +1,242 @@ + + + + \ No newline at end of file diff --git a/src/modules/timesheets/components/shift/shifts-legend.vue b/src/modules/timesheets/components/shift/shifts-legend.vue index 039abfc..08a568f 100644 --- a/src/modules/timesheets/components/shift/shifts-legend.vue +++ b/src/modules/timesheets/components/shift/shifts-legend.vue @@ -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(()=> 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 93% rename from src/modules/timesheets/components/timesheet/timesheet-details-shifts-row.vue rename to src/modules/timesheets/components/shift/timesheet-details-shifts-row.vue index 6b7b8df..5058fb9 100644 --- a/src/modules/timesheets/components/timesheet/timesheet-details-shifts-row.vue +++ b/src/modules/timesheets/components/shift/timesheet-details-shifts-row.vue @@ -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 }); @@ -49,9 +51,10 @@ const on_click_delete = () => emit('request-delete', { shift: props.shift }); \ 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 } +);