Merge branch 'main' of git.targo.ca:Targo/targo_frontend into dev/nicolas/profile
This commit is contained in:
commit
dc20f8bf0a
|
|
@ -258,7 +258,8 @@ export default {
|
||||||
timeSheetValidations: 'Time sheet approvals',
|
timeSheetValidations: 'Time sheet approvals',
|
||||||
},
|
},
|
||||||
timesheet: {
|
timesheet: {
|
||||||
//employee's timesheet page
|
title:'Timesheet',
|
||||||
|
date_ranges_to:'to',
|
||||||
days: ['Sunday','Monday','Tuesday','Wednesday','Thursday','Friday','Saturday'],
|
days: ['Sunday','Monday','Tuesday','Wednesday','Thursday','Friday','Saturday'],
|
||||||
nav_button: {
|
nav_button: {
|
||||||
calendar_date_picker:'Calendar',
|
calendar_date_picker:'Calendar',
|
||||||
|
|
@ -269,68 +270,72 @@ export default {
|
||||||
save_button:'Save',
|
save_button:'Save',
|
||||||
cancel_button:'Cancel',
|
cancel_button:'Cancel',
|
||||||
remote_button: 'Remote work',
|
remote_button: 'Remote work',
|
||||||
add_shift:'Add Shift',
|
delete_button: 'Delete',
|
||||||
shift_types_label: 'Shift`s Type',
|
shift: {
|
||||||
shift_types: {
|
actions: {
|
||||||
EMERGENCY: 'Emergency',
|
add:'Add Shift',
|
||||||
EVENING: 'Evening',
|
edit: 'Edit shift',
|
||||||
HOLIDAY: 'Holiday',
|
delete: 'Delete shift',
|
||||||
REGULAR: 'Regular',
|
delete_confirmation_msg: 'Do you want to delete this shift completly?',
|
||||||
SICK: 'Sick Leave',
|
},
|
||||||
VACATION: 'Vacation',
|
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: {
|
expense: {
|
||||||
start:'Start',
|
add_expense:'Add Expense',
|
||||||
end:'End',
|
amount:'Amount',
|
||||||
header_comment:'Shift`s comment',
|
date:'Date',
|
||||||
textarea_comment: 'Leave a comment here',
|
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: {
|
timeSheetValidations: {
|
||||||
tableColumnLabelFullname: 'Full name',
|
tableColumnLabelFullname: 'Full name',
|
||||||
|
|
|
||||||
|
|
@ -308,7 +308,8 @@ export default {
|
||||||
noDataLabel: 'Je n’ai rien trouvé pour toi',
|
noDataLabel: 'Je n’ai rien trouvé pour toi',
|
||||||
},
|
},
|
||||||
timesheet: {
|
timesheet: {
|
||||||
//employee's timesheet page
|
title:'Carte de temps',
|
||||||
|
date_ranges_to:'au',
|
||||||
days: ['Dimanche','Lundi','Mardi','Mercredi','Jeudi','Vendredi','Samedi'],
|
days: ['Dimanche','Lundi','Mardi','Mercredi','Jeudi','Vendredi','Samedi'],
|
||||||
nav_button: {
|
nav_button: {
|
||||||
calendar_date_picker:'Calendrier',
|
calendar_date_picker:'Calendrier',
|
||||||
|
|
@ -319,69 +320,72 @@ export default {
|
||||||
save_button:'Enregistrer',
|
save_button:'Enregistrer',
|
||||||
cancel_button:'Annuler',
|
cancel_button:'Annuler',
|
||||||
remote_button: 'Télétravail',
|
remote_button: 'Télétravail',
|
||||||
|
delete_button: 'Supprimer',
|
||||||
add_shift:'Ajouter une quart',
|
shift: {
|
||||||
shift_types_label: 'Type de quart',
|
actions: {
|
||||||
shift_types: {
|
add:'Ajouter un Quart',
|
||||||
EMERGENCY: 'Urgence',
|
edit: 'Modifier un Quart',
|
||||||
EVENING: 'Soir',
|
delete: 'Supprimer un Quart',
|
||||||
HOLIDAY: 'Férier',
|
delete_confirmation_msg: 'Voulez-vous complètement supprimer ce quart?',
|
||||||
SICK: 'Absence',
|
},
|
||||||
REGULAR: 'Régulier',
|
types: {
|
||||||
VACATION: 'Vacance',
|
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: {
|
expense: {
|
||||||
start:'Entrée',
|
add_expense:'Ajouter une dépense',
|
||||||
end:'Sortie',
|
amount:'Montant',
|
||||||
header_comment:'Commentaire du Quart',
|
date:'Date',
|
||||||
textarea_comment:'Laissez votre commentaire',
|
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: {
|
timeSheetValidations: {
|
||||||
tableColumnLabelFullname: 'nom complet',
|
tableColumnLabelFullname: 'nom complet',
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { useTimesheetStore } from "src/stores/timesheet-store";
|
||||||
import { useAuthStore } from "src/stores/auth-store";
|
import { useAuthStore } from "src/stores/auth-store";
|
||||||
import type { PayPeriodReportFilters } from "../types/timesheet-approval-pay-period-report-interface";
|
import type { PayPeriodReportFilters } from "../types/timesheet-approval-pay-period-report-interface";
|
||||||
import { default_pay_period_overview_employee, type PayPeriodOverviewEmployee } from "../types/timesheet-approval-pay-period-overview-employee-interface";
|
import { default_pay_period_overview_employee, type PayPeriodOverviewEmployee } from "../types/timesheet-approval-pay-period-overview-employee-interface";
|
||||||
|
import { date } from "quasar";
|
||||||
|
|
||||||
export const useTimesheetApprovalApi = () => {
|
export const useTimesheetApprovalApi = () => {
|
||||||
const timesheet_store = useTimesheetStore();
|
const timesheet_store = useTimesheetStore();
|
||||||
|
|
@ -63,11 +64,29 @@ export const useTimesheetApprovalApi = () => {
|
||||||
await timesheet_store.getTimesheetApprovalCSVReport(options);
|
await timesheet_store.getTimesheetApprovalCSVReport(options);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getCurrentPayPerdioOverview = async (): Promise<void> => {
|
||||||
|
const today = date.formatDate(new Date(), 'YYYY-MM-DD');
|
||||||
|
const success = await timesheet_store.getPayPeriodByDate(today);
|
||||||
|
if(!success) return;
|
||||||
|
|
||||||
|
const { pay_year, pay_period_no } = timesheet_store.current_pay_period;
|
||||||
|
|
||||||
|
await timesheet_store.getTimesheetApprovalPayPeriodEmployeeOverviews(
|
||||||
|
pay_year,
|
||||||
|
pay_period_no,
|
||||||
|
auth_store.user.email
|
||||||
|
);
|
||||||
|
|
||||||
|
await timesheet_store.getTimesheetsByPayPeriodAndEmail(auth_store.user.email);
|
||||||
|
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
getPayPeriodOverviewByDate,
|
getPayPeriodOverviewByDate,
|
||||||
getNextPayPeriodOverview,
|
getNextPayPeriodOverview,
|
||||||
getPayPeriodOverviewByEmployeeEmail,
|
getPayPeriodOverviewByEmployeeEmail,
|
||||||
getTimesheetsByPayPeriodAndEmail,
|
getTimesheetsByPayPeriodAndEmail,
|
||||||
getTimesheetApprovalCSVReport
|
getTimesheetApprovalCSVReport,
|
||||||
|
getCurrentPayPerdioOverview
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -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>
|
||||||
242
src/modules/timesheets/components/shift/shift-crud-dialog.vue
Normal file
242
src/modules/timesheets/components/shift/shift-crud-dialog.vue
Normal 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>
|
||||||
48
src/modules/timesheets/components/shift/shifts-legend.vue
Normal file
48
src/modules/timesheets/components/shift/shifts-legend.vue
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
const props = defineProps<{ isLoading: boolean; }>();
|
||||||
|
|
||||||
|
type ShiftLegendItem = {
|
||||||
|
type: 'REGULAR'|'EVENING'|'EMERGENCY'|'OVERTIME'|'VACATION'|'HOLIDAY'|'SICK';
|
||||||
|
color: string;
|
||||||
|
label_key: string;
|
||||||
|
text_color?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
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'},
|
||||||
|
]
|
||||||
|
|
||||||
|
const shift_type_legend = computed(()=>
|
||||||
|
legend.map(item => ({ ...item, label: t(item.label_key)} ))
|
||||||
|
);
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<q-card class="q-px-xs q-pt-xs col rounded-10 q-mx-xs q-py-xs">
|
||||||
|
<q-card-section
|
||||||
|
class="q-py-xs q-pa-none text-center q-my-s"
|
||||||
|
v-if="!props.isLoading"
|
||||||
|
>
|
||||||
|
<q-badge
|
||||||
|
v-for="shift_type in shift_type_legend"
|
||||||
|
:key="shift_type.type"
|
||||||
|
:color="shift_type.color"
|
||||||
|
:label="shift_type.label"
|
||||||
|
:text-color="shift_type.text_color || 'white'"
|
||||||
|
class="q-px-md q-py-xs q-mx-xs q-my-none text-uppercase text-weight-bolder justify-center"
|
||||||
|
style="width: 120px; font-size: 0.8em;"
|
||||||
|
/>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</template>
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
<template>
|
||||||
|
<q-card-section
|
||||||
|
horizontal
|
||||||
|
class="text-uppercase text-center items-center q-pa-none"
|
||||||
|
>
|
||||||
|
<!-- shift row itself -->
|
||||||
|
<q-card-section class="col q-pa-none">
|
||||||
|
<q-card-section horizontal class="col q-pa-none">
|
||||||
|
<!-- punch-in timestamps -->
|
||||||
|
<q-card-section class="col q-pa-none">
|
||||||
|
<q-item-label class="text-weight-bolder text-primary" style="font-size: 0.7em;">
|
||||||
|
{{ $t('shiftColumns.labelIn') }}
|
||||||
|
</q-item-label>
|
||||||
|
</q-card-section>
|
||||||
|
|
||||||
|
<!-- arrows pointing to punch-out timestamps -->
|
||||||
|
<q-card-section class="col q-py-none q-px-sm">
|
||||||
|
</q-card-section>
|
||||||
|
|
||||||
|
<!-- punch-out timestamps -->
|
||||||
|
<q-card-section class="col q-pa-none">
|
||||||
|
<q-item-label class="text-weight-bolder text-primary" style="font-size: 0.7em;">
|
||||||
|
{{ $t('shiftColumns.labelOut') }}
|
||||||
|
</q-item-label>
|
||||||
|
</q-card-section>
|
||||||
|
|
||||||
|
<!-- comment button -->
|
||||||
|
<q-card-section class="col column q-pa-none">
|
||||||
|
</q-card-section>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card-section>
|
||||||
|
</template>
|
||||||
|
|
@ -0,0 +1,136 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { Shift } from 'src/modules/timesheets/types/timesheet-shift-interface';
|
||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
|
/* eslint-disable */
|
||||||
|
const props = defineProps<{
|
||||||
|
shift: Shift;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'save-comment' : [payload: { comment: string; shift: Shift }];
|
||||||
|
'request-edit' : [payload: { shift: Shift }];
|
||||||
|
'request-delete': [payload: { shift: Shift }];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const has_comment = computed(()=> {
|
||||||
|
const comment = (props.shift as any).description ?? (props.shift as any).comment ?? '';
|
||||||
|
return typeof comment === 'string' && comment.trim().length > 0;
|
||||||
|
})
|
||||||
|
const comment_icon = computed(()=> (has_comment.value ? 'announcement' : 'chat_bubble_outline'));
|
||||||
|
const comment_color = computed(()=> (has_comment.value ? 'primary' : 'grey-8'));
|
||||||
|
|
||||||
|
|
||||||
|
const get_shift_color = (type: string): string => {
|
||||||
|
switch(type) {
|
||||||
|
case 'REGULAR': return 'secondary';
|
||||||
|
case 'EVENING': return 'warning';
|
||||||
|
case 'EMERGENCY': return 'amber-10';
|
||||||
|
case 'OVERTIME': return 'negative';
|
||||||
|
case 'VACATION': return 'purple-10';
|
||||||
|
case 'HOLIDAY': return 'purple-10';
|
||||||
|
case 'SICK': return 'grey-8';
|
||||||
|
default : return 'transparent';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const get_text_color = (type: string): string => {
|
||||||
|
switch(type) {
|
||||||
|
case 'REGULAR': return 'grey-8';
|
||||||
|
case '': return 'grey-5';
|
||||||
|
default: return 'white';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<q-card-section
|
||||||
|
horizontal
|
||||||
|
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(props.shift.type)"
|
||||||
|
>
|
||||||
|
<!-- punch-in timestamps -->
|
||||||
|
<q-card-section class="q-pa-none col">
|
||||||
|
<q-item-label
|
||||||
|
class="text-weight-bolder q-pa-xs rounded-5"
|
||||||
|
:class="'bg-' + get_shift_color(props.shift.type) + ' text-' + get_text_color(props.shift.type)"
|
||||||
|
style="font-size: 1.5em; line-height: 80% !important;"
|
||||||
|
>
|
||||||
|
{{ props.shift.start_time }}
|
||||||
|
</q-item-label>
|
||||||
|
</q-card-section>
|
||||||
|
|
||||||
|
<!-- arrows pointing to punch-out timestamps -->
|
||||||
|
<q-card-section
|
||||||
|
horizontal
|
||||||
|
class="items-center justify-center q-mx-sm col"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="icon_data, index in [
|
||||||
|
{ transform: 'transform: translateX(5px);', color: 'accent' },
|
||||||
|
{ transform: 'transform: translateX(-5px);', color: 'primary' }]"
|
||||||
|
:key="index"
|
||||||
|
>
|
||||||
|
<q-icon
|
||||||
|
v-if="props.shift.type !== ''"
|
||||||
|
name="double_arrow"
|
||||||
|
:color="icon_data.color"
|
||||||
|
size="24px"
|
||||||
|
:style="icon_data.transform"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</q-card-section>
|
||||||
|
|
||||||
|
<!-- punch-out timestamps -->
|
||||||
|
<q-card-section class="q-pa-none col">
|
||||||
|
<q-item-label
|
||||||
|
class="text-weight-bolder text-white q-pa-xs rounded-5"
|
||||||
|
:class="'bg-' + get_shift_color(props.shift.type) + ' text-' + get_text_color(props.shift.type)"
|
||||||
|
style="font-size: 1.5em; line-height: 80% !important;"
|
||||||
|
>
|
||||||
|
{{ props.shift.end_time }}
|
||||||
|
</q-item-label>
|
||||||
|
</q-card-section>
|
||||||
|
|
||||||
|
<!-- comment and expenses buttons -->
|
||||||
|
<q-card-section
|
||||||
|
class="col q-pa-none text-right"
|
||||||
|
>
|
||||||
|
<!-- comment btn -->
|
||||||
|
<q-icon
|
||||||
|
v-if="props.shift.type !== ''"
|
||||||
|
:name="comment_icon"
|
||||||
|
:color="comment_color"
|
||||||
|
class="q-pa-none q-mx-xs"
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
<!-- expenses btn -->
|
||||||
|
<q-btn
|
||||||
|
v-if="props.shift.type !== ''"
|
||||||
|
flat
|
||||||
|
dense
|
||||||
|
color='grey-8'
|
||||||
|
icon="attach_money"
|
||||||
|
class="q-pa-none q-mx-xs"
|
||||||
|
/>
|
||||||
|
<!-- delete btn -->
|
||||||
|
<q-btn
|
||||||
|
v-if="props.shift.type !== ''"
|
||||||
|
push
|
||||||
|
dense
|
||||||
|
size="sm"
|
||||||
|
color="red-6"
|
||||||
|
icon="close"
|
||||||
|
class="q-ml-xs"
|
||||||
|
@click.stop="on_click_delete"
|
||||||
|
/>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card-section>
|
||||||
|
</template>
|
||||||
|
|
@ -0,0 +1,96 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { default_shift, type Shift } from 'src/modules/timesheets/types/timesheet-shift-interface';
|
||||||
|
import type { PayPeriod } from 'src/modules/shared/types/pay-period-interface';
|
||||||
|
import type { TimesheetPayPeriodDetailsOverview } from '../../types/timesheet-pay-period-details-overview-interface';
|
||||||
|
import TimesheetDetailsShiftsRowHeader from './timesheet-details-shifts-row-header.vue';
|
||||||
|
import TimesheetDetailsShiftsRow from './timesheet-details-shifts-row.vue';
|
||||||
|
import { date } from 'quasar';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
rawData: TimesheetPayPeriodDetailsOverview;
|
||||||
|
currentPayPeriod: PayPeriod;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'request-add' : [payload: { date: string }];
|
||||||
|
'request-edit' : [payload: { date: string; shift: Shift }];
|
||||||
|
'request-delete' : [payload: { date: string; shift: Shift }];
|
||||||
|
// 'save-comment' : [payload: { date: string; shift: Shift; comment: string }];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const get_date_from_short = (short_date: string):
|
||||||
|
Date => new Date(props.currentPayPeriod.pay_year.toString() + '/' + short_date);
|
||||||
|
const to_iso_date = (short_date: string):
|
||||||
|
string => date.formatDate(get_date_from_short(short_date), 'YYYY-MM-DD');
|
||||||
|
|
||||||
|
const shifts_or_placeholder = (shifts: Shift[]):
|
||||||
|
Shift[] => { return shifts.length > 0 ? shifts : [default_shift]; };
|
||||||
|
|
||||||
|
const getDate = (shift_date: string): Date => {
|
||||||
|
return new Date(props.currentPayPeriod.pay_year.toString() + '/' + shift_date);
|
||||||
|
};
|
||||||
|
|
||||||
|
const on_request_add = (iso_date: string) => emit('request-add', { date: iso_date });
|
||||||
|
const on_request_edit = (iso_date: string, shift: Shift) => emit('request-edit', { date: iso_date, shift });
|
||||||
|
const on_request_delete = (iso_date: string, shift: Shift) => emit('request-delete', { date: iso_date, shift });
|
||||||
|
// const on_save_comment = (iso_date: string, shift: Shift, comment: string) => emit('save-comment', { date: iso_date, shift, comment });
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
v-for="week, index in props.rawData"
|
||||||
|
:key="index"
|
||||||
|
class="q-px-xs q-pt-xs rounded-5 col"
|
||||||
|
>
|
||||||
|
<q-card
|
||||||
|
v-for="day, day_index in week.shifts"
|
||||||
|
:key="day_index"
|
||||||
|
flat
|
||||||
|
bordered
|
||||||
|
class="row items-center rounded-10 q-mb-xs"
|
||||||
|
>
|
||||||
|
<!-- Dates column -->
|
||||||
|
<q-card-section class="col-auto q-pa-xs text-white">
|
||||||
|
<div
|
||||||
|
class="bg-primary rounded-10 q-pa-xs text-center"
|
||||||
|
>
|
||||||
|
<q-item-label
|
||||||
|
style="font-size: 0.7em;"
|
||||||
|
class="text-uppercase"
|
||||||
|
>{{ $d(getDate(day.short_date), {weekday: $q.screen.lt.md ? 'short' : 'long'}) }}</q-item-label>
|
||||||
|
<q-item-label
|
||||||
|
class="text-weight-bolder"
|
||||||
|
style="font-size: 2.5em; line-height: 90% !important;"
|
||||||
|
>{{ day.short_date.split('/')[1] }}</q-item-label>
|
||||||
|
<q-item-label
|
||||||
|
style="font-size: 0.7em;"
|
||||||
|
class="text-uppercase"
|
||||||
|
>{{ $d(getDate(day.short_date), {month: $q.screen.lt.md ? 'short' : 'long'}) }}</q-item-label>
|
||||||
|
</div>
|
||||||
|
</q-card-section>
|
||||||
|
|
||||||
|
<!-- List of shifts column -->
|
||||||
|
<q-card-section class="col q-pa-none">
|
||||||
|
<TimesheetDetailsShiftsRowHeader />
|
||||||
|
<TimesheetDetailsShiftsRow
|
||||||
|
v-for="shift, shift_index in shifts_or_placeholder(day.shifts)"
|
||||||
|
:key="shift_index"
|
||||||
|
:shift="shift"
|
||||||
|
@request-edit=" ({ shift }) => on_request_edit(to_iso_date(day.short_date), shift )"
|
||||||
|
@request-delete="({ shift }) => on_request_delete(to_iso_date(day.short_date), shift )"
|
||||||
|
/>
|
||||||
|
</q-card-section>
|
||||||
|
<!-- add shift btn column -->
|
||||||
|
<q-card-section class="q-pr-xs col-auto">
|
||||||
|
<q-btn
|
||||||
|
push
|
||||||
|
color="primary"
|
||||||
|
icon="more_time"
|
||||||
|
class="q-pa-sm"
|
||||||
|
@click="on_request_add(to_iso_date(day.short_date))"
|
||||||
|
/>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
@ -1,29 +1,28 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
/* eslint-disable */
|
/* eslint-disable */
|
||||||
import { ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
import { date } from 'quasar';
|
import { date} from 'quasar';
|
||||||
import type { QDateDetails } from 'src/modules/shared/types/q-date-details';
|
import type { QDateDetails } from 'src/modules/shared/types/q-date-details';
|
||||||
|
|
||||||
const is_showing_calendar_picker = ref(false);
|
const is_showing_calendar_picker = ref(false);
|
||||||
const calendar_date = ref(date.formatDate( Date.now(), 'YYYY-MM-DD' ));
|
const calendar_date = ref(date.formatDate( Date.now(), 'YYYY-MM-DD' ));
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
isDisabled: boolean,
|
isDisabled?: boolean;
|
||||||
isPreviousLimit: boolean,
|
isPreviousLimit:boolean;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
'date-selected': [value: string, reason?: string, details?: QDateDetails]
|
'date-selected': [value: string, reason?: string, details?: QDateDetails]
|
||||||
'pressed-previous-button': []
|
'pressed-previous-button': []
|
||||||
'pressed-next-button': []
|
'pressed-next-button': []
|
||||||
'pressed-current-button' : []
|
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const onDateSelected = (value: string, reason: string, details: QDateDetails) => {
|
const onDateSelected = (value: string, reason: string, details: QDateDetails) => {
|
||||||
calendar_date.value = value;
|
calendar_date.value = value;
|
||||||
is_showing_calendar_picker.value = false;
|
is_showing_calendar_picker.value = false;
|
||||||
emit('date-selected', value, reason, details);
|
emit('date-selected', value, reason, details);
|
||||||
}
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|
@ -44,22 +43,6 @@
|
||||||
> {{ $t( 'timesheet.nav_button.previous_week' )}}
|
> {{ $t( 'timesheet.nav_button.previous_week' )}}
|
||||||
</q-tooltip>
|
</q-tooltip>
|
||||||
</q-btn>
|
</q-btn>
|
||||||
<!-- navigation to current week -->
|
|
||||||
<q-btn
|
|
||||||
push rounded
|
|
||||||
icon="today"
|
|
||||||
color="primary"
|
|
||||||
@click="emit('pressed-current-button')"
|
|
||||||
:disable="props.isDisabled"
|
|
||||||
class="q-mr-sm q-px-lg"
|
|
||||||
>
|
|
||||||
<q-tooltip
|
|
||||||
anchor="top middle"
|
|
||||||
self="center middle"
|
|
||||||
class="bg-primary text-uppercase text-weight-bold"
|
|
||||||
>{{ $t('timesheet.nav_button.current_week') }}
|
|
||||||
</q-tooltip>
|
|
||||||
</q-btn>
|
|
||||||
<!-- navigation through calendar date picker -->
|
<!-- navigation through calendar date picker -->
|
||||||
<q-btn
|
<q-btn
|
||||||
push rounded
|
push rounded
|
||||||
|
|
@ -95,14 +78,18 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- date picker calendar -->
|
<!-- date picker calendar -->
|
||||||
<q-dialog v-model="is_showing_calendar_picker" transition-show="jump-down" transition-hide="jump-up" position="top">
|
<q-dialog
|
||||||
|
v-model="is_showing_calendar_picker"
|
||||||
|
transition-show="jump-down"
|
||||||
|
transition-hide="jump-up"
|
||||||
|
position="top">
|
||||||
<q-date
|
<q-date
|
||||||
v-model="calendar_date"
|
v-model="calendar_date"
|
||||||
color="primary"
|
color="primary"
|
||||||
class="q-mt-xl"
|
class="q-mt-xl"
|
||||||
today-btn
|
today-btn
|
||||||
mask="YYYY-MM-DD"
|
mask="YYYY-MM-DD"
|
||||||
:options="date => date > '2023-12-16'"
|
:options="date => date > '2023/12/16'"
|
||||||
@update:model-value="onDateSelected"
|
@update:model-value="onDateSelected"
|
||||||
/>
|
/>
|
||||||
</q-dialog>
|
</q-dialog>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
/* eslint-disable */
|
/* eslint-disable */
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import type { CreateShiftPayload, Shift } from '../../types/timesheet-shift-interface';
|
import type { CreateShiftPayload } from '../../types/timesheet-shifts-payload-interface';
|
||||||
|
import type { Shift } from '../../types/timesheet-shift-interface';
|
||||||
|
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
|
|
@ -29,7 +30,7 @@ const buildPayload = (): CreateShiftPayload[] => {
|
||||||
end_time: row.end_time,
|
end_time: row.end_time,
|
||||||
is_remote: row.is_remote,
|
is_remote: row.is_remote,
|
||||||
};
|
};
|
||||||
if(row.comment) item.description = row.comment;
|
if(row.comment) item.comment = row.comment;
|
||||||
return[item];
|
return[item];
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,266 +0,0 @@
|
||||||
<script setup lang="ts">
|
|
||||||
/* eslint-disable */
|
|
||||||
import { computed, ref, watch } from 'vue';
|
|
||||||
import { useI18n } from 'vue-i18n';
|
|
||||||
import { useTimesheetStore } from 'src/stores/timesheet-store';
|
|
||||||
import TimesheetShiftComment from '../shift/timesheet-shift-comment.vue';
|
|
||||||
import TimesheetSavePayload from './timesheet-save-payload.vue';
|
|
||||||
import { Shift } from '../../types/timesheet-shift-interface';
|
|
||||||
import type { CreateShiftPayload } from '../../types/timesheet-shift-interface';
|
|
||||||
|
|
||||||
const timesheet_store = useTimesheetStore();
|
|
||||||
|
|
||||||
const { t, tm, locale } = useI18n();
|
|
||||||
|
|
||||||
const SHIFT_KEY = ['REGULAR', 'EVENING', 'EMERGENCY', 'HOLIDAY', 'VACATION', 'SICK'] as const;
|
|
||||||
const days = computed(()=> {
|
|
||||||
void locale.value;
|
|
||||||
return (tm('timesheet.days') as string[]) ?? [];
|
|
||||||
});
|
|
||||||
|
|
||||||
const shift_options = computed(()=> {
|
|
||||||
void locale.value;
|
|
||||||
return SHIFT_KEY.map(key => ({ value: key, label: t(`timesheet.shift_types.${key}`)}))
|
|
||||||
});
|
|
||||||
|
|
||||||
const empty_row = { date:'', type: '', start_time: '', end_time: '', comment: '', is_approved: false, is_remote: false };
|
|
||||||
//Week dates
|
|
||||||
const week_dates = computed(() => {
|
|
||||||
const start_date = timesheet_store.current_timesheet.start_day;
|
|
||||||
if(!start_date) return [];
|
|
||||||
|
|
||||||
const mm = /^(\d{4})-(\d{2})-(\d{2})$/.exec(start_date);
|
|
||||||
if(!mm) return [];
|
|
||||||
const year = Number(mm[1]), month = Number(mm[2]), day = Number(mm[3])
|
|
||||||
|
|
||||||
const base = new Date(Date.UTC(year, month - 1, day));
|
|
||||||
const yyyymmdd = (date: Date) => {
|
|
||||||
const yyyy = date.getFullYear();
|
|
||||||
const mm = String(date.getUTCMonth() + 1).padStart(2,'0');
|
|
||||||
const dd = String(date.getUTCDate()).padStart(2,'0');
|
|
||||||
return `${yyyy}-${mm}-${dd}`};
|
|
||||||
|
|
||||||
return Array.from({length:7 }, (_, i) => {
|
|
||||||
const date = new Date(base);
|
|
||||||
date.setUTCDate(base.getDate() + i);
|
|
||||||
return yyyymmdd(date);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
//filling timesheet with shifts
|
|
||||||
const rows = ref<Shift[]>(
|
|
||||||
days.value.map((_,index) => {
|
|
||||||
const date_ISO = week_dates.value[index];
|
|
||||||
const shift = timesheet_store.current_timesheet.shifts.find(sh => sh.date === date_ISO);
|
|
||||||
return shift ? {
|
|
||||||
date:shift.date || '',
|
|
||||||
type: shift.bank_type || '',
|
|
||||||
start_time: shift.start_time || '',
|
|
||||||
end_time: shift.end_time || '',
|
|
||||||
comment: shift.description || '',
|
|
||||||
is_approved: !!shift.is_approved,
|
|
||||||
is_remote: !!shift.is_remote,
|
|
||||||
}
|
|
||||||
: { ...empty_row };
|
|
||||||
})
|
|
||||||
);
|
|
||||||
const hasData = (row: Shift) => !!(row.type || row.start_time || row.end_time || row.comment);
|
|
||||||
|
|
||||||
const show_comment = ref(false);
|
|
||||||
const selected_index = ref<number | null>(null);
|
|
||||||
const selected_row = computed<Shift | undefined>(()=>
|
|
||||||
selected_index.value != null ? rows.value[selected_index.value] : undefined
|
|
||||||
);
|
|
||||||
|
|
||||||
const setComment = (comment: string) => {
|
|
||||||
if(selected_row.value) selected_row.value.comment = comment;
|
|
||||||
show_comment.value = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const onClickComment = (index: number)=> {
|
|
||||||
selected_index.value = index;
|
|
||||||
show_comment.value = true;
|
|
||||||
};
|
|
||||||
|
|
||||||
const clearRow = (index: number) => {
|
|
||||||
rows.value[index] = { ...empty_row };
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
const emit = defineEmits<{ (e: 'save', payload: CreateShiftPayload[]): void }>();
|
|
||||||
|
|
||||||
//onMounted?
|
|
||||||
watch(
|
|
||||||
() => [timesheet_store.current_timesheet.start_day, timesheet_store.current_timesheet.shifts],
|
|
||||||
() => {
|
|
||||||
const dates = week_dates.value;
|
|
||||||
rows.value = days.value.map((_, idx)=> {
|
|
||||||
const shift = timesheet_store.current_timesheet.shifts.find(sh => sh.date === dates[idx]);
|
|
||||||
return shift
|
|
||||||
? { date: shift.date || '',
|
|
||||||
type: shift.bank_type || '',
|
|
||||||
start_time: shift.start_time || '',
|
|
||||||
end_time: shift.end_time || '',
|
|
||||||
comment: shift.description || '',
|
|
||||||
is_approved: shift.is_approved,
|
|
||||||
is_remote: shift.is_remote,
|
|
||||||
}
|
|
||||||
: { date: '',
|
|
||||||
type: '',
|
|
||||||
start_time: '',
|
|
||||||
end_time: '',
|
|
||||||
comment: '',
|
|
||||||
is_approved: false,
|
|
||||||
is_remote: false
|
|
||||||
};
|
|
||||||
});
|
|
||||||
},
|
|
||||||
{ deep: true, immediate: true }
|
|
||||||
);
|
|
||||||
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<q-card class="bg-transparent q-pa-md q-ma-md">
|
|
||||||
<q-dialog
|
|
||||||
v-model="show_comment"
|
|
||||||
transition-show="fade"
|
|
||||||
transition-hide="fade"
|
|
||||||
persistent
|
|
||||||
>
|
|
||||||
<!-- comment popup -->
|
|
||||||
<TimesheetShiftComment
|
|
||||||
:comment-string="selected_row?.comment ?? ''"
|
|
||||||
@click-save="setComment"
|
|
||||||
@click-close="show_comment = false"
|
|
||||||
/>
|
|
||||||
</q-dialog>
|
|
||||||
|
|
||||||
<q-form
|
|
||||||
autofocus
|
|
||||||
class="bg-white q-pa-sm q-pt-lg rounded-10">
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-for="(row, index) in rows"
|
|
||||||
:key="week_dates[index] ?? index"
|
|
||||||
class="q-gutter-sm q-mb-sm"
|
|
||||||
:class="$q.screen.lt.md ? 'column' : 'row'" >
|
|
||||||
<!--Week days-->
|
|
||||||
<span class="text-weight-bold text-primary col-1">{{ days[index] }}</span>
|
|
||||||
<!-- remote work toggle -->
|
|
||||||
<q-toggle
|
|
||||||
v-model="row.is_remote"
|
|
||||||
:disable="row.is_approved"
|
|
||||||
color="primary"
|
|
||||||
checked-icon="home_work"
|
|
||||||
unchecked-icon="business"
|
|
||||||
icon="home_work"
|
|
||||||
class="col-auto q-ml-sm"
|
|
||||||
>
|
|
||||||
<q-tooltip
|
|
||||||
anchor="top middle"
|
|
||||||
self="center middle"
|
|
||||||
class="bg-primary text-uppercase text-weight-bold"
|
|
||||||
>{{ $t('timesheet.remote_button') }}
|
|
||||||
</q-tooltip>
|
|
||||||
</q-toggle>
|
|
||||||
<!-- type selection -->
|
|
||||||
<q-select
|
|
||||||
v-model="row.type"
|
|
||||||
:options="shift_options"
|
|
||||||
:readonly="row.is_approved"
|
|
||||||
class="col-3"
|
|
||||||
:label="$t('timesheet.shift_types_label')"
|
|
||||||
dense
|
|
||||||
filled
|
|
||||||
color="primary"
|
|
||||||
standout="bg-primary text-white"
|
|
||||||
options-dense
|
|
||||||
emit-value
|
|
||||||
map-options
|
|
||||||
option-value="value"
|
|
||||||
option-label="label"
|
|
||||||
hide-dropdown-icon
|
|
||||||
/>
|
|
||||||
<!-- start time input -->
|
|
||||||
<q-input
|
|
||||||
v-model="row.start_time"
|
|
||||||
:readonly="row.is_approved"
|
|
||||||
class="col-auto"
|
|
||||||
:label="$t('timesheet.fields.start')"
|
|
||||||
dense
|
|
||||||
filled
|
|
||||||
color="primary"
|
|
||||||
type="time"
|
|
||||||
step="300"
|
|
||||||
standout="bg-primary text-white"
|
|
||||||
/>
|
|
||||||
<!-- end time input -->
|
|
||||||
<q-input
|
|
||||||
v-model="row.end_time"
|
|
||||||
:readonly="row.is_approved"
|
|
||||||
class="col-auto"
|
|
||||||
:label="$t('timesheet.fields.end')"
|
|
||||||
dense
|
|
||||||
filled
|
|
||||||
color="primary"
|
|
||||||
type="time"
|
|
||||||
step="300"
|
|
||||||
standout="bg-primary text-white"
|
|
||||||
/>
|
|
||||||
<div class="col-3">
|
|
||||||
<!-- comment button -->
|
|
||||||
<q-btn
|
|
||||||
:icon="row.comment.length > 0 ? 'announcement':'chat_bubble_outline'"
|
|
||||||
:color="row.comment.length > 0 ? 'primary' : 'grey-8'"
|
|
||||||
:disable="row.is_approved"
|
|
||||||
flat
|
|
||||||
class="col-auto"
|
|
||||||
@click="onClickComment(index)"
|
|
||||||
/>
|
|
||||||
<!-- expense button -->
|
|
||||||
<q-btn
|
|
||||||
:icon="row.comment.length > 0 ? 'receipt_long':'attach_money'"
|
|
||||||
flat
|
|
||||||
dense
|
|
||||||
class="q-pa-none q-ma-sm col-1"
|
|
||||||
:color="hasData(row) ? 'primary' : 'grey-8'"
|
|
||||||
@click="clearRow(index)"
|
|
||||||
/>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
<!-- reset entries button -->
|
|
||||||
<q-btn
|
|
||||||
icon="cleaning_services"
|
|
||||||
flat
|
|
||||||
dense
|
|
||||||
class="q-pa-none q-ma-sm col-auto"
|
|
||||||
:color="hasData(row) ? 'primary' : 'grey-4'"
|
|
||||||
@click="clearRow(index)"
|
|
||||||
/>
|
|
||||||
<!-- add one more shift buttons -->
|
|
||||||
<q-btn
|
|
||||||
icon="more_time"
|
|
||||||
flat
|
|
||||||
dense
|
|
||||||
class="q-pa-none q-ma-sm col-auto"
|
|
||||||
color="primary"
|
|
||||||
>
|
|
||||||
<q-tooltip
|
|
||||||
anchor="top middle"
|
|
||||||
self="center middle"
|
|
||||||
class="bg-primary text-uppercase text-weight-bold"
|
|
||||||
>{{ $t('timesheet.add_shift') }}
|
|
||||||
</q-tooltip>
|
|
||||||
</q-btn>
|
|
||||||
</div>
|
|
||||||
<TimesheetSavePayload
|
|
||||||
:week_dates="week_dates"
|
|
||||||
:rows="rows"
|
|
||||||
@save="(payload) => emit('save', payload)"
|
|
||||||
/>
|
|
||||||
</q-form>
|
|
||||||
|
|
||||||
</q-card>
|
|
||||||
|
|
||||||
</template>
|
|
||||||
198
src/modules/timesheets/composables/use-expense-api.ts
Normal file
198
src/modules/timesheets/composables/use-expense-api.ts
Normal file
|
|
@ -0,0 +1,198 @@
|
||||||
|
import { isProxy, toRaw } from "vue";
|
||||||
|
import { type ExpenseType, type TimesheetExpense } from "../types/timesheet-expenses-interface";
|
||||||
|
import { type PayPeriodExpenses } from "../types/timesheet-expenses-list-interface";
|
||||||
|
import { normalize_expense, validate_expense_UI } from "../utils/timesheet-expenses-validators";
|
||||||
|
import { api } from "src/boot/axios";
|
||||||
|
/* eslint-disable */
|
||||||
|
export interface ExpensePayload{
|
||||||
|
date: string;
|
||||||
|
type: ExpenseType;
|
||||||
|
amount?: number;
|
||||||
|
mileage?: number;
|
||||||
|
comment: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpsertExpensesBody {
|
||||||
|
expenses: ExpensePayload[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpsertExpensesResponse {
|
||||||
|
data: PayPeriodExpenses;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiErrorPayload {
|
||||||
|
status_code: number;
|
||||||
|
error_code?: string;
|
||||||
|
message?: string;
|
||||||
|
context?: Record<string,unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ExpensesApiError extends Error {
|
||||||
|
status_code: number;
|
||||||
|
error_code?: string;
|
||||||
|
context?: Record<string, unknown>;
|
||||||
|
constructor(payload: ApiErrorPayload) {
|
||||||
|
super(payload.message || 'Request failed');
|
||||||
|
this.name = 'ExpensesApiError';
|
||||||
|
this.status_code = payload.status_code;
|
||||||
|
|
||||||
|
if(payload.error_code !== undefined) this.error_code = payload.error_code;
|
||||||
|
if(payload.context !== undefined) this.context = payload.context;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const to_plain = <T extends object>(obj:T): T => {
|
||||||
|
const raw = isProxy(obj) ? toRaw(obj) : obj;
|
||||||
|
if(typeof (globalThis as any).structuredClone === 'function') {
|
||||||
|
return (globalThis as any).structuredClone(raw);
|
||||||
|
}
|
||||||
|
return JSON.parse(JSON.stringify(raw));
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalize_payload = (expense: ExpensePayload): ExpensePayload => {
|
||||||
|
const exp = normalize_expense(expense as unknown as TimesheetExpense);
|
||||||
|
const out: ExpensePayload = {
|
||||||
|
date: exp.date,
|
||||||
|
type: exp.type as ExpenseType,
|
||||||
|
comment: exp.comment || '',
|
||||||
|
};
|
||||||
|
if(typeof exp.amount === 'number') out.amount = exp.amount;
|
||||||
|
if(typeof exp.mileage === 'number') out.mileage = exp.mileage;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
//GET by email, year and period no
|
||||||
|
export const get_pay_period_expenses = async (
|
||||||
|
email: string,
|
||||||
|
pay_year: number,
|
||||||
|
pay_period_no: number
|
||||||
|
) : Promise<PayPeriodExpenses> => {
|
||||||
|
const encoded_email = encodeURIComponent(email);
|
||||||
|
const encoded_year = encodeURIComponent(String(pay_year));
|
||||||
|
const encoded_pay_period_no = encodeURIComponent(String(pay_period_no));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { data } = await api.get<PayPeriodExpenses>(`/expenses/${encoded_email}/${encoded_year}/${encoded_pay_period_no}`);
|
||||||
|
|
||||||
|
const items = Array.isArray(data.expenses) ? data.expenses.map(normalize_expense) : [];
|
||||||
|
return {
|
||||||
|
...data,
|
||||||
|
expenses: items,
|
||||||
|
};
|
||||||
|
} catch(err:any) {
|
||||||
|
const status_code: number = err?.response?.status ?? 500;
|
||||||
|
const data = err?.response?.data ?? {};
|
||||||
|
throw new ExpensesApiError({
|
||||||
|
status_code,
|
||||||
|
error_code: data.error_code,
|
||||||
|
message: data.message || data.error || err.message,
|
||||||
|
context: data.context,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
//PUT by email, year and period no
|
||||||
|
export const put_pay_period_expenses = async (
|
||||||
|
email: string,
|
||||||
|
pay_year: number,
|
||||||
|
pay_period_no: number,
|
||||||
|
expenses: TimesheetExpense[]
|
||||||
|
): Promise<PayPeriodExpenses> => {
|
||||||
|
const encoded_email = encodeURIComponent(email);
|
||||||
|
const encoded_year = encodeURIComponent(String(pay_year));
|
||||||
|
const encoded_pay_period_no = encodeURIComponent(String(pay_period_no));
|
||||||
|
|
||||||
|
const plain = Array.isArray(expenses) ? expenses.map(to_plain): [];
|
||||||
|
|
||||||
|
const normalized: ExpensePayload[] = plain.map((exp) => {
|
||||||
|
const norm = normalize_expense(exp as TimesheetExpense);
|
||||||
|
validate_expense_UI(norm, 'expense_item');
|
||||||
|
return normalize_payload(norm as unknown as ExpensePayload);
|
||||||
|
});
|
||||||
|
|
||||||
|
const body: UpsertExpensesBody = {expenses: normalized};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { data } = await api.put<UpsertExpensesResponse>(
|
||||||
|
`/expenses/${encoded_email}/${encoded_year}/${encoded_pay_period_no}`,
|
||||||
|
body,
|
||||||
|
{ headers: {'Content-Type': 'application/json'}}
|
||||||
|
);
|
||||||
|
|
||||||
|
const items = Array.isArray(data?.data?.expenses)
|
||||||
|
? data.data.expenses.map(normalize_expense)
|
||||||
|
: [];
|
||||||
|
return {
|
||||||
|
...(data?.data ?? {
|
||||||
|
pay_period_no,
|
||||||
|
pay_year,
|
||||||
|
employee_email: email,
|
||||||
|
is_approved: false,
|
||||||
|
expenses: [],
|
||||||
|
totals: {amount: 0, mileage: 0},
|
||||||
|
}),
|
||||||
|
expenses: items,
|
||||||
|
};
|
||||||
|
} catch (err: any) {
|
||||||
|
const status_code: number = err?.response?.status ?? 500;
|
||||||
|
const data = err?.response?.data ?? {};
|
||||||
|
throw new ExpensesApiError({
|
||||||
|
status_code,
|
||||||
|
error_code: data.error_code,
|
||||||
|
message: data.message || data.error || err.message,
|
||||||
|
context: data.context,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const post_pay_period_expenses = async (
|
||||||
|
email: string,
|
||||||
|
pay_year: number,
|
||||||
|
pay_period_no: number,
|
||||||
|
new_expenses: TimesheetExpense[]
|
||||||
|
): Promise<PayPeriodExpenses> => {
|
||||||
|
const encoded_email = encodeURIComponent(email);
|
||||||
|
const encoded_year = encodeURIComponent(String(pay_year));
|
||||||
|
const encoded_pp = encodeURIComponent(String(pay_period_no));
|
||||||
|
|
||||||
|
const plain = Array.isArray(new_expenses) ? new_expenses.map(to_plain) : [];
|
||||||
|
const normalized: ExpensePayload[] = plain.map((exp) => {
|
||||||
|
const norm = normalize_expense(exp as TimesheetExpense);
|
||||||
|
validate_expense_UI(norm, 'expense_item');
|
||||||
|
return normalize_payload(norm as unknown as ExpensePayload);
|
||||||
|
});
|
||||||
|
|
||||||
|
const body: UpsertExpensesBody = { expenses: normalized };
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { data } = await api.post<UpsertExpensesResponse>(
|
||||||
|
`/expenses/${encoded_email}/${encoded_year}/${encoded_pp}`,
|
||||||
|
body,
|
||||||
|
{ headers: { 'content-type': 'application/json' } }
|
||||||
|
);
|
||||||
|
const items = Array.isArray(data?.data?.expenses)
|
||||||
|
? data.data.expenses.map(normalize_expense)
|
||||||
|
: [];
|
||||||
|
return {
|
||||||
|
...(data?.data ?? {
|
||||||
|
pay_period_no,
|
||||||
|
pay_year,
|
||||||
|
employee_email: email,
|
||||||
|
is_approved: false,
|
||||||
|
expenses: [],
|
||||||
|
totals: { amount: 0, mileage: 0 },
|
||||||
|
}),
|
||||||
|
expenses: items,
|
||||||
|
};
|
||||||
|
} catch (err: any) {
|
||||||
|
const status_code: number = err?.response?.status ?? 500;
|
||||||
|
const data = err?.response?.data ?? {};
|
||||||
|
throw new ExpensesApiError({
|
||||||
|
status_code,
|
||||||
|
error_code: data.error_code,
|
||||||
|
message: data.message || data.error || err.message,
|
||||||
|
context: data.context,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
@ -0,0 +1,171 @@
|
||||||
|
import { api } from "src/boot/axios";
|
||||||
|
import { isProxy, toRaw } from "vue";
|
||||||
|
/* eslint-disable */
|
||||||
|
export interface ShiftPayload {
|
||||||
|
start_time: string;
|
||||||
|
end_time: string;
|
||||||
|
type: string;
|
||||||
|
is_remote: boolean;
|
||||||
|
comment?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpsertShiftsBody {
|
||||||
|
old_shift?: ShiftPayload;
|
||||||
|
new_shift?: ShiftPayload;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UpsertAction = 'created' | 'updated' | 'deleted';
|
||||||
|
|
||||||
|
export interface DayShift {
|
||||||
|
start_time: string;
|
||||||
|
end_time: string;
|
||||||
|
type: string;
|
||||||
|
is_remote: boolean;
|
||||||
|
comment?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpsertShiftsResponse {
|
||||||
|
action: UpsertAction;
|
||||||
|
day: DayShift[];
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
//normalize payload to match backend data
|
||||||
|
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_payload = (payload: ShiftPayload): ShiftPayload => {
|
||||||
|
const comment = normalize_comment(payload.comment);
|
||||||
|
return {
|
||||||
|
start_time: payload.start_time,
|
||||||
|
end_time: payload.end_time,
|
||||||
|
type: normalize_type(payload.type),
|
||||||
|
is_remote: Boolean(payload.is_remote),
|
||||||
|
...(comment !== undefined ? { comment } : {}),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const toPlain = <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));
|
||||||
|
}
|
||||||
|
|
||||||
|
//error handling
|
||||||
|
export interface ApiErrorPayload {
|
||||||
|
status_code: number;
|
||||||
|
error_code?: string;
|
||||||
|
message?: string;
|
||||||
|
context?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpsertShiftsError extends Error {
|
||||||
|
status_code: number;
|
||||||
|
error_code?: string | undefined;
|
||||||
|
context?: Record<string, unknown> | undefined;
|
||||||
|
constructor(payload: ApiErrorPayload) {
|
||||||
|
super(payload.message || 'Request failed');
|
||||||
|
this.name = 'UpsertShiftsError';
|
||||||
|
this.status_code = payload.status_code;
|
||||||
|
this.error_code = payload.error_code;
|
||||||
|
this.context = payload.context;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const parseHHMM = (s:string): [number, number] => {
|
||||||
|
const m = /^(\d{2}):(\d{2})$/.exec(s);
|
||||||
|
if(!m) {
|
||||||
|
throw new UpsertShiftsError({ status_code: 400, message: `Invalid time format: ${s}. Expected HH:MM.`});
|
||||||
|
}
|
||||||
|
|
||||||
|
const h = Number(m[1]);
|
||||||
|
const min = Number(m[2]);
|
||||||
|
|
||||||
|
if(Number.isNaN(h) || Number.isNaN(min) || h < 0 || h> 23 || min < 0 || min > 59) {
|
||||||
|
throw new UpsertShiftsError({ status_code: 400, message: `Invalid time value: ${s}.`})
|
||||||
|
}
|
||||||
|
return [h, min];
|
||||||
|
}
|
||||||
|
|
||||||
|
const toMinutes = (hhmm: string): number => {
|
||||||
|
const [h,m] = parseHHMM(hhmm);
|
||||||
|
return h * 60 + m;
|
||||||
|
}
|
||||||
|
|
||||||
|
const validateShift = (payload: ShiftPayload, label: 'old_shift'|'new_shift') => {
|
||||||
|
if(!TIME_FORMAT_PATTERN.test(payload.start_time) || !TIME_FORMAT_PATTERN.test(payload.end_time)) {
|
||||||
|
throw new UpsertShiftsError({
|
||||||
|
status_code: 400,
|
||||||
|
message: `Invalid time format in ${label}. Expected HH:MM`,
|
||||||
|
context: { [label]: payload }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if(toMinutes(payload.end_time) <= toMinutes(payload.start_time)) {
|
||||||
|
throw new UpsertShiftsError({
|
||||||
|
status_code: 400,
|
||||||
|
message: `Invalid time range in ${label}. The End time must be after the Start time`,
|
||||||
|
context: { [label]: payload}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const upsert_shifts_by_date = async (
|
||||||
|
email: string,
|
||||||
|
date: string,
|
||||||
|
body: UpsertShiftsBody,
|
||||||
|
): Promise<UpsertShiftsResponse> => {
|
||||||
|
|
||||||
|
if (!DATE_FORMAT_PATTERN.test(date)){
|
||||||
|
throw new UpsertShiftsError({
|
||||||
|
status_code: 400,
|
||||||
|
message: 'Invalid date format, expected YYYY-MM-DD',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const flatBody: UpsertShiftsBody = {
|
||||||
|
...(body.old_shift ? { old_shift: toPlain(body.old_shift) }: {}),
|
||||||
|
...(body.new_shift ? { new_shift: toPlain(body.new_shift) }: {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalized: UpsertShiftsBody = {
|
||||||
|
...(flatBody.old_shift ? { old_shift: normalize_payload(flatBody.old_shift) } : {}),
|
||||||
|
...(flatBody.new_shift ? { new_shift: normalize_payload(flatBody.new_shift) } : {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
if(normalized.old_shift) validateShift(normalized.old_shift, 'old_shift');
|
||||||
|
if(normalized.new_shift) validateShift(normalized.new_shift, 'new_shift');
|
||||||
|
|
||||||
|
const encoded_email = encodeURIComponent(email);
|
||||||
|
const encoded_date = encodeURIComponent(date);
|
||||||
|
|
||||||
|
//error handling to be used with notify in case of bad input
|
||||||
|
try {
|
||||||
|
const { data } = await api.put<UpsertShiftsResponse>(
|
||||||
|
`/shifts/upsert/${encoded_email}/${encoded_date}`,
|
||||||
|
normalized,
|
||||||
|
{ headers: {'content-type': 'application/json'}}
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
} catch (err: any) {
|
||||||
|
const status_code: number = err?.response?.status ?? 500;
|
||||||
|
const data = err?.response?.data ?? {};
|
||||||
|
const payload: ApiErrorPayload = {
|
||||||
|
status_code,
|
||||||
|
error_code: data.error_code,
|
||||||
|
message: data.message || data.error || err.message,
|
||||||
|
context: data.context,
|
||||||
|
};
|
||||||
|
throw new UpsertShiftsError(payload);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -1,60 +1,49 @@
|
||||||
import { useAuthStore } from "src/stores/auth-store";
|
import { useAuthStore } from "src/stores/auth-store";
|
||||||
import { useTimesheetStore } from "src/stores/timesheet-store"
|
import { useTimesheetStore } from "src/stores/timesheet-store"
|
||||||
import { ref } from "vue";
|
|
||||||
import { timesheetTempService } from "../services/timesheet-services";
|
|
||||||
import type { CreateShiftPayload } from "../types/timesheet-shift-interface";
|
|
||||||
|
|
||||||
|
|
||||||
export const useTimesheetApi = () => {
|
export const useTimesheetApi = () => {
|
||||||
const timesheet_store = useTimesheetStore();
|
const timesheet_store = useTimesheetStore();
|
||||||
const auth_store = useAuthStore();
|
const auth_store = useAuthStore();
|
||||||
const week_offset = ref(0);
|
|
||||||
|
|
||||||
const fetchWeek = async (offset = week_offset.value) => {
|
const getTimesheetsByDate = async (date_string: string) => {
|
||||||
const email = auth_store.user?.email;
|
const success = await timesheet_store.getPayPeriodByDate(date_string);
|
||||||
if(!email) return;
|
|
||||||
try{
|
if (success) {
|
||||||
timesheet_store.is_loading = true;
|
await timesheet_store.getTimesheetsByPayPeriodAndEmail(auth_store.user.email)
|
||||||
const timesheet = await timesheetTempService.getTimesheetsByEmail(email, offset);
|
}
|
||||||
timesheet_store.current_timesheet = timesheet;
|
}
|
||||||
week_offset.value = offset;
|
|
||||||
}catch (err) {
|
const fetchPayPeriod = async (direction: number) => {
|
||||||
console.error('fetch week error', err);
|
const current_pay_period = timesheet_store.current_pay_period;
|
||||||
timesheet_store.current_timesheet = { ...timesheet_store.current_timesheet, shifts: [], expenses: [] };
|
let new_pay_period_no = current_pay_period.pay_period_no + direction;
|
||||||
} finally {
|
let new_pay_year = current_pay_period.pay_year;
|
||||||
timesheet_store.is_loading = false;
|
|
||||||
|
if (new_pay_period_no > 26) {
|
||||||
|
new_pay_period_no = 1;
|
||||||
|
new_pay_year += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (new_pay_period_no < 1) {
|
||||||
|
new_pay_period_no = 26;
|
||||||
|
new_pay_year -= 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const success = await timesheet_store.getPayPeriodByYearAndPeriodNumber(new_pay_year, new_pay_period_no);
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
await timesheet_store.getTimesheetsByPayPeriodAndEmail(auth_store.user.email);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const this_week = async () => fetchWeek(0);
|
const getCurrentPayPeriod = async () => fetchPayPeriod(0);
|
||||||
const next_week = async () => fetchWeek(week_offset.value + 1);
|
const getNextPayPeriod = async () => fetchPayPeriod(1);
|
||||||
const previous_week = async () => fetchWeek(week_offset.value - 1);
|
const getPreviousPayPeriod = async () => fetchPayPeriod(-1);
|
||||||
|
|
||||||
const saveTimesheetShifts = async (shifts: CreateShiftPayload[]) => {
|
|
||||||
const email = auth_store.user?.email;
|
|
||||||
if(!email || shifts.length === 0) return;
|
|
||||||
await timesheet_store.createTimesheetShifts(email, shifts, week_offset.value);
|
|
||||||
};
|
|
||||||
|
|
||||||
const weekStart = (date: Date) => {
|
|
||||||
const x = new Date(date);
|
|
||||||
x.setHours(0, 0, 0, 0);
|
|
||||||
x.setDate(x.getDate() - x.getDay());
|
|
||||||
return x;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getCurrentWeekTimesheetOverview = async (when: Date = new Date()) => {
|
|
||||||
const off = Math.trunc((weekStart(when).getTime() - weekStart(new Date()).getTime()) / 604800000);
|
|
||||||
await fetchWeek(off);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
week_offset,
|
getTimesheetsByDate,
|
||||||
fetchWeek,
|
fetchPayPeriod,
|
||||||
this_week,
|
getCurrentPayPeriod,
|
||||||
next_week,
|
getNextPayPeriod,
|
||||||
previous_week,
|
getPreviousPayPeriod,
|
||||||
saveTimesheetShifts,
|
|
||||||
getCurrentWeekTimesheetOverview,
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
304
src/modules/timesheets/pages/timesheet-details-overview.vue
Normal file
304
src/modules/timesheets/pages/timesheet-details-overview.vue
Normal file
|
|
@ -0,0 +1,304 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useTimesheetStore } from 'src/stores/timesheet-store';
|
||||||
|
import { useAuthStore } from 'src/stores/auth-store';
|
||||||
|
import { useTimesheetApi } from '../composables/use-timesheet-api';
|
||||||
|
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/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, 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',
|
||||||
|
year: 'numeric',
|
||||||
|
};
|
||||||
|
|
||||||
|
const pay_period_label = computed(() => {
|
||||||
|
const label = timesheet_store.current_pay_period?.label ?? '';
|
||||||
|
const dates = label.split('.');
|
||||||
|
if ( dates.length < 2 ) {
|
||||||
|
return { start_date: '—', end_date:'—' }
|
||||||
|
}
|
||||||
|
const start_date = new Intl.DateTimeFormat(locale.value, date_options).format(date.extractDate(dates[0] as string, 'YYYY-MM-DD'));
|
||||||
|
const end_date = new Intl.DateTimeFormat(locale.value, date_options).format(date.extractDate(dates[1] as string, 'YYYY-MM-DD'));
|
||||||
|
return { start_date, end_date };
|
||||||
|
});
|
||||||
|
|
||||||
|
const is_calendar_limit = computed( () => {
|
||||||
|
return timesheet_store.current_pay_period.pay_year === 2024 &&
|
||||||
|
timesheet_store.current_pay_period.pay_period_no <= 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
const SHIFT_KEY = ['REGULAR', 'EVENING', 'EMERGENCY', 'HOLIDAY', 'VACATION', 'SICK'] as const;
|
||||||
|
const shift_options = computed(()=> {
|
||||||
|
void locale.value;
|
||||||
|
return SHIFT_KEY.map(key => ({ value: key, label: t(`timesheet.shift_types.${key}`)}))
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
const onDateSelected = async (date_string: string) => {
|
||||||
|
await timesheet_api.getTimesheetsByDate(date_string);
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadByDate = async (isoDate: string) => {
|
||||||
|
await timesheet_store.getPayPeriodByDate(isoDate);
|
||||||
|
await timesheet_store.getTimesheetsByPayPeriodAndEmail(auth_store.user.email);
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted( async () => {
|
||||||
|
await loadByDate(date.formatDate(new Date(), 'YYYY-MM-DD' ));
|
||||||
|
});
|
||||||
|
|
||||||
|
const onShiftSaved = async () => {
|
||||||
|
await timesheet_store.getTimesheetsByPayPeriodAndEmail(auth_store.user.email);
|
||||||
|
}
|
||||||
|
|
||||||
|
type FormMode = 'create' | 'edit' | 'delete';
|
||||||
|
|
||||||
|
const is_dialog_open = ref<boolean>(false);
|
||||||
|
const form_mode = ref<FormMode>('create');
|
||||||
|
const selected_date = ref<string>('');
|
||||||
|
const old_shift_ref = ref<ShiftPayload | undefined>(undefined);
|
||||||
|
|
||||||
|
const open_create_dialog = (iso_date: string) => {
|
||||||
|
form_mode.value = 'create';
|
||||||
|
selected_date.value = iso_date;
|
||||||
|
old_shift_ref.value = undefined;
|
||||||
|
is_dialog_open.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const open_edit_dialog = (iso_date: string, shift: any) => {
|
||||||
|
form_mode.value = 'edit';
|
||||||
|
selected_date.value = iso_date;
|
||||||
|
is_dialog_open.value = true;
|
||||||
|
old_shift_ref.value = {
|
||||||
|
start_time: shift.start_time,
|
||||||
|
end_time: shift.end_time,
|
||||||
|
type: shift.type,
|
||||||
|
is_remote: !!shift.is_remote,
|
||||||
|
...(shift.comment ? { comment: String(shift.comment)} : {}),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const open_delete_dialog = (iso_date: string, shift: any) => {
|
||||||
|
form_mode.value = 'delete';
|
||||||
|
selected_date.value = iso_date;
|
||||||
|
old_shift_ref.value = {
|
||||||
|
start_time: shift.start_time,
|
||||||
|
end_time: shift.end_time,
|
||||||
|
type: shift.type,
|
||||||
|
is_remote: !!shift.is_remote,
|
||||||
|
...(shift.comment ? { comment: String(shift.comment)} : {}),
|
||||||
|
};
|
||||||
|
is_dialog_open.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const expenses_error = ref<string|null>(null);
|
||||||
|
|
||||||
|
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);
|
||||||
|
const on_request_delete = async ({ date, shift }: { date: string; shift: any }) => open_delete_dialog(date, shift);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<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('timesheet.title') }}
|
||||||
|
</div>
|
||||||
|
<div class="row items-center justify-center q-py-none q-my-none">
|
||||||
|
<div
|
||||||
|
class="text-primary text-uppercase text-weight-bold"
|
||||||
|
:class="$q.screen.lt.md ? '' : 'text-h6'"
|
||||||
|
>
|
||||||
|
{{ pay_period_label.start_date }}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="text-grey-8 text-uppercase q-mx-md"
|
||||||
|
:class="$q.screen.lt.md ? 'text-weight-medium text-caption' : 'text-weight-bold'"
|
||||||
|
>
|
||||||
|
{{ $t('timesheet.date_ranges_to') }}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="text-primary text-uppercase text-center text-weight-bold"
|
||||||
|
:class="$q.screen.lt.md ? '' : 'text-h6'"
|
||||||
|
>
|
||||||
|
{{ pay_period_label.end_date }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<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"
|
||||||
|
@date-selected="value => onDateSelected(value)"
|
||||||
|
@pressed-previous-button="timesheet_api.getPreviousPayPeriod()"
|
||||||
|
@pressed-next-button="timesheet_api.getNextPayPeriod()"
|
||||||
|
/>
|
||||||
|
</q-card-section>
|
||||||
|
<!-- shift's colored legend -->
|
||||||
|
<ShiftsLegend
|
||||||
|
:is-loading="false"
|
||||||
|
/>
|
||||||
|
<q-card-section horizontal>
|
||||||
|
<!-- display of shifts for 2 timesheets -->
|
||||||
|
<TimesheetDetailsShifts
|
||||||
|
:raw-data="timesheet_store.pay_period_employee_details"
|
||||||
|
:current-pay-period="timesheet_store.current_pay_period"
|
||||||
|
@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>
|
||||||
|
<!-- read/edit/create/delete expense dialog -->
|
||||||
|
<q-dialog
|
||||||
|
v-model="show_expenses_dialog"
|
||||||
|
persistent
|
||||||
|
>
|
||||||
|
<q-card
|
||||||
|
class="q-pa=md"
|
||||||
|
style="min-width:900px; max-width: 95vw;"
|
||||||
|
>
|
||||||
|
<q-inner-loading :showing="is_loading_expenses">
|
||||||
|
<q-spinner size="32px"/>
|
||||||
|
</q-inner-loading>
|
||||||
|
<q-banner
|
||||||
|
v-if="expenses_error"
|
||||||
|
dense
|
||||||
|
class="bg-red-2 text-negative q-mt-sm"
|
||||||
|
>
|
||||||
|
{{ expenses_error }}
|
||||||
|
</q-banner>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<!-- 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>
|
||||||
|
|
@ -1,86 +0,0 @@
|
||||||
<script setup lang="ts">
|
|
||||||
import { useTimesheetStore } from 'src/stores/timesheet-store';
|
|
||||||
import { useTimesheetApi } from '../composables/use-timesheet-api';
|
|
||||||
import { computed, onMounted } from 'vue';
|
|
||||||
import { useI18n } from 'vue-i18n';
|
|
||||||
import TimesheetShiftForm from '../components/timesheet/timesheet-shift-form.vue';
|
|
||||||
import type { CreateShiftPayload } from '../types/timesheet-shift-interface';
|
|
||||||
import TimesheetNavigation from '../components/timesheet/timesheet-navigation.vue';
|
|
||||||
import { date as qdate } from 'quasar';
|
|
||||||
|
|
||||||
const { locale } = useI18n();
|
|
||||||
const timesheet_store = useTimesheetStore();
|
|
||||||
const timesheet_api = useTimesheetApi();
|
|
||||||
const { this_week, saveTimesheetShifts } = timesheet_api;
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
await this_week();
|
|
||||||
});
|
|
||||||
|
|
||||||
const date_options: Intl.DateTimeFormatOptions = {
|
|
||||||
day: 'numeric',
|
|
||||||
month: 'long',
|
|
||||||
year: 'numeric',
|
|
||||||
};
|
|
||||||
|
|
||||||
const timesheet_label = computed(() => {
|
|
||||||
const dates = timesheet_store.current_timesheet.label.split('.');
|
|
||||||
const start_date = new Intl.DateTimeFormat(locale.value, date_options).format(qdate.extractDate(dates[0] as string, 'YYYY-MM-DD'));
|
|
||||||
const end_date = new Intl.DateTimeFormat(locale.value, date_options).format(qdate.extractDate(dates[1] as string, 'YYYY-MM-DD'));
|
|
||||||
|
|
||||||
if ( dates.length === 1) {
|
|
||||||
return { start_date: '_', end_date: '_', }
|
|
||||||
}
|
|
||||||
return { start_date, end_date };
|
|
||||||
});
|
|
||||||
|
|
||||||
const onSaveShifts = async (payload: CreateShiftPayload[]) => {
|
|
||||||
await saveTimesheetShifts(payload);
|
|
||||||
};
|
|
||||||
|
|
||||||
const is_calendar_limit = computed( () => {
|
|
||||||
return timesheet_store.current_pay_period.pay_year === 2024 &&
|
|
||||||
timesheet_store.current_pay_period.pay_period_no <= 1;
|
|
||||||
});
|
|
||||||
|
|
||||||
const onDateSelected = async (date_string: string) => {
|
|
||||||
const when = qdate.extractDate(date_string, 'YYYY-MM-DD');
|
|
||||||
await timesheet_api.getCurrentWeekTimesheetOverview(when);
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
|
|
||||||
<template>
|
|
||||||
|
|
||||||
<q-page padding class="q-pa-md bg-secondary" >
|
|
||||||
<div class="text-h4 row justify-center q-mt-lg text-uppercase text-weight-bolder text-grey-8 col-auto">
|
|
||||||
{{ $t('pageTitles.timeSheets') }}
|
|
||||||
</div>
|
|
||||||
<div class="row items-center justify-center q-py-none q-my-none">
|
|
||||||
<div class="text-primary text-h6 text-uppercase">
|
|
||||||
{{ timesheet_label.start_date }}
|
|
||||||
</div>
|
|
||||||
<div class="text-grey-8 text-weight-bold text-uppercase q-mx-md">
|
|
||||||
{{ $t('timesheet.dateRangesTo') }}
|
|
||||||
</div>
|
|
||||||
<div class="text-primary text-h6 text-uppercase">
|
|
||||||
{{ timesheet_label.end_date }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<TimesheetNavigation
|
|
||||||
:is-disabled="timesheet_store.is_loading"
|
|
||||||
:is-previous-limit="is_calendar_limit"
|
|
||||||
@date-selected="onDateSelected"
|
|
||||||
@pressed-previous-button="timesheet_api.previous_week()"
|
|
||||||
@pressed-current-button="timesheet_api.getCurrentWeekTimesheetOverview()"
|
|
||||||
@pressed-next-button="timesheet_api.next_week()"
|
|
||||||
/>
|
|
||||||
<q-card flat class="q-mt-md bg-secondary">
|
|
||||||
<TimesheetShiftForm @save="onSaveShifts" class="col-12"/>
|
|
||||||
<q-inner-loading :showing="timesheet_store.is_loading" color="primary"/>
|
|
||||||
</q-card>
|
|
||||||
</div>
|
|
||||||
<!-- navigation buttons -->
|
|
||||||
</q-page>
|
|
||||||
</template>
|
|
||||||
|
|
@ -1,6 +1,10 @@
|
||||||
import { api } from "src/boot/axios";
|
import { api } from "src/boot/axios";
|
||||||
import type {Timesheet} from "src/modules/timesheets/types/timesheet-interface";
|
import type {Timesheet} from "src/modules/timesheets/types/timesheet-interface";
|
||||||
import type { CreateShiftPayload, CreateWeekShiftPayload } from "../types/timesheet-shift-interface";
|
import type { CreateShiftPayload, CreateWeekShiftPayload } from "../types/timesheet-shifts-payload-interface";
|
||||||
|
import type { PayPeriod } from "src/modules/shared/types/pay-period-interface";
|
||||||
|
import type { PayPeriodEmployeeDetails } from "src/modules/timesheet-approval/types/timesheet-approval-pay-period-employee-details-interface";
|
||||||
|
import type { PayPeriodOverview } from "src/modules/timesheet-approval/types/timesheet-approval-pay-period-overview-interface";
|
||||||
|
import type { PayPeriodReportFilters } from "src/modules/timesheet-approval/types/timesheet-approval-pay-period-report-interface";
|
||||||
|
|
||||||
export const timesheetTempService = {
|
export const timesheetTempService = {
|
||||||
//GET
|
//GET
|
||||||
|
|
@ -14,5 +18,34 @@ export const timesheetTempService = {
|
||||||
const payload: CreateWeekShiftPayload = { shifts };
|
const payload: CreateWeekShiftPayload = { shifts };
|
||||||
const response = await api.post(`/timesheets/shifts/${encodeURIComponent(email)}`, payload, { params: offset ? { offset }: undefined });
|
const response = await api.post(`/timesheets/shifts/${encodeURIComponent(email)}`, payload, { params: offset ? { offset }: undefined });
|
||||||
return response.data as Timesheet;
|
return response.data as Timesheet;
|
||||||
}
|
},
|
||||||
|
|
||||||
|
getPayPeriodByDate: async (date_string: string): Promise<PayPeriod> => {
|
||||||
|
const response = await api.get(`pay-periods/date/${date_string}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getPayPeriodByYearAndPeriodNumber: async (year: number, period_number: number): Promise<PayPeriod> => {
|
||||||
|
const response = await api.get(`pay-periods/${year}/${period_number}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
|
||||||
|
getPayPeriodEmployeeOverviews: async (year: number, period_number: number, supervisor_email: string): Promise<PayPeriodOverview> => {
|
||||||
|
// TODO: REMOVE MOCK DATA PEFORE PUSHING TO PROD
|
||||||
|
const response = await api.get(`pay-periods/${year}/${period_number}/${supervisor_email}`);
|
||||||
|
console.log('pay period data: ', response.data);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getTimesheetsByPayPeriodAndEmail: async (year: number, period_no: number, email: string): Promise<PayPeriodEmployeeDetails> => {
|
||||||
|
const response = await api.get('timesheets', { params: { year, period_no, email, }});
|
||||||
|
console.log('employee details: ', response.data);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getTimesheetApprovalCSVReport: async (year: number, period_number: number, report_filters?: PayPeriodReportFilters) => {
|
||||||
|
const response = await api.get(`csv/${year}/${period_number}`, { params: { report_filters, }});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
@ -13,12 +13,15 @@ export interface TimesheetDetailsDailySchedule {
|
||||||
emergency_hours: number;
|
emergency_hours: number;
|
||||||
overtime_hours: number;
|
overtime_hours: number;
|
||||||
total_hours: number;
|
total_hours: number;
|
||||||
|
comment: string;
|
||||||
short_date: string; // ex. 08/24
|
short_date: string; // ex. 08/24
|
||||||
break_duration?: number;
|
break_duration?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Expense {
|
export interface Expense {
|
||||||
is_approved: boolean;
|
is_approved: boolean;
|
||||||
|
comment: string;
|
||||||
|
supervisor_comment: string;
|
||||||
amount: number;
|
amount: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -63,6 +66,7 @@ const emptyDailySchedule = (): TimesheetDetailsDailySchedule => ({
|
||||||
emergency_hours: 0,
|
emergency_hours: 0,
|
||||||
overtime_hours: 0,
|
overtime_hours: 0,
|
||||||
total_hours: 0,
|
total_hours: 0,
|
||||||
|
comment: "",
|
||||||
short_date: "",
|
short_date: "",
|
||||||
break_duration: 0,
|
break_duration: 0,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
21
src/modules/timesheets/types/timesheet-expenses-interface.ts
Normal file
21
src/modules/timesheets/types/timesheet-expenses-interface.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
export interface TimesheetExpense {
|
||||||
|
date: string;
|
||||||
|
amount?: number;
|
||||||
|
mileage?: number;
|
||||||
|
comment?: string;
|
||||||
|
supervisor_comment?: string;
|
||||||
|
is_approved?: boolean;
|
||||||
|
type: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EXPENSE_TYPE = [
|
||||||
|
'PER_DIEM',
|
||||||
|
'MILEAGE',
|
||||||
|
'EXPENSES',
|
||||||
|
'PRIME_DISPO',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type ExpenseType = typeof EXPENSE_TYPE[number];
|
||||||
|
|
||||||
|
export const TYPES_WITH_MILEAGE_ONLY: Readonly<ExpenseType[]> = ['MILEAGE'];
|
||||||
|
export const TYPES_WITH_AMOUNT_ONLY: Readonly<ExpenseType[]> = ['PER_DIEM', 'EXPENSES', 'PRIME_DISPO']
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
import type { TimesheetExpense } from "./timesheet-expenses-interface";
|
||||||
|
|
||||||
|
export interface PayPeriodExpenses {
|
||||||
|
pay_period_no: number;
|
||||||
|
pay_year: number;
|
||||||
|
employee_email: string;
|
||||||
|
is_approved: boolean;
|
||||||
|
expenses: TimesheetExpense[];
|
||||||
|
totals: {
|
||||||
|
amount: number;
|
||||||
|
mileage: number;
|
||||||
|
reimbursable_total?: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -12,7 +12,7 @@ type Shifts = {
|
||||||
date: string;
|
date: string;
|
||||||
start_time: string;
|
start_time: string;
|
||||||
end_time: string;
|
end_time: string;
|
||||||
description: string;
|
comment: string;
|
||||||
is_approved: boolean;
|
is_approved: boolean;
|
||||||
is_remote: boolean;
|
is_remote: boolean;
|
||||||
}
|
}
|
||||||
|
|
@ -22,7 +22,7 @@ type Expenses = {
|
||||||
date: string;
|
date: string;
|
||||||
amount: number;
|
amount: number;
|
||||||
km: number;
|
km: number;
|
||||||
description: string;
|
comment: string;
|
||||||
supervisor_comment: string;
|
supervisor_comment: string;
|
||||||
is_approved: boolean;
|
is_approved: boolean;
|
||||||
}
|
}
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
import { default_timesheet_details_week, type TimesheetDetailsWeek } from "./timesheet-details-interface";
|
||||||
|
|
||||||
|
export interface TimesheetPayPeriodDetailsOverview {
|
||||||
|
week1: TimesheetDetailsWeek;
|
||||||
|
week2: TimesheetDetailsWeek;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const default_pay_period_employee_details = {
|
||||||
|
week1: default_timesheet_details_week(),
|
||||||
|
week2: default_timesheet_details_week(),
|
||||||
|
}
|
||||||
|
|
@ -1,16 +1,3 @@
|
||||||
export interface CreateShiftPayload {
|
|
||||||
date: string;
|
|
||||||
type: string;
|
|
||||||
start_time: string;
|
|
||||||
end_time: string;
|
|
||||||
description?: string;
|
|
||||||
is_remote?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface CreateWeekShiftPayload {
|
|
||||||
shifts: CreateShiftPayload[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Shift {
|
export interface Shift {
|
||||||
date : string;
|
date : string;
|
||||||
type : string;
|
type : string;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
export interface CreateShiftPayload {
|
||||||
|
date: string;
|
||||||
|
type: string;
|
||||||
|
start_time: string;
|
||||||
|
end_time: string;
|
||||||
|
comment?: string;
|
||||||
|
is_remote?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface CreateWeekShiftPayload {
|
||||||
|
shifts: CreateShiftPayload[];
|
||||||
|
}
|
||||||
148
src/modules/timesheets/utils/timesheet-expenses-validators.ts
Normal file
148
src/modules/timesheets/utils/timesheet-expenses-validators.ts
Normal file
|
|
@ -0,0 +1,148 @@
|
||||||
|
import { type ExpenseType, type TimesheetExpense, TYPES_WITH_AMOUNT_ONLY, TYPES_WITH_MILEAGE_ONLY } from "../types/timesheet-expenses-interface";
|
||||||
|
|
||||||
|
|
||||||
|
export const DATE_FORMAT_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
|
||||||
|
export const COMMENT_MAX_LENGTH = 512 as const;
|
||||||
|
|
||||||
|
|
||||||
|
//errors handling
|
||||||
|
export interface ApiErrorPayload {
|
||||||
|
status_code: number;
|
||||||
|
error_code?: string;
|
||||||
|
message?: string;
|
||||||
|
context?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ExpensesValidationError extends Error {
|
||||||
|
status_code: number;
|
||||||
|
error_code?: string | undefined;
|
||||||
|
context?: Record<string, unknown> | undefined;
|
||||||
|
|
||||||
|
constructor(payload: ApiErrorPayload) {
|
||||||
|
super(payload.message || 'Invalid expense payload');
|
||||||
|
this.name = 'ExpensesValidationError';
|
||||||
|
this.status_code = payload.status_code;
|
||||||
|
this.error_code = payload.error_code;
|
||||||
|
this.context = payload.context;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//normalization helpers
|
||||||
|
export const toNumOrUndefined = (value: unknown): number | undefined => {
|
||||||
|
if(value === undefined || value === null || (typeof value === 'string' && value.trim() === '')) return undefined;
|
||||||
|
const num = Number(value);
|
||||||
|
return Number.isFinite(num) ? num : undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const normalize_comment = (input?: string): string | undefined => {
|
||||||
|
if(typeof input === 'undefined' || input === null) return undefined;
|
||||||
|
const trimmed = String(input).trim();
|
||||||
|
return trimmed.length ? trimmed : undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const normalize_type = (input: string): string => (input ?? '').trim().toUpperCase();
|
||||||
|
|
||||||
|
export const normalize_expense = (expense: TimesheetExpense): TimesheetExpense => {
|
||||||
|
const comment = normalize_comment(expense.comment);
|
||||||
|
const amount = toNumOrUndefined(expense.amount);
|
||||||
|
const mileage = toNumOrUndefined(expense.mileage);
|
||||||
|
return {
|
||||||
|
date: (expense.date ?? '').trim(),
|
||||||
|
type: normalize_type(expense.type),
|
||||||
|
...(amount !== undefined ? { amount } : {}),
|
||||||
|
...(mileage !== undefined ? { mileage } : {}),
|
||||||
|
...(comment !== undefined ? { comment } : {}),
|
||||||
|
...(typeof expense.supervisor_comment === 'string' && expense.supervisor_comment.trim().length
|
||||||
|
? { supervisor_comment: expense.supervisor_comment.trim() }
|
||||||
|
: {}),
|
||||||
|
...(typeof expense.is_approved === 'boolean' ? { is_approved: expense.is_approved }: {} ),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
//UI validation error messages
|
||||||
|
export const validate_expense_UI = (raw: TimesheetExpense, label: string = 'expense'): void => {
|
||||||
|
const expense = normalize_expense(raw);
|
||||||
|
|
||||||
|
//Date input validation
|
||||||
|
if(!DATE_FORMAT_PATTERN.test(expense.date)) {
|
||||||
|
throw new ExpensesValidationError({
|
||||||
|
status_code: 400,
|
||||||
|
message: 'timesheet.expense.errors.date_required_or_invalid',
|
||||||
|
context: { [label]: expense },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
//comment input validation
|
||||||
|
if(!expense.comment) {
|
||||||
|
throw new ExpensesValidationError({
|
||||||
|
status_code: 400,
|
||||||
|
message: 'timesheet.expense.errors.comment_required',
|
||||||
|
context: { [label]: expense },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if((expense.comment.length ?? 0) > COMMENT_MAX_LENGTH) {
|
||||||
|
throw new ExpensesValidationError({
|
||||||
|
status_code: 400,
|
||||||
|
message: 'timesheet.expense.errors.comment_too_long',
|
||||||
|
context: { [label]: { ...expense, comment_length: expense.comment?.length } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
//amount input validation
|
||||||
|
if(expense.amount !== undefined && expense.amount <= 0) {
|
||||||
|
throw new ExpensesValidationError({
|
||||||
|
status_code: 400,
|
||||||
|
message: 'timesheet.expense.errors.amount_must_be_positive',
|
||||||
|
context: { [label]: expense },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
//mileage input validation
|
||||||
|
if(expense.mileage !== undefined && expense.mileage <= 0) {
|
||||||
|
throw new ExpensesValidationError({
|
||||||
|
status_code: 400,
|
||||||
|
message: 'timesheet.expense.errors.mileage_must_be_positive',
|
||||||
|
context: { [label]: expense },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
//cross origin amount/mileage validation
|
||||||
|
const has_amount = typeof expense.amount === 'number' && expense.amount > 0;
|
||||||
|
const has_mileage = typeof expense.mileage === 'number' && expense.mileage > 0;
|
||||||
|
|
||||||
|
if(has_amount === has_mileage) {
|
||||||
|
throw new ExpensesValidationError({
|
||||||
|
status_code: 400,
|
||||||
|
message: 'timesheet.expense.errors.amount_xor_mileage',
|
||||||
|
context: { [label]: expense },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
//type constraint validation
|
||||||
|
const type = expense.type as ExpenseType;
|
||||||
|
if(TYPES_WITH_MILEAGE_ONLY.includes(type) && !has_mileage) {
|
||||||
|
throw new ExpensesValidationError({
|
||||||
|
status_code: 400,
|
||||||
|
message: 'timesheet.expense.errors.mileage_required_for_type',
|
||||||
|
context: { [label]: expense },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if(TYPES_WITH_AMOUNT_ONLY.includes(type) && !has_amount) {
|
||||||
|
throw new ExpensesValidationError({
|
||||||
|
status_code: 400,
|
||||||
|
message: 'timesheet.expense.errors.amount_required_for_type',
|
||||||
|
context: { [label]: expense },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
//totals per pay-period
|
||||||
|
export const compute_expense_totals = (items: TimesheetExpense[]) => items.reduce(
|
||||||
|
(acc, raw) => {
|
||||||
|
const expense = normalize_expense(raw);
|
||||||
|
if(typeof expense.amount === 'number' && expense.amount > 0) acc.amount += expense.amount;
|
||||||
|
if(typeof expense.mileage === 'number' && expense.mileage > 0) acc.mileage += expense.mileage;
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{ amount: 0, mileage: 0 }
|
||||||
|
);
|
||||||
|
|
@ -25,7 +25,7 @@ const routes: RouteRecordRaw[] = [
|
||||||
{
|
{
|
||||||
path: 'timesheet-temp',
|
path: 'timesheet-temp',
|
||||||
name: RouteNames.TIMESHEET_TEMP,
|
name: RouteNames.TIMESHEET_TEMP,
|
||||||
component: () => import('src/modules/timesheets/pages/timesheet-temp-page.vue'),
|
component: () => import('src/modules/timesheets/pages/timesheet-details-overview.vue')
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'user/profile',
|
path: 'user/profile',
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,12 @@ import { defineStore } from 'pinia';
|
||||||
import { ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
import { timesheetApprovalService } from 'src/modules/timesheet-approval/services/services-timesheet-approval';
|
import { timesheetApprovalService } from 'src/modules/timesheet-approval/services/services-timesheet-approval';
|
||||||
import { timesheetTempService } from 'src/modules/timesheets/services/timesheet-services';
|
import { timesheetTempService } from 'src/modules/timesheets/services/timesheet-services';
|
||||||
|
import { default_pay_period_employee_details, type PayPeriodEmployeeDetails } from 'src/modules/timesheet-approval/types/timesheet-approval-pay-period-employee-details-interface';
|
||||||
import type { PayPeriod } from 'src/modules/shared/types/pay-period-interface';
|
import type { PayPeriod } from 'src/modules/shared/types/pay-period-interface';
|
||||||
import type { PayPeriodOverviewEmployee } from "src/modules/timesheet-approval/types/timesheet-approval-pay-period-overview-employee-interface";
|
import type { PayPeriodOverviewEmployee } from "src/modules/timesheet-approval/types/timesheet-approval-pay-period-overview-employee-interface";
|
||||||
import { default_pay_period_employee_details, type PayPeriodEmployeeDetails } from 'src/modules/timesheet-approval/types/timesheet-approval-pay-period-employee-details-interface';
|
|
||||||
import type { PayPeriodReportFilters } from 'src/modules/timesheet-approval/types/timesheet-approval-pay-period-report-interface';
|
import type { PayPeriodReportFilters } from 'src/modules/timesheet-approval/types/timesheet-approval-pay-period-report-interface';
|
||||||
import type { Timesheet } from 'src/modules/timesheets/types/timesheet-interface';
|
import type { Timesheet } from 'src/modules/timesheets/types/timesheet-interface';
|
||||||
import type { CreateShiftPayload } from 'src/modules/timesheets/types/timesheet-shift-interface';
|
import type { CreateShiftPayload } from 'src/modules/timesheets/types/timesheet-shifts-payload-interface';
|
||||||
|
|
||||||
const default_pay_period: PayPeriod = {
|
const default_pay_period: PayPeriod = {
|
||||||
pay_period_no: -1,
|
pay_period_no: -1,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user