Merge pull request 'dev/matthieu/timesheet-form' (#15) from dev/matthieu/timesheet-form into main

Reviewed-on: Targo/targo_frontend#15
This commit is contained in:
matthieuh 2025-09-16 09:19:59 -04:00
commit 008b1363b5
28 changed files with 982 additions and 467 deletions

View File

@ -261,19 +261,28 @@ 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',
EVENING: 'Evening', EVENING: 'Evening',
HOLIDAY: 'Holiday', HOLIDAY: 'Holiday',
OVERTIME: 'Overtime',
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',
}, },

View File

@ -311,20 +311,28 @@ 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',
EVENING: 'Soir', EVENING: 'Soir',
HOLIDAY: 'Férier', HOLIDAY: 'Férier',
OVERTIME: 'Supplémentaire',
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',
}, },

View File

@ -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
} }
}; };

View 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>

View File

@ -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>

View File

@ -0,0 +1,133 @@
<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 'transparent';
default: return 'white';
}
}
const on_click_edit = () => 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 cursor-pointer rounded-10"
style="line-height: 1;"
@click.stop="on_click_edit"
>
<!-- 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>

View File

@ -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>

View File

@ -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>

View File

@ -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];
}) })
}; };

View File

@ -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>

View 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);
}
};

View File

@ -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,
}; };
}; };

View File

@ -0,0 +1,326 @@
<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 { computed, onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { date } from 'quasar';
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, tm, t } = useI18n();
const timesheet_store = useTimesheetStore();
const auth_store = useAuthStore();
const timesheet_api = useTimesheetApi();
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 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>
<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('pageTitles.timeSheets') }}
</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.dateRangesTo') }}
</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="q-mt-md bg-secondary">
<!-- navigation btn -->
<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()"
/>
<!-- 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>
<!-- 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>
</template>

View File

@ -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>

View File

@ -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;
},
}; };

View File

@ -12,12 +12,15 @@ export interface TimesheetDetailsDailySchedule {
evening_hours: number; evening_hours: number;
emergency_hours: number; emergency_hours: number;
overtime_hours: number; overtime_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;
}; };
@ -61,6 +64,7 @@ const emptyDailySchedule = (): TimesheetDetailsDailySchedule => ({
evening_hours: 0, evening_hours: 0,
emergency_hours: 0, emergency_hours: 0,
overtime_hours: 0, overtime_hours: 0,
comment: "",
short_date: "", short_date: "",
break_duration: 0, break_duration: 0,
}); });

View File

@ -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;
} }

View File

@ -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(),
}

View File

@ -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;

View File

@ -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[];
}

View File

@ -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')
} }
], ],
}, },

View File

@ -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,