diff --git a/src/i18n/en-ca/index.ts b/src/i18n/en-ca/index.ts index d53effc..67a4577 100644 --- a/src/i18n/en-ca/index.ts +++ b/src/i18n/en-ca/index.ts @@ -261,7 +261,14 @@ export default { save_button:'Save', cancel_button:'Cancel', remote_button: 'Remote work', + delete_button: 'Delete', + + delete_confirmation_msg: 'Do you want to delete this shift completly?', + add_shift:'Add Shift', + edit_shift: 'Edit shift', + delete_shift: 'Delete shift', + shift_types_label: 'Shift`s Type', shift_types: { EMERGENCY: 'Emergency', @@ -271,10 +278,11 @@ export default { REGULAR: 'Regular', SICK: 'Sick Leave', VACATION: 'Vacation', + REMOTE: 'Remote work', }, fields: { - start:'Start', - end:'End', + start:'Start (HH:mm)', + end:'End (HH:mm)', header_comment:'Shift`s comment', textarea_comment: 'Leave a comment here', }, diff --git a/src/i18n/fr-ca/index.ts b/src/i18n/fr-ca/index.ts index 490c724..670c2be 100644 --- a/src/i18n/fr-ca/index.ts +++ b/src/i18n/fr-ca/index.ts @@ -311,8 +311,14 @@ export default { save_button:'Enregistrer', cancel_button:'Annuler', remote_button: 'Télétravail', + delete_button: 'Supprimer', + + delete_confirmation_msg: 'Voulez-vous supprimer complètement ce quart?', add_shift:'Ajouter une quart', + edit_shift: 'Modifier un quart', + delete_shift: 'Supprimer un quart', + shift_types_label: 'Type de quart', shift_types: { EMERGENCY: 'Urgence', @@ -322,10 +328,11 @@ export default { SICK: 'Absence', REGULAR: 'Régulier', VACATION: 'Vacance', + REMOTE: 'Télétravail', }, fields: { - start:'Entrée', - end:'Sortie', + start:'Entrée (HH:mm)', + end:'Sortie (HH:mm)', header_comment:'Commentaire du Quart', textarea_comment:'Laissez votre commentaire', }, diff --git a/src/modules/timesheets/components/timesheet/timesheet-details-shifts-row.vue b/src/modules/timesheets/components/timesheet/timesheet-details-shifts-row.vue index 2c7fa7b..6b7b8df 100644 --- a/src/modules/timesheets/components/timesheet/timesheet-details-shifts-row.vue +++ b/src/modules/timesheets/components/timesheet/timesheet-details-shifts-row.vue @@ -1,37 +1,27 @@ @@ -58,12 +51,13 @@ import ShiftComment from '../shift/shift-comment.vue'; horizontal class="q-pa-none text-uppercase text-center items-center cursor-pointer rounded-10" style="line-height: 1;" + @click.stop="on_click_edit" > {{ props.shift.start_time }} @@ -95,7 +89,7 @@ import ShiftComment from '../shift/shift-comment.vue'; {{ props.shift.end_time }} @@ -106,17 +100,15 @@ import ShiftComment from '../shift/shift-comment.vue'; - - + - + + + - - - - \ No newline at end of file diff --git a/src/modules/timesheets/components/timesheet/timesheet-details-shifts.vue b/src/modules/timesheets/components/timesheet/timesheet-details-shifts.vue index 24338c1..a2903dd 100644 --- a/src/modules/timesheets/components/timesheet/timesheet-details-shifts.vue +++ b/src/modules/timesheets/components/timesheet/timesheet-details-shifts.vue @@ -1,22 +1,40 @@ @@ -32,10 +50,10 @@ import type { TimesheetPayPeriodDetailsOverview } from '../../types/timesheet-pa bordered class="row items-center rounded-10 q-mb-xs" > + {{ $d(getDate(day.short_date), {month: $q.screen.lt.md ? 'short' : 'long'}) }} + + - - + on_request_edit(to_iso_date(day.short_date), shift )" + @request-delete="({ shift }) => on_request_delete(to_iso_date(day.short_date), shift )" /> + diff --git a/src/modules/timesheets/composables/use-shift-api.ts b/src/modules/timesheets/composables/use-shift-api.ts new file mode 100644 index 0000000..ddb67e7 --- /dev/null +++ b/src/modules/timesheets/composables/use-shift-api.ts @@ -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 = /^([01]\d|2[0-3]):([0-5]\d)$/; +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 = (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; +} + +export class UpsertShiftsError extends Error { + status_code: number; + error_code?: string | undefined; + context?: Record | 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 => { + + 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( + `/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); + } +}; \ No newline at end of file diff --git a/src/modules/timesheets/pages/timesheet-details-overview.vue b/src/modules/timesheets/pages/timesheet-details-overview.vue index 015632b..0ebc297 100644 --- a/src/modules/timesheets/pages/timesheet-details-overview.vue +++ b/src/modules/timesheets/pages/timesheet-details-overview.vue @@ -2,15 +2,16 @@ import { useTimesheetStore } from 'src/stores/timesheet-store'; import { useAuthStore } from 'src/stores/auth-store'; import { useTimesheetApi } from '../composables/use-timesheet-api'; -import { computed, onMounted } from 'vue'; +import { computed, onMounted, ref } from 'vue'; import { useI18n } from 'vue-i18n'; import { date } from 'quasar'; -import TimesheetEmployeeDetailsShifts from '../components/timesheet/timesheet-details-shifts.vue'; import TimesheetNavigation from '../components/timesheet/timesheet-navigation.vue'; import ShiftsLegend from '../components/shift/shifts-legend.vue'; +import TimesheetDetailsShifts from '../components/timesheet/timesheet-details-shifts.vue'; +import { upsert_shifts_by_date, type ShiftPayload, type UpsertShiftsBody } from '../composables/use-shift-api'; /* eslint-disable */ -const { locale } = useI18n(); +const { locale, tm, t } = useI18n(); const timesheet_store = useTimesheetStore(); const auth_store = useAuthStore(); const timesheet_api = useTimesheetApi(); @@ -37,6 +38,12 @@ const is_calendar_limit = computed( () => { 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); @@ -51,11 +58,134 @@ onMounted( async () => { await loadByDate(date.formatDate(new Date(), 'YYYY-MM-DD' )); }); +const reload_current_period = async () => { + await timesheet_store.getPayPeriodByDate(date.formatDate(new Date(), 'YYYY-MM-DD')); + await timesheet_store.getTimesheetsByPayPeriodAndEmail(auth_store.user.email); +}; + +type FormMode = 'create' | 'edit' | 'delete'; + +const is_dialog_open = ref(false); +const form_mode = ref('create'); +const selected_date = ref(''); +const old_shift_ref = ref(undefined); + +const start_time = ref(''); +const end_time = ref(''); +const type = ref(''); +const is_remote = ref(false); +const comment = ref(''); + +const open_create_dialog = (iso_date: string) => { + form_mode.value = 'create'; + selected_date.value = iso_date; + old_shift_ref.value = undefined; + start_time.value = ''; + end_time.value = ''; + type.value = ''; + is_remote.value = false; + comment.value = ''; + is_dialog_open.value = true; +}; + +const open_edit_dialog = (iso_date: string, shift: any) => { + form_mode.value = 'edit'; + selected_date.value = iso_date; + start_time.value = shift.start_time, + end_time.value = shift.end_time, + type.value = shift.type, + is_remote.value = shift.is_remote, + comment.value = shift.comment, + is_dialog_open.value = true; + old_shift_ref.value = { + start_time: shift.start_time, + 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 build_new_shift_payload = () => { + const base = { + start_time: start_time.value, + end_time: end_time.value, + type: type.value, + is_remote: !!is_remote.value, + }; + const trimmed_comment = (comment.value ?? '').trim(); + return { + ...base, + ...(trimmed_comment.length > 0 ? { comment: trimmed_comment }: {}), + }; +}; + +const is_submitting = ref(false); +const error_banner = ref(null); +const conflicts = ref>([]); + +const submit_dialog = async () => { + error_banner.value = null; + conflicts.value = []; + is_submitting.value = true; + + try { + const email = auth_store.user.email; + const date_iso = selected_date.value; + let body: UpsertShiftsBody; + + if(form_mode.value === 'create') { + body = { new_shift: build_new_shift_payload() }; + } else if (form_mode.value === 'edit') { + body = { old_shift: old_shift_ref.value!, new_shift: build_new_shift_payload() }; + } else { + body = { old_shift: old_shift_ref.value! }; + } + + await upsert_shifts_by_date(email, date_iso, body); + await timesheet_store.getTimesheetsByPayPeriodAndEmail(email); + + is_dialog_open.value = false; + } catch (e:any) { + const status = e?.status_code ?? e?.response?.status ?? 500; + if (status === 404) { + error_banner.value = 'Ce quart a été modifié ou supprimé. Rafraichissez la page.'; + } else if (status === 409) { + error_banner.value = 'Chevauchement détecté avec un autre quart'; + } else if (status === 422) { + error_banner.value = 'Certains champs sont invalides. Vérifiez le formulaire.'; + } else { + error_banner.value = e?.message || 'Erreur inconnue'; + } + } finally { + is_submitting.value = false; + } +}; + +const close_dialog = () => { is_dialog_open.value = false; }; + +const 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); - + + {{ $t('pageTitles.timeSheets') }} @@ -81,6 +211,7 @@ onMounted( async () => { + { @pressed-previous-button="timesheet_api.getPreviousPayPeriod()" @pressed-next-button="timesheet_api.getNextPayPeriod()" /> + - + + + + + + + + + {{ form_mode === 'create' ? $t('timesheet.add_shift') : form_mode === 'edit' ? $t('timesheet.edit_shift') : $t('timesheet.delete_shift') }} + + + {{ selected_date }} + + + + + + + + + + + + + + + + + + + + + + {{ $t('timesheet.delete_confirmation_msg') }} + + + + {{ error_banner }} + + Conflits : + + + {{ c.start_time }}–{{ c.end_time }} ({{ c.type }}) + + + + + + + + + + + + + + \ No newline at end of file