feat(timesheet): added create-edit-delete shifts
This commit is contained in:
parent
123befb5f8
commit
a252ad98ef
|
|
@ -261,7 +261,14 @@ export default {
|
||||||
save_button:'Save',
|
save_button:'Save',
|
||||||
cancel_button:'Cancel',
|
cancel_button:'Cancel',
|
||||||
remote_button: 'Remote work',
|
remote_button: 'Remote work',
|
||||||
|
delete_button: 'Delete',
|
||||||
|
|
||||||
|
delete_confirmation_msg: 'Do you want to delete this shift completly?',
|
||||||
|
|
||||||
add_shift:'Add Shift',
|
add_shift:'Add Shift',
|
||||||
|
edit_shift: 'Edit shift',
|
||||||
|
delete_shift: 'Delete shift',
|
||||||
|
|
||||||
shift_types_label: 'Shift`s Type',
|
shift_types_label: 'Shift`s Type',
|
||||||
shift_types: {
|
shift_types: {
|
||||||
EMERGENCY: 'Emergency',
|
EMERGENCY: 'Emergency',
|
||||||
|
|
@ -271,10 +278,11 @@ export default {
|
||||||
REGULAR: 'Regular',
|
REGULAR: 'Regular',
|
||||||
SICK: 'Sick Leave',
|
SICK: 'Sick Leave',
|
||||||
VACATION: 'Vacation',
|
VACATION: 'Vacation',
|
||||||
|
REMOTE: 'Remote work',
|
||||||
},
|
},
|
||||||
fields: {
|
fields: {
|
||||||
start:'Start',
|
start:'Start (HH:mm)',
|
||||||
end:'End',
|
end:'End (HH:mm)',
|
||||||
header_comment:'Shift`s comment',
|
header_comment:'Shift`s comment',
|
||||||
textarea_comment: 'Leave a comment here',
|
textarea_comment: 'Leave a comment here',
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -311,8 +311,14 @@ 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',
|
||||||
|
|
||||||
|
delete_confirmation_msg: 'Voulez-vous supprimer complètement ce quart?',
|
||||||
|
|
||||||
add_shift:'Ajouter une quart',
|
add_shift:'Ajouter une quart',
|
||||||
|
edit_shift: 'Modifier un quart',
|
||||||
|
delete_shift: 'Supprimer un quart',
|
||||||
|
|
||||||
shift_types_label: 'Type de quart',
|
shift_types_label: 'Type de quart',
|
||||||
shift_types: {
|
shift_types: {
|
||||||
EMERGENCY: 'Urgence',
|
EMERGENCY: 'Urgence',
|
||||||
|
|
@ -322,10 +328,11 @@ export default {
|
||||||
SICK: 'Absence',
|
SICK: 'Absence',
|
||||||
REGULAR: 'Régulier',
|
REGULAR: 'Régulier',
|
||||||
VACATION: 'Vacance',
|
VACATION: 'Vacance',
|
||||||
|
REMOTE: 'Télétravail',
|
||||||
},
|
},
|
||||||
fields: {
|
fields: {
|
||||||
start:'Entrée',
|
start:'Entrée (HH:mm)',
|
||||||
end:'Sortie',
|
end:'Sortie (HH:mm)',
|
||||||
header_comment:'Commentaire du Quart',
|
header_comment:'Commentaire du Quart',
|
||||||
textarea_comment:'Laissez votre commentaire',
|
textarea_comment:'Laissez votre commentaire',
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,37 +1,27 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Shift } from 'src/modules/timesheets/types/timesheet-shift-interface';
|
import type { Shift } from 'src/modules/timesheets/types/timesheet-shift-interface';
|
||||||
import { computed, ref } from 'vue';
|
import { computed } from 'vue';
|
||||||
import ShiftComment from '../shift/shift-comment.vue';
|
|
||||||
/* eslint-disable */
|
/* eslint-disable */
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
shift: Shift;
|
shift: Shift;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
'save-comment': [payload: { comment: string; shift_id?: string | number; date?: string }];
|
'save-comment' : [payload: { comment: string; shift: Shift }];
|
||||||
|
'request-edit' : [payload: { shift: Shift }];
|
||||||
|
'request-delete': [payload: { shift: Shift }];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const getComment = computed(()=> (props.shift as any).description || (props.shift as any).comment || '');
|
const has_comment = computed(()=> {
|
||||||
const show_comment = ref(false);
|
|
||||||
const openComment = ()=> { show_comment.value = true; };
|
|
||||||
const closeComment = ()=> { show_comment.value = false; };
|
|
||||||
const handleSave = (comment: string)=> {
|
|
||||||
emit('save-comment', {
|
|
||||||
comment,
|
|
||||||
shift_id: (props.shift as any).id,
|
|
||||||
date:(props.shift as any).date
|
|
||||||
});
|
|
||||||
show_comment.value = false;
|
|
||||||
}
|
|
||||||
const hasComment = computed(()=> {
|
|
||||||
const comment = (props.shift as any).description ?? (props.shift as any).comment ?? '';
|
const comment = (props.shift as any).description ?? (props.shift as any).comment ?? '';
|
||||||
return typeof comment === 'string' && comment.trim().length > 0;
|
return typeof comment === 'string' && comment.trim().length > 0;
|
||||||
})
|
})
|
||||||
const comment_icon = computed(()=> (hasComment.value ? 'announcement' : 'chat_bubble_outline'));
|
const comment_icon = computed(()=> (has_comment.value ? 'announcement' : 'chat_bubble_outline'));
|
||||||
const comment_color = computed(()=> (hasComment.value ? 'primary' : 'grey-8'));
|
const comment_color = computed(()=> (has_comment.value ? 'primary' : 'grey-8'));
|
||||||
|
|
||||||
|
|
||||||
const getShiftColor = (type: string): string => {
|
const get_shift_color = (type: string): string => {
|
||||||
switch(type) {
|
switch(type) {
|
||||||
case 'REGULAR': return 'secondary';
|
case 'REGULAR': return 'secondary';
|
||||||
case 'EVENING': return 'warning';
|
case 'EVENING': return 'warning';
|
||||||
|
|
@ -44,13 +34,16 @@ import ShiftComment from '../shift/shift-comment.vue';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const getTextColor = (type: string): string => {
|
const get_text_color = (type: string): string => {
|
||||||
switch(type) {
|
switch(type) {
|
||||||
case 'REGULAR': return 'grey-8';
|
case 'REGULAR': return 'grey-8';
|
||||||
case '': return 'transparent';
|
case '': return 'transparent';
|
||||||
default: return 'white';
|
default: return 'white';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const on_click_edit = () => emit('request-edit', { shift: props.shift });
|
||||||
|
const on_click_delete = () => emit('request-delete', { shift: props.shift });
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|
@ -58,12 +51,13 @@ import ShiftComment from '../shift/shift-comment.vue';
|
||||||
horizontal
|
horizontal
|
||||||
class="q-pa-none text-uppercase text-center items-center cursor-pointer rounded-10"
|
class="q-pa-none text-uppercase text-center items-center cursor-pointer rounded-10"
|
||||||
style="line-height: 1;"
|
style="line-height: 1;"
|
||||||
|
@click.stop="on_click_edit"
|
||||||
>
|
>
|
||||||
<!-- punch-in timestamps -->
|
<!-- punch-in timestamps -->
|
||||||
<q-card-section class="q-pa-none col">
|
<q-card-section class="q-pa-none col">
|
||||||
<q-item-label
|
<q-item-label
|
||||||
class="text-weight-bolder q-pa-xs rounded-5"
|
class="text-weight-bolder q-pa-xs rounded-5"
|
||||||
:class="'bg-' + getShiftColor(props.shift.type) + ' text-' + getTextColor(props.shift.type)"
|
:class="'bg-' + get_shift_color(props.shift.type) + ' text-' + get_text_color(props.shift.type)"
|
||||||
style="font-size: 1.5em; line-height: 80% !important;"
|
style="font-size: 1.5em; line-height: 80% !important;"
|
||||||
>
|
>
|
||||||
{{ props.shift.start_time }}
|
{{ props.shift.start_time }}
|
||||||
|
|
@ -95,7 +89,7 @@ import ShiftComment from '../shift/shift-comment.vue';
|
||||||
<q-card-section class="q-pa-none col">
|
<q-card-section class="q-pa-none col">
|
||||||
<q-item-label
|
<q-item-label
|
||||||
class="text-weight-bolder text-white q-pa-xs rounded-5"
|
class="text-weight-bolder text-white q-pa-xs rounded-5"
|
||||||
:class="'bg-' + getShiftColor(props.shift.type) + ' text-' + getTextColor(props.shift.type)"
|
:class="'bg-' + get_shift_color(props.shift.type) + ' text-' + get_text_color(props.shift.type)"
|
||||||
style="font-size: 1.5em; line-height: 80% !important;"
|
style="font-size: 1.5em; line-height: 80% !important;"
|
||||||
>
|
>
|
||||||
{{ props.shift.end_time }}
|
{{ props.shift.end_time }}
|
||||||
|
|
@ -106,17 +100,15 @@ import ShiftComment from '../shift/shift-comment.vue';
|
||||||
<q-card-section
|
<q-card-section
|
||||||
class="col q-pa-none text-right"
|
class="col q-pa-none text-right"
|
||||||
>
|
>
|
||||||
<!-- chat_bubble_outline or announcement -->
|
<!-- comment btn -->
|
||||||
<q-btn
|
<q-icon
|
||||||
v-if="props.shift.type !== ''"
|
v-if="props.shift.type !== ''"
|
||||||
flat
|
:name="comment_icon"
|
||||||
dense
|
|
||||||
:color="comment_color"
|
:color="comment_color"
|
||||||
:icon="comment_icon"
|
class="q-pa-none q-mx-xs"
|
||||||
class="q-pa-none"
|
size="sm"
|
||||||
@click="openComment"
|
|
||||||
/>
|
/>
|
||||||
<!-- insert_drive_file or request_quote -->
|
<!-- expenses btn -->
|
||||||
<q-btn
|
<q-btn
|
||||||
v-if="props.shift.type !== ''"
|
v-if="props.shift.type !== ''"
|
||||||
flat
|
flat
|
||||||
|
|
@ -125,19 +117,17 @@ import ShiftComment from '../shift/shift-comment.vue';
|
||||||
icon="attach_money"
|
icon="attach_money"
|
||||||
class="q-pa-none q-mx-xs"
|
class="q-pa-none q-mx-xs"
|
||||||
/>
|
/>
|
||||||
</q-card-section>
|
<!-- delete btn -->
|
||||||
</q-card-section>
|
<q-btn
|
||||||
|
v-if="props.shift.type !== ''"
|
||||||
<q-dialog
|
push
|
||||||
v-model="show_comment"
|
dense
|
||||||
transition-show="fade"
|
size="sm"
|
||||||
transition-hide="fade"
|
color="red-6"
|
||||||
persistent
|
icon="close"
|
||||||
>
|
class="q-ml-xs"
|
||||||
<ShiftComment
|
@click.stop="on_click_delete"
|
||||||
:comment-string="getComment"
|
|
||||||
@click-close="closeComment"
|
|
||||||
@click-save="handleSave"
|
|
||||||
/>
|
/>
|
||||||
</q-dialog>
|
</q-card-section>
|
||||||
|
</q-card-section>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -1,22 +1,40 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { default_shift, type Shift } from 'src/modules/timesheets/types/timesheet-shift-interface';
|
import { default_shift, type Shift } from 'src/modules/timesheets/types/timesheet-shift-interface';
|
||||||
import TimesheetEmployeeDetailsShiftsRowHeader from './timesheet-details-shifts-row-header.vue';
|
|
||||||
import TimesheetEmployeeDetailsShiftsRow from './timesheet-details-shifts-row.vue';
|
|
||||||
import type { PayPeriod } from 'src/modules/shared/types/pay-period-interface';
|
import type { PayPeriod } from 'src/modules/shared/types/pay-period-interface';
|
||||||
import type { TimesheetPayPeriodDetailsOverview } from '../../types/timesheet-pay-period-details-overview-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<{
|
const props = defineProps<{
|
||||||
rawData: TimesheetPayPeriodDetailsOverview;
|
rawData: TimesheetPayPeriodDetailsOverview;
|
||||||
currentPayPeriod: PayPeriod;
|
currentPayPeriod: PayPeriod;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const shifts_or_placeholder = (shifts: Shift[]): Shift[] => {
|
const emit = defineEmits<{
|
||||||
return shifts.length > 0 ? shifts : [default_shift];
|
'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 => {
|
const getDate = (shift_date: string): Date => {
|
||||||
return new Date(props.currentPayPeriod.pay_year.toString() + '/' + shift_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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|
@ -32,10 +50,10 @@ import type { TimesheetPayPeriodDetailsOverview } from '../../types/timesheet-pa
|
||||||
bordered
|
bordered
|
||||||
class="row items-center rounded-10 q-mb-xs"
|
class="row items-center rounded-10 q-mb-xs"
|
||||||
>
|
>
|
||||||
|
<!-- Dates column -->
|
||||||
<q-card-section class="col-auto q-pa-xs text-white">
|
<q-card-section class="col-auto q-pa-xs text-white">
|
||||||
<div
|
<div
|
||||||
class="bg-primary rounded-10 q-pa-xs text-center"
|
class="bg-primary rounded-10 q-pa-xs text-center"
|
||||||
:style="$q.screen.lt.md ? '' : 'width: 75px;'"
|
|
||||||
>
|
>
|
||||||
<q-item-label
|
<q-item-label
|
||||||
style="font-size: 0.7em;"
|
style="font-size: 0.7em;"
|
||||||
|
|
@ -51,20 +69,26 @@ import type { TimesheetPayPeriodDetailsOverview } from '../../types/timesheet-pa
|
||||||
>{{ $d(getDate(day.short_date), {month: $q.screen.lt.md ? 'short' : 'long'}) }}</q-item-label>
|
>{{ $d(getDate(day.short_date), {month: $q.screen.lt.md ? 'short' : 'long'}) }}</q-item-label>
|
||||||
</div>
|
</div>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
|
|
||||||
|
<!-- List of shifts column -->
|
||||||
<q-card-section class="col q-pa-none">
|
<q-card-section class="col q-pa-none">
|
||||||
<TimesheetEmployeeDetailsShiftsRowHeader />
|
<TimesheetDetailsShiftsRowHeader />
|
||||||
<TimesheetEmployeeDetailsShiftsRow
|
<TimesheetDetailsShiftsRow
|
||||||
v-for="shift, shift_index in shifts_or_placeholder(day.shifts)"
|
v-for="shift, shift_index in shifts_or_placeholder(day.shifts)"
|
||||||
:key="shift_index"
|
:key="shift_index"
|
||||||
:shift="shift"
|
: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>
|
</q-card-section>
|
||||||
|
<!-- add shift btn column -->
|
||||||
<q-card-section class="q-pr-xs col-auto">
|
<q-card-section class="q-pr-xs col-auto">
|
||||||
<q-btn
|
<q-btn
|
||||||
push
|
push
|
||||||
color="primary"
|
color="primary"
|
||||||
icon="more_time"
|
icon="more_time"
|
||||||
class="q-pa-sm"
|
class="q-pa-sm"
|
||||||
|
@click="on_request_add(to_iso_date(day.short_date))"
|
||||||
/>
|
/>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
</q-card>
|
</q-card>
|
||||||
|
|
|
||||||
171
src/modules/timesheets/composables/use-shift-api.ts
Normal file
171
src/modules/timesheets/composables/use-shift-api.ts
Normal file
|
|
@ -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 = <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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -2,15 +2,16 @@
|
||||||
import { useTimesheetStore } from 'src/stores/timesheet-store';
|
import { useTimesheetStore } from 'src/stores/timesheet-store';
|
||||||
import { useAuthStore } from 'src/stores/auth-store';
|
import { useAuthStore } from 'src/stores/auth-store';
|
||||||
import { useTimesheetApi } from '../composables/use-timesheet-api';
|
import { useTimesheetApi } from '../composables/use-timesheet-api';
|
||||||
import { computed, onMounted } from 'vue';
|
import { computed, onMounted, ref } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { date } from 'quasar';
|
import { date } from 'quasar';
|
||||||
import TimesheetEmployeeDetailsShifts from '../components/timesheet/timesheet-details-shifts.vue';
|
|
||||||
import TimesheetNavigation from '../components/timesheet/timesheet-navigation.vue';
|
import TimesheetNavigation from '../components/timesheet/timesheet-navigation.vue';
|
||||||
import ShiftsLegend from '../components/shift/shifts-legend.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 */
|
/* eslint-disable */
|
||||||
const { locale } = useI18n();
|
const { locale, tm, t } = useI18n();
|
||||||
const timesheet_store = useTimesheetStore();
|
const timesheet_store = useTimesheetStore();
|
||||||
const auth_store = useAuthStore();
|
const auth_store = useAuthStore();
|
||||||
const timesheet_api = useTimesheetApi();
|
const timesheet_api = useTimesheetApi();
|
||||||
|
|
@ -37,6 +38,12 @@ const is_calendar_limit = computed( () => {
|
||||||
timesheet_store.current_pay_period.pay_period_no <= 1;
|
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) => {
|
const onDateSelected = async (date_string: string) => {
|
||||||
await timesheet_api.getTimesheetsByDate(date_string);
|
await timesheet_api.getTimesheetsByDate(date_string);
|
||||||
|
|
@ -51,11 +58,134 @@ onMounted( async () => {
|
||||||
await loadByDate(date.formatDate(new Date(), 'YYYY-MM-DD' ));
|
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<boolean>(false);
|
||||||
|
const form_mode = ref<FormMode>('create');
|
||||||
|
const selected_date = ref<string>('');
|
||||||
|
const old_shift_ref = ref<ShiftPayload | undefined>(undefined);
|
||||||
|
|
||||||
|
const start_time = ref<string>('');
|
||||||
|
const end_time = ref<string>('');
|
||||||
|
const type = ref<string>('');
|
||||||
|
const is_remote = ref<boolean>(false);
|
||||||
|
const comment = ref<string>('');
|
||||||
|
|
||||||
|
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<boolean>(false);
|
||||||
|
const error_banner = ref<string|null>(null);
|
||||||
|
const conflicts = ref<Array<{start_time: string; end_time: string; type: string }>>([]);
|
||||||
|
|
||||||
|
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);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<q-page padding class="q-pa-md bg-secondary" >
|
<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">
|
<div class="text-h4 row justify-center text-center q-mt-lg text-uppercase text-weight-bolder text-grey-8">
|
||||||
{{ $t('pageTitles.timeSheets') }}
|
{{ $t('pageTitles.timeSheets') }}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -81,6 +211,7 @@ onMounted( async () => {
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<q-card flat class="q-mt-md bg-secondary">
|
<q-card flat class="q-mt-md bg-secondary">
|
||||||
|
<!-- navigation btn -->
|
||||||
<TimesheetNavigation
|
<TimesheetNavigation
|
||||||
:is-disabled="timesheet_store.is_loading"
|
:is-disabled="timesheet_store.is_loading"
|
||||||
:is-previous-limit="is_calendar_limit"
|
:is-previous-limit="is_calendar_limit"
|
||||||
|
|
@ -88,17 +219,108 @@ onMounted( async () => {
|
||||||
@pressed-previous-button="timesheet_api.getPreviousPayPeriod()"
|
@pressed-previous-button="timesheet_api.getPreviousPayPeriod()"
|
||||||
@pressed-next-button="timesheet_api.getNextPayPeriod()"
|
@pressed-next-button="timesheet_api.getNextPayPeriod()"
|
||||||
/>
|
/>
|
||||||
|
<!-- shift's colored legend -->
|
||||||
<ShiftsLegend
|
<ShiftsLegend
|
||||||
:is-loading="false"
|
:is-loading="false"
|
||||||
/>
|
/>
|
||||||
<q-card-section horizontal>
|
<q-card-section horizontal>
|
||||||
<TimesheetEmployeeDetailsShifts
|
<!-- display of shifts for 2 timesheets -->
|
||||||
|
<TimesheetDetailsShifts
|
||||||
:raw-data="timesheet_store.pay_period_employee_details"
|
:raw-data="timesheet_store.pay_period_employee_details"
|
||||||
:current-pay-period="timesheet_store.current_pay_period"
|
: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-inner-loading :showing="timesheet_store.is_loading" color="primary"/>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
</q-card>
|
</q-card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- create/edit/delete dialog -->
|
||||||
|
<q-dialog
|
||||||
|
v-model="is_dialog_open"
|
||||||
|
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">
|
||||||
|
{{ form_mode === 'create' ? $t('timesheet.add_shift') : form_mode === 'edit' ? $t('timesheet.edit_shift') : $t('timesheet.delete_shift') }}
|
||||||
|
</div>
|
||||||
|
<q-space/>
|
||||||
|
<q-badge outline color="primary"> {{ selected_date }}</q-badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<q-separator spaced/>
|
||||||
|
|
||||||
|
<div v-if="form_mode !== 'delete'" class="column q-gutter-md">
|
||||||
|
<div class="row ">
|
||||||
|
<div class="col">
|
||||||
|
<q-input
|
||||||
|
v-model="start_time"
|
||||||
|
:label="$t('timesheet.fields.start')" filled dense inputmode="numeric" mask="##:##" />
|
||||||
|
</div>
|
||||||
|
<div class="col">
|
||||||
|
<q-input v-model="end_time" :label="$t('timesheet.fields.end')" filled dense inputmode="numeric" mask="##:##" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row items-center">
|
||||||
|
<q-select
|
||||||
|
v-model="type"
|
||||||
|
options-dense
|
||||||
|
:options="shift_options"
|
||||||
|
:label="$t('timesheet.shift_types_label')"
|
||||||
|
class="col"
|
||||||
|
color="primary"
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
hide-dropdown-icon
|
||||||
|
emit-value
|
||||||
|
map-options
|
||||||
|
/>
|
||||||
|
<q-toggle v-model="is_remote" :label="$t('timesheet.shift_types.REMOTE')" class="col-auto" />
|
||||||
|
</div>
|
||||||
|
<q-input v-model="comment" type="textarea" autogrow filled dense :label="$t('timesheet.fields.header_comment')" :counter="true" :maxlength="512" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="q-pa-md">
|
||||||
|
{{ $t('timesheet.delete_confirmation_msg') }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="error_banner" class="q-mt-md">
|
||||||
|
<q-banner dense class="bg-red-2 text-negative">{{ error_banner }}</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="close_dialog" />
|
||||||
|
<q-btn
|
||||||
|
v-if="form_mode === 'delete'"
|
||||||
|
outline color="negative" icon="cancel" :label="$t('timesheet.delete_button')"
|
||||||
|
:loading="is_submitting"
|
||||||
|
@click="submit_dialog"
|
||||||
|
/>
|
||||||
|
<q-btn
|
||||||
|
v-else
|
||||||
|
color="primary" icon="save_alt" :label="$t('timesheet.save_button')"
|
||||||
|
:loading="is_submitting"
|
||||||
|
@click="submit_dialog"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</q-card>
|
||||||
|
</q-dialog>
|
||||||
</q-page>
|
</q-page>
|
||||||
</template>
|
</template>
|
||||||
Loading…
Reference in New Issue
Block a user