Merge pull request 'dev/nicolas/timesheet-gui-refactor' (#23) from dev/nicolas/timesheet-gui-refactor into main

Reviewed-on: Targo/targo_frontend#23
This commit is contained in:
Nicolas 2025-11-11 12:48:47 -05:00
commit dccf5a8d82
44 changed files with 1338 additions and 711 deletions

View File

@ -7,15 +7,12 @@ WORKDIR /app
# Set environment variables # Set environment variables
ENV VITE_TARGO_BACKEND_URL="http://targo-backend:3000" ENV VITE_TARGO_BACKEND_URL="http://targo-backend:3000"
# Copy package.json & package-lock.json first (for caching) # Copy the code
COPY package*.json ./ COPY . .
# Install dependencies # Install dependencies
RUN npm install RUN npm install
# Copy the rest of the code
COPY . .
# Expose Quasar dev port # Expose Quasar dev port
EXPOSE 9000 EXPOSE 9000

View File

@ -104,7 +104,6 @@ export default defineConfig((ctx) => {
config: { config: {
notify: { notify: {
color: 'primary', color: 'primary',
avatar: 'https://cdn.quasar.dev/img/boy-avatar.png',
}, },
dark: false, dark: false,
}, },

View File

@ -25,7 +25,7 @@
} }
body.body--dark { body.body--dark {
--q-secondary: #2b2f34; --q-secondary: #151520;
color: $grey-2; color: $grey-2;
} }
@ -41,4 +41,16 @@ body.body--dark {
.frosted-glass { .frosted-glass {
background-color: #FFFA !important; background-color: #FFFA !important;
backdrop-filter: blur(5px); backdrop-filter: blur(5px);
}
.q-btn--push::before {
border-bottom: 4px solid rgba(0,0,0, 0.25);
}
.q-btn--push:active {
transform: translateY(3px);
}
.q-btn--push:active::before {
border-bottom-width: 1px;
} }

View File

@ -12,9 +12,9 @@
// to match your app's branding. // to match your app's branding.
// Tip: Use the "Theme Builder" on Quasar's documentation website. // Tip: Use the "Theme Builder" on Quasar's documentation website.
$primary : #019547; $primary : #30303A;
$secondary : #DAE0E7; $secondary : #DAE0E7;
$accent : #AAD5C4; $accent : #0c9a3b;
$dark-shadow-color : #00220f; $dark-shadow-color : #00220f;
@ -30,9 +30,10 @@ $input-autofill-color : #AAD5C4;
$field-dense-label-top : 5px !default; $field-dense-label-top : 5px !default;
$field-dense-label-font-size : 16px !default; $field-dense-label-font-size : 16px !default;
$button-shadow : 0 0 0 transparent;
$dark : #42444b; $dark : #40404C;
$dark-page : #343434; $dark-page : #343444;
$positive : #21ba45; $positive : #21ba45;
$negative : #e6364b; $negative : #e6364b;

View File

@ -66,6 +66,11 @@ export default {
dark_mode: "dark", dark_mode: "dark",
light_mode: "light", light_mode: "light",
}, },
schedule_presets: {
tab_title: "Schedule",
selected_schedule: "Selected Schedule Preset",
new_preset: "Build a new preset",
},
errors: { errors: {
must_enter_birthdate: "You must enter a valid birthdate", must_enter_birthdate: "You must enter a valid birthdate",
} }
@ -151,12 +156,12 @@ export default {
REMOTE: "Remote work", REMOTE: "Remote work",
}, },
errors: { errors: {
not_found:"Shift not found", not_found: "Shift not found",
overlap:"An overlaps occured between 2 or more shifts", SHIFT_OVERLAP: "An overlaps occured between 2 or more shifts",
invalid:"Invalid shift`s entry", invalid: "Invalid shift`s entry",
unknown:"Unknown error", unknown: "Unknown error",
comment_required:"A comment is required", comment_required: "A comment is required",
comment_too_long:"Your comment is too long", comment_too_long: "Your comment is too long",
}, },
fields: { fields: {
start:"Start (HH:mm)", start:"Start (HH:mm)",
@ -197,7 +202,7 @@ export default {
PER_DIEM:"Per Diem", PER_DIEM:"Per Diem",
EXPENSES:"expense", EXPENSES:"expense",
MILEAGE:"mileage", MILEAGE:"mileage",
PRIME_GARDE:"on-call allowance", ON_CALL:"on-call allowance",
}, },
}, },
}, },

View File

@ -66,6 +66,12 @@ export default {
dark_mode: "sombre", dark_mode: "sombre",
light_mode: "clair", light_mode: "clair",
}, },
schedule_presets: {
tab_title: "horaire",
selected_schedule: "Horaire Sélectionné",
new_preset: "Construire un nouvel horaire",
},
errors: { errors: {
must_enter_birthdate: "Vous devez entrer une date de naissance valide", must_enter_birthdate: "Vous devez entrer une date de naissance valide",
} }
@ -151,12 +157,12 @@ export default {
REMOTE: "Télétravail", REMOTE: "Télétravail",
}, },
errors: { errors: {
not_found:"Aucun quart trouvé", not_found: "Aucun quart trouvé",
overlap:"Il y a un chevauchement entre deux ou plusieurs quarts", SHIFT_OVERLAP: "Il y a un chevauchement entre deux ou plusieurs quarts",
invalid:"Entrée du quart invalide", invalid: "Entrée du quart invalide",
unknown:"Erreur inconnue", unknown: "Erreur inconnue",
comment_required:"un commentaire est requis", comment_required: "un commentaire est requis",
comment_too_long:"votre commentaire est trop long", comment_too_long: "votre commentaire est trop long",
}, },
fields: { fields: {
start:"Début (HH:mm)", start:"Début (HH:mm)",
@ -197,7 +203,7 @@ export default {
PER_DIEM:"Per diem", PER_DIEM:"Per diem",
EXPENSES:"dépense", EXPENSES:"dépense",
MILEAGE:"kilométrage", MILEAGE:"kilométrage",
PRIME_GARDE:"Prime de garde", ON_CALL:"Prime de garde",
}, },
}, },
}, },

View File

@ -1,22 +1,36 @@
<script lang="ts" setup> <script
import { useUiStore } from 'src/stores/ui-store'; lang="ts"
import HeaderBarNotification from './main-layout-header-bar-notification.vue'; setup
>
import { useUiStore } from 'src/stores/ui-store';
import HeaderBarNotification from './main-layout-header-bar-notification.vue';
const uiStore = useUiStore(); const uiStore = useUiStore();
</script> </script>
<template> <template>
<q-header elevated> <q-header elevated>
<q-toolbar> <q-toolbar class="q-px-sm">
<q-toolbar-title> <q-toolbar-title>
<q-btn flat dense color="white" icon="menu" @click="uiStore.toggleRightDrawer"> <q-btn
<q-img src="src/assets/logo-targo-white.svg" fit="contain" width="150px" height="30px"/> flat
</q-btn> dense
</q-toolbar-title> color="white"
<q-item class="q-pa-none"> @click="uiStore.toggleRightDrawer"
<HeaderBarNotification /> class="q-px-none"
</q-item> >
</q-toolbar> <q-icon name="menu" size="lg" class="q-mr-lg"/>
</q-header> <q-img
src="src/assets/logo-targo-white.svg"
fit="contain"
width="150px"
height="30px"
/>
</q-btn>
</q-toolbar-title>
<q-item class="q-pa-none">
<HeaderBarNotification />
</q-item>
</q-toolbar>
</q-header>
</template> </template>

View File

@ -32,14 +32,14 @@
<template> <template>
<q-drawer <q-drawer
v-model="ui_store.is_left_drawer_open" v-model="ui_store.is_left_drawer_open"
persistent :persistent="!ui_store.is_mobile_mode"
mini-to-overlay mini-to-overlay
elevated elevated
side="left" side="left"
:mini="is_mini" :mini="is_mini"
@mouseenter="is_mini = false" @mouseenter="is_mini = false"
@mouseleave="is_mini = true" @mouseleave="is_mini = true"
class="bg-dark" class="bg-dark z-max"
> >
<q-scroll-area class="fit"> <q-scroll-area class="fit">
<q-list> <q-list>
@ -53,7 +53,8 @@
<q-item-section avatar> <q-item-section avatar>
<q-icon <q-icon
name="home" name="home"
color="primary" color="accent"
size="lg"
/> />
</q-item-section> </q-item-section>
<q-item-section> <q-item-section>
@ -72,7 +73,8 @@
<q-item-section avatar> <q-item-section avatar>
<q-icon <q-icon
name="event_available" name="event_available"
color="primary" color="accent"
size="lg"
/> />
</q-item-section> </q-item-section>
<q-item-section> <q-item-section>
@ -92,7 +94,8 @@
<q-item-section avatar> <q-item-section avatar>
<q-icon <q-icon
name="view_list" name="view_list"
color="primary" color="accent"
size="lg"
/> />
</q-item-section> </q-item-section>
<q-item-section> <q-item-section>
@ -112,7 +115,8 @@
<q-item-section avatar> <q-item-section avatar>
<q-icon <q-icon
name="punch_clock" name="punch_clock"
color="primary" color="accent"
size="lg"
/> />
</q-item-section> </q-item-section>
<q-item-section> <q-item-section>
@ -131,7 +135,8 @@
<q-item-section avatar> <q-item-section avatar>
<q-icon <q-icon
name="account_box" name="account_box"
color="primary" color="accent"
size="lg"
/> />
</q-item-section> </q-item-section>
<q-item-section> <q-item-section>
@ -148,7 +153,8 @@
<q-item-section avatar> <q-item-section avatar>
<q-icon <q-icon
name="contact_support" name="contact_support"
color="primary" color="accent"
size="lg"
/> />
</q-item-section> </q-item-section>
<q-item-section> <q-item-section>
@ -167,7 +173,8 @@
<q-item-section avatar> <q-item-section avatar>
<q-icon <q-icon
name="exit_to_app" name="exit_to_app"
color="primary" color="accent"
size="lg"
/> />
</q-item-section> </q-item-section>
<q-item-section> <q-item-section>

View File

@ -34,7 +34,7 @@
v-model="email" v-model="email"
dense dense
outlined outlined
label-color="primary" label-color="accent"
class="rounded-5 inset-shadow bg-blue-grey-1" class="rounded-5 inset-shadow bg-blue-grey-1"
label-slot label-slot
input-class="text-weight-medium text-h6" input-class="text-weight-medium text-h6"
@ -44,12 +44,28 @@
</template> </template>
</q-input> </q-input>
<q-card-section class="q-ma-none q-pa-none text-uppercase text-caption text-weight-medium"> <q-card-section
horizontal
class="q-mb-md q-pa-none text-uppercase text-caption text-weight-medium"
>
<q-toggle <q-toggle
v-model="is_remembered" v-model="is_remembered"
color="primary" size="sm"
:label="$t('login.button.remember_me')" color="accent"
class="col-auto"
/> />
<transition
enter-active-class="animated rubberBand fast"
leave-active-class=""
mode="out-in"
>
<span
:key="is_remembered ? 'yep' : 'nope'"
class="col-auto text-weight-bold self-center q-ml-sm"
:class="is_remembered ? 'text-accent' : ''"
>{{ $t('login.button.remember_me') }}</span>
</transition>
</q-card-section> </q-card-section>
<q-card-actions> <q-card-actions>
@ -58,7 +74,7 @@
rounded rounded
disabled disabled
type="submit" type="submit"
color="primary" color="accent"
:label="$t('login.button.connect')" :label="$t('login.button.connect')"
class="full-width" class="full-width"
/> />
@ -72,12 +88,14 @@
<q-card-section class="row q-pt-sm"> <q-card-section class="row q-pt-sm">
<q-separator <q-separator
size="2px"
color="primary" color="primary"
class="col self-center" class="col self-center"
/> />
<span class="col text-primary text-weight-bolder text-center text-uppercase self-center">{{ <span class="col text-primary text-weight-bolder text-center text-uppercase self-center">{{
$t('shared.misc.or') }}</span> $t('shared.misc.or') }}</span>
<q-separator <q-separator
size="2px"
color="primary" color="primary"
class="col self-center" class="col self-center"
/> />
@ -108,7 +126,7 @@
<q-btn <q-btn
push push
rounded rounded
color="primary" color="accent"
icon="img:src/assets/logo-targo-simple.svg" icon="img:src/assets/logo-targo-simple.svg"
:label="$t('login.button.employee')" :label="$t('login.button.employee')"
class="full-width row" class="full-width row"

View File

@ -5,13 +5,18 @@
import MenuPanelPersonal from 'src/modules/profile/components/employee/menu-panel-personal.vue'; import MenuPanelPersonal from 'src/modules/profile/components/employee/menu-panel-personal.vue';
import MenuPanelEmployee from 'src/modules/profile/components/employee/menu-panel-employee.vue'; import MenuPanelEmployee from 'src/modules/profile/components/employee/menu-panel-employee.vue';
import MenuPanelPreferences from 'src/modules/profile/components/shared/menu-panel-preferences.vue'; import MenuPanelPreferences from 'src/modules/profile/components/shared/menu-panel-preferences.vue';
import MenuPanelSchedulePresets from 'src/modules/profile/components/shared/menu-panel-schedule-presets.vue';
import MenuTemplate from 'src/modules/profile/components/shared/menu-template.vue'; import MenuTemplate from 'src/modules/profile/components/shared/menu-template.vue';
import { default_employee_profile, type EmployeeProfile } from 'src/modules/employee-list/models/employee-profile.models'; import { default_employee_profile, type EmployeeProfile } from 'src/modules/employee-list/models/employee-profile.models';
import { useAuthStore } from 'src/stores/auth-store';
const auth_store = useAuthStore();
const PanelNames = { const PanelNames = {
PERSONAL_INFO: 'personal_info', PERSONAL_INFO: 'personal_info',
EMPLOYEE_INFO: 'employee_info', EMPLOYEE_INFO: 'employee_info',
PREFERENCES: 'references', PREFERENCES: 'references',
SCHEDULE_PRESETS: 'schedule_presets',
}; };
const employee_profile = defineModel<EmployeeProfile>({ default: default_employee_profile }); const employee_profile = defineModel<EmployeeProfile>({ default: default_employee_profile });
@ -24,8 +29,8 @@
class="rounded-5 bg-transparent q-pa-none" class="rounded-5 bg-transparent q-pa-none"
> >
<MenuTemplate <MenuTemplate
:first-name="employee_profile.first_name" :first-name="employee_profile.first_name === '' ? auth_store.user?.first_name ?? '' : employee_profile.first_name"
:last-name="employee_profile.last_name" :last-name="employee_profile.last_name === '' ? auth_store.user?.last_name ?? '' : employee_profile.last_name"
:initial-menu="PanelNames.PERSONAL_INFO" :initial-menu="PanelNames.PERSONAL_INFO"
> >
<template #tabs> <template #tabs>
@ -44,6 +49,11 @@
icon="display_settings" icon="display_settings"
:label="$q.screen.lt.md ? '' : $t('profile.preferences.tab_title')" :label="$q.screen.lt.md ? '' : $t('profile.preferences.tab_title')"
/> />
<q-tab
:name="PanelNames.SCHEDULE_PRESETS"
icon="list_alt"
:label="$q.screen.lt.md ? '' : $t('profile.schedule_presets.tab_title')"
/>
</template> </template>
<template #panels> <template #panels>
@ -67,6 +77,12 @@
> >
<MenuPanelPreferences /> <MenuPanelPreferences />
</q-tab-panel> </q-tab-panel>
<q-tab-panel
:name="PanelNames.SCHEDULE_PRESETS"
class="q-pa-none"
>
<MenuPanelSchedulePresets />
</q-tab-panel>
</template> </template>
</MenuTemplate> </MenuTemplate>
</q-card> </q-card>

View File

@ -1,18 +1,24 @@
<script setup lang="ts"> <script
const { userFirstName = '', userLastName = '' } = defineProps<{ setup
lang="ts"
>
defineProps<{
userFirstName: string; userFirstName: string;
userLastName: string; userLastName: string;
}>(); }>();
</script> </script>
<template> <template>
<q-img <q-img
src="src/assets/profile_header_default.png" src="src/assets/profile_header_default.png"
height="15vh" height="15vh"
:width="$q.screen.lt.md ? '80vw' : '40vw'" :width="$q.screen.lt.md ? '80vw' : '40vw'"
class="rounded-5 q-mb-md shadow-2 col-auto" class="rounded-5 q-mb-md shadow-2 col-auto"
fit="cover" fit="cover"
> >
<div class="absolute-bottom text-h5 text-uppercase text-weight-bolder" style="line-height: 0.8em;">{{ userFirstName }} {{ userLastName }}</div> <div
class="absolute-bottom text-h5 text-uppercase text-weight-bolder"
style="line-height: 0.8em;"
>{{ userFirstName }} {{ userLastName }}</div>
</q-img> </q-img>
</template> </template>

View File

@ -22,7 +22,7 @@
autogrow autogrow
filled filled
debounce="500" debounce="500"
label-color="primary" label-color="accent"
class="q-ma-xs text-uppercase" class="q-ma-xs text-uppercase"
input-class="text-weight-medium text-h6" input-class="text-weight-medium text-h6"
:hide-hint="hint === ''" :hide-hint="hint === ''"

View File

@ -0,0 +1,45 @@
<script setup lang="ts">
const model = defineModel<string>();
</script>
<template>
<q-form class="q-pa-md column fit">
<div
class="col-auto text-uppercase rounded-5"
style="line-height: 1em;"
>{{ $t('profile.schedule_presets.selected_schedule') }}</div>
<!-- where the selected preset schedule will be displayed-->
<q-card
flat
class="col-auto column justify-center items-center content-center q-mb-lg q-pa-md"
style="border: solid #AAA 1px;"
>
<!-- will display the list of schedules by name -->
<q-select v-model="model" />
<!-- will display a list with details about the selected schedule -->
<q-card-section>
<q-list>
</q-list>
</q-card-section>
</q-card>
<div
class="col-auto text-uppercase rounded-5"
style="line-height: 1em;"
>{{ $t('profile.schedule_presets.new_preset') }}</div>
<!-- where the user will be able to create a new preset schedule -->
<q-card
flat
class="col-auto column justify-center items-center content-center q-mb-lg q-pa-md"
style="border: solid #AAA 1px;"
>
<q-form>
</q-form>
</q-card>
</q-form>
</template>

View File

@ -17,7 +17,7 @@
dense dense
:stack-label="!isEditing" :stack-label="!isEditing"
filled filled
label-color="primary" label-color="accent"
class="q-ma-xs text-h6 text-uppercase" class="q-ma-xs text-h6 text-uppercase"
popup-content-class="text-weight-medium text-h6" popup-content-class="text-weight-medium text-h6"
input-class="text-weight-medium" input-class="text-weight-medium"

View File

@ -2,7 +2,7 @@
import { ref } from 'vue'; import { ref } from 'vue';
import MenuHeader from 'src/modules/profile/components/shared/menu-header.vue'; import MenuHeader from 'src/modules/profile/components/shared/menu-header.vue';
const { firstName, lastName, initialMenu } = defineProps<{ const { initialMenu } = defineProps<{
firstName: string; firstName: string;
lastName: string; lastName: string;
initialMenu: string; initialMenu: string;
@ -33,8 +33,8 @@
v-model="current_menu" v-model="current_menu"
:vertical="$q.screen.gt.sm" :vertical="$q.screen.gt.sm"
dense dense
active-color="primary" active-color="accent"
indicator-color="primary" indicator-color="accent"
> >
<slot name="tabs"></slot> <slot name="tabs"></slot>
</q-tabs> </q-tabs>

View File

@ -0,0 +1,27 @@
export interface SchedulePreset {
name: string;
is_default: boolean;
presets_shifts: ShiftPreset[];
}
class ShiftPreset {
week_day: Weekday;
preset_id: number;
sort_order: number;
type: string;
start_time: string;
end_time: string;
is_remote: boolean;
constructor() {
this.week_day = '';
this.preset_id = -1;
this.sort_order = -1;
this.type = '';
this.start_time = '';
this.end_time = '';
this.is_remote = false;
}
}
export type Weekday = 'SUN' | 'MON' | 'TUE' | 'WED' | 'THU' | 'FRI' | 'SAT' | '';

View File

@ -0,0 +1,29 @@
import { api } from "src/boot/axios";
import type { SchedulePreset } from "src/modules/profile/models/schedule-presets.models";
export const SchedulePresetsService = {
createSchedulePresets: async (new_schedule: SchedulePreset) => {
const response = await api.post(`/schedule-presets/create/`, new_schedule);
return response.data;
},
updateSchedulePresets: async (preset_id: number, dto: Partial<SchedulePreset>) => {
const response = await api.patch(`/schedule-presets/update/${preset_id}`, dto);
return response.data;
},
deleteSchedulePresets: async (preset_id: number) => {
const response = await api.delete(`/schedule-presets/delete/${preset_id}`);
return response.data;
},
findListOfSchedulePresets: async () => {
const response = await api.get(`/schedule-presets/find-list`);
return response.data;
},
applyPresets: async (preset_name: string, start_date: string) => {
const response = await api.post(`/schedule-presets/apply-presets/`, { preset: preset_name, start: start_date });
return response.data;
},
};

View File

@ -21,13 +21,13 @@
v-if="startDate.length > 0" v-if="startDate.length > 0"
class="col row flex-center full-width q-py-none q-my-none" class="col row flex-center full-width q-py-none q-my-none"
> >
<div class="text-primary text-weight-bold text-h6"> <div class="text-accent text-weight-bold text-h6">
{{ $d(date.extractDate(startDate, 'YYYY-MM-DD'), date_format_options) }} {{ $d(date.extractDate(startDate, 'YYYY-MM-DD'), date_format_options) }}
</div> </div>
<div class="text-body2 q-mx-md text-weight-medium"> <div class="text-body2 q-mx-md text-weight-medium">
{{ $t('shared.misc.to') }} {{ $t('shared.misc.to') }}
</div> </div>
<div class="text-primary text-weight-bold text-h6"> <div class="text-accent text-weight-bold text-h6">
{{ $d(date.extractDate(endDate, 'YYYY-MM-DD'), date_format_options) }} {{ $d(date.extractDate(endDate, 'YYYY-MM-DD'), date_format_options) }}
</div> </div>
</div> </div>

View File

@ -5,6 +5,7 @@
const NEXT = 1; const NEXT = 1;
const PREVIOUS = -1; const PREVIOUS = -1;
const PAY_PERIOD_DATE_LIMIT = '2023/12/17';
const timesheet_store = useTimesheetStore(); const timesheet_store = useTimesheetStore();
@ -63,7 +64,7 @@
<q-btn <q-btn
push rounded push rounded
icon="keyboard_arrow_left" icon="keyboard_arrow_left"
color="primary" color="accent"
@click="getPreviousPayPeriod" @click="getPreviousPayPeriod"
:disable="is_previous_pay_period_limit || timesheet_store.is_loading || is_disabled" :disable="is_previous_pay_period_limit || timesheet_store.is_loading || is_disabled"
class="q-mr-sm q-px-sm" class="q-mr-sm q-px-sm"
@ -81,7 +82,7 @@
<q-btn <q-btn
push rounded push rounded
icon="calendar_month" icon="calendar_month"
color="primary" color="accent"
@click="is_showing_calendar_picker = true" @click="is_showing_calendar_picker = true"
:disable="timesheet_store.is_loading || is_disabled" :disable="timesheet_store.is_loading || is_disabled"
class="q-px-xl" class="q-px-xl"
@ -99,7 +100,7 @@
<q-btn <q-btn
push rounded push rounded
icon="keyboard_arrow_right" icon="keyboard_arrow_right"
color="primary" color="accent"
@click="getNextPayPeriod" @click="getNextPayPeriod"
:disable="timesheet_store.is_loading || is_disabled" :disable="timesheet_store.is_loading || is_disabled"
class="q-ml-sm q-px-sm" class="q-ml-sm q-px-sm"
@ -125,7 +126,7 @@
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 >= PAY_PERIOD_DATE_LIMIT"
@update:model-value="onDateSelected" @update:model-value="onDateSelected"
/> />
</q-dialog> </q-dialog>

View File

@ -2,14 +2,14 @@
setup setup
lang="ts" lang="ts"
> >
/* eslint-disable */
import { inject, ref } from 'vue'; import { inject, ref } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useExpensesStore } from 'src/stores/expense-store'; import { useExpensesStore } from 'src/stores/expense-store';
import { empty_expense, EXPENSE_TYPE, TYPES_WITH_AMOUNT_ONLY } from 'src/modules/timesheets/models/expense.models'; import { Expense, EXPENSE_TYPE, TYPES_WITH_AMOUNT_ONLY } from 'src/modules/timesheets/models/expense.models';
import { useExpenseRules } from 'src/modules/timesheets/utils/expense.util'; import { useExpenseRules } from 'src/modules/timesheets/utils/expense.util';
import { useExpensesApi } from 'src/modules/timesheets/composables/use-expense-api'; import { useExpensesApi } from 'src/modules/timesheets/composables/use-expense-api';
import { useTimesheetStore } from 'src/stores/timesheet-store'; import { useTimesheetStore } from 'src/stores/timesheet-store';
import { date } from 'quasar';
const { t } = useI18n(); const { t } = useI18n();
@ -18,61 +18,79 @@
const expenses_api = useExpensesApi(); const expenses_api = useExpensesApi();
const files = defineModel<File[] | null>('files'); const files = defineModel<File[] | null>('files');
const is_navigator_open = ref(false); const is_navigator_open = ref(false);
const mode = ref<'create' | 'update' | 'delete'>('create');
const COMMENT_MAX_LENGTH = 280; const COMMENT_MAX_LENGTH = 280;
const employee_email = inject<string>('employeeEmail'); const employee_email = inject<string>('employeeEmail');
const rules = useExpenseRules(t); const rules = useExpenseRules(t);
const openDatePicker = () => {
is_navigator_open.value = true;
if (expenses_store.current_expense.date === '') {
expenses_store.current_expense.date = date.formatDate(new Date(), 'YYYY-MM-DD');
}
};
const cancelUpdateMode = () => { const cancelUpdateMode = () => {
expenses_store.current_expense = empty_expense; expenses_store.current_expense = new Expense(date.formatDate(new Date(), 'YYYY-MM-DD'));
expenses_store.initial_expense = empty_expense; expenses_store.initial_expense = new Expense(date.formatDate(new Date(), 'YYYY-MM-DD'));
} expenses_store.mode = 'create';
};
const requestExpenseCreationOrUpdate = async () => { const requestExpenseCreationOrUpdate = async () => {
if (mode.value === 'create') await expenses_api.createExpenseByEmployeeEmail(employee_email ?? '', expenses_store.current_expense?.date ?? ''); if (expenses_store.mode === 'create') await expenses_api.createExpenseByEmployeeEmail(employee_email ?? '', expenses_store.current_expense?.date ?? '');
else await expenses_api.updateExpenseByEmployeeEmail(employee_email ?? '', expenses_store.current_expense?.date ?? ''); else await expenses_api.updateExpenseByEmployeeEmail(employee_email ?? '', expenses_store.current_expense?.date ?? '');
};
const getExpenseCalendarRange = (current_date: string) => {
const period = timesheet_store.pay_period;
if (period !== undefined) return current_date >= period.period_start && current_date <= period.period_end;
return false;
} }
</script> </script>
<template> <template>
<q-form <q-form
flat
v-if="!timesheet_store.timesheets?.every(timesheet => timesheet.is_approved)" v-if="!timesheet_store.timesheets?.every(timesheet => timesheet.is_approved)"
:key="expenses_store.current_expense.id"
flat
@submit.prevent="requestExpenseCreationOrUpdate" @submit.prevent="requestExpenseCreationOrUpdate"
> >
<div class="text-subtitle2 q-py-sm"> <div class="text-uppercase text-weight-medium q-pt-sm q-px-lg">
{{ $t('timesheet.expense.add_expense') }} {{ $t('timesheet.expense.add_expense') }}
</div> </div>
<div <div class="row justify-between items-start rounded-5 q-px-lg q-pb-sm">
class="row justify-between rounded-5"
:class="mode === 'update' ? 'bg-accent' : ''"
>
<!-- date selection input --> <!-- date selection input -->
<q-input <q-input
v-model="expenses_store.current_expense.date" v-model="expenses_store.current_expense.date"
dense dense
filled outlined
readonly readonly
stack-label stack-label
class="col q-px-xs" class="col q-px-xs"
color="primary" color="primary"
:label="$t('timesheet.expense.date')" :label="$t('timesheet.expense.date')"
> >
<template #before> <template #prepend>
<q-btn <q-btn
push push
dense dense
icon="event" icon="event"
color="primary" color="accent"
@click="is_navigator_open = true" class="q-mr-sm"
@click="openDatePicker"
/> />
<q-dialog v-model="is_navigator_open">
<q-dialog
v-model="is_navigator_open"
transition-show="jump-right"
transition-hide="jump-right"
>
<q-date <q-date
v-model="expenses_store.current_expense.date" v-model="expenses_store.current_expense.date"
@update:model-value="is_navigator_open = false"
mask="YYYY-MM-DD" mask="YYYY-MM-DD"
event-color="accent"
:options="getExpenseCalendarRange"
@update:model-value="is_navigator_open = false"
/> />
</q-dialog> </q-dialog>
</template> </template>
@ -82,19 +100,25 @@
<q-select <q-select
v-model="expenses_store.current_expense.type" v-model="expenses_store.current_expense.type"
:options="EXPENSE_TYPE" :options="EXPENSE_TYPE"
filled standout="bg-blue-grey-9"
dense dense
class="col q-px-xs"
color="primary"
emit-value emit-value
map-options map-options
hide-dropdown-icon
class="col q-px-xs"
color="primary"
:label="$t('timesheet.expense.type')" :label="$t('timesheet.expense.type')"
:menu-offset="[0, 10]"
menu-anchor="bottom middle"
menu-self="top middle"
popup-content-class="text-uppercase text-weight-bold text-center rounded-5"
popup-content-style="border: 2px solid var(--q-accent)"
:rules="[rules.typeRequired]" :rules="[rules.typeRequired]"
:option-label="label => $t(`timesheet.expense.types.${label}`)" :option-label="label => $t(`timesheet.expense.types.${label}`)"
/> />
<!-- amount input --> <!-- amount input -->
<template v-if="TYPES_WITH_AMOUNT_ONLY.includes(expenses_store.current_expense?.type ?? 'EXPENSES')"> <div v-if="TYPES_WITH_AMOUNT_ONLY.includes(expenses_store.current_expense?.type ?? 'EXPENSES')">
<q-input <q-input
key="amount" key="amount"
v-model.number="expenses_store.current_expense.amount" v-model.number="expenses_store.current_expense.amount"
@ -102,18 +126,23 @@
input-class="text-right" input-class="text-right"
dense dense
stack-label stack-label
clearable
color="primary" color="primary"
class="col q-px-xs" class="col q-px-xs"
:label="$t('timesheet.expense.amount')" label-slot
suffix="$" suffix="$"
lazy-rules="ondemand" lazy-rules="ondemand"
:rules="[rules.amountRequired]" :rules="[rules.amountRequired]"
/> >
</template> <template #label>
<span class="text-weight-bold text-accent text-uppercase text-caption">
{{ $t('timesheet.expense.amount') }}
</span>
</template>
</q-input>
</div>
<!-- mileage input --> <!-- mileage input -->
<template v-else> <div v-else>
<q-input <q-input
key="mileage" key="mileage"
v-model.number="expenses_store.current_expense.mileage" v-model.number="expenses_store.current_expense.mileage"
@ -124,31 +153,37 @@
clearable clearable
color="primary" color="primary"
class="col q-px-xs" class="col q-px-xs"
:label="$t('timesheet.expense.mileage')" label-slot
suffix="km" suffix="km"
lazy-rules="ondemand" lazy-rules="ondemand"
:rules="[rules.mileageRequired]" :rules="[rules.mileageRequired]"
/> >
</template> <template #label>
<span class="text-weight-bold text-accent text-uppercase text-caption">
{{ $t('timesheet.expense.mileage') }}
</span>
</template>
</q-input>
</div>
<!-- employee comment input --> <!-- employee comment input -->
<q-input <q-input
v-model="expenses_store.current_expense.comment" v-model="expenses_store.current_expense.comment"
filled filled
dense
stack-label
label-slot
color="primary" color="primary"
type="text" type="text"
class="col q-px-sm" class="col q-px-sm"
dense
stack-label
clearable
:counter="true" :counter="true"
:maxlength="COMMENT_MAX_LENGTH" :maxlength="COMMENT_MAX_LENGTH"
lazy-rules="ondemand" lazy-rules="ondemand"
:rules="[rules.commentRequired]" :rules="[rules.commentRequired]"
> >
<template #label> <template #label>
<span class="text-weight-bold "> <span class="text-weight-bold text-accent text-uppercase text-caption">
{{ $t('timesheet.expense.comment') }} {{ $t('timesheet.expense.employee_comment') }}
</span> </span>
</template> </template>
</q-input> </q-input>
@ -156,14 +191,14 @@
<!-- import attach file section --> <!-- import attach file section -->
<q-file <q-file
v-model="files" v-model="files"
:label="$t('timesheet.expense.hints.attach_file')" dense
filled filled
use-chips use-chips
multiple multiple
stack-label stack-label
:label="$t('timesheet.expense.hints.attach_file')"
class="col" class="col"
style="max-width: 300px;" style="max-width: 300px;"
dense
> >
<template #prepend> <template #prepend>
<q-icon <q-icon
@ -173,28 +208,29 @@
/> />
</template> </template>
</q-file> </q-file>
</div>
<div class="col row full-width items-center">
<q-space />
<!-- add btn section --> <q-btn
<div> v-if="expenses_store.mode === 'update'"
<q-btn flat
v-if="mode === 'update'" dense
flat class="col-auto q-ml-sm"
dense icon="clear"
size="sm" color="negative"
class="q-mt-sm q-ml-sm" :label="$q.screen.gt.sm ? $t('shared.label.cancel') : ''"
@click="cancelUpdateMode" @click="cancelUpdateMode"
/> />
<q-btn <q-btn
push push
dense color="accent"
color="primary" :icon="expenses_store.mode === 'update' ? 'save' : 'upload'"
icon="add" :label="$q.screen.gt.sm ? (expenses_store.mode === 'update' ? $t('shared.label.update') : $t('shared.label.add')) : ''"
size="sm" class="q-px-sm q-mb-sm q-mx-lg"
class="q-mt-sm q-ml-sm" type="submit"
type="submit" />
/>
</div>
</div> </div>
</q-form> </q-form>
</template> </template>

View File

@ -2,43 +2,91 @@
setup setup
lang="ts" lang="ts"
> >
/* eslint-disable */ import { computed } from 'vue';
import { useExpensesStore } from 'src/stores/expense-store'; import { useExpensesStore } from 'src/stores/expense-store';
import { useTimesheetStore } from 'src/stores/timesheet-store';
const timesheet_store = useTimesheetStore();
const expense_store = useExpensesStore(); const expense_store = useExpensesStore();
const weekly_totals = computed(() => {
let expenses = 0;
let mileage = 0;
timesheet_store.timesheets.forEach(timesheet => {
expenses += timesheet.weekly_expenses.expenses ?? 0;
mileage += timesheet.weekly_expenses.mileage ?? 0;
});
return { expenses, mileage };
});
</script> </script>
<template> <template>
<q-item class="row justify-between items-center q-pa-none"> <div class="column items-center q-pa-none">
<q-item-label <div class="col row full-width">
header <q-item-label class="col text-h6 text-weight-bolder text-uppercase q-py-sm q-px-md">
class="text-h6 col q-pa-none" {{ $t('timesheet.expense.title') }}
> </q-item-label>
{{ $t('timesheet.expense.title') }}
</q-item-label>
<!-- <q-item-section <q-space />
no-wrap
class="col-auto items-center"
>
<q-badge
outline
class="q-py-xs q-px-md"
color="primary"
:label="$t('timesheet.expense.total_amount') + ': $' + expense_store.pay_period_expenses?.toFixed(2)"
/>
</q-item-section>
<q-item-section <q-btn
no-wrap square
class="col-auto items-center" icon="clear"
> color="negative"
<q-badge class="col-auto"
outline style="border-radius: 0 0 0 5px;"
class="q-py-xs q-px-md" @click="expense_store.close"
color="primary"
:label="$t('timesheet.expense.total_mileage') + ': ' + expense_store.pay_period_expenses?.total_mileage.toFixed(1) + ' km'"
/> />
</q-item-section> --> </div>
</q-item>
<div class="col column items-end full-width q-pt-sm q-px-md">
<div class="col-auto row items-center q-px-sm">
<span
v-if="$q.screen.gt.sm"
class="col-auto text-uppercase text-weight-light text-accent q-mr-xs"
>
{{ $t('timesheet.expense.total_amount') }} :
</span>
<q-icon
v-else
name="payments"
size="sm"
color="accent"
class="col"
/>
<span
class="col-auto text-weight-light"
style="font-size: 2.5em; line-height: 1em;"
>
{{ weekly_totals.expenses.toFixed(2) }}
</span>
</div>
<div class="col-auto row items-center q-px-sm">
<span
v-if="$q.screen.gt.sm"
class="col text-uppercase text-weight-light text-accent q-mr-xs"
>
{{ $t('timesheet.expense.total_mileage') }} :
</span>
<q-icon
v-else
name="drive_eta"
size="sm"
color="accent"
class="col"
/>
<span
class="col-auto text-weight-light"
style="font-size: 2.5em; line-height: 1em;"
>
{{ weekly_totals.mileage.toFixed(1) }}
</span>
</div>
</div>
</div>
</template> </template>

View File

@ -2,60 +2,56 @@
setup setup
lang="ts" lang="ts"
> >
/* eslint-disable */ import { date } from 'quasar';
import { computed, inject, ref } from 'vue'; import { computed, ref } from 'vue';
import { unwrapAndClone } from 'src/utils/unwrap-and-clone'; import { unwrapAndClone } from 'src/utils/unwrap-and-clone';
import { deepEqual } from 'src/utils/deep-equal';
import { useExpensesApi } from 'src/modules/timesheets/composables/use-expense-api'; import { useExpensesApi } from 'src/modules/timesheets/composables/use-expense-api';
import { useExpensesStore } from 'src/stores/expense-store'; import { useExpensesStore } from 'src/stores/expense-store';
import { useTimesheetStore } from 'src/stores/timesheet-store';
import { getExpenseIcon } from 'src/modules/timesheets/utils/expense.util'; import { getExpenseIcon } from 'src/modules/timesheets/utils/expense.util';
import { useAuthStore } from 'src/stores/auth-store'; import { useAuthStore } from 'src/stores/auth-store';
import { CAN_APPROVE_PAY_PERIODS } from 'src/modules/shared/models/user.models'; import { CAN_APPROVE_PAY_PERIODS } from 'src/modules/shared/models/user.models';
import { empty_expense, type Expense } from 'src/modules/timesheets/models/expense.models'; import { Expense } from 'src/modules/timesheets/models/expense.models';
const { expense, horizontal = false } = defineProps<{ const { expense, horizontal = false } = defineProps<{
expense: Expense; expense: Expense;
index: number; index: number;
horizontal?: boolean; horizontal?: boolean;
}>(); }>();
const is_approved = defineModel<boolean>({ required: true });
const timesheet_store = useTimesheetStore();
const expenses_store = useExpensesStore(); const expenses_store = useExpensesStore();
const auth_store = useAuthStore(); const auth_store = useAuthStore();
const expenses_api = useExpensesApi(); const expenses_api = useExpensesApi();
const is_approved = defineModel<boolean>({ required: true });
const is_selected = ref(false);
const refresh_key = ref(1); const refresh_key = ref(1);
const background_class = computed(() => deepEqual(expense, expenses_store.current_expense) ? '' : '');
const background_style = computed(() => deepEqual(expense, expenses_store.current_expense) ? 'border: 3px solid var(--q-accent);' : '');
const approved_class = computed(() => expense.is_approved ? ' bg-accent text-white' : '')
const is_authorized_to_approve = computed(() => CAN_APPROVE_PAY_PERIODS.includes(auth_store.user?.role ?? 'GUEST')) const is_authorized_to_approve = computed(() => CAN_APPROVE_PAY_PERIODS.includes(auth_store.user?.role ?? 'GUEST'))
const expenseItemStyle = computed(() => is_approved.value ? 'border: solid 2px var(--q-primary);' : 'border: solid 2px grey;');
// const highlightClass = computed(() => (expenses_store.mode === 'update' && is_selected) ? 'bg-accent' : '');
const approvedClass = computed(() => horizontal ? ' q-mx-xs q-pa-xs cursor-pointer' : '')
const employeeEmail = inject<string>('employeeEmail') ?? '';
const setExpenseToModify = () => {
// expenses_store.mode = 'update';
expenses_store.current_expense = expense;
expenses_store.initial_expense = unwrapAndClone(expense);
};
const requestExpenseDeletion = async () => { const requestExpenseDeletion = async () => {
// expenses_store.mode = 'delete'; await expenses_api.deleteExpenseById(expense.id);
expenses_store.initial_expense = expense;
expenses_store.current_expense = empty_expense;
await expenses_api.deleteExpenseByEmployeeEmail(employeeEmail, expenses_store.initial_expense.date);
} }
function onExpenseClicked() { const onExpenseClicked = () => {
if (is_authorized_to_approve.value) { if (is_authorized_to_approve.value) {
is_approved.value = !is_approved.value; is_approved.value = !is_approved.value;
refresh_key.value += 1; refresh_key.value += 1;
} }
} }
const onUpdateClicked = () => {
if (deepEqual(expense, expenses_store.current_expense)) {
expenses_store.mode = 'create';
expenses_store.current_expense = new Expense(date.formatDate(new Date(), 'YYYY-MM-DD'));
return;
}
expenses_store.mode = 'update';
expenses_store.current_expense = expense;
expenses_store.initial_expense = unwrapAndClone(expense);
}
</script> </script>
<template> <template>
@ -66,27 +62,16 @@
<q-item <q-item
:key="refresh_key" :key="refresh_key"
:clickable="horizontal" :clickable="horizontal"
class="row col-4 q-ma-xs shadow-2" class="row col-4 items-center q-my-sm q-py-none shadow-3 rounded-5 bg-dark"
:style="expenseItemStyle + approvedClass" :class="background_class + approved_class"
:style="background_style"
@click="onExpenseClicked" @click="onExpenseClicked"
> >
<q-badge
v-if="expense.is_approved"
class="absolute z-top rounded-20 bg-dark q-pa-none"
style="transform: translate(-15px, -15px);"
>
<q-icon
name="verified"
color="primary"
size="md"
/>
</q-badge>
<!-- avatar type icon section --> <!-- avatar type icon section -->
<q-item-section avatar> <q-item-section avatar>
<q-icon <q-icon
:name="getExpenseIcon(expense.type)" :name="getExpenseIcon(expense.type)"
:color="expense.is_approved ? 'primary' : ($q.dark.isActive ? 'blue-grey-2' : 'grey-8')" :color="expense.is_approved ? 'white' : ($q.dark.isActive ? 'blue-grey-2' : 'primary')"
size="lg" size="lg"
> >
<q-badge <q-badge
@ -97,14 +82,14 @@
<q-icon <q-icon
name="shield" name="shield"
size="xs" size="xs"
:color="expense.is_approved ? 'primary' : ($q.dark.isActive ? 'blue-grey-2' : 'grey-8')" :color="expense.is_approved ? 'white' : ($q.dark.isActive ? 'blue-grey-2' : 'primary')"
/> />
</q-badge> </q-badge>
</q-icon> </q-icon>
</q-item-section> </q-item-section>
<!-- amount or mileage section --> <!-- amount or mileage section -->
<q-item-section class="col-auto"> <q-item-section class="col col-md-2 text-weight-bold">
<q-item-label v-if="String(expense.type).trim().toUpperCase() === 'MILEAGE'"> <q-item-label v-if="String(expense.type).trim().toUpperCase() === 'MILEAGE'">
<template v-if="typeof expense.mileage === 'number'"> <template v-if="typeof expense.mileage === 'number'">
{{ expense.mileage?.toFixed(1) }} km {{ expense.mileage?.toFixed(1) }} km
@ -121,37 +106,45 @@
<q-item-label <q-item-label
caption caption
lines="1" lines="1"
class="text-uppercase text-weight-light"
:class="approved_class"
> >
<!-- {{ $d(new Date(expense.date), { year: 'numeric', month: 'short', day: 'numeric', weekday: 'short' }) }} --> {{ $d(new Date(expense.date), { month: 'short', day: 'numeric', weekday: 'long' }) }}
{{ expense.date }}
</q-item-label> </q-item-label>
</q-item-section> </q-item-section>
<q-space v-if="horizontal" /> <q-space v-if="horizontal" />
<!-- attachment file icon --> <!-- attachment file icon -->
<q-item-section side> <q-item-section avatar>
<q-btn <q-btn
push push
dense :color="expense.is_approved ? 'white' : 'accent'"
size="md" :text-color="expense.is_approved ? 'accent' : 'white'"
color="primary" class="col-auto q-mx-sm q-px-sm q-pb-sm"
class="q-mx-lg"
icon="attach_file" icon="attach_file"
/> />
</q-item-section> </q-item-section>
<q-item-label class="col text-weight-light text-caption">
<span>attachment_goes_here.jpg</span>
</q-item-label>
<!-- comment section --> <!-- comment section -->
<q-item-section <q-item-section
v-if="!horizontal" v-if="!horizontal"
top top
> >
<q-item-label lines="1"> <q-item-label
lines="1"
class="text-weight-medium text-uppercase"
>
{{ $t('timesheet.expense.employee_comment') }} {{ $t('timesheet.expense.employee_comment') }}
</q-item-label> </q-item-label>
<q-item-label <q-item-label
caption caption
lines="1" lines="1"
:class="approved_class"
> >
{{ expense.comment }} {{ expense.comment }}
</q-item-label> </q-item-label>
@ -159,10 +152,13 @@
<!-- supervisor comment section --> <!-- supervisor comment section -->
<q-item-section <q-item-section
v-if="expense.supervisor_comment && !horizontal" v-if="is_authorized_to_approve"
top top
> >
<q-item-label lines="1"> <q-item-label
lines="1"
class="text-weight-medium text-uppercase"
>
{{ $t('timesheet.expense.supervisor_comment') }} {{ $t('timesheet.expense.supervisor_comment') }}
</q-item-label> </q-item-label>
<q-item-label <q-item-label
@ -174,27 +170,27 @@
</q-item-label> </q-item-label>
</q-item-section> </q-item-section>
<q-item-section <q-item-section :side="$q.screen.gt.sm">
side
class="q-pa-none"
>
<q-btn <q-btn
push flat
dense size="lg"
size="xs"
color="primary"
icon="edit" icon="edit"
class="q-mb-xs z-top" color="accent"
@click.stop="setExpenseToModify" :disable="expense.is_approved"
class="q-pa-none z-top"
:class="expense.is_approved ? 'invisible no-pointer' : ''"
@click.stop="onUpdateClicked"
/> />
</q-item-section>
<q-item-section :side="$q.screen.gt.sm">
<q-btn <q-btn
push flat
dense size="lg"
size="xs" :icon="expense.is_approved ? 'verified' : 'close'"
color="negative" :color="expense.is_approved ? 'white' : 'negative'"
icon="close" class="q-pa-none z-top"
class="z-top" :class="expense.is_approved ? 'no-pointer' : ''"
@click.stop="requestExpenseDeletion" @click.stop="requestExpenseDeletion"
/> />
</q-item-section> </q-item-section>

View File

@ -2,32 +2,41 @@
setup setup
lang="ts" lang="ts"
> >
import { useExpensesStore } from 'src/stores/expense-store'; import { computed } from 'vue';
import { useTimesheetStore } from 'src/stores/timesheet-store';
import ExpenseDialogListItem from 'src/modules/timesheets/components/expense-dialog-list-item.vue'; import ExpenseDialogListItem from 'src/modules/timesheets/components/expense-dialog-list-item.vue';
const expenses_store = useExpensesStore(); const timesheet_store = useTimesheetStore();
const { horizontal = false } = defineProps<{ const { horizontal = false } = defineProps<{
horizontal?: boolean; horizontal?: boolean;
}>(); }>();
const expenses_list = computed(() => {
if (timesheet_store.timesheets !== undefined) {
return timesheet_store.timesheets.flatMap(week => week.days).flatMap(day => day.expenses);
}
return [];
})
</script> </script>
<template> <template>
<!-- liste des dépenses pré existantes --> <!-- liste des dépenses pré existantes -->
<q-list <q-list
padding padding
class="rounded-borders" class="q-px-lg"
:class="horizontal ? 'row flex-center' : ''" :class="horizontal ? 'row flex-center' : ''"
> >
<q-item-label <q-item-label
v-if="expenses_store.pay_period_expenses?.length === 0" v-if="expenses_list.length > 0"
class="text-italic q-px-sm" class="text-italic q-px-sm"
> >
{{ $t('timesheet.expense.empty_list') }} {{ $t('timesheet.expense.empty_list') }}
</q-item-label> </q-item-label>
<ExpenseDialogListItem <ExpenseDialogListItem
v-for="(expense, index) in expenses_store.pay_period_expenses" v-for="(expense, index) in expenses_list"
:key="index" :key="index"
v-model="expense.is_approved" v-model="expense.is_approved"
:index="index" :index="index"

View File

@ -14,16 +14,18 @@
<q-dialog <q-dialog
v-model="expense_store.is_open" v-model="expense_store.is_open"
persistent persistent
transition-show="jump-down"
transition-hide="jump-down"
> >
<q-card <q-card
class="q-pa-md" class="q-pa-none rounded-10 shadow-10 bg-secondary"
style=" min-width: 70vw;" style=" min-width: 70vw;"
> >
<q-inner-loading :showing="expense_store.is_loading"> <q-inner-loading :showing="expense_store.is_loading">
<q-spinner size="32px" /> <q-spinner size="32px" />
</q-inner-loading> </q-inner-loading>
<q-card-section> <q-card-section class="q-pa-none">
<!-- <q-banner <!-- <q-banner
v-if="expenses_error" v-if="expenses_error"
dense dense
@ -36,29 +38,21 @@
<ExpenseDialogList /> <ExpenseDialogList />
<ExpenseDialogForm v-if="!expense_store.current_expense.is_approved" /> <transition
<q-icon appear
v-else enter-active-class="animated fadeInDown faster"
name="block" leave-active-class="animated fadeOutDown faster"
color="negative" mode="out-in"
size="lg" >
/> <ExpenseDialogForm v-if="!expense_store.current_expense.is_approved" />
<q-icon
<q-separator spaced /> v-else
name="block"
color="negative"
size="lg"
/>
</transition>
</q-card-section> </q-card-section>
<q-card-actions align="right">
<!-- close btn -->
<q-btn
flat
class="col-auto q-mr-sm"
color="primary"
:label="$t('shared.label.close')"
@click="expense_store.close"
/>
</q-card-actions>
</q-card> </q-card>
</q-dialog> </q-dialog>
</template> </template>

View File

@ -0,0 +1,53 @@
<script
setup
lang="ts"
>
import { computed } from 'vue';
import { date, useQuasar } from 'quasar';
const q = useQuasar();
const { extractDate } = date;
const { displayDate, dense = false, approved = false} = defineProps<{
displayDate: string;
dense?: boolean;
approved?: 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 || is_mobile.value ? 'width: 40px; height: 75px;' : 'width: 75px; height: 75px;');
const display_date = extractDate(displayDate, 'YYYY-MM-DD');
</script>
<template>
<div
class="column flex-center rounded-10 text-center self-center bg-transparent"
:style="date_box_size"
>
<span
v-if="!dense"
class="col-auto text-uppercase text-weight-bold"
:class="approved ? 'text-white' : ''"
:style="'font-size: ' + weekday_font_size"
>
{{ $d(display_date, { weekday: $q.screen.lt.md ? 'short' : 'long'}) }}
</span>
<span
class="col-auto text-weight-bolder"
:class="approved ? 'text-white' : ''"
:style="'font-size: ' + date_font_size + '; line-height: 90% !important;'"
>
{{ display_date.getDate() }}
</span>
<span
class="col-auto text-uppercase text-weight-bold"
:class="approved ? 'text-white' : ''"
:style="'font-size: ' + weekday_font_size"
>
{{ $d(display_date, { month: $q.screen.lt.md ? 'short' : 'long' }) }}
</span>
</div>
</template>

View File

@ -0,0 +1,265 @@
<script
setup
lang="ts"
>
/* eslint-disable*/
import { onBeforeUnmount, onMounted, ref, toRaw, useTemplateRef } from 'vue';
import { unwrapAndClone } from 'src/utils/unwrap-and-clone';
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 { t } = useI18n();
const ui_store = useUiStore();
interface ShiftOption {
label: string;
value: ShiftType;
icon: string;
icon_color: string;
}
const COMMENT_LENGTH_MAX = 280;
const SHIFT_OPTIONS: ShiftOption[] = [
{ 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 = defineModel<Shift>('shift', { required: true });
const { dense = false, outlined = false } = defineProps<{
dense?: boolean;
outlined?: boolean;
}>();
const emit = defineEmits<{
'saveComment': [comment: string, shift_id: number];
'requestDelete': [void];
}>();
const is_showing_time_picker = ref(false);
const select_ref = useTemplateRef<QSelect>('select');
const initial_shift = ref<Shift>(unwrapAndClone(toRaw(shift.value)))
let timer: NodeJS.Timeout;
const shift_type_selected = ref(SHIFT_OPTIONS.find(option => option.value == shift.value.type));
const onBlurShiftTypeSelect = () => {
if (shift_type_selected.value === undefined) {
shift.value.type = 'REGULAR';
shift.value.id = 0;
emit('requestDelete');
}
};
const slideDeleteShift = async (reset: () => void) => {
timer = setTimeout(() => {
reset();
emit('requestDelete');
}, 200);
};
const getCommentCounterColor = (comment_length: number) => {
if (comment_length < 200) return 'primary';
if (comment_length < 250) return 'warning';
return 'negative';
};
onMounted(() => {
if (ui_store.focus_next_component) {
select_ref.value?.focus();
select_ref.value?.showPopup();
shift_type_selected.value = undefined;
ui_store.focus_next_component = false;
}
});
onBeforeUnmount(() => {
clearTimeout(timer);
});
</script>
<template>
<q-slide-item
right-color="negative"
class="q-my-xs rounded-5 bg-transparent"
@right="details => slideDeleteShift(details.reset)"
>
<template
#right
v-if="ui_store.is_mobile_mode"
>
<q-icon name="delete" />
</template>
<div
class="row flex-center text-uppercase rounded-5 bg-transparent"
>
<!-- shift type -->
<q-select
ref="select"
v-model="shift_type_selected"
standout="bg-blue-grey-9"
dense
:readonly="shift.is_approved"
:options-dense="!ui_store.is_mobile_mode"
hide-dropdown-icon
:menu-offset="[0, 10]"
menu-anchor="bottom middle"
menu-self="top middle"
:options="SHIFT_OPTIONS"
class="rounded-5 q-mx-xs bg-dark"
:class="(ui_store.is_mobile_mode ? 'col-12 q-mb-xs ' : 'col ')"
popup-content-class="text-uppercase text-weight-bold text-center rounded-5"
popup-content-style="border: 2px solid var(--q-accent)"
@blur="onBlurShiftTypeSelect"
@update:model-value="option => shift.type = option.value"
>
<template #selected-item="scope">
<div
class="row flex-center text-weight-bold q-ma-none q-pa-none no-wrap ellipsis full-width"
:class="ui_store.is_mobile_mode ? 'items-center full-height' : 'flex-center'"
:tabindex="scope.tabindex"
>
<q-icon
:name="scope.opt.icon"
:color="scope.opt.icon_color"
size="sm"
class="col-auto q-mx-xs"
/>
<span
style="line-height: 0.9em;"
class="col-auto ellipsis"
>{{ scope.opt.label }}</span>
</div>
</template>
</q-select>
<!-- punch in field -->
<q-input
v-model="shift.start_time"
dense
:readonly="shift.is_approved"
type="time"
:standout="$q.dark.isActive ? 'bg-blue-grey-9' : 'bg-blue-grey-1 text-white'"
label-slot
label-color="accent"
:input-class="'text-weight-medium ' + (shift.id === -2 ? 'text-white ' : ' ') + (shift.is_approved ? 'cursor-not-allowed' : '')"
input-style="font-size: 1.2em;"
class="col rounded-5 bg-dark"
:class="(shift.id === -2 ? 'bg-negative ' : ' ') + (ui_store.is_mobile_mode ? 'q-mr-xs ' : 'q-mx-xs ') + (shift.is_approved ? 'cursor-not-allowed' : '')"
>
<template #label>
<span
class="text-weight-bolder"
style="font-size: 0.95em;"
>{{ $t('shared.misc.in') }}</span>
</template>
</q-input>
<!-- punch out field -->
<q-input
v-model="shift.end_time"
dense
:readonly="shift.is_approved"
type="time"
standout="bg-blue-grey-9"
label-slot
label-color="accent"
:input-class="'text-weight-medium ' + (shift.id === -2 ? 'text-white ' : ' ') + (shift.is_approved ? 'cursor-not-allowed' : '')"
input-style="font-size: 1.2em;"
class="col rounded-5 bg-dark"
:class="(shift.id === -2 ? 'bg-negative ' : ' ') + (ui_store.is_mobile_mode ? 'q-ml-xs ' : 'q-mx-xs ') + (shift.is_approved ? 'cursor-not-allowed' : '')"
>
<template #label>
<span
class="text-weight-bolder"
style="font-size: 0.95em;"
>{{ $t('shared.misc.out') }}</span>
</template>
</q-input>
<!-- comment and delete buttons -->
<div :class="ui_store.is_mobile_mode ? 'col-12 row' : 'col-auto'">
<q-icon
v-if="shift.type && dense"
:name="shift.comment ? 'comment' : ''"
color="primary"
:size="dense ? 'xs' : 'sm'"
class="col"
/>
<q-btn
v-else
flat
dense
:icon="shift.comment ? 'chat' : 'chat_bubble_outline'"
:text-color="shift.comment ? 'accent' : 'grey-5'"
class="col"
:class="ui_store.is_mobile_mode ? 'q-mt-xs bg-dark' : ''"
>
<q-popup-edit
v-model="shift.comment"
:title="$t('timesheet.shift.fields.header_comment')"
auto-save
v-slot="scope"
class="bg-dark"
>
<q-input
color="white"
v-model="scope.value"
dense
:readonly="shift.is_approved"
autofocus
counter
bottom-slots
:maxlength="COMMENT_LENGTH_MAX"
class="q-pb-lg"
:class="shift.is_approved ? 'cursor-not-allowed' : ''"
@keyup.enter="scope.set"
>
<template #append>
<q-icon name="edit" />
</template>
<template #counter>
<div class="row flex-center">
<q-space />
<q-knob
:model-value="scope.value?.length"
readonly
:max="COMMENT_LENGTH_MAX"
size="1.6em"
:thickness="0.4"
:color="getCommentCounterColor(scope.value?.length ?? 0)"
track-color="grey-4"
class="col-auto q-mr-xs"
/>
<span
:class="'col-auto text-weight-bolder text-' + getCommentCounterColor(scope.value?.length ?? 0)"
>{{ 280 - (scope.value?.length ?? 0) }}</span>
</div>
</template>
</q-input>
</q-popup-edit>
</q-btn>
<q-btn
v-if="!ui_store.is_mobile_mode"
flat
dense
:disable="shift.is_approved"
tabindex="-1"
icon="cancel"
text-color="negative"
class="col"
:class="shift.is_approved ? 'invisible' : ''"
@click="$emit('requestDelete')"
/>
</div>
</div>
</q-slide-item>
</template>

View File

@ -0,0 +1,44 @@
<script
setup
lang="ts"
>
import ShiftListDayRow from 'src/modules/timesheets/components/shift-list-day-row.vue';
import { useShiftApi } from 'src/modules/timesheets/composables/use-shift-api';
import type { Shift } from 'src/modules/timesheets/models/shift.models';
import type { TimesheetDay } from 'src/modules/timesheets/models/timesheet.models';
const shift_api = useShiftApi();
const { day, dense = false, outlined = false, approved = false } = defineProps<{
day: TimesheetDay;
dense?: boolean;
outlined?: boolean;
approved?: boolean;
}>();
const emit = defineEmits<{
'deleteUnsavedShift': [void];
}>();
const deleteCurrentShift = async (shift: Shift) => {
if (shift.id <= 0) {
shift.id = 0;
emit('deleteUnsavedShift');
return;
}
await shift_api.deleteShiftById(shift.id);
};
</script>
<template>
<div class="column justify-center q-py-xs" :class="approved ? 'bg-dark' : ''">
<ShiftListDayRow
v-for="shift, shift_index in day.shifts"
:key="shift_index"
v-model:shift="day.shifts[shift_index]!"
:outlined="outlined"
:dense="dense"
@request-delete="deleteCurrentShift(shift)"
/>
</div>
</template>

View File

@ -1,197 +0,0 @@
<script
setup
lang="ts"
>
/* 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 { t } = useI18n();
const ui_store = useUiStore();
const shift = defineModel<Shift>('shift', { required: true });
const { dense = false } = defineProps<{
dense?: boolean;
}>();
defineEmits<{
'saveComment': [comment: string, shift_id: number];
'requestUpdate': [shift_id: number];
'requestDelete': [void];
}>();
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>
<div
v-if="shift.shift_id !== 0"
class="col row flex-center text-uppercase rounded-10"
>
<!-- 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)"
>
<template #selected-item="scope">
<div
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
:name="scope.opt.icon"
:color="scope.opt.icon_color"
size="sm"
class="col-auto q-mx-xs"
/>
<span
v-if="$q.screen.gt.md"
style="line-height: 0.9em;"
class="col ellipsis"
>{{ scope.opt.label }}</span>
</div>
</template>
</q-select>
<!-- 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
v-if="ui_store.is_mobile_mode"
dense
flat
icon="access_time"
color="primary"
@click.stop="showTimePicker(shift.start_time)"
/>
</template>
</q-input>
<!-- 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="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-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

@ -3,27 +3,17 @@
lang="ts" lang="ts"
> >
import { date } from 'quasar'; 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 { 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'; import { useUiStore } from 'src/stores/ui-store';
import { useTimesheetStore } from 'src/stores/timesheet-store';
import { Shift } from 'src/modules/timesheets/models/shift.models';
import ShiftListDay from 'src/modules/timesheets/components/shift-list-day.vue';
import ShiftListDateWidget from 'src/modules/timesheets/components/shift-list-date-widget.vue';
import type { TimesheetDay } from 'src/modules/timesheets/models/timesheet.models';
const { extractDate } = date;
const q = useQuasar();
const ui_store = useUiStore(); const ui_store = useUiStore();
const timesheet_store = useTimesheetStore(); 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 || is_mobile.value ? 'width: 40px; height: 75px;' : 'width: 75px; height: 75px;');
const addNewShift = (day_shifts: Shift[], date: string, timesheet_id: number) => { const addNewShift = (day_shifts: Shift[], date: string, timesheet_id: number) => {
ui_store.focus_next_component = true; ui_store.focus_next_component = true;
@ -33,90 +23,139 @@
day_shifts.push(new_shift); day_shifts.push(new_shift);
}; };
const deleteCurrentShift = async (shift: Shift) => { const deleteUnsavedShift = (timesheet_index: number, day_index: number) => {
console.log('shift to delete: ', shift); if (timesheet_store.timesheets !== undefined) {
if (shift.shift_id < 0) { const day = timesheet_store.timesheets[timesheet_index]!.days[day_index]!;
shift.shift_id = 0; const shifts_without_deleted_shift = day.shifts.filter(shift => shift.id !== 0);
return; day.shifts = shifts_without_deleted_shift;
console.log("day's shifts after cleanup: ", day.shifts);
} }
await shift_api.deleteShiftById(shift.shift_id); }
const getDayApproval = (day: TimesheetDay) => {
if (day.shifts.length < 1) return false;
return day.shifts.every(shift => shift.is_approved === true);
} }
</script> </script>
<template> <template>
<div :class="$q.screen.lt.md ? 'column' : 'row'"> <div
:class="$q.screen.lt.md ? 'column full-width' : 'row'"
:style="$q.screen.lt.md ? 'width: 90vw !important;' : ''"
>
<div <div
v-for="timesheet in timesheet_store.timesheets" v-for="timesheet, timesheet_index in timesheet_store.timesheets"
:key="timesheet.timesheet_id" :key="timesheet.timesheet_id"
class="col column" class="col column"
> >
<div <div
v-for="day in timesheet.days" v-for="day, day_index in timesheet.days"
:key="day.date" :key="day.date"
class="col-auto row shadow-2 rounded-10 q-ma-xs" class="col-auto row rounded-10 q-ma-sm shadow-10"
> >
<div <div
class="col row bg-dark" v-if="ui_store.is_mobile_mode"
style="border-radius: 10px 0 0 10px;" class="col column full-width"
> >
<!-- Dates column --> <q-card class="rounded-5 q-my-md" :class="getDayApproval(day) ? 'bg-accent' : 'bg-dark'">
<div
class="col-auto column flex-center bg-primary rounded-10 text-center q-ma-sm self-center" <q-card-section
:class="$q.screen.lt.md ? '' : ''" class="text-white text-weight-bolder text-uppercase text-h6 q-py-xs"
:style="date_box_size" :class="getDayApproval(day) ? 'bg-dark' : 'bg-accent'"
> style="line-height: 1em;"
<span
v-if="!dense"
class="col-auto text-uppercase text-white"
:style="'font-size: ' + weekday_font_size"
> >
{{ $d(date.extractDate(day.date, 'YYYY-MM-DD'), { <span> {{ $d(extractDate(day.date, 'YYYY-MM-DD'), {
weekday: $q.screen.lt.md ? 'short' : weekday: 'long', day: 'numeric', month:
'long' 'long'
}) }) }}</span>
}} </q-card-section>
</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() }}
</span>
<span
class="col-auto text-uppercase text-white"
:style="'font-size: ' + weekday_font_size"
>
{{ $d(date.extractDate(day.date, 'YYYY-MM-DD'), {
month: $q.screen.lt.md ? 'short' : 'long'
})
}}
</span>
</div>
<!-- List of shifts column --> <q-card-section
<div class="col column"> v-if="day.shifts.filter(shift => shift.id !== 0).length > 0"
<ShiftListRow class="q-pa-none transparent"
v-for="shift, shift_index in day.shifts" >
:key="shift_index" <ShiftListDay
v-model:shift="day.shifts[shift_index]!" outlined
:dense="dense" :approved="getDayApproval(day)"
@request-delete="deleteCurrentShift(shift)" :day="day"
/> @delete-unsaved-shift="deleteUnsavedShift(timesheet_index, day_index)"
</div> />
</q-card-section>
<q-card-actions class="q-pa-none">
<q-btn
v-if="!getDayApproval(day)"
push
square
color="accent"
icon="more_time"
class="full-width"
style="border-radius: 0 0 5px 5px;"
@click="addNewShift(day.shifts, day.date, timesheet.timesheet_id)"
/>
</q-card-actions>
<q-badge
v-if="getDayApproval(day)"
floating
class="bg-secondary q-pa-none rounded-50"
style="transform: translate(15px, -5px);"
>
<q-icon
name="verified"
size="5em"
color="accent"
/>
</q-badge>
</q-card>
</div> </div>
<div class="col-auto self-stretch"> <div
<q-btn v-else
unelevated class="col row full-width"
icon="more_time" :class="getDayApproval(day) ? 'rounded-10 bg-accent' : ''"
:size="$q.screen.lt.md ? 'md' : 'lg'" >
color="primary" <div
text-color="white" class="col row bg-dark"
class="full-height" :class="getDayApproval(day) ? 'bg-transparent' : ''"
:class="$q.screen.lt.md ? 'q-px-xs' : ''" style="border-radius: 10px 0 0 10px;"
style="border-radius: 0 10px 10px 0;" >
@click="addNewShift(day.shifts, day.date, timesheet.timesheet_id)" <!-- Date block -->
/> <ShiftListDateWidget
:display-date="day.date"
:approved="getDayApproval(day)"
class="col-auto"
/>
<!-- List of shifts -->
<ShiftListDay
:day="day"
class="col"
@delete-unsaved-shift="deleteUnsavedShift(timesheet_index, day_index)"
/>
</div>
<div class="col-auto self-stretch">
<q-icon
v-if="getDayApproval(day)"
name="verified"
color="white"
size="xl"
class="full-height"
/>
<q-btn
v-else
:dense="!ui_store.is_mobile_mode"
icon="more_time"
size="lg"
color="accent"
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> </div>
</div> </div>

View File

@ -0,0 +1,56 @@
<script
setup
lang="ts"
>
import { useShiftStore } from 'src/stores/shift-store';
import { computed } from 'vue';
const shift_store = useShiftStore();
const is_showing_errors = computed(() => shift_store.shift_errors.length > 0);
</script>
<template>
<q-slide-transition>
<div
clickable
v-if="is_showing_errors"
class="full-width q-px-md q-mb-sm"
@click="shift_store.shift_errors = []"
>
<q-list>
<q-item
clickable
dense
v-for="error, index in shift_store.shift_errors"
:key="index"
class="row items-center full-width bg-dark shadow-2 rounded-5 q-my-xs"
style="border: 2px solid var(--q-negative)"
>
<q-item-section class="col-auto">
<q-badge
outline
color="negative"
class="bg-dark text-weight-bolder"
>{{ error.conflicts.date }}</q-badge>
</q-item-section>
<q-item-section class="col-auto">
<q-badge
outline
color="negative"
class="bg-dark text-weight-bolder"
>
{{ error.conflicts.start_time }} - {{ error.conflicts.end_time }}
</q-badge>
</q-item-section>
<q-item-label class="text-weight-medium text-caption q-ml-md">
{{ $t('timesheet.shift.errors.' + error.error_code) }}
</q-item-label>
</q-item>
</q-list>
</div>
</q-slide-transition>
</template>

View File

@ -5,12 +5,12 @@
import ShiftList from 'src/modules/timesheets/components/shift-list.vue'; import ShiftList from 'src/modules/timesheets/components/shift-list.vue';
import ExpenseDialog from 'src/modules/timesheets/components/expense-dialog.vue'; import ExpenseDialog from 'src/modules/timesheets/components/expense-dialog.vue';
import PayPeriodNavigator from 'src/modules/shared/components/pay-period-navigator.vue'; import PayPeriodNavigator from 'src/modules/shared/components/pay-period-navigator.vue';
// import ShiftListLegend from 'src/modules/timesheets/components/shift-list-legend.vue'; import TimesheetErrorWidget from 'src/modules/timesheets/components/timesheet-error-widget.vue';
import { useTimesheetStore } from 'src/stores/timesheet-store'; import { useTimesheetStore } from 'src/stores/timesheet-store';
import { useTimesheetApi } from 'src/modules/timesheets/composables/use-timesheet-api'; import { useTimesheetApi } from 'src/modules/timesheets/composables/use-timesheet-api';
import { useExpensesStore } from 'src/stores/expense-store'; import { useExpensesStore } from 'src/stores/expense-store';
import { provide } from 'vue'; import { provide } from 'vue';
import { useShiftApi } from 'src/modules/timesheets/composables/use-shift-api'; import { useShiftApi } from 'src/modules/timesheets/composables/use-shift-api';
const { open } = useExpensesStore(); const { open } = useExpensesStore();
const shift_api = useShiftApi(); const shift_api = useShiftApi();
@ -77,12 +77,12 @@ import { useShiftApi } from 'src/modules/timesheets/composables/use-shift-api';
<q-space /> <q-space />
<!-- save timesheet changes button --> <!-- save timesheet changes button -->
<q-btn <q-btn
v-if="$q.screen.gt.sm" v-if="$q.screen.gt.sm"
push push
rounded rounded
:disable="timesheet_store.is_loading" :disable="timesheet_store.is_loading"
color="primary" color="accent"
icon="upload" icon="upload"
:label="$t('shared.label.save')" :label="$t('shared.label.save')"
class="q-mr-md" class="q-mr-md"
@ -94,7 +94,7 @@ import { useShiftApi } from 'src/modules/timesheets/composables/use-shift-api';
v-if="$q.screen.gt.sm" v-if="$q.screen.gt.sm"
push push
rounded rounded
color="primary" color="accent"
icon="receipt_long" icon="receipt_long"
:label="$t('timesheet.expense.open_btn')" :label="$t('timesheet.expense.open_btn')"
@click="open" @click="open"
@ -102,6 +102,10 @@ import { useShiftApi } from 'src/modules/timesheets/composables/use-shift-api';
</q-card-section> </q-card-section>
<q-card-section class="q-pa-none">
<TimesheetErrorWidget />
</q-card-section>
<ShiftList :dense="dense" /> <ShiftList :dense="dense" />
</q-card> </q-card>
<ExpenseDialog /> <ExpenseDialog />

View File

@ -7,7 +7,7 @@ import type { Expense } from "src/modules/timesheets/models/expense.models";
export const useExpensesApi = () => { export const useExpensesApi = () => {
const expenses_store = useExpensesStore(); const expenses_store = useExpensesStore();
const createExpenseByEmployeeEmail = async (employee_email: string, date: string): Promise<void> => { const createExpenseByEmployeeEmail = async (employee_email: string, date: string): Promise<void> => {
// await expenses_store.upsertOrDeleteExpensesByEmployeeEmail(employee_email, date, upsert_expense); // await expenses_store.upsertOrDeleteExpensesByEmployeeEmail(employee_email, date, upsert_expense);
}; };
@ -16,13 +16,13 @@ export const useExpensesApi = () => {
// await expenses_store.upsertOrDeleteExpensesByEmployeeEmail(employee_email, date, upsert_expense); // await expenses_store.upsertOrDeleteExpensesByEmployeeEmail(employee_email, date, upsert_expense);
}; };
const deleteExpenseByEmployeeEmail = async (employee_email: string, date: string): Promise<void> => { const deleteExpenseById = async (expense_id: number): Promise<void> => {
// await expenses_store.upsertOrDeleteExpensesByEmployeeEmail(employee_email, date, upsert_expense); await expenses_store.deleteExpenseById(expense_id);
}; };
return { return {
createExpenseByEmployeeEmail, createExpenseByEmployeeEmail,
updateExpenseByEmployeeEmail, updateExpenseByEmployeeEmail,
deleteExpenseByEmployeeEmail, deleteExpenseById,
}; };
}; };

View File

@ -20,15 +20,14 @@ export const useShiftApi = () => {
const saveShiftChanges = async () => { const saveShiftChanges = async () => {
timesheet_store.is_loading = true; timesheet_store.is_loading = true;
const create_success = await shift_store.createNewShifts(); const create_success = await shift_store.createNewShifts();
const update_success = await shift_store.updateShifts();
if (create_success) { if (create_success || update_success){
const update_success = await shift_store.updateShifts(); await timesheet_store.getTimesheetsByEmployeeEmail(auth_store.user?.email ?? '');
if (update_success) {
await timesheet_store.getTimesheetsByEmployeeEmail(auth_store.user?.email ?? '')
}
} }
timesheet_store.is_loading = false; timesheet_store.is_loading = false;
} }

View File

@ -4,44 +4,22 @@ export const EXPENSE_TYPE: ExpenseType[] = ['PER_DIEM', 'MILEAGE', 'EXPENSES', '
export const TYPES_WITH_MILEAGE_ONLY: Readonly<ExpenseType[]> = ['MILEAGE']; export const TYPES_WITH_MILEAGE_ONLY: Readonly<ExpenseType[]> = ['MILEAGE'];
export const TYPES_WITH_AMOUNT_ONLY: Readonly<ExpenseType[]> = ['PER_DIEM', 'EXPENSES', 'ON_CALL',]; export const TYPES_WITH_AMOUNT_ONLY: Readonly<ExpenseType[]> = ['PER_DIEM', 'EXPENSES', 'ON_CALL',];
export interface Expense { export class Expense {
id: number; id: number;
date: string; //YYYY-MM-DD date: string; //YYYY-MM-DD
type: ExpenseType; type: ExpenseType;
amount: number; amount: number;
mileage?: number; mileage?: number;
comment: string; comment: string;
supervisor_comment?: string; supervisor_comment?: string;
is_approved: boolean; is_approved: boolean;
};
export const empty_expense: Expense = {
id: -1,
date: '',
type: 'EXPENSES',
amount: 0,
comment: '',
is_approved: false,
};
export const test_expenses: Expense[] = [
{
id: 201,
date: '2025-01-06',
type: 'EXPENSES',
amount: 15.5,
comment: 'Lunch receipt',
is_approved: false,
},
{
id: 202,
date: '2025-01-07',
type: 'MILEAGE',
amount: 0,
mileage: 32.4,
comment: 'Travel to client site',
is_approved: true,
},
];
constructor(date: string) {
this.id = -1;
this.date = date;
this.type = 'EXPENSES';
this.amount = 0;
this.comment = '';
this.is_approved = false;
};
};

View File

@ -9,6 +9,8 @@ export const SHIFT_TYPES: ShiftType[] = [
export type ShiftType = 'REGULAR' | 'EVENING' | 'EMERGENCY' | 'HOLIDAY' | 'VACATION' | 'SICK'; export type ShiftType = 'REGULAR' | 'EVENING' | 'EMERGENCY' | 'HOLIDAY' | 'VACATION' | 'SICK';
export type ShiftErrorCode = 'SHIFT_OVERLAP' | 'MISSING_START_TIME' | 'MISSING_END_TIME' | 'COMMENT_LENGTH_EXCEEDED' | 'APPROVAL_LOCK' | 'INVALID_DATE' | 'INVALID TYPE' | 'INVALID_TIMESHEET';
export type ShiftLegendItem = { export type ShiftLegendItem = {
type: ShiftType; type: ShiftType;
color: string; color: string;
@ -17,7 +19,7 @@ export type ShiftLegendItem = {
}; };
export class Shift { export class Shift {
shift_id: number; id: number;
timesheet_id: number; timesheet_id: number;
date: string; //YYYY-MM-DD date: string; //YYYY-MM-DD
type: ShiftType; type: ShiftType;
@ -28,7 +30,7 @@ export class Shift {
is_remote: boolean; is_remote: boolean;
constructor() { constructor() {
this.shift_id = -1; this.id = -1;
this.timesheet_id = -1; this.timesheet_id = -1;
this.date = ''; this.date = '';
this.type = 'REGULAR'; this.type = 'REGULAR';
@ -40,7 +42,21 @@ export class Shift {
} }
} }
export interface NewShift { export interface ShiftAPIResponse {
timesheet_id: number; ok: boolean;
shifts: Shift[]; data?: {
shift: Shift;
overtime: unknown;
}
error?: ShiftAPIError;
}
export interface ShiftAPIError {
error_code: ShiftErrorCode;
conflicts:
{
date: string;
start_time: string;
end_time: string;
}
} }

View File

@ -1,13 +1,19 @@
import { api } from "src/boot/axios"; import { api } from "src/boot/axios";
import type { Expense } from "src/modules/timesheets/models/expense.models";
export const ExpenseService = { export const ExpenseService = {
getExpensesByTimesheetId: async (timesheet_id: number) => { createExpense: async (expense: Expense) => {
const response = await api.get(`timesheet/${timesheet_id}`); const response = await api.post('expense/create', expense);
return response.data; return response.data;
}, },
upsertOrDeleteExpenseById: async (expense_id: number) => { updateExpenseById: async (expense: Expense) => {
const response = await api.post(`epxense/${expense_id}`); const response = await api.patch(`expense/update`, expense);
return response.data; return response.data;
}, },
deleteExpenseById: async (expense_id: number): Promise<{ok: boolean, id: number, error?: unknown}> => {
const response = await api.delete(`expense/delete/${expense_id}`);
return response.data;
}
}; };

View File

@ -1,6 +1,5 @@
/* eslint-disable */
import { api } from "src/boot/axios"; import { api } from "src/boot/axios";
import type { Shift } from "src/modules/timesheets/models/shift.models"; import type { Shift, ShiftAPIResponse } from "src/modules/timesheets/models/shift.models";
export const ShiftService = { export const ShiftService = {
deleteShiftById: async (shift_id: number) => { deleteShiftById: async (shift_id: number) => {
@ -8,17 +7,15 @@ export const ShiftService = {
return response.data; return response.data;
}, },
createNewShifts: async (new_shifts: Shift[]) => { createNewShifts: async (new_shifts: Shift[]):Promise<ShiftAPIResponse[]> => {
// const response = await api.post(`/shift/`, { dtos: new_shifts }); const response = await api.post(`/shift/create`, new_shifts);
// return response; return response.data;
console.log('create shift payload: ', new_shifts);
return {status: 200};
}, },
updateShifts: async (existing_shifts: Shift[]) => { updateShifts: async (existing_shifts: Shift[]) => {
// const response = await api.patch(`/shift/`, { dtos: existing_shifts }); console.log('sent shifts: ', existing_shifts)
// return response; const response = await api.patch(`/shift/update`, existing_shifts);
console.log('update shift payload: ', existing_shifts); console.log('API response to existing shifts: ', response.data);
return {status: 200}; return response;
} }
}; };

View File

@ -19,8 +19,8 @@ export const timesheetService = {
return response.data; return response.data;
}, },
getTimesheetsByPayPeriodAndEmployeeEmail: async (employee_email: string, year: number, period_number: number): Promise<TimesheetResponse> => { getTimesheetsByPayPeriod: async (year: number, period_number: number): Promise<TimesheetResponse> => {
const response = await api.get('timesheets', { params: { employee_email, year, period_number } }); const response = await api.get('timesheets', { params: { year, period_number } });
return response.data; return response.data;
}, },
}; };

View File

@ -2,7 +2,16 @@
setup setup
lang="ts" lang="ts"
> >
import { Notify } from 'quasar'; import { ref } from 'vue';
import { Notify } from 'quasar';
const LOREM_IPSUM = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et \
dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip \
ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu \
fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia \
deserunt mollit anim id est laborum."
const slide = ref<string>('welcome');
const clickNotify = () => { const clickNotify = () => {
Notify.create({ Notify.create({
@ -10,38 +19,84 @@ import { Notify } from 'quasar';
color: 'info' color: 'info'
}) })
} }
</script> </script>
<template> <template>
<q-page <q-page
padding padding
class="q-pa-md row items-center justify-center" class="q-pa-md row justify-center"
> >
<q-card class="shadow-2 col-9 dark-font"> <q-card flat class="column col-9 transparent ">
<q-img src="src/assets/line-truck-1.jpg"> <div class="col-1"></div>
<div class="absolute-bottom text-h5"> <q-carousel
Welcome to App Targo, ! v-model="slide"
</div> transition-prev="jump-right"
</q-img> transition-next="jump-left"
swipeable
<q-card-section class="text-center"> animated
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et control-color="accent"
dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip navigation-icon="radio_button_unchecked"
ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu navigation
fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia class="col-5 bg-dark rounded-15 shadow-2"
deserunt mollit anim id est laborum. >
</q-card-section> <q-carousel-slide
name="welcome"
<q-separator /> class="column no-wrap flex-center q-pa-none q-pb-xl"
>
<q-card-actions align="center"> <q-img src="src/assets/line-truck-1.jpg" class="full-height">
<div class="absolute-bottom text-h5">
Welcome to App Targo!
</div>
</q-img>
<div class="q-mt-md text-center">
{{ LOREM_IPSUM }}
</div>
</q-carousel-slide>
<q-carousel-slide
name="tv"
class="column no-wrap flex-center q-pa-none q-pb-xl"
>
<q-icon
name="live_tv"
size="56px"
/>
<div class="q-mt-md text-center">
{{ LOREM_IPSUM }}
</div>
</q-carousel-slide>
<q-carousel-slide
name="layers"
class="column no-wrap flex-center q-pa-none q-pb-xl"
>
<q-icon
name="layers"
size="56px"
/>
<div class="q-mt-md text-center">
{{ LOREM_IPSUM }}
</div>
</q-carousel-slide>
<q-carousel-slide
name="map"
class="column no-wrap flex-center q-pa-none q-pb-xl"
>
<q-icon
name="terrain"
size="56px"
/>
<div class="q-mt-md text-center">
{{ LOREM_IPSUM }}
</div>
</q-carousel-slide>
</q-carousel>
<div class="col column flex-center">
<q-btn <q-btn
color="primary" push
color="accent"
label="Click Me" label="Click Me"
@click="clickNotify" @click="clickNotify"
/> />
</q-card-actions> </div>
</q-card> </q-card>
</q-page> </q-page>
</template> </template>

View File

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

View File

@ -1,92 +1,56 @@
import { ref } from "vue"; import { ref } from "vue";
import { defineStore } from "pinia"; import { defineStore } from "pinia";
import { ExpensesApiError, type GenericApiError } from "src/modules/timesheets/models/expense-validation.models"; import { Expense } from "src/modules/timesheets/models/expense.models";
import { empty_expense, test_expenses, type Expense } from "src/modules/timesheets/models/expense.models";
import { ExpenseService } from "src/modules/timesheets/services/expense-service"; import { ExpenseService } from "src/modules/timesheets/services/expense-service";
import { date } from "quasar";
export const useExpensesStore = defineStore('expenses', () => { export const useExpensesStore = defineStore('expenses', () => {
const is_open = ref(false); const is_open = ref(false);
const is_loading = ref(false); const is_loading = ref(false);
const pay_period_expenses = ref<Expense[]>(test_expenses); const mode = ref<'create' | 'update' | 'delete'>('create');
const current_expense = ref<Expense>(empty_expense); const current_expense = ref<Expense>(new Expense(date.formatDate(new Date(), 'YYYY-MM-DD')));
const initial_expense = ref<Expense>(empty_expense); const initial_expense = ref<Expense>(new Expense(date.formatDate(new Date(), 'YYYY-MM-DD')));
const error = ref<string | null>(null);
// const setErrorFrom = (err: unknown) => {
// const e = err as any;
// error.value = e?.message || 'Unknown error';
// };
const open = (): void => { const open = (): void => {
is_open.value = true; is_open.value = true;
is_loading.value = true; current_expense.value = new Expense(date.formatDate(new Date(), 'YYYY-MM-DD'));
error.value = null; initial_expense.value = new Expense(date.formatDate(new Date(), 'YYYY-MM-DD'));
current_expense.value = empty_expense; mode.value = 'create';
initial_expense.value = empty_expense;
// await getPayPeriodExpensesByTimesheetId(timesheet_id);
is_loading.value = false;
} }
const close = () => { const close = () => {
error.value = null;
is_open.value = false; is_open.value = false;
}; };
const getPayPeriodExpensesByTimesheetId = async (timesheet_id: number): Promise<void> => { const upsertExpensesById = async (expense_id: number, expense: Expense): Promise<void> => {
is_loading.value = true;
error.value = null;
try { try {
const expenses = await ExpenseService.getExpensesByTimesheetId(timesheet_id); if (expense_id < 0) {
pay_period_expenses.value = expenses; const data = await ExpenseService.createExpense(expense);
} catch (err: unknown) { return data;
if (typeof err === 'object') {
const error = err as GenericApiError;
const status_code: number = error.status_code ?? 500;
// const data = error.context ?? '';
// error.value = data.message || data.error || err.message;
throw new ExpensesApiError({
status_code,
// error_code: data.error_code,
// message: data.message || data.error || err.message,
// context: data.context,
});
} }
} finally {
is_loading.value = false;
}
};
const upsertOrDeleteExpensesById = async (expense_id: number): Promise<void> => {
is_loading.value = true;
error.value = null;
try {
await ExpenseService.upsertOrDeleteExpenseById(expense_id);
// TODO: Save response data into proper ref // TODO: Save response data into proper ref
} catch (err) { } catch (err) {
// setErrorFrom(err); // setErrorFrom(err);
console.error(err); console.error(err);
} finally {
is_loading.value = false;
} }
}; };
const deleteExpenseById = async (expense_id: number): Promise<boolean> => {
const data = await ExpenseService.deleteExpenseById(expense_id);
return data.ok;
}
return { return {
is_open, is_open,
is_loading, is_loading,
pay_period_expenses, mode,
current_expense, current_expense,
initial_expense, initial_expense,
error,
open, open,
getPayPeriodExpensesByTimesheetId, upsertExpensesById,
upsertOrDeleteExpensesById, deleteExpenseById,
close, close,
}; };
}); });

View File

@ -0,0 +1,68 @@
/* eslint-disable */
import { ref } from "vue";
import { defineStore } from "pinia";
import { SchedulePresetsService } from "src/modules/profile/services/schedule-presets-service";
import type { SchedulePreset } from "src/modules/profile/models/schedule-presets.models";
export const useSchedulePresetsStore = defineStore('schedule_presets_store', () => {
const schedule_presets = ref<SchedulePreset>();
const createSchedulePreset = async (): Promise<boolean> => {
try {
// const new_preset: SchedulePreset = ??
// await SchedulePresetsService.createSchedulePresets(new_preset);
return true;
} catch (error) {
console.error('DEV ERROR || error while creating schedule preset: ', error);
return false;
}
}
const updateSchedulePreset = async (): Promise<boolean> => {
try {
return true;
} catch (error) {
console.error('DEV ERROR || error while updating schedule preset: ', error);
return false;
}
}
const deleteSchedulePreset = async (preset_id: number): Promise<boolean> => {
try {
await SchedulePresetsService.deleteSchedulePresets(preset_id);
return true;
} catch (error) {
console.error('DEV ERROR || error while deleting schedule preset: ', error);
return false
}
}
const findSchedulePresetList = async (): Promise<boolean> => {
try {
return true;
} catch (error) {
console.error('DEV ERROR || error while searching for schedule presets: ', error);
return false
}
}
const applySchedulePreset = async (): Promise<boolean> => {
try {
return true;
} catch (error) {
console.error('DEV ERROR || error while building schedule: ', error);
return false
}
}
return {
schedule_presets,
createSchedulePreset,
updateSchedulePreset,
deleteSchedulePreset,
findSchedulePresetList,
applySchedulePreset,
}
})

View File

@ -1,12 +1,13 @@
import { ref } from "vue"; import { ref } from "vue";
import { Notify } from "quasar";
import { defineStore } from "pinia"; import { defineStore } from "pinia";
import { ShiftService } from "src/modules/timesheets/services/shift-service"; import { ShiftService } from "src/modules/timesheets/services/shift-service";
import { useTimesheetStore } from "src/stores/timesheet-store"; import { useTimesheetStore } from "src/stores/timesheet-store";
import { Notify } from "quasar"; import type { ShiftAPIError } from "src/modules/timesheets/models/shift.models";
export const useShiftStore = defineStore('shift_store', () => { export const useShiftStore = defineStore('shift_store', () => {
const timesheet_store = useTimesheetStore(); const timesheet_store = useTimesheetStore();
const shift_error = ref(); const shift_errors = ref<ShiftAPIError[]>([]);
const deleteShiftById = async (shift_id: number): Promise<boolean> => { const deleteShiftById = async (shift_id: number): Promise<boolean> => {
try { try {
@ -19,20 +20,25 @@ export const useShiftStore = defineStore('shift_store', () => {
}; };
const createNewShifts = async (): Promise<boolean> => { const createNewShifts = async (): Promise<boolean> => {
if (timesheet_store.timesheets === undefined) return false; if (timesheet_store.timesheets === undefined) {
console.log('no changes in existing shifts detected');
return false;
}
try { try {
const new_shifts = timesheet_store.timesheets.flatMap(week => week.days).flatMap(day => day.shifts).filter(shift => shift.shift_id < 0); const new_shifts = timesheet_store.timesheets.flatMap(week => week.days).flatMap(day => day.shifts).filter(shift => shift.id < 0);
if (new_shifts?.length > 0) { if (new_shifts?.length > 0) {
const response = await ShiftService.createNewShifts(new_shifts); const response = await ShiftService.createNewShifts(new_shifts);
if (response.status <= 200) { if (response.every(res => res.ok)) {
return true; return true;
} }
else {
response.forEach(res => {
shift_errors.value.push(res.error!);
});
}
} }
console.log('No new shifts to save');
Notify.create('no new shifts to save')
return false; return false;
} catch (error) { } catch (error) {
console.error('Error creating new shifts: ', error); console.error('Error creating new shifts: ', error);
@ -44,11 +50,12 @@ export const useShiftStore = defineStore('shift_store', () => {
if (timesheet_store.timesheets === undefined) return false; if (timesheet_store.timesheets === undefined) return false;
try { try {
const existing_shifts = timesheet_store.timesheets.flatMap(week => week.days).flatMap(day => day.shifts).filter(shift => shift.shift_id > 0); const existing_shifts = timesheet_store.timesheets.flatMap(week => week.days).flatMap(day => day.shifts).filter(shift => shift.id > 0);
if (existing_shifts?.length > 0) { if (existing_shifts?.length > 0) {
const response = await ShiftService.updateShifts(existing_shifts); const response = await ShiftService.updateShifts(existing_shifts);
if (response.status <= 200) {
if (response.status < 400) {
return true; return true;
} }
} }
@ -63,7 +70,7 @@ export const useShiftStore = defineStore('shift_store', () => {
} }
return { return {
shift_error, shift_errors,
deleteShiftById, deleteShiftById,
createNewShifts, createNewShifts,
updateShifts, updateShifts,

View File

@ -1,6 +1,7 @@
import { ref } from 'vue'; import { ref } from 'vue';
import { defineStore } from 'pinia'; import { defineStore } from 'pinia';
import { useAuthStore } from 'src/stores/auth-store'; import { useAuthStore } from 'src/stores/auth-store';
import { unwrapAndClone } from 'src/utils/unwrap-and-clone';
import { timesheetApprovalService } from 'src/modules/timesheet-approval/services/timesheet-approval-service'; import { timesheetApprovalService } from 'src/modules/timesheet-approval/services/timesheet-approval-service';
import { timesheetService } from 'src/modules/timesheets/services/timesheet-service'; import { timesheetService } from 'src/modules/timesheets/services/timesheet-service';
import type { TimesheetOverview } from "src/modules/timesheet-approval/models/timesheet-overview.models"; import type { TimesheetOverview } from "src/modules/timesheet-approval/models/timesheet-overview.models";
@ -15,7 +16,8 @@ export const useTimesheetStore = defineStore('timesheet', () => {
const pay_period = ref<PayPeriod>(); const pay_period = ref<PayPeriod>();
const pay_period_overviews = ref<TimesheetOverview[]>([]); const pay_period_overviews = ref<TimesheetOverview[]>([]);
const current_pay_period_overview = ref<TimesheetOverview>(); const current_pay_period_overview = ref<TimesheetOverview>();
const timesheets = ref<Timesheet[]>(); const timesheets = ref<Timesheet[]>([]);
const initial_timesheets = ref<Timesheet[]>([]);
const pay_period_report = ref(); const pay_period_report = ref();
const getPayPeriodByDateOrYearAndNumber = async (date_or_year: string | number, period_number?: number): Promise<boolean> => { const getPayPeriodByDateOrYearAndNumber = async (date_or_year: string | number, period_number?: number): Promise<boolean> => {
@ -58,13 +60,17 @@ export const useTimesheetStore = defineStore('timesheet', () => {
} }
}; };
const getTimesheetsByEmployeeEmail = async (employee_email: string) => { const getTimesheetsByEmployeeEmail = async (employee_email?: string) => {
is_loading.value = true; is_loading.value = true;
if (pay_period.value === undefined) return; if (pay_period.value === undefined) return;
try { try {
const response = await timesheetService.getTimesheetsByPayPeriodAndEmployeeEmail(employee_email, pay_period.value.pay_year, pay_period.value.pay_period_no); if (employee_email) {
console.log('email: ', employee_email);
}
const response = await timesheetService.getTimesheetsByPayPeriod(pay_period.value.pay_year, pay_period.value.pay_period_no);
timesheets.value = response.timesheets; timesheets.value = response.timesheets;
initial_timesheets.value = unwrapAndClone(timesheets.value);
is_loading.value = false; is_loading.value = false;
} catch (error) { } catch (error) {
console.error('There was an error retrieving timesheet details for this employee: ', error); console.error('There was an error retrieving timesheet details for this employee: ', error);
@ -97,6 +103,7 @@ export const useTimesheetStore = defineStore('timesheet', () => {
pay_period_overviews, pay_period_overviews,
current_pay_period_overview, current_pay_period_overview,
timesheets, timesheets,
initial_timesheets,
getPayPeriodByDateOrYearAndNumber, getPayPeriodByDateOrYearAndNumber,
getTimesheetOverviewsByPayPeriod, getTimesheetOverviewsByPayPeriod,
getTimesheetsByEmployeeEmail, getTimesheetsByEmployeeEmail,