feat(docker): Add/Correct Dockerfile for remote Docker Lab deployment

This commit is contained in:
Nicolas Drolet 2025-10-29 15:19:10 -04:00
parent 33061ef2ab
commit 6c6cecbe7d
25 changed files with 689 additions and 437 deletions

23
Dockerfile Normal file
View File

@ -0,0 +1,23 @@
# targo-frontend
FROM node:22
# Set working directory inside container
WORKDIR /app
# Set environment variables
ENV VITE_TARGO_BACKEND_URL="http://targo-backend:3000"
# Copy package.json & package-lock.json first (for caching)
COPY package*.json ./
# Install dependencies
RUN npm install
# Copy the rest of the code
COPY . .
# Expose Quasar dev port
EXPOSE 9000
# Default command
CMD ["quasar", "dev"]

View File

@ -15,7 +15,7 @@ declare module 'vue' {
// "export default () => {}" function below (which runs individually
// for each client)
const api = axios.create({
baseURL: import.meta.env.VITE_TARGO_BACKEND_AUTH_URL,
baseURL: import.meta.env.VITE_TARGO_BACKEND_URL,
withCredentials: true
});

View File

@ -1,5 +1,5 @@
// app global css in SCSS form
@each $size in (1, 2, 3, 4, 5, 10, 15, 20, 25, 50, 75, 100) {
@each $size in (1, 2, 3, 4, 5, 10, 15, 20, 25, 50, 75, 100, 200) {
.rounded-#{$size} {
border-radius: #{$size}px !important;
}
@ -36,4 +36,9 @@ body.body--dark {
.shift-highlight {
background: #0195462a;
}
.frosted-glass {
background-color: #FFFA !important;
backdrop-filter: blur(5px);
}

View File

@ -27,6 +27,8 @@ $layout-shadow-dark : 0 0 10px 5px rgba($dark-shadow-color, 0.5);
$input-text-color : #455A64;
$input-autofill-color : #AAD5C4;
$field-dense-label-top : 5px !default;
$field-dense-label-font-size : 16px !default;
$dark : #42444b;

View File

@ -31,7 +31,7 @@
<template>
<q-drawer
v-model="ui_store.isRightDrawerOpen"
v-model="ui_store.is_left_drawer_open"
persistent
mini-to-overlay
elevated

View File

@ -1,4 +1,7 @@
<script setup lang="ts">
<script
setup
lang="ts"
>
import { computed, ref } from 'vue';
import { useAuthApi } from 'src/modules/auth/composables/use-auth-api';
@ -6,16 +9,20 @@
const email = defineModel<string>('email', { default: '', });
const is_remembered = ref<boolean>(false);
const is_employee_email = computed( () => email.value.includes('@targ'));
const is_employee_email = computed(() => email.value.includes('@targ'));
</script>
<template>
<q-card class="rounded-15">
<q-card class="rounded-15 shadow-10">
<q-card-section class="text-center bg-primary q-pa-lg">
<q-img src="/src/assets/logo-targo-white.svg" ratio="4.6" fit="contain" />
<q-img
src="/src/assets/logo-targo-white.svg"
ratio="4.6"
fit="contain"
/>
</q-card-section>
<div class="q-pt-sm q-px-xl q-pb-lg">
<div class="q-pt-sm q-px-xl q-pb-lg ">
<q-card-section class="text-center text-uppercase">
<div class="text-h6 text-weight-bold">
{{ $t('login.page_header') }}
@ -23,34 +30,40 @@
</q-card-section>
<q-form @submit="auth_api.login">
<q-input
v-model="email"
dense
outlined
label-color="primary"
:label="$t('login.email')"
/>
<q-input
v-model="email"
dense
outlined
label-color="primary"
class="rounded-5 inset-shadow bg-blue-grey-1"
label-slot
input-class="text-weight-medium text-h6"
>
<template #label>
<span class="text-weight-bolder text-uppercase text-overline"> {{ $t('login.email') }} </span>
</template>
</q-input>
<q-card-section class="q-ma-none q-pa-none text-uppercase text-caption text-weight-medium">
<q-toggle
v-model="is_remembered"
color="primary"
:label="$t('login.button.remember_me')"
<q-toggle
v-model="is_remembered"
color="primary"
:label="$t('login.button.remember_me')"
/>
</q-card-section>
<q-card-actions>
<q-btn
push
rounded
disabled
type="submit"
color="primary"
:label="$t('login.button.connect')"
class="full-width"
<q-btn
push
rounded
disabled
type="submit"
color="primary"
:label="$t('login.button.connect')"
class="full-width"
/>
</q-card-actions>
<!-- A implémenter plus tard sans doute, pour les clients. A revoir avec Authentik API pour création de users -->
<!-- <q-card-section class="text-center q-pa-none q-mt-none">
<RouterLink disabled class="text-primary" to="/signup">{{ $t('loginPage.signUp') }}</RouterLink>
@ -58,39 +71,49 @@
</q-form>
<q-card-section class="row q-pt-sm">
<q-separator color="primary" class="col self-center"/>
<span class="col text-primary text-weight-bolder text-center text-uppercase self-center">{{ $t('shared.misc.or') }}</span>
<q-separator color="primary" class="col self-center"/>
<q-separator
color="primary"
class="col self-center"
/>
<span class="col text-primary text-weight-bolder text-center text-uppercase self-center">{{
$t('shared.misc.or') }}</span>
<q-separator
color="primary"
class="col self-center"
/>
</q-card-section>
<q-card-section class="column q-px-sm q-pt-none">
<q-btn
rounded
push
disabled
color="fb-blue"
<q-btn
rounded
push
disabled
color="fb-blue"
icon="img:src/assets/Facebook-f_Logo-White-Logo.wine.svg"
:label="$t('login.button.facebook')"
class="full-width row q-mb-sm"
:label="$t('login.button.facebook')"
class="full-width row q-mb-sm"
>
<q-tooltip anchor="top middle" class="bg-primary">{{$t('login.tooltip.coming_soon')}}</q-tooltip>
<q-tooltip
anchor="top middle"
class="bg-primary"
>{{ $t('login.tooltip.coming_soon') }}</q-tooltip>
</q-btn>
<q-slide-transition>
<div v-if="is_employee_email">
<transition
slow
enter-active-class="animated zoomIn"
<transition
slow
enter-active-class="animated zoomIn"
leave-active-class="animated zoomOut"
>
<q-btn
push
rounded
color="primary"
icon="img:src/assets/logo-targo-simple.svg"
:label="$t('login.button.employee')"
class="full-width row"
@click="auth_api.oidcLogin"
/>
<q-btn
push
rounded
color="primary"
icon="img:src/assets/logo-targo-simple.svg"
:label="$t('login.button.employee')"
class="full-width row"
@click="auth_api.oidcLogin"
/>
</transition>
</div>
</q-slide-transition>

View File

@ -1,27 +0,0 @@
<script
setup
lang="ts"
>
defineProps<{
isLoading: boolean;
}>();
</script>
<template>
<q-card
v-if="isLoading"
flat
class="column flex-center rounded-10"
style="width: 20vw !important; height: 20vh !important;"
>
<q-spinner
color="primary"
size="5em"
:thickness="10"
class="col-auto"
/>
<div class="col-auto text-primary text-h6 text-weight-bold text-center ">
{{ $t('shared.label.loading') }}
</div>
</q-card>
</template>

View File

@ -72,7 +72,7 @@
>
<q-badge
v-if="expense.is_approved"
class="absolute z-top rounded-20 bg-white q-pa-none"
class="absolute z-top rounded-20 bg-dark q-pa-none"
style="transform: translate(-15px, -15px);"
>
<q-icon

View File

@ -13,7 +13,6 @@
{ type: 'REGULAR', color: 'secondary', label_type: 'timesheet.shift.types.REGULAR' },
{ type: 'EVENING', color: 'warning', label_type: 'timesheet.shift.types.EVENING' },
{ type: 'EMERGENCY', color: 'amber-10', label_type: 'timesheet.shift.types.EMERGENCY' },
{ type: 'OVERTIME', color: 'negative', label_type: 'timesheet.shift.types.OVERTIME' },
{ type: 'VACATION', color: 'purple-10', label_type: 'timesheet.shift.types.VACATION' },
{ type: 'HOLIDAY', color: 'purple-5', label_type: 'timesheet.shift.types.HOLIDAY' },
{ type: 'SICK', color: 'grey-8', label_type: 'timesheet.shift.types.SICK' },

View File

@ -2,151 +2,196 @@
setup
lang="ts"
>
import { ref } from 'vue';
import type { Shift } from 'src/modules/timesheets/models/shift.models';
/* eslint-disable*/
import { onMounted, ref, useTemplateRef } from 'vue';
import { useI18n } from 'vue-i18n';
import { QSelect } from 'quasar';
import { Shift, ShiftType } from 'src/modules/timesheets/models/shift.models';
import { useUiStore } from 'src/stores/ui-store';
const { shift, dense = false } = defineProps<{
shift: Shift;
const { t } = useI18n();
const ui_store = useUiStore();
const shift = defineModel<Shift>('shift', { required: true });
const { dense = false } = defineProps<{
dense?: boolean;
}>();
defineEmits<{
'save-comment': [comment: string, shift: Shift];
'request-update': [shift: Shift];
'saveComment': [comment: string, shift_id: number];
'requestUpdate': [shift_id: number];
'requestDelete': [void];
}>();
const shift_start = ref(shift.start_time);
const shift_end = ref(shift.end_time);
const time_picker_model = ref('');
const is_showing_time_picker = ref(false);
const select_ref = useTemplateRef<QSelect>('select');
const options: { label: string, value: ShiftType, icon: string, icon_color: string }[] = [
{ label: t('timesheet.shift.types.REGULAR'), value: 'REGULAR', icon: 'wb_sunny', icon_color: '' },
{ label: t('timesheet.shift.types.EVENING'), value: 'EVENING', icon: 'bedtime', icon_color: 'indigo-8' },
{ label: t('timesheet.shift.types.EMERGENCY'), value: 'EMERGENCY', icon: 'ring_volume', icon_color: 'red-8' },
{ label: t('timesheet.shift.types.VACATION'), value: 'VACATION', icon: 'beach_access', icon_color: 'yellow-8' },
{ label: t('timesheet.shift.types.HOLIDAY'), value: 'HOLIDAY', icon: 'forest', icon_color: 'green-8' },
{ label: t('timesheet.shift.types.SICK'), value: 'SICK', icon: 'medication_liquid', icon_color: 'cyan-8' },
];
const shift_type_selected = ref(options.find(option => option.value == shift.value.type));
const showTimePicker = (time: string) => {
is_showing_time_picker.value = true;
time_picker_model.value = time;
};
onMounted(() => {
if (ui_store.focus_next_component) {
select_ref.value?.focus();
select_ref.value?.showPopup();
ui_store.focus_next_component = false;
}
});
</script>
<template>
<q-card-section
horizontal
class="q-py-none q-mx-md text-uppercase text-center items-center rounded-10"
style="line-height: 1;"
<div
v-if="shift.shift_id !== 0"
class="col row flex-center text-uppercase rounded-10"
>
<!-- time-picker for mobile timesheet -->
<q-dialog
v-model="is_showing_time_picker"
class="z-max"
<!-- shift type -->
<q-select
ref="select"
v-model="shift_type_selected"
standout="bg-blue-grey-9"
dense
options-dense
hide-dropdown-icon
:menu-offset="[0, 10]"
:options="options"
class="rounded-5 q-mx-xs shadow-1"
:class="ui_store.is_mobile_mode ? 'col-auto' : 'col'"
popup-content-class="text-uppercase text-weight-bold text-center rounded-5"
popup-content-style="border: 2px solid var(--q-primary)"
>
<q-time
v-model="time_picker_model"
format24h
color="primary"
/>
</q-dialog>
<div class="col row">
<!-- punch-in timestamp -->
<q-input
dense
standout="bg-blue-grey-9"
label-slot
v-model="shift_start"
class="col q-pa-none q-my-xs"
input-class="text-weight-bold text-h6"
label-color="primary"
mask="## : ##"
lazy-rules
>
<template #label>
<span
class="text-weight-bolder"
style="font-size: 0.95em;"
>{{ $t('shared.misc.in') }}</span>
</template>
<template #append>
<q-btn
dense
flat
icon="access_time"
color="primary"
class="q-ma-none"
@click.stop="showTimePicker(shift_start)"
/>
</template>
</q-input>
<!-- arrows pointing to punch-out timestamps -->
<q-card-section
horizontal
class="col-auto items-center justify-center q-mx-sm"
>
<template #selected-item="scope">
<div
v-for="icon_data, index in [
{ transform: 'transform: translateX(5px);', color: 'accent' },
{ transform: 'transform: translateX(-5px);', color: 'primary' }]"
:key="index"
class="row text-weight-bold q-ma-none q-pa-none no-wrap ellipsis"
:class="ui_store.is_mobile_mode ? 'items-center' : 'flex-center'"
:tabindex="scope.tabindex"
>
<q-icon
v-if="shift.type && !dense"
name="double_arrow"
:color="icon_data.color"
:size="dense ? '16px' : '24px'"
:style="icon_data.transform"
:name="scope.opt.icon"
:color="scope.opt.icon_color"
size="sm"
class="col-auto q-mx-xs"
/>
<span
v-else
class="text-primary"
> > </span>
v-if="$q.screen.gt.md"
style="line-height: 0.9em;"
class="col ellipsis"
>{{ scope.opt.label }}</span>
</div>
</q-card-section>
</template>
</q-select>
<!-- punch-out timestamps -->
<q-input
dense
standout="bg-blue-grey-9"
label-slot
v-model="shift_end"
class="col q-pa-none q-my-xs"
input-class="text-weight-bold text-h6"
label-color="primary"
>
<template #label>
<span
class="text-weight-bolder"
style="font-size: 0.95em;"
>{{ $t('shared.misc.out') }}</span>
</template>
<!-- punch-in timestamp -->
<q-input
v-model="shift.start_time"
dense
type="time"
standout="bg-blue-grey-9"
label-slot
label-color="primary"
input-class="text-weight-medium"
input-style="font-size: 1.2em;"
class="col q-mx-xs"
>
<template #label>
<span
class="text-weight-bolder"
style="font-size: 0.95em;"
>{{ $t('shared.misc.in') }}</span>
</template>
<template #append>
<q-btn
dense
flat
icon="access_time"
color="primary"
@click="showTimePicker(shift_end)"
/>
</template>
</q-input>
</div>
<template #append>
<q-btn
v-if="ui_store.is_mobile_mode"
dense
flat
icon="access_time"
color="primary"
@click.stop="showTimePicker(shift.start_time)"
/>
</template>
</q-input>
<q-card-section class="col-grow q-pa-none no-wrap q-mx-xs">
<!-- comment btn -->
<!-- punch-out timestamps -->
<q-input
v-model="shift.end_time"
dense
type="time"
standout="bg-blue-grey-9"
label-slot
label-color="primary"
input-class="text-weight-medium"
input-style="font-size: 1.2em;"
class="col q-mx-xs"
lazy-rules
>
<template #label>
<span
class="text-weight-bolder"
style="font-size: 0.95em;"
>{{ $t('shared.misc.out') }}</span>
</template>
<template #append>
<q-btn
v-if="ui_store.is_mobile_mode"
dense
flat
icon="access_time"
color="primary"
@click="showTimePicker(shift.end_time)"
/>
</template>
</q-input>
<!-- comment and delete buttons -->
<div
v-if="$q.screen.gt.sm"
class="col-auto"
>
<q-icon
v-if="shift.type && dense"
:name="shift.comment ? 'comment' : ''"
color="primary"
:size="dense ? 'xs' : 'sm'"
class="q-pa-none q-mr-xs"
class="col-auto q-pa-none q-mr-xs"
/>
<q-btn
v-else
flat
dense
:icon="shift.comment ? 'chat' : 'chat_bubble_outline'"
:text-color="shift.comment ? 'primary' : 'grey-8'"
class="col-auto q-ma-none q-pl-md full-height"
/>
</q-card-section>
</q-card-section>
<q-btn
dense
flat
round
unelevated
tabindex="-1"
icon="cancel"
color="negative"
class="q-pa-none q-mr-xs"
@click="$emit('requestDelete')"
/>
</div>
</div>
</template>

View File

@ -2,103 +2,123 @@
setup
lang="ts"
>
/* eslint-disable */
import { date } from 'quasar';
import { computed } from 'vue';
import { useQuasar } from 'quasar';
import ShiftListRow from 'src/modules/timesheets/components/shift-list-row.vue';
import { useTimesheetStore } from 'src/stores/timesheet-store';
import { type Shift } from 'src/modules/timesheets/models/shift.models';
import { computed } from 'vue';
import { useShiftApi } from 'src/modules/timesheets/composables/use-shift-api';
import { Shift } from 'src/modules/timesheets/models/shift.models';
import { useUiStore } from 'src/stores/ui-store';
const q = useQuasar();
const ui_store = useUiStore();
const timesheet_store = useTimesheetStore();
const shift_api = useShiftApi();
const { dense = false } = defineProps<{
dense?: boolean;
}>();
const is_mobile = computed(() => q.screen.lt.md);
const date_font_size = computed(() => dense ? '1.5em' : '2.5em');
const weekday_font_size = computed(() => dense ? '0.55em;' : '0.7em;');
const date_box_size = computed(() => dense ? 'width: 50px;' : 'width: 75px;');
const date_box_size = computed(() => dense || is_mobile.value ? 'width: 40px; height: 75px;' : 'width: 75px; height: 75px;');
// const get_date_from_short = (short_date: string): Date => {
// if (timesheet_store.pay_period === undefined) return new Date();
// return new Date(timesheet_store.pay_period.pay_year.toString() + '/' + short_date);
// };
// const to_iso_date = (short_date: string): string => {
// return date.formatDate(get_date_from_short(short_date), 'YYYY-MM-DD');
// };
const shifts_or_placeholder = (shifts: Shift[]): Shift[] => {
return shifts.length > 0 ? shifts : [];
const addNewShift = (day_shifts: Shift[], date: string, timesheet_id: number) => {
ui_store.focus_next_component = true;
const new_shift = new Shift;
new_shift.date = date;
new_shift.timesheet_id = timesheet_id;
day_shifts.push(new_shift);
};
const deleteCurrentShift = async (shift: Shift) => {
console.log('shift to delete: ', shift);
if (shift.shift_id < 0) {
shift.shift_id = 0;
return;
}
await shift_api.deleteShiftById(shift.shift_id);
}
</script>
<template>
<div
v-for="week, index in timesheet_store.timesheets"
:key="index"
class="column col q-mx-xs"
>
<div class="col row shadow-2 rounded-10 q-my-xs">
<q-card
v-for="day, day_index in week.days"
:key="day_index + index"
class="row col items-center no-shadow"
style="border-radius: 10px 0 0 10px;"
<div :class="$q.screen.lt.md ? 'column' : 'row'">
<div
v-for="timesheet in timesheet_store.timesheets"
:key="timesheet.timesheet_id"
class="col column"
>
<div
v-for="day in timesheet.days"
:key="day.date"
class="col-auto row shadow-2 rounded-10 q-ma-xs"
>
<!-- Dates column -->
<q-card-section class="col-auto q-pa-none text-white">
<div
class="col row bg-dark"
style="border-radius: 10px 0 0 10px;"
>
<!-- Dates column -->
<div
class="bg-primary rounded-10 q-pa-xs text-center q-ml-sm"
class="col-auto column flex-center bg-primary rounded-10 text-center q-ma-sm self-center"
:class="$q.screen.lt.md ? '' : ''"
:style="date_box_size"
>
<q-item-label
<span
v-if="!dense"
class="col-auto text-uppercase text-white"
:style="'font-size: ' + weekday_font_size"
class="text-uppercase"
>{{ $d(date.extractDate(day.date, 'YYYY-MM-DD'), { weekday: $q.screen.lt.md ? 'short' : 'long' })
}}</q-item-label>
<q-item-label
class="text-weight-bolder"
>
{{ $d(date.extractDate(day.date, 'YYYY-MM-DD'), {
weekday: $q.screen.lt.md ? 'short' :
'long'
})
}}
</span>
<span
class="col-auto text-weight-bolder text-grey-1"
:style="'font-size: ' + date_font_size + '; line-height: 90% !important;'"
>{{ date.extractDate(day.date, 'YYYY-MM-DD').getDate() }}</q-item-label>
<q-item-label
>
{{ date.extractDate(day.date, 'YYYY-MM-DD').getDate() }}
</span>
<span
class="col-auto text-uppercase text-white"
:style="'font-size: ' + weekday_font_size"
class="text-uppercase"
>{{ $d(date.extractDate(day.date, 'YYYY-MM-DD'), { month: $q.screen.lt.md ? 'short' : 'long' })
}}</q-item-label>
>
{{ $d(date.extractDate(day.date, 'YYYY-MM-DD'), {
month: $q.screen.lt.md ? 'short' : 'long'
})
}}
</span>
</div>
</q-card-section>
<!-- List of shifts column -->
<q-card-section class="col column q-pa-none full-height">
<div
v-if="day.shifts.length > 0"
class="col-grow column justify-center"
>
<!-- List of shifts column -->
<div class="col column">
<ShiftListRow
v-for="shift, shift_index in shifts_or_placeholder(day.shifts)"
v-for="shift, shift_index in day.shifts"
:key="shift_index"
class="col"
v-model:shift="day.shifts[shift_index]!"
:dense="dense"
:shift="shift"
@request-update=""
@request-delete="deleteCurrentShift(shift)"
/>
</div>
</q-card-section>
</q-card>
</div>
<q-btn
unelevated
class="col-auto bg-primary"
icon="more_time"
size="lg"
text-color="white"
style="border-radius: 0 10px 10px 0;"
/>
<div class="col-auto self-stretch">
<q-btn
unelevated
icon="more_time"
:size="$q.screen.lt.md ? 'md' : 'lg'"
color="primary"
text-color="white"
class="full-height"
:class="$q.screen.lt.md ? 'q-px-xs' : ''"
style="border-radius: 0 10px 10px 0;"
@click="addNewShift(day.shifts, day.date, timesheet.timesheet_id)"
/>
</div>
</div>
</div>
</div>
</template>

View File

@ -2,17 +2,18 @@
setup
lang="ts"
>
import GenericLoader from 'src/modules/shared/components/generic-loader.vue';
import ShiftList from 'src/modules/timesheets/components/shift-list.vue';
import ExpenseDialog from 'src/modules/timesheets/components/expense-dialog.vue';
import PayPeriodNavigator from 'src/modules/shared/components/pay-period-navigator.vue';
import ShiftListLegend from 'src/modules/timesheets/components/shift-list-legend.vue';
// import ShiftListLegend from 'src/modules/timesheets/components/shift-list-legend.vue';
import { useTimesheetStore } from 'src/stores/timesheet-store';
import { useTimesheetApi } from 'src/modules/timesheets/composables/use-timesheet-api';
import { useExpensesStore } from 'src/stores/expense-store';
import { provide } from 'vue';
import { useShiftApi } from 'src/modules/timesheets/composables/use-shift-api';
const { open } = useExpensesStore();
const shift_api = useShiftApi();
const { employeeEmail, dense = false } = defineProps<{
employeeEmail: string;
@ -27,21 +28,28 @@
<template>
<div class="column flex-center full-width">
<GenericLoader
:is-loading="timesheet_store.is_loading"
class="col-auto text-center"
/>
<q-dialog
v-model="timesheet_store.is_loading"
transition-show="jump-down"
transition-hide="jump-down"
>
<q-card class="q-pa-xl rounded-200 bg-white frosted-glass">
<q-spinner-radio
color="primary"
size="20vh"
/>
</q-card>
</q-dialog>
<q-card
v-if="!timesheet_store.is_loading"
flat
class="transparent full-width"
>
<q-card-section
v-if="!dense"
:horizontal="$q.screen.gt.sm"
class="q-px-md items-center"
class="q-px-md items-center q-mb-md"
:class="$q.screen.lt.md ? 'column' : ''"
>
<!-- navigation btn -->
@ -65,9 +73,21 @@
/>
<!-- shift's colored legend -->
<ShiftListLegend :is-loading="false" />
<!-- <ShiftListLegend :is-loading="false" /> -->
<q-space />
<!-- save timesheet changes button -->
<q-btn
v-if="$q.screen.gt.sm"
push
rounded
:disable="timesheet_store.is_loading"
color="primary"
icon="upload"
:label="$t('shared.label.save')"
class="q-mr-md"
@click="shift_api.saveShiftChanges"
/>
<!-- desktop expenses button -->
<q-btn
@ -82,14 +102,8 @@
</q-card-section>
<q-card-section
:horizontal="$q.screen.gt.sm"
class="bg-secondary q-pa-sm rounded-10"
>
<ShiftList :dense="dense" />
</q-card-section>
<ShiftList :dense="dense" />
</q-card>
<ExpenseDialog />
</div>
</template>

View File

@ -0,0 +1,39 @@
import { useAuthStore } from "src/stores/auth-store";
import { useShiftStore } from "src/stores/shift-store";
import { useTimesheetStore } from "src/stores/timesheet-store";
export const useShiftApi = () => {
const timesheet_store = useTimesheetStore();
const shift_store = useShiftStore();
const auth_store = useAuthStore();
const deleteShiftById = async (shift_id: number) => {
timesheet_store.is_loading = true;
const success = await shift_store.deleteShiftById(shift_id);
if (success) {
await timesheet_store.getTimesheetsByEmployeeEmail(auth_store.user?.email ?? '');
}
timesheet_store.is_loading = false;
};
const saveShiftChanges = async () => {
timesheet_store.is_loading = true;
const create_success = await shift_store.createNewShifts();
if (create_success) {
const update_success = await shift_store.updateShifts();
if (update_success) {
await timesheet_store.getTimesheetsByEmployeeEmail(auth_store.user?.email ?? '')
}
}
timesheet_store.is_loading = false;
}
return {
deleteShiftById,
saveShiftChanges,
};
}

View File

@ -6,21 +6,29 @@ export const useTimesheetApi = () => {
const auth_store = useAuthStore();
const getTimesheetsByDate = async (date_string: string, employee_email?: string) => {
timesheet_store.is_loading = true;
const success = await timesheet_store.getPayPeriodByDateOrYearAndNumber(date_string);
if (success) {
await timesheet_store.getTimesheetsByEmployeeEmail(employee_email ?? auth_store.user?.email ?? '');
timesheet_store.is_loading = false;
}
timesheet_store.is_loading = false;
}
const getTimesheetsByCurrentPayPeriod = async (employee_email?: string) => {
if (timesheet_store.pay_period === undefined) return false;
timesheet_store.is_loading = true;
const success = await timesheet_store.getPayPeriodByDateOrYearAndNumber(timesheet_store.pay_period.pay_year, timesheet_store.pay_period.pay_period_no );
if (success) {
await timesheet_store.getTimesheetsByEmployeeEmail(employee_email ?? auth_store.user?.email ?? '');
timesheet_store.is_loading = false;
}
timesheet_store.is_loading = false;
};
return {

View File

@ -1,29 +1,46 @@
export const SHIFT_TYPES: ShiftType[] = [
'REGULAR',
'EVENING',
'REGULAR',
'EVENING',
'EMERGENCY',
'OVERTIME',
'HOLIDAY',
'VACATION',
'HOLIDAY',
'VACATION',
'SICK'
];
export type ShiftType = 'REGULAR' | 'EVENING' | 'EMERGENCY' | 'OVERTIME' | 'HOLIDAY' | 'VACATION' | 'SICK' ;
export type ShiftType = 'REGULAR' | 'EVENING' | 'EMERGENCY' | 'HOLIDAY' | 'VACATION' | 'SICK';
export type ShiftLegendItem = {
type: ShiftType;
color: string;
label_type: string;
type: ShiftType;
color: string;
label_type: string;
text_color?: string;
};
export interface Shift {
id: number;
date: string; //YYYY-MM-DD
type: ShiftType;
start_time: string; //HH:mm:ss
end_time: string; //HH:mm:ss
comment: string | undefined;
export class Shift {
shift_id: number;
timesheet_id: number;
date: string; //YYYY-MM-DD
type: ShiftType;
start_time: string; //HH:mm:ss
end_time: string; //HH:mm:ss
comment: string | undefined;
is_approved: boolean;
is_remote: boolean;
is_remote: boolean;
constructor() {
this.shift_id = -1;
this.timesheet_id = -1;
this.date = '';
this.type = 'REGULAR';
this.start_time = '';
this.end_time = '';
this.comment = undefined;
this.is_approved = false;
this.is_remote = false;
}
}
export interface NewShift {
timesheet_id: number;
shifts: Shift[];
}

View File

@ -4,8 +4,13 @@ import type { Expense } from "src/modules/timesheets/models/expense.models";
export const TIME_FORMAT_PATTERN = /^(\d{2}:\d{2})?$/;
export const DATE_FORMAT_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
export interface TimesheetResponse {
employee_full_name: string;
timesheets: Timesheet[];
}
export interface Timesheet {
id: number;
timesheet_id: number;
is_approved: boolean;
weekly_hours: TotalHours;
weekly_expenses: TotalExpenses;
@ -36,77 +41,77 @@ export interface TotalExpenses {
mileage: number;
}
export const test_timesheets: Timesheet[] = [
{
id: 1,
is_approved: false,
weekly_hours: { regular: 8, evening: 0, emergency: 0, overtime: 0, vacation: 0, holiday: 0, sick: 0, absent: 0 },
weekly_expenses: { expenses: 15.5, mileage: 0 },
days: [
{
date: '2025-10-18',
daily_hours: { regular: 8, evening: 0, emergency: 0, overtime: 0, vacation: 0, holiday: 0, sick: 0, absent: 0 },
daily_expenses: { expenses: 15.5, mileage: 0 },
shifts: [
{ id: 101, date: '2025-01-06', type: 'REGULAR', start_time: '08:00', end_time: '12:00', comment: 'blah', is_approved: false, is_remote: false, },
{ id: 102, date: '2025-01-06', type: 'REGULAR', start_time: '13:00', end_time: '17:00', comment: undefined, is_approved: false, is_remote: false, },
],
expenses: [
{ id: 201, date: '2025-01-06', type: 'EXPENSES', amount: 15.5, comment: 'Lunch receipt', is_approved: false, },
],
},
],
},
{
id: 2,
is_approved: true,
weekly_hours: {
regular: 0,
evening: 0,
emergency: 0,
overtime: 8,
vacation: 0,
holiday: 0,
sick: 0,
absent: 0,
},
weekly_expenses: {
expenses: 0,
mileage: 32.4,
},
days: [
{
date: '2025-10-27',
daily_hours: {
regular: 0,
evening: 0,
emergency: 0,
overtime: 8,
vacation: 0,
holiday: 0,
sick: 0,
absent: 0,
},
daily_expenses: {
expenses: 0,
mileage: 32.4,
},
shifts: [
{ id: 101, date: '2025-10-27', type: 'REGULAR', start_time: '08:00', end_time: '12:00', comment: undefined, is_approved: false, is_remote: false, },
{ id: 102, date: '2025-10-27', type: 'REGULAR', start_time: '13:00', end_time: '17:00', comment: undefined, is_approved: false, is_remote: false, },
],
expenses: [
{
id: 202,
date: '2025-10-27',
type: 'MILEAGE',
amount: 0,
mileage: 32.4,
comment: 'Travel to client site',
is_approved: true,
},
],
},
],
},
];
// export const test_timesheets: Timesheet[] = [
// {
// timehsid: 1,
// is_approved: false,
// weekly_hours: { regular: 8, evening: 0, emergency: 0, overtime: 0, vacation: 0, holiday: 0, sick: 0, absent: 0 },
// weekly_expenses: { expenses: 15.5, mileage: 0 },
// days: [
// {
// date: '2025-10-18',
// daily_hours: { regular: 8, evening: 0, emergency: 0, overtime: 0, vacation: 0, holiday: 0, sick: 0, absent: 0 },
// daily_expenses: { expenses: 15.5, mileage: 0 },
// shifts: [
// { id: 101, date: '2025-01-06', type: 'REGULAR', start_time: '08:00', end_time: '12:00', comment: 'blah', is_approved: false, is_remote: false, },
// { id: 102, date: '2025-01-06', type: 'REGULAR', start_time: '13:00', end_time: '17:00', comment: undefined, is_approved: false, is_remote: false, },
// ],
// expenses: [
// { id: 201, date: '2025-01-06', type: 'EXPENSES', amount: 15.5, comment: 'Lunch receipt', is_approved: false, },
// ],
// },
// ],
// },
// {
// id: 2,
// is_approved: true,
// weekly_hours: {
// regular: 0,
// evening: 0,
// emergency: 0,
// overtime: 8,
// vacation: 0,
// holiday: 0,
// sick: 0,
// absent: 0,
// },
// weekly_expenses: {
// expenses: 0,
// mileage: 32.4,
// },
// days: [
// {
// date: '2025-10-27',
// daily_hours: {
// regular: 0,
// evening: 0,
// emergency: 0,
// overtime: 8,
// vacation: 0,
// holiday: 0,
// sick: 0,
// absent: 0,
// },
// daily_expenses: {
// expenses: 0,
// mileage: 32.4,
// },
// shifts: [
// { id: 101, date: '2025-10-27', type: 'REGULAR', start_time: '08:00', end_time: '12:00', comment: undefined, is_approved: false, is_remote: false, },
// { id: 102, date: '2025-10-27', type: 'REGULAR', start_time: '13:00', end_time: '17:00', comment: undefined, is_approved: false, is_remote: false, },
// ],
// expenses: [
// {
// id: 202,
// date: '2025-10-27',
// type: 'MILEAGE',
// amount: 0,
// mileage: 32.4,
// comment: 'Travel to client site',
// is_approved: true,
// },
// ],
// },
// ],
// },
// ];

View File

@ -0,0 +1,13 @@
import { api } from "src/boot/axios";
export const ExpenseService = {
getExpensesByTimesheetId: async (timesheet_id: number) => {
const response = await api.get(`timesheet/${timesheet_id}`);
return response.data;
},
upsertOrDeleteExpenseById: async (expense_id: number) => {
const response = await api.post(`epxense/${expense_id}`);
return response.data;
},
};

View File

@ -0,0 +1,24 @@
/* eslint-disable */
import { api } from "src/boot/axios";
import type { Shift } from "src/modules/timesheets/models/shift.models";
export const ShiftService = {
deleteShiftById: async (shift_id: number) => {
const response = await api.delete(`/shift/${shift_id}`);
return response.data;
},
createNewShifts: async (new_shifts: Shift[]) => {
// const response = await api.post(`/shift/`, { dtos: new_shifts });
// return response;
console.log('create shift payload: ', new_shifts);
return {status: 200};
},
updateShifts: async (existing_shifts: Shift[]) => {
// const response = await api.patch(`/shift/`, { dtos: existing_shifts });
// return response;
console.log('update shift payload: ', existing_shifts);
return {status: 200};
}
};

View File

@ -1,11 +1,7 @@
/* eslint-disable */
import { api } from "src/boot/axios";
import type { PayPeriod } from "src/modules/shared/models/pay-period.models";
import type { Timesheet } from "src/modules/timesheets/models/timesheet.models";
import type { TimesheetResponse } from "src/modules/timesheets/models/timesheet.models";
import type { TimesheetOverview } from "src/modules/timesheet-approval/models/timesheet-overview.models";
import type { Expense } from "src/modules/timesheets/models/expense.models";
import { Shift } from "src/modules/timesheets/models/shift.models";
export const timesheetService = {
getPayPeriodByDate: async (date_string: string): Promise<PayPeriod> => {
@ -23,28 +19,8 @@ export const timesheetService = {
return response.data;
},
getTimesheetsByPayPeriodAndEmployeeEmail: async (employee_email: string, year: number, period_number: number): Promise<Timesheet[]> => {
getTimesheetsByPayPeriodAndEmployeeEmail: async (employee_email: string, year: number, period_number: number): Promise<TimesheetResponse> => {
const response = await api.get('timesheets', { params: { employee_email, year, period_number } });
return response.data;
},
getExpensesByTimesheetId: async (timesheet_id: number): Promise<Expense[]> => {
const response = await api.get(`/expenses/list/${timesheet_id}`);
return response.data;
},
upsertShiftsByEmployeeEmailAndAction: async (email: string, payload: Shift[]): Promise<Timesheet[]> => {
const response = await api.put(`/shifts/upsert/${email}`, payload);
return response.data;
},
deleteShiftsByEmployeeEmailAndDate: async (email: string, date: string, payload: Shift[]) => {
const response = await api.delete(`/shifts/delete/${email}/${date}`, { data: payload });
return response;
},
upsertOrDeleteExpenseByEmailAndExpenseId: async (email: string, expense_id: number): Promise<Timesheet[]> => {
const response = await api.put(`/expenses/upsert/${email}`, expense_id);
return response.data;
},
};

View File

@ -33,13 +33,10 @@
/>
<div
class="col column items-center"
class="col"
:style="$q.screen.gt.sm ? 'width: 90vw' : ''"
>
<TimesheetWrapper
class="col-auto"
:employee-email="user?.email ?? ''"
/>
<TimesheetWrapper :employee-email="user?.email ?? ''" />
</div>
</q-page>

View File

@ -20,7 +20,7 @@ export const useAuthStore = defineStore('auth', () => {
void handleAuthMessage(event);
});
const oidc_popup = window.open('http://localhost:3000/auth/v1/login', 'authPopup', 'width=600,height=800');
const oidc_popup = window.open(`${import.meta.env.VITE_TARGO_BACKEND_AUTH_URL}auth/v1/login`, 'authPopup', 'width=600,height=800');
if (!oidc_popup)
Notify.create({

View File

@ -1,8 +1,8 @@
import { ref } from "vue";
import { defineStore } from "pinia";
import { timesheetService } from "src/modules/timesheets/services/timesheet-service";
import { ExpensesApiError, type GenericApiError } from "src/modules/timesheets/models/expense-validation.models";
import { empty_expense, test_expenses, type Expense } from "src/modules/timesheets/models/expense.models";
import { ExpenseService } from "src/modules/timesheets/services/expense-service";
@ -40,7 +40,7 @@ export const useExpensesStore = defineStore('expenses', () => {
error.value = null;
try {
const expenses = await timesheetService.getExpensesByTimesheetId(timesheet_id);
const expenses = await ExpenseService.getExpensesByTimesheetId(timesheet_id);
pay_period_expenses.value = expenses;
} catch (err: unknown) {
if (typeof err === 'object') {
@ -62,15 +62,12 @@ export const useExpensesStore = defineStore('expenses', () => {
}
};
const upsertOrDeleteExpensesById = async (employee_email: string, date: string, expense_id: number): Promise<void> => {
const upsertOrDeleteExpensesById = async (expense_id: number): Promise<void> => {
is_loading.value = true;
error.value = null;
try {
await timesheetService.upsertOrDeleteExpenseByEmailAndExpenseId(
encodeURIComponent(employee_email),
expense_id,
);
await ExpenseService.upsertOrDeleteExpenseById(expense_id);
// TODO: Save response data into proper ref
} catch (err) {
// setErrorFrom(err);

71
src/stores/shift-store.ts Normal file
View File

@ -0,0 +1,71 @@
import { ref } from "vue";
import { defineStore } from "pinia";
import { ShiftService } from "src/modules/timesheets/services/shift-service";
import { useTimesheetStore } from "src/stores/timesheet-store";
import { Notify } from "quasar";
export const useShiftStore = defineStore('shift_store', () => {
const timesheet_store = useTimesheetStore();
const shift_error = ref();
const deleteShiftById = async (shift_id: number): Promise<boolean> => {
try {
await ShiftService.deleteShiftById(shift_id);
return true;
} catch (error) {
console.error('DEV ERROR || error while deleting shift: ', error);
return false;
}
};
const createNewShifts = async (): Promise<boolean> => {
if (timesheet_store.timesheets === undefined) return false;
try {
const new_shifts = timesheet_store.timesheets.flatMap(week => week.days).flatMap(day => day.shifts).filter(shift => shift.shift_id < 0);
if (new_shifts?.length > 0) {
const response = await ShiftService.createNewShifts(new_shifts);
if (response.status <= 200) {
return true;
}
}
console.log('No new shifts to save');
Notify.create('no new shifts to save')
return false;
} catch (error) {
console.error('Error creating new shifts: ', error);
return false;
}
};
const updateShifts = async (): Promise<boolean> => {
if (timesheet_store.timesheets === undefined) return false;
try {
const existing_shifts = timesheet_store.timesheets.flatMap(week => week.days).flatMap(day => day.shifts).filter(shift => shift.shift_id > 0);
if (existing_shifts?.length > 0) {
const response = await ShiftService.updateShifts(existing_shifts);
if (response.status <= 200) {
return true;
}
}
console.log('No shifts to update');
Notify.create('no shifts to update')
return false;
} catch (error) {
console.error('Error updating shifts: ', error);
return false;
}
}
return {
shift_error,
deleteShiftById,
createNewShifts,
updateShifts,
}
})

View File

@ -1,28 +1,24 @@
import { ref } from 'vue';
import { defineStore } from 'pinia';
import { computed, ref } from 'vue';
import { withLoading } from 'src/utils/store-helpers';
import { useAuthStore } from 'src/stores/auth-store';
import { timesheetApprovalService } from 'src/modules/timesheet-approval/services/timesheet-approval-service';
import { timesheetService } from 'src/modules/timesheets/services/timesheet-service';
import type { TimesheetOverview } from "src/modules/timesheet-approval/models/timesheet-overview.models";
import type { PayPeriod } from 'src/modules/shared/models/pay-period.models';
import { test_timesheets, type Timesheet } from 'src/modules/timesheets/models/timesheet.models';
import type { Timesheet } from 'src/modules/timesheets/models/timesheet.models';
import type { TimesheetApprovalCSVReportFilters } from 'src/modules/timesheet-approval/models/timesheet-approval-csv-report.models';
const auth_store = useAuthStore();
export const useTimesheetStore = defineStore('timesheet', () => {
const auth_store = useAuthStore();
const is_loading = ref<boolean>(false);
const pay_period = ref<PayPeriod>();
const pay_period_overviews = ref<TimesheetOverview[]>([]);
const current_pay_period_overview = ref<TimesheetOverview>();
const timesheets = ref<Timesheet[]>(test_timesheets);
const timesheets = ref<Timesheet[]>();
const pay_period_report = ref();
const is_calendar_limit = computed(() => (pay_period.value?.pay_year === 2024 && pay_period.value?.pay_period_no <= 1) ?? false);
const getPayPeriodByDateOrYearAndNumber = async (date_or_year: string | number, period_number?: number): Promise<boolean> => {
is_loading.value = true;
try {
if (typeof date_or_year === 'string') {
pay_period.value = await timesheetService.getPayPeriodByDate(date_or_year);
@ -31,7 +27,6 @@ export const useTimesheetStore = defineStore('timesheet', () => {
pay_period.value = await timesheetService.getPayPeriodByYearAndPeriodNumber(date_or_year, period_number);
}
else pay_period.value = undefined;
is_loading.value = false;
return true;
} catch (error) {
@ -39,7 +34,6 @@ export const useTimesheetStore = defineStore('timesheet', () => {
pay_period.value = undefined;
pay_period_overviews.value = [];
//TODO: More in-depth error-handling here
is_loading.value = false;
return false;
}
@ -59,49 +53,46 @@ export const useTimesheetStore = defineStore('timesheet', () => {
pay_period_overviews.value = [];
// TODO: More in-depth error-handling here
is_loading.value = false;
return false;
}
};
const getTimesheetsByEmployeeEmail = async (employee_email: string) => {
is_loading.value = true;
if (pay_period.value === undefined) return;
try {
const response = await timesheetService.getTimesheetsByPayPeriodAndEmployeeEmail(employee_email, pay_period.value.pay_year, pay_period.value.pay_period_no);
timesheets.value = response;
timesheets.value = response.timesheets;
is_loading.value = false;
} catch (error) {
console.error('There was an error retrieving timesheet details for this employee: ', error);
// TODO: More in-depth error-handling here
timesheets.value = [];
is_loading.value = false;
}
}
};
const getPayPeriodReportByYearAndPeriodNumber = async (year: number, period_number: number, report_filters?: TimesheetApprovalCSVReportFilters) => {
return withLoading(is_loading.value, async () => {
try {
const response = await timesheetApprovalService.getPayPeriodReportByYearAndPeriodNumber(
year,
period_number,
report_filters
);
pay_period_report.value = response;
try {
const response = await timesheetApprovalService.getPayPeriodReportByYearAndPeriodNumber(
year,
period_number,
report_filters
);
pay_period_report.value = response;
return true;
} catch (error) {
console.error('There was an error retrieving the report CSV: ', error);
// TODO: More in-depth error-handling here
}
return true;
} catch (error) {
console.error('There was an error retrieving the report CSV: ', error);
// TODO: More in-depth error-handling here
}
return false;
});
return false;
};
return {
is_loading,
is_calendar_limit,
pay_period,
pay_period_overviews,
current_pay_period_overview,

View File

@ -1,12 +1,22 @@
import { defineStore } from 'pinia';
import { ref } from 'vue';
import { useQuasar } from 'quasar';
import { computed, ref } from 'vue';
export const useUiStore = defineStore('ui', () => {
const isRightDrawerOpen = ref(true);
const q = useQuasar();
const is_left_drawer_open = ref(true);
const focus_next_component = ref(false);
const is_mobile_mode = computed(() => q.screen.lt.md);
const toggleRightDrawer = () => {
isRightDrawerOpen.value = !isRightDrawerOpen.value;
is_left_drawer_open.value = !is_left_drawer_open.value;
}
return { isRightDrawerOpen, toggleRightDrawer };
return {
is_mobile_mode,
focus_next_component,
is_left_drawer_open,
toggleRightDrawer
};
});