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

Reviewed-on: Targo/targo_frontend#22
This commit is contained in:
Nicolas 2025-10-29 15:21:15 -04:00
commit 52984c88e9
97 changed files with 2430 additions and 2416 deletions

23
Dockerfile Normal file
View File

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

View File

@ -14,7 +14,10 @@ declare module 'vue' {
// good idea to move this instance creation inside of the // good idea to move this instance creation inside of the
// "export default () => {}" function below (which runs individually // "export default () => {}" function below (which runs individually
// for each client) // for each client)
const api = axios.create({ baseURL: import.meta.env.VITE_TARGO_BACKEND_AUTH_URL }); const api = axios.create({
baseURL: import.meta.env.VITE_TARGO_BACKEND_URL,
withCredentials: true
});
export default defineBoot(({ app }) => { export default defineBoot(({ app }) => {
// for use inside Vue files (Options API) through this.$axios and this.$api // for use inside Vue files (Options API) through this.$axios and this.$api

View File

@ -1,5 +1,5 @@
// app global css in SCSS form // app global css in SCSS form
@each $size in (1, 2, 3, 4, 5, 10, 15, 20, 25, 50, 75, 100) { @each $size in (1, 2, 3, 4, 5, 10, 15, 20, 25, 50, 75, 100, 200) {
.rounded-#{$size} { .rounded-#{$size} {
border-radius: #{$size}px !important; border-radius: #{$size}px !important;
} }
@ -25,7 +25,7 @@
} }
body.body--dark { body.body--dark {
--q-secondary: #0f1114; --q-secondary: #2b2f34;
color: $grey-2; color: $grey-2;
} }
@ -33,3 +33,12 @@ body.body--dark {
--q-dark: #FFF; --q-dark: #FFF;
color: $blue-grey-8; color: $blue-grey-8;
} }
.shift-highlight {
background: #0195462a;
}
.frosted-glass {
background-color: #FFFA !important;
backdrop-filter: blur(5px);
}

View File

@ -16,16 +16,22 @@ $primary : #019547;
$secondary : #DAE0E7; $secondary : #DAE0E7;
$accent : #AAD5C4; $accent : #AAD5C4;
$dark-shadow-color : #019547; $dark-shadow-color : #00220f;
$elevation-dark-umbra : rgba($dark-shadow-color, 0.4); $elevation-dark-umbra : rgba($dark-shadow-color, 1);
$elevation-dark-penumbra : rgba($dark-shadow-color, 0); $elevation-dark-penumbra : rgba($dark-shadow-color, 0.2);
$elevation-dark-ambient : rgba($dark-shadow-color, 0); $elevation-dark-ambient : rgba($dark-shadow-color, 0.2);
$dark-shadow-2 : 0 3px 5px -1px $elevation-dark-umbra, 0 5px 8px $elevation-dark-penumbra, 0 1px 14px $elevation-dark-ambient; $dark-shadow-2 : 0 3px 5px -1px $elevation-dark-umbra, 0 5px 8px $elevation-dark-penumbra, 0 1px 14px $elevation-dark-ambient;
$layout-shadow-dark : 0 0 10px 5px rgba($dark-shadow-color, 0.5); $layout-shadow-dark : 0 0 10px 5px rgba($dark-shadow-color, 0.5);
$dark : #333; $input-text-color : #455A64;
$input-autofill-color : #AAD5C4;
$field-dense-label-top : 5px !default;
$field-dense-label-font-size : 16px !default;
$dark : #42444b;
$dark-page : #343434; $dark-page : #343434;
$positive : #21ba45; $positive : #21ba45;

View File

@ -25,6 +25,10 @@ export default {
tooltip: { tooltip: {
coming_soon: "coming soon!", coming_soon: "coming soon!",
}, },
error: {
login_failed: "Failed to login",
popups_blocked: "Popups are blocked on this device",
},
}, },
nav_bar: { nav_bar: {

View File

@ -25,6 +25,10 @@ export default {
tooltip: { tooltip: {
coming_soon: "à venir!", coming_soon: "à venir!",
}, },
error: {
login_failed: "Échec à la connexion",
popups_blocked: "Les fenêtres contextuelles sont bloqués sur cet appareil",
},
}, },
nav_bar: { nav_bar: {

View File

@ -1,26 +1,29 @@
<script setup lang="ts"> <script
import { useAuthStore } from 'src/stores/auth-store'; setup
lang="ts"
>
import { ref } from 'vue'; import { ref } from 'vue';
const authStore = useAuthStore();
const currentUser = authStore.user;
// Will need to implement this eventually, just testing the look for now // Will need to implement this eventually, just testing the look for now
const notifAmount = ref(7); const notification_count = ref(7);
</script> </script>
<template> <template>
<q-item clickable v-ripple dark class="q-pa-none"> <q-btn
<q-item-section :side="$q.screen.gt.sm"> flat
<q-avatar rounded > transparent
<q-img src="src/assets/targo-default-avatar.png" /> dense
<q-badge floating color="negative" v-if="notifAmount > 0" >{{ notifAmount }}</q-badge> :icon="notification_count > 0 ? 'notifications_active' : 'notifications_off'"
</q-avatar> size="lg"
</q-item-section> color="white"
>
<q-item-section v-if="$q.screen.gt.sm"> <q-badge
<q-item-label>{{ currentUser.firstName }} {{ currentUser.lastName }}</q-item-label> v-if="notification_count > 0"
<q-item-label caption>{{ notifAmount }} new messages</q-item-label> floating
</q-item-section> color="negative"
</q-item> class="text-weight-bolder q-mt-xs"
>
{{ notification_count }}
</q-badge>
</q-btn>
</template> </template>

View File

@ -1,23 +1,27 @@
<script setup lang="ts"> <script
setup
lang="ts"
>
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import { useAuthStore } from 'src/stores/auth-store'; import { useAuthStore } from 'src/stores/auth-store';
import { useUiStore } from 'src/stores/ui-store'; import { useUiStore } from 'src/stores/ui-store';
import { ref } from 'vue'; import { ref } from 'vue';
import { RouteNames } from 'src/router/router-constants'; import { RouteNames } from 'src/router/router-constants';
import { CAN_APPROVE_PAY_PERIODS } from 'src/modules/shared/models/user.models';
const authStore = useAuthStore(); const auth_store = useAuthStore();
const uiStore = useUiStore(); const ui_store = useUiStore();
const router = useRouter(); const router = useRouter();
const miniState = ref(true); const is_mini = ref(true);
const goToPageName = (pageName: string) => { const goToPageName = (page_name: string) => {
router.push({ name: pageName }).catch(err => { router.push({ name: page_name }).catch(err => {
console.error('Error with Vue Router: ', err); console.error('Error with Vue Router: ', err);
}); });
}; };
const handleLogout = () => { const handleLogout = () => {
authStore.logout(); auth_store.logout();
router.push({ name: 'login' }).catch(err => { router.push({ name: 'login' }).catch(err => {
console.log('could not log you out: ', err); console.log('could not log you out: ', err);
@ -27,21 +31,30 @@
<template> <template>
<q-drawer <q-drawer
v-model="uiStore.isRightDrawerOpen" v-model="ui_store.is_left_drawer_open"
overlay persistent
mini-to-overlay
elevated elevated
side="left" side="left"
:mini="miniState" :mini="is_mini"
@mouseenter="miniState = false" @mouseenter="is_mini = false"
@mouseleave="miniState = true" @mouseleave="is_mini = true"
class="bg-dark" class="bg-dark"
> >
<q-scroll-area class="fit"> <q-scroll-area class="fit">
<q-list> <q-list>
<!-- Home --> <!-- Home -->
<q-item v-ripple clickable side @click="goToPageName(RouteNames.DASHBOARD)"> <q-item
v-ripple
clickable
side
@click="goToPageName(RouteNames.DASHBOARD)"
>
<q-item-section avatar> <q-item-section avatar>
<q-icon name="home" color="primary" /> <q-icon
name="home"
color="primary"
/>
</q-item-section> </q-item-section>
<q-item-section> <q-item-section>
<q-item-label class="text-uppercase text-weight-bold">{{ $t('nav_bar.home') }}</q-item-label> <q-item-label class="text-uppercase text-weight-bold">{{ $t('nav_bar.home') }}</q-item-label>
@ -49,42 +62,77 @@
</q-item> </q-item>
<!-- Timesheet Validation -- Supervisor and Accounting only --> <!-- Timesheet Validation -- Supervisor and Accounting only -->
<q-item v-ripple clickable side @click="goToPageName(RouteNames.TIMESHEET_APPROVALS)" <q-item
v-if="['supervisor', 'accounting'].includes(authStore.user.role)"> v-ripple
clickable
side
@click="goToPageName(RouteNames.TIMESHEET_APPROVALS)"
v-if="CAN_APPROVE_PAY_PERIODS.includes(auth_store.user?.role ?? 'GUEST')"
>
<q-item-section avatar> <q-item-section avatar>
<q-icon name="event_available" color="primary" /> <q-icon
name="event_available"
color="primary"
/>
</q-item-section> </q-item-section>
<q-item-section> <q-item-section>
<q-item-label class="text-uppercase text-weight-bold">{{ $t('nav_bar.timesheet_approvals') }}</q-item-label> <q-item-label class="text-uppercase text-weight-bold">{{ $t('nav_bar.timesheet_approvals')
}}</q-item-label>
</q-item-section> </q-item-section>
</q-item> </q-item>
<!-- Employee List -- Supervisor, Accounting and HR only --> <!-- Employee List -- Supervisor, Accounting and HR only -->
<q-item v-ripple clickable side @click="goToPageName(RouteNames.EMPLOYEE_LIST)" <q-item
v-if="['supervisor', 'accounting', 'human_resources'].includes(authStore.user.role)"> v-ripple
clickable
side
@click="goToPageName(RouteNames.EMPLOYEE_LIST)"
v-if="CAN_APPROVE_PAY_PERIODS.includes(auth_store.user?.role ?? 'GUEST')"
>
<q-item-section avatar> <q-item-section avatar>
<q-icon name="view_list" color="primary" /> <q-icon
name="view_list"
color="primary"
/>
</q-item-section> </q-item-section>
<q-item-section> <q-item-section>
<q-item-label class="text-uppercase text-weight-bold">{{ $t('nav_bar.employee_list') }}</q-item-label> <q-item-label class="text-uppercase text-weight-bold">{{ $t('nav_bar.employee_list')
}}</q-item-label>
</q-item-section> </q-item-section>
</q-item> </q-item>
<!-- Employee Timesheet temp -- Employee, Supervisor, Accounting only --> <!-- Employee Timesheet temp -- Employee, Supervisor, Accounting only -->
<q-item v-ripple clickable side @click="goToPageName(RouteNames.TIMESHEET_TEMP)" <q-item
v-if="['supervisor', 'accounting', 'employee'].includes(authStore.user.role)"> v-ripple
clickable
side
@click="goToPageName(RouteNames.TIMESHEET)"
v-if="CAN_APPROVE_PAY_PERIODS.includes(auth_store.user?.role ?? 'GUEST')"
>
<q-item-section avatar> <q-item-section avatar>
<q-icon name="punch_clock" color="primary" /> <q-icon
name="punch_clock"
color="primary"
/>
</q-item-section> </q-item-section>
<q-item-section> <q-item-section>
<q-item-label class="text-uppercase text-weight-bold">{{ $t('nav_bar.timesheet') }}</q-item-label> <q-item-label class="text-uppercase text-weight-bold">{{ $t('nav_bar.timesheet')
}}</q-item-label>
</q-item-section> </q-item-section>
</q-item> </q-item>
<!-- Profile --> <!-- Profile -->
<q-item v-ripple clickable side @click="goToPageName(RouteNames.PROFILE)"> <q-item
v-ripple
clickable
side
@click="goToPageName(RouteNames.PROFILE)"
>
<q-item-section avatar> <q-item-section avatar>
<q-icon name="account_box" color="primary" /> <q-icon
name="account_box"
color="primary"
/>
</q-item-section> </q-item-section>
<q-item-section> <q-item-section>
<q-item-label class="text-uppercase text-weight-bold">{{ $t('nav_bar.profile') }}</q-item-label> <q-item-label class="text-uppercase text-weight-bold">{{ $t('nav_bar.profile') }}</q-item-label>
@ -92,9 +140,16 @@
</q-item> </q-item>
<!-- Help --> <!-- Help -->
<q-item v-ripple clickable @click="goToPageName('help')"> <q-item
v-ripple
clickable
@click="goToPageName('help')"
>
<q-item-section avatar> <q-item-section avatar>
<q-icon name="contact_support" color="primary" /> <q-icon
name="contact_support"
color="primary"
/>
</q-item-section> </q-item-section>
<q-item-section> <q-item-section>
<q-item-label class="text-uppercase text-weight-bold">{{ $t('nav_bar.help') }}</q-item-label> <q-item-label class="text-uppercase text-weight-bold">{{ $t('nav_bar.help') }}</q-item-label>
@ -103,9 +158,17 @@
</q-list> </q-list>
<!-- Logout --> <!-- Logout -->
<q-item v-ripple clickable @click="handleLogout" class="absolute-bottom"> <q-item
v-ripple
clickable
@click="handleLogout"
class="absolute-bottom"
>
<q-item-section avatar> <q-item-section avatar>
<q-icon name="exit_to_app" color="primary" /> <q-icon
name="exit_to_app"
color="primary"
/>
</q-item-section> </q-item-section>
<q-item-section> <q-item-section>
<q-item-label class="text-uppercase text-weight-bold">{{ $t('nav_bar.logout') }}</q-item-label> <q-item-label class="text-uppercase text-weight-bold">{{ $t('nav_bar.logout') }}</q-item-label>

View File

@ -1,4 +1,7 @@
<script setup lang="ts"> <script
setup
lang="ts"
>
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
import { useAuthApi } from 'src/modules/auth/composables/use-auth-api'; import { useAuthApi } from 'src/modules/auth/composables/use-auth-api';
@ -6,16 +9,20 @@
const email = defineModel<string>('email', { default: '', }); const email = defineModel<string>('email', { default: '', });
const is_remembered = ref<boolean>(false); const is_remembered = ref<boolean>(false);
const is_employee_email = computed( () => email.value.includes('@targ')); const is_employee_email = computed(() => email.value.includes('@targ'));
</script> </script>
<template> <template>
<q-card class="rounded-15"> <q-card class="rounded-15 shadow-10">
<q-card-section class="text-center bg-primary q-pa-lg"> <q-card-section class="text-center bg-primary q-pa-lg">
<q-img src="/src/assets/logo-targo-white.svg" ratio="4.6" fit="contain" /> <q-img
src="/src/assets/logo-targo-white.svg"
ratio="4.6"
fit="contain"
/>
</q-card-section> </q-card-section>
<div class="q-pt-sm q-px-xl q-pb-lg"> <div class="q-pt-sm q-px-xl q-pb-lg ">
<q-card-section class="text-center text-uppercase"> <q-card-section class="text-center text-uppercase">
<div class="text-h6 text-weight-bold"> <div class="text-h6 text-weight-bold">
{{ $t('login.page_header') }} {{ $t('login.page_header') }}
@ -28,8 +35,14 @@
dense dense
outlined outlined
label-color="primary" label-color="primary"
:label="$t('login.email')" class="rounded-5 inset-shadow bg-blue-grey-1"
/> label-slot
input-class="text-weight-medium text-h6"
>
<template #label>
<span class="text-weight-bolder text-uppercase text-overline"> {{ $t('login.email') }} </span>
</template>
</q-input>
<q-card-section class="q-ma-none q-pa-none text-uppercase text-caption text-weight-medium"> <q-card-section class="q-ma-none q-pa-none text-uppercase text-caption text-weight-medium">
<q-toggle <q-toggle
@ -58,9 +71,16 @@
</q-form> </q-form>
<q-card-section class="row q-pt-sm"> <q-card-section class="row q-pt-sm">
<q-separator color="primary" class="col self-center"/> <q-separator
<span class="col text-primary text-weight-bolder text-center text-uppercase self-center">{{ $t('shared.misc.or') }}</span> color="primary"
<q-separator color="primary" class="col self-center"/> class="col self-center"
/>
<span class="col text-primary text-weight-bolder text-center text-uppercase self-center">{{
$t('shared.misc.or') }}</span>
<q-separator
color="primary"
class="col self-center"
/>
</q-card-section> </q-card-section>
<q-card-section class="column q-px-sm q-pt-none"> <q-card-section class="column q-px-sm q-pt-none">
@ -73,7 +93,10 @@
:label="$t('login.button.facebook')" :label="$t('login.button.facebook')"
class="full-width row q-mb-sm" class="full-width row q-mb-sm"
> >
<q-tooltip anchor="top middle" class="bg-primary">{{$t('login.tooltip.coming_soon')}}</q-tooltip> <q-tooltip
anchor="top middle"
class="bg-primary"
>{{ $t('login.tooltip.coming_soon') }}</q-tooltip>
</q-btn> </q-btn>
<q-slide-transition> <q-slide-transition>
<div v-if="is_employee_email"> <div v-if="is_employee_email">

View File

@ -1,33 +0,0 @@
<script setup lang="ts">
import { useAuthApi } from '../composables/use-auth-api';
import { useRouter } from 'vue-router';
const auth_api = useAuthApi();
const router = useRouter();
const setBypassUser = (bypassRole: string) => {
auth_api.setUser(bypassRole);
router.push({ name: 'dashboard' }).catch( err => {
console.error('Router navigation failed: ', err);
});
};
</script>
<template>
<q-card class="absolute-bottom-right q-ma-sm">
<q-card-section class="q-pa-sm text-uppercase text-center"> impersonate </q-card-section>
<q-card-actions vertical>
<q-btn
v-for="role, index in [ 'supervisor', 'accounting', 'human_resources', 'employee' ]"
:key="index"
push
color="primary"
text-color="white"
:label="role"
class="text-uppercase"
@click="setBypassUser(role)"
/>
</q-card-actions>
</q-card>
</template>

View File

@ -3,8 +3,6 @@ import { useAuthStore } from "../../../stores/auth-store";
export const useAuthApi = () => { export const useAuthApi = () => {
const authStore = useAuthStore(); const authStore = useAuthStore();
const login = () => { const login = () => {
authStore.login(); authStore.login();
}; };
@ -17,19 +15,9 @@ export const useAuthApi = () => {
authStore.logout(); authStore.logout();
}; };
const isAuthorizedUser = () => {
return authStore.isAuthorizedUser;
};
const setUser = (bypassRole: string) => {
authStore.setUser(bypassRole);
}
return { return {
login, login,
oidcLogin, oidcLogin,
logout, logout,
isAuthorizedUser,
setUser,
}; };
}; };

View File

@ -1,5 +1,6 @@
/* eslint-disable */ /* eslint-disable */
import { api } from 'src/boot/axios'; import { api } from 'src/boot/axios';
import type { User } from 'src/modules/shared/models/user.models';
export const AuthService = { export const AuthService = {
// Will likely be deprecated and relegated to Authentik // Will likely be deprecated and relegated to Authentik
@ -7,16 +8,6 @@ export const AuthService = {
//TODO: OIDC customer sign-in, eventually //TODO: OIDC customer sign-in, eventually
}, },
oidcLogin: (): Window | null => {
window.addEventListener('message', (event) => {
if (event.data.type === 'authSuccess') {
//some kind of logic here to set user in store
}
})
return window.open('http://localhost:3000/auth/v1/login', 'authPopup', 'width=600,height=800');
},
logout: () => { logout: () => {
// TODO: logout logic // TODO: logout logic
api.post('/auth/logout') api.post('/auth/logout')
@ -27,8 +18,8 @@ export const AuthService = {
api.post('/auth/refresh') api.post('/auth/refresh')
}, },
getProfile: () => { getProfile: async (): Promise<User> => {
// TODO: user info fetch logic const response = await api.get('/auth/me');
api.get('/auth/me') return response.data;
}, },
}; };

View File

@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import type { EmployeeListTableItem } from 'src/modules/employee-list/types/employee-list-table-interface'; import type { EmployeeProfile } from 'src/modules/employee-list/models/employee-profile.models';
// const getEmployeeAvatar = (first_name: string, last_name: string) => { // const getEmployeeAvatar = (first_name: string, last_name: string) => {
// // add logic here to see if user has an avatar image and return that instead of initials // // add logic here to see if user has an avatar image and return that instead of initials
@ -7,7 +7,7 @@
// }; // };
const { row } = defineProps<{ const { row } = defineProps<{
row: EmployeeListTableItem row: EmployeeProfile
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
onProfileClick: [email: string] onProfileClick: [email: string]
@ -25,7 +25,7 @@
<q-avatar <q-avatar
color="primary" color="primary"
size="8em" size="8em"
class="shadow-3" class="shadow-3 q-mb-md"
> >
<img <img
src="src/assets/targo-default-avatar.png" src="src/assets/targo-default-avatar.png"

View File

@ -3,9 +3,8 @@
import { useEmployeeListApi } from 'src/modules/employee-list/composables/use-employee-api'; import { useEmployeeListApi } from 'src/modules/employee-list/composables/use-employee-api';
import { useEmployeeStore } from 'src/stores/employee-store'; import { useEmployeeStore } from 'src/stores/employee-store';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import SupervisorCrewTableItem from './supervisor-crew-table-item.vue'; import EmployeeListTableItem from 'src/modules/employee-list/components/employee-list-table-item.vue';
import type { EmployeeProfile } from 'src/modules/employee-list/models/employee-profile.models';
import type { EmployeeListTableItem } from '../../types/employee-list-table-interface';
import type { QTableColumn } from 'quasar'; import type { QTableColumn } from 'quasar';
const employee_list_api = useEmployeeListApi(); const employee_list_api = useEmployeeListApi();
@ -17,7 +16,7 @@
const is_grid_mode = ref(true); const is_grid_mode = ref(true);
const pagination = ref({ rowsPerPage: 0 }); const pagination = ref({ rowsPerPage: 0 });
const employee_list_columns = computed((): QTableColumn<EmployeeListTableItem>[] => [ const employee_list_columns = computed((): QTableColumn<EmployeeProfile>[] => [
{name: 'first_name', label: t('employee_list.table.first_name'), field: 'first_name', align: 'left'}, {name: 'first_name', label: t('employee_list.table.first_name'), field: 'first_name', align: 'left'},
{name: 'last_name', label: t('employee_list.table.last_name'), field: 'last_name', align: 'left'}, {name: 'last_name', label: t('employee_list.table.last_name'), field: 'last_name', align: 'left'},
{name: 'email', label: t('employee_list.table.email'), field: 'email', align: 'left'}, {name: 'email', label: t('employee_list.table.email'), field: 'email', align: 'left'},
@ -34,7 +33,7 @@
</script> </script>
<template> <template>
<div class="q-pa-lg col"> <div class="q-pa-lg">
<q-table <q-table
dense dense
flat flat
@ -49,7 +48,8 @@
:rows-per-page-options="[0]" :rows-per-page-options="[0]"
:filter="filter" :filter="filter"
class="q-pa-md bg-transparent" class="q-pa-md bg-transparent"
:class="is_grid_mode ? '': 'my-sticky-header-table'" :class="is_grid_mode ? '': 'sticky-header-table'"
:style="$q.screen.lt.md ? '' : 'width: 80vw;'"
:table-class="$q.dark.isActive ? 'q-px-md q-py-none q-mx-md rounded-10 bg-dark' : 'q-px-md q-py-none q-mx-md rounded-10 bg-white'" :table-class="$q.dark.isActive ? 'q-px-md q-py-none q-mx-md rounded-10 bg-dark' : 'q-px-md q-py-none q-mx-md rounded-10 bg-white'"
color="primary" color="primary"
table-header-class="text-primary text-uppercase" table-header-class="text-primary text-uppercase"
@ -62,7 +62,7 @@
@row-click="() => console.log('click!')" @row-click="() => console.log('click!')"
> >
<template v-slot:item="props"> <template v-slot:item="props">
<SupervisorCrewTableItem :row="props.row"/> <EmployeeListTableItem :row="props.row"/>
</template> </template>
<template v-slot:top> <template v-slot:top>
@ -126,7 +126,7 @@
</template> </template>
<style lang="sass"> <style lang="sass">
.my-sticky-header-table .sticky-header-table
thead tr:first-child th thead tr:first-child th
background-color: var(--q-dark) background-color: var(--q-dark)
margin-top: none margin-top: none

View File

@ -0,0 +1,14 @@
import { api } from 'src/boot/axios';
import type { EmployeeProfile } from 'src/modules/employee-list/models/employee-profile.models';
export const EmployeeListService = {
getEmployeeList: async (): Promise<EmployeeProfile[]> => {
const response = await api.get<EmployeeProfile[]>('/employees/employee-list')
return response.data;
},
getEmployeeDetails: async (email: string): Promise<EmployeeProfile> => {
const response = await api.get<EmployeeProfile>('employees/profile/' + email);
return response.data;
},
};

View File

@ -1,17 +0,0 @@
// /* eslint-disable */
import { api } from 'src/boot/axios';
import type { EmployeeListTableItem } from '../types/employee-list-table-interface';
import type { EmployeeProfile } from '../types/employee-profile-interface';
export const EmployeeListService = {
getEmployeeList: async (): Promise<EmployeeListTableItem[]> => {
const response = await api.get<EmployeeListTableItem[]>('/employees/employee-list')
return response.data;
},
getEmployeeDetails: async (email: string): Promise<EmployeeProfile> => {
const response = await api.get<EmployeeProfile>('employees/profile/' + email);
return response.data;
},
};

View File

@ -1,8 +0,0 @@
export interface EmployeeListTableItem {
first_name: string;
last_name: string;
email: string;
supervisor_full_name: string | null;
company_name: number;
job_title: string;
};

View File

@ -0,0 +1,73 @@
<script
setup
lang="ts"
>
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 MenuPanelPreferences from 'src/modules/profile/components/shared/menu-panel-preferences.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';
const PanelNames = {
PERSONAL_INFO: 'personal_info',
EMPLOYEE_INFO: 'employee_info',
PREFERENCES: 'references',
};
const employee_profile = defineModel<EmployeeProfile>({ default: default_employee_profile });
</script>
<template>
<q-card
flat
class="rounded-5 bg-transparent q-pa-none"
>
<MenuTemplate
:first-name="employee_profile.first_name"
:last-name="employee_profile.last_name"
:initial-menu="PanelNames.PERSONAL_INFO"
>
<template #tabs>
<q-tab
:name='PanelNames.PERSONAL_INFO'
icon='person_outline'
:label="$q.screen.lt.md ? '' : $t('profile.personal.tab_title')"
/>
<q-tab
:name="PanelNames.EMPLOYEE_INFO"
icon="work_outline"
:label="$q.screen.lt.md ? '' : $t('profile.employee.tab_title')"
/>
<q-tab
:name="PanelNames.PREFERENCES"
icon="display_settings"
:label="$q.screen.lt.md ? '' : $t('profile.preferences.tab_title')"
/>
</template>
<template #panels>
<q-tab-panel
:name="PanelNames.PERSONAL_INFO"
class="q-pa-none"
>
<MenuPanelPersonal v-model="employee_profile" />
</q-tab-panel>
<q-tab-panel
:name="PanelNames.EMPLOYEE_INFO"
class="q-pa-none"
>
<MenuPanelEmployee v-model="employee_profile" />
</q-tab-panel>
<q-tab-panel
:name="PanelNames.PREFERENCES"
class="q-pa-none"
>
<MenuPanelPreferences />
</q-tab-panel>
</template>
</MenuTemplate>
</q-card>
</template>

View File

@ -1,37 +1,39 @@
<script setup lang="ts"> <script
setup
lang="ts"
>
import { ref } from 'vue'; import { ref } from 'vue';
import { deepEqual } from 'src/utils/deep-equal'; import { deepEqual } from 'src/utils/deep-equal';
import ProfileInputField from 'src/modules/profile/components/shared/profile-panel-input-field.vue'; import MenuPanelInputField from 'src/modules/profile/components/shared/menu-panel-input-field.vue';
import ProfileSelectField from 'src/modules/profile/components/shared/profile-panel-select-field.vue'; import MenuPanelSelectField from 'src/modules/profile/components/shared/menu-panel-select-field.vue';
import { type EmployeeProfile } from 'src/modules/employee-list/types/employee-profile-interface'; import type { EmployeeProfile } from 'src/modules/employee-list/models/employee-profile.models';
import { unwrapAndClone } from 'src/utils/unwrap-and-clone';
const { employeeProfile } = defineProps<{ const employee_profile = defineModel<EmployeeProfile>({required: true});
employeeProfile: EmployeeProfile;
}>();
let initial_info: EmployeeProfile = employeeProfile;
let employee_form_data = ref<EmployeeProfile>({ ...employeeProfile });
const is_editing = ref<boolean>(false); const is_editing = ref<boolean>(false);
let initial_info: EmployeeProfile = unwrapAndClone(employee_profile.value);
const supervisor_options = [{ label: 'AAA', value: '1' }, { label: 'BBB', value: '2' }, { label: 'CCC', value: '3' }, { label: 'DDD', value: '4' }]; const supervisor_options = [{ label: 'AAA', value: '1' }, { label: 'BBB', value: '2' }, { label: 'CCC', value: '3' }, { label: 'DDD', value: '4' }];
const onSubmit = () => { const onSubmit = () => {
if (!is_editing.value) { if (!is_editing.value) {
is_editing.value = true; is_editing.value = true;
console.log('clicky!');
return; return;
} }
is_editing.value = false; is_editing.value = false;
initial_info = { ...employee_form_data.value }; // update initial value for future possible resets initial_info = unwrapAndClone(employee_profile.value); // update initial value for future possible resets
if (!deepEqual(employee_form_data, initial_info)) { if (!deepEqual(employee_profile.value, initial_info)) {
// save the new data here // save the new data here
return; return;
} }
}; };
const onReset = () => { const onReset = () => {
employee_form_data = ref<EmployeeProfile>(initial_info); employee_profile.value = unwrapAndClone(initial_info);
is_editing.value = false; is_editing.value = false;
} }
</script> </script>
@ -43,14 +45,14 @@
@reset="onReset" @reset="onReset"
> >
<div :class="$q.screen.lt.md ? 'column' : 'row'"> <div :class="$q.screen.lt.md ? 'column' : 'row'">
<ProfileInputField <MenuPanelInputField
v-model="employee_form_data.job_title" v-model="employee_profile.job_title"
class="col" class="col"
:is-editing="is_editing" :is-editing="is_editing"
:label-string="$t('profile.employee.job_title')" :label-string="$t('profile.employee.job_title')"
/> />
<ProfileInputField <MenuPanelInputField
v-model="employee_form_data.company_name" v-model="employee_profile.company_name"
class="col" class="col"
:is-editing="is_editing" :is-editing="is_editing"
:label-string="$t('profile.employee.company')" :label-string="$t('profile.employee.company')"
@ -58,8 +60,8 @@
</div> </div>
<div class="q-mx-xs"> <div class="q-mx-xs">
<ProfileSelectField <MenuPanelSelectField
v-model="employee_form_data.supervisor_full_name" v-model="employee_profile.supervisor_full_name"
:options="supervisor_options" :options="supervisor_options"
:label-string="$t('profile.employee.supervisor')" :label-string="$t('profile.employee.supervisor')"
:is-editing="is_editing" :is-editing="is_editing"
@ -68,14 +70,14 @@
<div :class="$q.screen.lt.md ? 'column' : 'row'"> <div :class="$q.screen.lt.md ? 'column' : 'row'">
<ProfileInputField <MenuPanelInputField
v-model="employee_form_data.email" v-model="employee_profile.email"
class="col" class="col"
:is-editing="is_editing" :is-editing="is_editing"
:label-string="$t('profile.employee.email')" :label-string="$t('profile.employee.email')"
/> />
<ProfileInputField <MenuPanelInputField
v-model="employee_form_data.first_work_day" v-model="employee_profile.first_work_day"
readonly readonly
class="col" class="col"
type="date" type="date"
@ -84,7 +86,10 @@
/> />
</div> </div>
<div class="absolute-bottom" :class="$q.screen.lt.md ? 'column' : 'row'"> <div
class="absolute-bottom"
:class="$q.screen.lt.md ? 'column' : 'row'"
>
<q-space /> <q-space />
<q-btn <q-btn
v-if="is_editing" v-if="is_editing"
@ -100,6 +105,7 @@
push push
size="sm" size="sm"
color="primary" color="primary"
type="submit"
:icon="is_editing ? 'save_alt' : 'create'" :icon="is_editing ? 'save_alt' : 'create'"
class="q-ma-sm" class="q-ma-sm"
:label="is_editing ? $t('shared.label.save') : $t('shared.label.update')" :label="is_editing ? $t('shared.label.save') : $t('shared.label.update')"

View File

@ -1,17 +1,18 @@
<script setup lang="ts"> <script
setup
lang="ts"
>
import { ref } from 'vue'; import { ref } from 'vue';
import { deepEqual } from 'src/utils/deep-equal'; import { deepEqual } from 'src/utils/deep-equal';
import ProfileInputField from 'src/modules/profile/components/shared/profile-panel-input-field.vue'; import MenuPanelInputField from 'src/modules/profile/components/shared/menu-panel-input-field.vue';
import { type EmployeeProfile } from 'src/modules/employee-list/types/employee-profile-interface'; import type { EmployeeProfile } from 'src/modules/employee-list/models/employee-profile.models';
import { unwrapAndClone } from 'src/utils/unwrap-and-clone';
const { employeeProfile } = defineProps<{ const employee_profile = defineModel<EmployeeProfile>({required: true});
employeeProfile: EmployeeProfile;
}>();
const is_editing = ref<boolean>(false); const is_editing = ref<boolean>(false);
let initial_info: EmployeeProfile = employeeProfile; let initial_info: EmployeeProfile = unwrapAndClone(employee_profile.value);
const personal_form_data = ref<EmployeeProfile>({ ...employeeProfile });
const onSubmit = () => { const onSubmit = () => {
if (!is_editing.value) { if (!is_editing.value) {
@ -20,16 +21,16 @@
} }
is_editing.value = false; is_editing.value = false;
initial_info = { ...personal_form_data.value }; // update initial value for future possible resets initial_info = unwrapAndClone(employee_profile.value); // update initial value for future possible resets
if (!deepEqual(personal_form_data.value, initial_info)) { if (!deepEqual(employee_profile.value, initial_info)) {
// save the new data here // save the new data here
return; return;
} }
}; };
const onReset = () => { const onReset = () => {
personal_form_data.value= { ...initial_info }; employee_profile.value = unwrapAndClone(initial_info);
is_editing.value = false; is_editing.value = false;
} }
</script> </script>
@ -41,15 +42,15 @@
@reset="onReset" @reset="onReset"
> >
<div :class="$q.screen.lt.md ? 'column' : 'row'"> <div :class="$q.screen.lt.md ? 'column' : 'row'">
<ProfileInputField <MenuPanelInputField
v-model="personal_form_data.first_name" v-model="employee_profile.first_name"
type="text" type="text"
class="col" class="col"
:is-editing="is_editing" :is-editing="is_editing"
:label-string="$t('profile.personal.first_name')" :label-string="$t('profile.personal.first_name')"
/> />
<ProfileInputField <MenuPanelInputField
v-model="personal_form_data.last_name" v-model="employee_profile.last_name"
class="col" class="col"
type="text" type="text"
:is-editing="is_editing" :is-editing="is_editing"
@ -58,15 +59,15 @@
</div> </div>
<div :class="$q.screen.lt.md ? 'column' : 'row'"> <div :class="$q.screen.lt.md ? 'column' : 'row'">
<ProfileInputField <MenuPanelInputField
v-model="personal_form_data.phone_number" v-model="employee_profile.phone_number"
class="col" class="col"
type="text" type="text"
:is-editing="is_editing" :is-editing="is_editing"
:label-string="$t('profile.personal.phone_number')" :label-string="$t('profile.personal.phone_number')"
/> />
<ProfileInputField <MenuPanelInputField
v-model="personal_form_data.birth_date" v-model="employee_profile.birth_date"
class="col" class="col"
mask="#### / ## / ##" mask="#### / ## / ##"
hint="ex: 1970 / 01 / 01" hint="ex: 1970 / 01 / 01"
@ -76,8 +77,8 @@
</div> </div>
<div :class="$q.screen.lt.md ? 'column' : 'row'"> <div :class="$q.screen.lt.md ? 'column' : 'row'">
<ProfileInputField <MenuPanelInputField
v-model="personal_form_data.residence" v-model="employee_profile.residence"
class="col" class="col"
:is-editing="is_editing" :is-editing="is_editing"
:label-string="$t('profile.personal.address')" :label-string="$t('profile.personal.address')"
@ -85,7 +86,10 @@
/> />
</div> </div>
<div class="absolute-bottom" :class="$q.screen.lt.md ? 'column' : 'row'"> <div
class="absolute-bottom"
:class="$q.screen.lt.md ? 'column' : 'row'"
>
<q-space /> <q-space />
<q-btn <q-btn
v-if="is_editing" v-if="is_editing"

View File

@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue'; import { ref } from 'vue';
import ProfileHeader from 'src/modules/profile/components/shared/profile-header.vue'; import MenuHeader from 'src/modules/profile/components/shared/menu-header.vue';
const { firstName, lastName, initialMenu } = defineProps<{ const { firstName, lastName, initialMenu } = defineProps<{
firstName: string; firstName: string;
@ -16,7 +16,7 @@
:class="$q.screen.lt.md ? 'column no-wrap' : 'row'" :class="$q.screen.lt.md ? 'column no-wrap' : 'row'"
:style="$q.screen.lt.md ? 'width: 90vw;' : 'width: 40vw;'" :style="$q.screen.lt.md ? 'width: 90vw;' : 'width: 40vw;'"
> >
<ProfileHeader <MenuHeader
:user-first-name="firstName" :user-first-name="firstName"
:user-last-name="lastName" :user-last-name="lastName"
/> />

View File

@ -1,60 +0,0 @@
<script setup lang="ts">
import PanelInfoPersonal from 'src/modules/profile/components/employee/profile-panel-info-personal.vue';
import PanelInfoEmployee from 'src/modules/profile/components/employee/profile-panel-info-employee.vue';
import PanelPreferences from 'src/modules/profile/components/shared/profile-panel-preferences.vue';
import ProfileTabMenuTemplate from 'src/modules/profile/components/shared/profile-tab-menu-template.vue';
import { default_employee_profile, type EmployeeProfile } from 'src/modules/employee-list/types/employee-profile-interface';
const PanelNames = {
PERSONAL_INFO: 'personal_info',
EMPLOYEE_INFO: 'employee_info',
PREFERENCES: 'references',
};
const { employeeProfile = default_employee_profile } = defineProps<{
employeeProfile?: EmployeeProfile | undefined;
}>();
</script>
<template>
<q-card flat class="rounded-5 bg-transparent q-pa-none">
<ProfileTabMenuTemplate
:first-name="employeeProfile.first_name"
:last-name="employeeProfile.last_name"
:initial-menu="PanelNames.PERSONAL_INFO"
>
<template #tabs>
<q-tab
:name='PanelNames.PERSONAL_INFO'
icon='person_outline'
:label="$q.screen.lt.md ? '' : $t('profile.personal.tab_title')"
/>
<q-tab
:name="PanelNames.EMPLOYEE_INFO"
icon="work_outline"
:label="$q.screen.lt.md ? '' : $t('profile.employee.tab_title')"
/>
<q-tab
:name="PanelNames.PREFERENCES"
icon="display_settings"
:label="$q.screen.lt.md ? '' : $t('profile.preferences.tab_title')"
/>
</template>
<template #panels>
<q-tab-panel :name="PanelNames.PERSONAL_INFO" class="q-pa-none">
<PanelInfoPersonal :employee-profile="employeeProfile" />
</q-tab-panel>
<q-tab-panel :name="PanelNames.EMPLOYEE_INFO" class="q-pa-none">
<PanelInfoEmployee :employee-profile="employeeProfile" />
</q-tab-panel>
<q-tab-panel :name="PanelNames.PREFERENCES" class="q-pa-none">
<PanelPreferences />
</q-tab-panel>
</template>
</ProfileTabMenuTemplate>
</q-card>
</template>

View File

@ -1,4 +1,9 @@
<script setup lang="ts"> <script
setup
lang="ts"
>
import { date } from 'quasar';
const { title, startDate = "", endDate = "" } = defineProps<{ const { title, startDate = "", endDate = "" } = defineProps<{
title: string; title: string;
startDate?: string; startDate?: string;
@ -17,13 +22,13 @@
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-primary text-weight-bold text-h6">
{{ $d(new Date(startDate), 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-primary text-weight-bold text-h6">
{{ $d(new Date(endDate), date_format_options) }} {{ $d(date.extractDate(endDate, 'YYYY-MM-DD'), date_format_options) }}
</div> </div>
</div> </div>
</div> </div>

View File

@ -3,10 +3,14 @@
import { date} from 'quasar'; import { date} from 'quasar';
import { useTimesheetStore } from 'src/stores/timesheet-store'; import { useTimesheetStore } from 'src/stores/timesheet-store';
const NEXT = 1;
const PREVIOUS = -1;
const timesheet_store = useTimesheetStore(); const timesheet_store = useTimesheetStore();
const is_showing_calendar_picker = ref(false); const is_showing_calendar_picker = ref(false);
const calendar_date = ref(date.formatDate( Date.now(), 'YYYY-MM-DD' )); const calendar_date = ref(date.formatDate( Date.now(), 'YYYY-MM-DD' ));
const is_disabled = computed(() => timesheet_store.pay_period === undefined);
const emit = defineEmits<{ const emit = defineEmits<{
'date-selected': [ value: string ] 'date-selected': [ value: string ]
@ -15,8 +19,8 @@
}>(); }>();
const is_previous_pay_period_limit = computed( ()=> const is_previous_pay_period_limit = computed( ()=>
timesheet_store.pay_period.pay_year === 2024 && ( timesheet_store.pay_period?.pay_year === 2024 &&
timesheet_store.pay_period.pay_period_no <= 1 timesheet_store.pay_period?.pay_period_no <= 1 ) ?? false
); );
const onDateSelected = (value: string) => { const onDateSelected = (value: string) => {
@ -24,6 +28,33 @@
is_showing_calendar_picker.value = false; is_showing_calendar_picker.value = false;
emit('date-selected', value); emit('date-selected', value);
}; };
const getNextOrPreviousPayPeriod = (direction: number) => {
const pay_period = timesheet_store.pay_period;
if (!pay_period) return;
pay_period.pay_period_no += direction;
if (pay_period.pay_period_no > 26) {
pay_period.pay_period_no = 1;
pay_period.pay_year += direction;
}
if (pay_period.pay_period_no < 1) {
pay_period.pay_period_no = 26;
pay_period.pay_year += direction;
}
};
const getNextPayPeriod = () => {
getNextOrPreviousPayPeriod(NEXT);
emit('pressed-next-button');
}
const getPreviousPayPeriod = () => {
getNextOrPreviousPayPeriod(PREVIOUS);
emit('pressed-previous-button');
};
</script> </script>
<template> <template>
@ -33,8 +64,8 @@
push rounded push rounded
icon="keyboard_arrow_left" icon="keyboard_arrow_left"
color="primary" color="primary"
@click="emit('pressed-previous-button')" @click="getPreviousPayPeriod"
:disable="is_previous_pay_period_limit || timesheet_store.is_loading" :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"
> >
<q-tooltip <q-tooltip
@ -52,7 +83,7 @@
icon="calendar_month" icon="calendar_month"
color="primary" color="primary"
@click="is_showing_calendar_picker = true" @click="is_showing_calendar_picker = true"
:disable="timesheet_store.is_loading" :disable="timesheet_store.is_loading || is_disabled"
class="q-px-xl" class="q-px-xl"
> >
<q-tooltip <q-tooltip
@ -69,8 +100,8 @@
push rounded push rounded
icon="keyboard_arrow_right" icon="keyboard_arrow_right"
color="primary" color="primary"
@click="emit('pressed-next-button')" @click="getNextPayPeriod"
:disable="timesheet_store.is_loading" :disable="timesheet_store.is_loading || is_disabled"
class="q-ml-sm q-px-sm" class="q-ml-sm q-px-sm"
> >
<q-tooltip <q-tooltip

View File

@ -1,6 +1,15 @@
export interface User { export interface User {
firstName: string; first_name: string;
lastName: string; last_name: string;
email: string; email: string;
role: string; role: UserRole;
} }
export type UserRole = 'ADMIN' |'SUPERVISOR' | 'HR' | 'ACCOUNTING' | 'EMPLOYEE' | 'DEALER' | 'CUSTOMER' | 'GUEST';
export const CAN_APPROVE_PAY_PERIODS: UserRole[] = [
'ADMIN',
'SUPERVISOR',
'HR',
'ACCOUNTING',
]

View File

@ -1,128 +0,0 @@
<script
setup
lang="ts"
>
import { ref } from 'vue';
import { useTimesheetStore } from 'src/stores/timesheet-store';
import DetailedDialogChartHoursWorked from 'src/modules/timesheet-approval/components/details-crud-dialog-chart-hours-worked.vue';
import DetailedDialogChartShiftTypes from 'src/modules/timesheet-approval/components/details-crud-dialog-chart-shift-types.vue';
import DetailedDialogChartExpenses from 'src/modules/timesheet-approval/components/details-crud-dialog-chart-expenses.vue';
import TimesheetWrapper from 'src/modules/timesheets/components/timesheet-wrapper.vue';
defineProps<{
employeeEmail: string;
}>();
const dialog_model = defineModel<boolean>('dialog', { default: false });
const timesheet_store = useTimesheetStore();
// const timesheet_store = useTimesheetStore();
const is_showing_graph = ref(true);
</script>
<template>
<q-dialog
v-model="dialog_model"
full-width
transition-show="jump-down"
transition-hide="jump-down"
>
<!-- loader -->
<q-card
v-if="timesheet_store.is_loading"
class="column flex-center text-center"
>
<q-spinner
color="primary"
size="5em"
:thickness="10"
class="col-auto"
/>
<div class="col-auto text-primary text-h6 text-weight-bold text-center ">
{{ $t('shared.loading') }}
</div>
</q-card>
<q-card
v-else
class="q-pa-sm shadow-12 rounded-15 column no-wrap relative"
:style="$q.screen.lt.md ? '' : 'width: 60vw !important;'"
>
<!-- employee name -->
<q-card-section
class="text-h5 text-weight-bolder text-center text-primary q-pa-none text-uppercase col-auto"
>
<span>{{ timesheet_store.pay_period_details.employee_full_name }}</span>
<q-separator
spaced
size="2px"
/>
<q-card-actions
align="center"
class="q-pa-none"
>
<q-btn-toggle
v-model="is_showing_graph"
color="white"
text-color="primary"
toggle-color="primary"
:options="[
{ icon: 'bar_chart', value: true },
{ icon: 'edit', value: false },
]"
/>
</q-card-actions>
</q-card-section>
<!-- employee timesheet for supervisor editting -->
<q-card-section
v-if="!is_showing_graph"
class="q-pa-none"
>
<!-- list of shifts -->
<q-card-section
:horizontal="$q.screen.gt.sm"
class="q-pa-none rounded-10"
>
<TimesheetWrapper
dense
:employee-email="employeeEmail"
/>
</q-card-section>
</q-card-section>
<!-- employee timesheet details with chart -->
<q-card-section
v-if="is_showing_graph"
class="q-pa-md col column full-width no-wrap"
>
<q-card-section
:horizontal="!$q.screen.lt.md"
class="q-pa-none col no-wrap"
style="min-height: 300px;"
>
<DetailedDialogChartHoursWorked class="col-7" />
<q-separator
spaced
:vertical="!$q.screen.lt.md"
/>
<div class="column col justify-center no-wrap q-pa-none">
<DetailedDialogChartShiftTypes class="col-5" />
<q-separator
spaced
:vertical="!$q.screen.lt.md"
/>
<DetailedDialogChartExpenses class="col" />
</div>
</q-card-section>
</q-card-section>
</q-card>
</q-dialog>
</template>

View File

@ -2,6 +2,8 @@
setup setup
lang="ts" lang="ts"
> >
/* eslint-disable */
import { ref } from 'vue'; import { ref } from 'vue';
import { Bar } from 'vue-chartjs'; import { Bar } from 'vue-chartjs';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
@ -23,27 +25,28 @@
const expenses_labels = ref<string[]>([]); const expenses_labels = ref<string[]>([]);
const getExpensesData = (): ChartData<'bar'> => { const getExpensesData = (): ChartData<'bar'> => {
const all_days = timesheet_store.pay_period_details.weeks.flatMap(week => Object.values(week.expenses)); // const all_days = timesheet_store.pay_period_details.weeks.flatMap(week => Object.values(week.expenses));
const all_days_dates = timesheet_store.pay_period_details.weeks.flatMap(week => Object.values(week.shifts)) // const all_days_dates = timesheet_store.pay_period_details.weeks.flatMap(week => Object.values(week.shifts))
const all_costs = all_days.map(day => day.total_expenses); // const all_costs = all_days.map(day => day.total_expenses);
const all_mileage = all_days.map(day => day.total_mileage); // console.log('costs, ', all_costs);
// const all_mileage = all_days.map(day => day.total_mileage);
expenses_dataset.value = [ // expenses_dataset.value = [
{ // {
label: t('timesheet_approvals.table.expenses'), // label: t('timesheet_approvals.table.expenses'),
data: all_costs, // data: all_costs,
backgroundColor: getComputedStyle(document.body).getPropertyValue('--q-primary').trim(), // backgroundColor: getComputedStyle(document.body).getPropertyValue('--q-primary').trim(),
}, // },
{ // {
label: t('timesheet_approvals.table.mileage'), // label: t('timesheet_approvals.table.mileage'),
data: all_mileage, // data: all_mileage,
backgroundColor: getComputedStyle(document.body).getPropertyValue('--q-info').trim(), // backgroundColor: getComputedStyle(document.body).getPropertyValue('--q-info').trim(),
} // }
] // ]
expenses_labels.value = all_days_dates.map(day => day.short_date); // expenses_labels.value = all_days_dates.map(day => day.short_date);
return { return {
datasets: expenses_dataset.value, datasets: expenses_dataset.value,

View File

@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
/* eslint-disable */
import { ref } from 'vue'; import { ref } from 'vue';
import { colors } from 'quasar'; import { colors } from 'quasar';
import { Bar } from 'vue-chartjs'; import { Bar } from 'vue-chartjs';
@ -15,44 +16,44 @@
ChartJS.defaults.maintainAspectRatio = false; ChartJS.defaults.maintainAspectRatio = false;
ChartJS.defaults.color = $q.dark.isActive ? '#F5F5F5' : '#616161'; ChartJS.defaults.color = $q.dark.isActive ? '#F5F5F5' : '#616161';
const { pay_period_details } = useTimesheetStore(); const timesheet_store = useTimesheetStore();
const hours_worked_labels = ref<string[]>([]); const hours_worked_labels = ref<string[]>([]);
const hours_worked_dataset = ref<ChartDataset<'bar'>[]>([]); const hours_worked_dataset = ref<ChartDataset<'bar'>[]>([]);
const getHoursWorkedData = (): ChartData<'bar'> => { const getHoursWorkedData = (): ChartData<'bar'> => {
const all_days = pay_period_details.weeks.flatMap( week => Object.values(week.shifts)); // const all_days = timesheet_store.pay_period_details.weeks.flatMap( week => Object.values(week.shifts));
const datasetConfig = [ // const datasetConfig = [
{ // {
key: 'regular_hours', // key: 'regular_hours',
label: t('shared.shift_type.regular'), // label: t('shared.shift_type.regular'),
color: colors.getPaletteColor('green-5'), // color: colors.getPaletteColor('green-5'),
}, // },
{ // {
key: 'evening_hours', // key: 'evening_hours',
label: t('shared.shift_type.evening'), // label: t('shared.shift_type.evening'),
color: colors.getPaletteColor('green-9'), // color: colors.getPaletteColor('green-9'),
}, // },
{ // {
key: 'emergency_hours', // key: 'emergency_hours',
label: t('shared.shift_type.emergency'), // label: t('shared.shift_type.emergency'),
color: getComputedStyle(document.body).getPropertyValue('--q-warning').trim(), // color: getComputedStyle(document.body).getPropertyValue('--q-warning').trim(),
}, // },
{ // {
key: 'overtime_hours', // key: 'overtime_hours',
label: t('shared.shift_type.overtime'), // label: t('shared.shift_type.overtime'),
color: getComputedStyle(document.body).getPropertyValue('--q-negative').trim(), // color: getComputedStyle(document.body).getPropertyValue('--q-negative').trim(),
}, // },
] as const; // ] as const;
hours_worked_dataset.value = datasetConfig.map(cfg => ({ // hours_worked_dataset.value = datasetConfig.map(cfg => ({
label: cfg.label, // label: cfg.label,
data: all_days.map(day => day[ cfg.key ]), // data: all_days.map(day => day[ cfg.key ]),
backgroundColor: cfg.color, // backgroundColor: cfg.color,
})); // }));
hours_worked_labels.value = all_days.map(day => day.short_date); // hours_worked_labels.value = all_days.map(day => day.short_date);
return { return {

View File

@ -2,6 +2,7 @@
setup setup
lang="ts" lang="ts"
> >
/* eslint-disable */
import { ref } from 'vue'; import { ref } from 'vue';
import { colors } from 'quasar'; import { colors } from 'quasar';
import { useQuasar } from 'quasar'; import { useQuasar } from 'quasar';
@ -22,27 +23,27 @@
const shift_type_totals = ref<ChartDataset<'doughnut'>[]>([{ data: [40, 0, 2, 5], }]); const shift_type_totals = ref<ChartDataset<'doughnut'>[]>([{ data: [40, 0, 2, 5], }]);
shift_type_totals.value = [{ // shift_type_totals.value = [{
data: [ // data: [
current_pay_period_overview.regular_hours, // current_pay_period_overview.regular_hours,
current_pay_period_overview.evening_hours, // current_pay_period_overview.other_hours.evening_hours,
current_pay_period_overview.emergency_hours, // current_pay_period_overview.other_hours.emergency_hours,
current_pay_period_overview.overtime_hours, // current_pay_period_overview.other_hours.overtime_hours,
], // ],
backgroundColor: [ // backgroundColor: [
colors.getPaletteColor('green-5'), // Regular // colors.getPaletteColor('green-5'), // Regular
colors.getPaletteColor('green-9'), // Evening // colors.getPaletteColor('green-9'), // Evening
getComputedStyle(document.body).getPropertyValue('--q-warning').trim(), // Emergency // getComputedStyle(document.body).getPropertyValue('--q-warning').trim(), // Emergency
getComputedStyle(document.body).getPropertyValue('--q-negative').trim(), // Overtime // getComputedStyle(document.body).getPropertyValue('--q-negative').trim(), // Overtime
] // ]
}]; // }];
shift_type_labels.value = [ // shift_type_labels.value = [
current_pay_period_overview.regular_hours.toString() + 'h', // current_pay_period_overview.regular_hours.toString() + 'h',
current_pay_period_overview.evening_hours.toString() + 'h', // current_pay_period_overview.other_hours.evening_hours.toString() + 'h',
current_pay_period_overview.emergency_hours.toString() + 'h', // current_pay_period_overview.other_hours.emergency_hours.toString() + 'h',
current_pay_period_overview.overtime_hours.toString() + 'h', // current_pay_period_overview.other_hours.overtime_hours.toString() + 'h',
] // ]
const data = { const data = {

View File

@ -0,0 +1,88 @@
<script
setup
lang="ts"
>
/* eslint-disable */
import { provide, ref } from 'vue';
import { useTimesheetStore } from 'src/stores/timesheet-store';
import DetailedDialogChartHoursWorked from 'src/modules/timesheet-approval/components/details-crud-dialog-chart-hours-worked.vue';
import DetailedDialogChartShiftTypes from 'src/modules/timesheet-approval/components/details-crud-dialog-chart-shift-types.vue';
import DetailedDialogChartExpenses from 'src/modules/timesheet-approval/components/details-crud-dialog-chart-expenses.vue';
import TimesheetWrapper from 'src/modules/timesheets/components/timesheet-wrapper.vue';
import ExpenseCrudDialogList from 'src/modules/timesheets/components/expense-crud-dialog-list.vue';
const { employeeEmail } = defineProps<{
employeeEmail: string;
}>();
const dialog_model = defineModel<boolean>('dialog', { default: false });
const timesheet_store = useTimesheetStore();
const render_key = ref(1);
provide('employeeEmail', employeeEmail);
</script>
<template>
<q-dialog
v-model="dialog_model"
full-width
full-height
transition-show="jump-down"
transition-hide="jump-down"
@show="render_key += 1"
>
<q-card
class="q-pa-sm shadow-12 rounded-15 column no-wrap relative"
:style="$q.screen.lt.md ? '' : 'width: 60vw !important;'"
>
<!-- employee name -->
<q-card-section
class="text-h5 text-weight-bolder text-center bg-primary q-pa-none text-uppercase text-white col-auto"
>
<span>TODO: Name goes here</span>
</q-card-section>
<!-- employee pay period details using chart -->
<q-card-section
:horizontal="!$q.screen.lt.md"
class=" col-auto q-px-sm no-wrap"
>
<DetailedDialogChartHoursWorked
:key="render_key"
class="col"
/>
<DetailedDialogChartShiftTypes
:key="render_key + 1"
class="col-2 q-ma-lg"
/>
<DetailedDialogChartExpenses
:key="render_key + 2"
class="col"
/>
</q-card-section>
<q-card-section class="col-auto">
<q-separator />
<ExpenseCrudDialogList
horizontal
:employee-email="employeeEmail"
/>
<q-separator />
</q-card-section>
<!-- list of shifts -->
<q-card-section
:horizontal="$q.screen.gt.sm"
class="col-auto q-px-sm rounded-5 no-wrap"
>
<TimesheetWrapper
dense
:employee-email="employeeEmail"
/>
</q-card-section>
</q-card>
</q-dialog>
</template>

View File

@ -1,21 +1,24 @@
<script setup lang="ts"> <script
import type { PayPeriodOverview } from 'src/modules/timesheet-approval/models/pay-period-overview.models'; setup
lang="ts"
>
import type { TimesheetOverview } from 'src/modules/timesheet-approval/models/timesheet-overview.models';
const modelApproval = defineModel<boolean>(); const modelApproval = defineModel<boolean>();
const { row } = defineProps<{ row: PayPeriodOverview; }>(); const { row } = defineProps<{ row: TimesheetOverview; }>();
const emit = defineEmits<{ const emit = defineEmits<{
'clickDetails': [overview: PayPeriodOverview]; 'clickDetails': [overview: TimesheetOverview];
}>(); }>();
const stack_label_class = "text-weight-bold text-primary text-uppercase text-caption q-pa-none q-my-none ellipsis";
</script> </script>
<template> <template>
<div class="q-px-sm q-pb-sm q-mt-sm col-xs-12 col-sm-6 col-md-4 col-lg-4 col-xl-3 grid-style-transition"> <div class="q-px-sm q-pb-sm q-mt-sm col-xs-12 col-sm-6 col-md-4 col-lg-4 col-xl-3 grid-style-transition">
<q-card class="rounded-10"> <q-card class="rounded-10">
<!-- Card header with employee name and details button--> <!-- Card header with employee name and details button-->
<q-card-section horizontal class="q-py-none q-px-sm q-ma-none justify-between items-center"> <q-card-section
horizontal
class="q-py-none q-px-sm q-ma-none justify-between items-center"
>
<span class="col text-primary text-h5 text-weight-bolder q-pt-xs"> {{ row.employee_name }} </span> <span class="col text-primary text-h5 text-weight-bolder q-pt-xs"> {{ row.employee_name }} </span>
<!-- Buttons to view detailed shifts or view employee timesheet --> <!-- Buttons to view detailed shifts or view employee timesheet -->
@ -42,35 +45,33 @@
<q-separator size="2px" /> <q-separator size="2px" />
<!-- Main body of pay period card --> <!-- Main body of pay period card -->
<q-card-section class="q-py-none q-px-sm q-mt-sm q-mb-md"> <q-card-section class="q-py-none q-px-sm q-my-sm">
<div class="row no-wrap"> <div class="row">
<!-- left portion of pay period card --> <!-- left portion of pay period card -->
<div class="col column no-wrap q-px-sm"> <div class="col column q-px-sm">
<!-- Regular hours segment --> <!-- Regular hours segment -->
<div class="column" :class="$q.screen.lt.md ? 'col' : 'col-8'"> <div class="col column">
<span :class="stack_label_class"> {{ $t('shared.shift_type.regular') }} </span> <span class="text-weight-bold text-primary text-uppercase q-pa-none q-my-none"> {{ $t('shared.shift_type.regular') }} </span>
<span class="text-weight-bolder text-h3 q-py-none"> {{ row.regular_hours }} </span> <span class="text-weight-bolder text-h3 q-py-none"> {{ row.regular_hours }} </span>
</div>
<q-separator class="q-mx-sm" /> <q-separator class="q-mx-sm" />
</div>
<!-- Other hour types segment --> <!-- Other hour types segment -->
<div class="row q-px-xs"> <div class="col-auto row ellipsis q-mt-xs">
<div class="col column no-wrap"> <div
<span :class="stack_label_class" style="font-size: 0.65em;"> {{ $t('shared.shift_type.evening') }} </span> v-for="hour_type, index in row.other_hours"
<span class="text-weight-bolder text-h6 q-pa-none" style="line-height: 0.9em;"> {{ row.evening_hours }} </span> :key="index"
</div> class="col-4 column ellipsis"
:class="hour_type === 0 ? 'invisible' : ''"
<div class="col column no-wrap"> >
<span :class="stack_label_class" style="font-size: 0.65em;"> {{ $t('shared.shift_type.emergency') }} </span> <span
<span class="text-weight-bolder text-h6 q-pa-none" style="line-height: 0.9em;"> {{ row.emergency_hours }} </span> class="text-weight-bold text-primary text-uppercase q-pa-none q-my-none"
</div> style="font-size: 0.7em;"
> {{ $t(`shared.shift_type.${index.replace('_hours', '')}`) }} </span>
<div class="col column no-wrap"> <span
<span :class="stack_label_class" style="font-size: 0.65em;"> {{ $t('shared.shift_type.overtime') }} </span> class="text-weight-bolder q-pa-none q-mb-xs"
<span class="text-weight-bolder text-h6 q-pa-none" style="line-height: 0.9em;"> {{ row.overtime_hours }} </span> style="font-size: 1.2em; line-height: 1em;"
> {{ hour_type }} </span>
</div> </div>
</div> </div>
</div> </div>
@ -83,19 +84,34 @@
<!-- Right portion of pay period card --> <!-- Right portion of pay period card -->
<div class="col-auto column q-px-sm"> <div class="col-auto column q-px-sm">
<div class="col column no-wrap"> <div class="col column no-wrap">
<span :class="stack_label_class" style="font-size: 0.8em;"> {{ $t('timesheet.expense.types.EXPENSES') }} </span> <span
<span class="text-weight-bolder text-h6 q-pa-none" style="line-height: 0.9em;"> {{ row.expenses }} </span> class="text-weight-bold text-primary text-uppercase q-pa-none q-my-none"
style="font-size: 0.8em;"
> {{ $t('timesheet.expense.types.EXPENSES') }} </span>
<span
class="text-weight-bolder text-h6 q-pa-none"
style="line-height: 0.9em;"
> {{ row.expenses }} </span>
</div> </div>
<div class="col column no-wrap"> <div class="col column no-wrap">
<span :class="stack_label_class" style="font-size: 0.8em;"> {{ $t('timesheet.expense.types.MILEAGE') }} </span> <span
<span class="text-weight-bolder text-h6 q-pa-none" style="line-height: 0.9em;"> {{ row.mileage }} </span> class="text-weight-bold text-primary text-uppercase q-pa-none q-my-none"
style="font-size: 0.8em;"
> {{ $t('timesheet.expense.types.MILEAGE') }} </span>
<span
class="text-weight-bolder text-h6 q-pa-none"
style="line-height: 0.9em;"
> {{ row.mileage }} </span>
</div> </div>
</div> </div>
</div> </div>
</q-card-section> </q-card-section>
<q-separator color="primary" size="2px" /> <q-separator
color="primary"
size="2px"
/>
<!-- Validate Pay Period section --> <!-- Validate Pay Period section -->
<q-card-section <q-card-section

View File

@ -2,18 +2,29 @@
setup setup
lang="ts" lang="ts"
> >
/* eslint-disable */
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
import { useExpensesStore } from 'src/stores/expense-store';
import { useTimesheetStore } from 'src/stores/timesheet-store'; import { useTimesheetStore } from 'src/stores/timesheet-store';
import { useTimesheetApprovalApi } from 'src/modules/timesheet-approval/composables/use-timesheet-approval-api';
import OverviewListItem from 'src/modules/timesheet-approval/components/overview-list-item.vue'; import OverviewListItem from 'src/modules/timesheet-approval/components/overview-list-item.vue';
import QTableFilters from 'src/modules/shared/components/q-table-filters.vue'; import QTableFilters from 'src/modules/shared/components/q-table-filters.vue';
import PayPeriodNavigator from 'src/modules/shared/components/pay-period-navigator.vue'; import PayPeriodNavigator from 'src/modules/shared/components/pay-period-navigator.vue';
import { pay_period_overview_columns, type PayPeriodOverview } from 'src/modules/timesheet-approval/models/pay-period-overview.models'; import { pay_period_overview_columns, type TimesheetOverview } from 'src/modules/timesheet-approval/models/timesheet-overview.models';
const expenses_store = useExpensesStore();
const timesheet_store = useTimesheetStore(); const timesheet_store = useTimesheetStore();
const timesheet_approval_api = useTimesheetApprovalApi();
const filter = ref<string | number | null>(''); const filter = ref<string | number | null>('');
const is_grid_mode = ref(true);
const IS_ABNORMAL_SHIFT = ['OVERTIME', 'EMERGENCY'];
const IS_PTO = ['HOLIDAY', 'VACATION', 'SICK'];
const employeeEmail = defineModel(); const employeeEmail = defineModel();
const visible_columns = ref<string[]>(['REGULAR', 'email']);
const emit = defineEmits<{ const emit = defineEmits<{
'clickedDetailsButton': [email: string]; 'clickedDetailsButton': [email: string];
}>(); }>();
@ -23,28 +34,46 @@
timesheet_store.pay_period_overviews timesheet_store.pay_period_overviews
) )
const onClickedDetails = async (employee_email: string, row: PayPeriodOverview) => { const onClickedDetails = async (employee_email: string, row: TimesheetOverview) => {
employeeEmail.value = employee_email; employeeEmail.value = employee_email;
emit('clickedDetailsButton', employee_email);
timesheet_store.current_pay_period_overview = row; timesheet_store.current_pay_period_overview = row;
await timesheet_store.getPayPeriodDetailsByEmployeeEmail(employee_email); emit('clickedDetailsButton', employee_email);
await timesheet_store.getTimesheetsByEmployeeEmail(employee_email);
// await expenses_store.getPayPeriodExpensesByTimesheetId(employee_email);
}; };
const getListModeTextColor = (type: string): string => {
console.log('type: ', type);
if (IS_ABNORMAL_SHIFT.includes(type)) {
return ' text-negative text-weight-bolder';
}
else if (IS_PTO.includes(type)) {
return ' text-warning text-weight-bold';
}
return '';
}
</script> </script>
<template> <template>
<div class="q-pa-md"> <div class="q-pa-md">
<q-table <q-table
:visible-columns="visible_columns"
:rows="overview_rows" :rows="overview_rows"
:columns="pay_period_overview_columns" :columns="pay_period_overview_columns"
row-key="email" row-key="email"
:filter="filter" :filter="filter"
grid :grid="is_grid_mode"
dense dense
hide-pagination hide-pagination
color="primary" color="primary"
:rows-per-page-options="[0]" :rows-per-page-options="[0]"
card-container-class="justify-center" card-container-class="justify-center"
:loading="timesheet_store.is_loading" :loading="timesheet_store.is_loading"
class="q-py-md bg-transparent"
:class="is_grid_mode ? '' : 'sticky-header-table no-shadow'"
table-class="q-pa-none q-py-none q-mx-md rounded-10 bg-dark shadow-4'"
:no-data-label="$t('shared.error.no_data_found')" :no-data-label="$t('shared.error.no_data_found')"
:no-results-label="$t('shared.error.no_search_results')" :no-results-label="$t('shared.error.no_search_results')"
:loading-label="$t('shared.label.loading')" :loading-label="$t('shared.label.loading')"
@ -54,18 +83,69 @@
class="full-width" class="full-width"
:class="$q.screen.lt.md ? 'text-center' : 'row'" :class="$q.screen.lt.md ? 'text-center' : 'row'"
> >
<PayPeriodNavigator /> <PayPeriodNavigator
@date-selected="timesheet_approval_api.getPayPeriodOverviewsByDateOrYearAndNumber"
/>
<q-space /> <q-space />
<!-- Grid-or-List toggle goes here --> <q-btn-toggle
v-model="is_grid_mode"
push
color="white"
text-color="primary"
toggle-color="primary"
class="q-mr-md"
:options="[
{ icon: 'grid_view', value: true },
{ icon: 'view_list', value: false },
]"
/>
<QTableFilters v-model="filter" /> <QTableFilters v-model="filter" />
</div> </div>
</template> </template>
<template #header="props">
<q-tr
:props="props"
class="bg-primary"
>
<q-th
v-for="col in props.cols"
:key="col.name"
:props="props"
>
<span
v-if="col.label !== 'timesheet_approvals.table.is_approved'"
class="text-uppercase text-weight-bolder text-white"
>
{{ $t(col.label) }}
</span>
</q-th>
</q-tr>
</template>
<template #body-cell="props">
<q-td
:props="props"
class="text-weight-medium"
>
<span
v-if="(props.value > 0 && typeof props.value !== 'boolean') || typeof props.value === 'string'"
:class="getListModeTextColor(props.col.name)"
>{{ props.value }}</span>
<q-icon
v-if="typeof props.value === 'boolean'"
:name="props.value ? 'verified' : 'fiber_manual_record'"
:color="props.value ? 'primary' : 'grey-5'"
size="sm"
/>
</q-td>
</template>
<!-- Template for individual employee cards --> <!-- Template for individual employee cards -->
<template #item="props: { row: PayPeriodOverview, key: string }"> <template #item="props: { row: TimesheetOverview, key: string }">
<OverviewListItem <OverviewListItem
v-model="props.row.is_approved" v-model="props.row.is_approved"
:row="props.row" :row="props.row"
@ -89,3 +169,22 @@
</q-table> </q-table>
</div> </div>
</template> </template>
<style lang="sass">
.sticky-header-table
thead tr:first-child th
background-color: var(--q-primary)
margin-top: none
thead tr th
position: sticky
z-index: 1
thead tr:first-child th
top: 0
&.q-table--loading thead tr:last-child th
top: 48px
tbody
scroll-margin-top: 48px
</style>

View File

@ -1,24 +1,51 @@
import { useTimesheetStore } from "src/stores/timesheet-store"; import { useTimesheetStore } from "src/stores/timesheet-store";
import { useAuthStore } from "src/stores/auth-store"; import { useAuthStore } from "src/stores/auth-store";
import type { TimesheetApprovalCSVReportFilters } from "src/modules/timesheet-approval/models/timesheet-approval-csv-report.models"; import type { TimesheetApprovalCSVReportFilters } from "src/modules/timesheet-approval/models/timesheet-approval-csv-report.models";
import { NavigatorConstants } from "src/modules/timesheet-approval/models/timesheet-overview.models";
export const useTimesheetApprovalApi = () => { export const useTimesheetApprovalApi = () => {
const timesheet_store = useTimesheetStore(); const timesheet_store = useTimesheetStore();
const auth_store = useAuthStore(); const auth_store = useAuthStore();
const getPayPeriodOverviewsByDate = async (date_string: string): Promise<void> => { const getPayPeriodOverviewsByDateOrYearAndNumber = async (date_or_year: string | number, period_number?: number): Promise<void> => {
const success = await timesheet_store.getPayPeriodByDateOrYearAndNumber(date_string); let success = false;
if (typeof date_or_year === 'string') success = await timesheet_store.getPayPeriodByDateOrYearAndNumber(date_or_year);
else if (typeof date_or_year === 'number' && period_number) success = await timesheet_store.getPayPeriodByDateOrYearAndNumber(date_or_year, period_number);
if (success) { if (success) {
await timesheet_store.getPayPeriodOverviewsBySupervisorEmail( await timesheet_store.getTimesheetOverviewsByPayPeriod(
timesheet_store.pay_period.pay_year, timesheet_store.pay_period?.pay_year ?? 1,
timesheet_store.pay_period.pay_period_no, timesheet_store.pay_period?.pay_period_no ?? 1,
auth_store.user.email auth_store.user?.email
); );
} }
}; };
const getNextOrPreviousPayPeriodOverview = async (direction: number) => {
if (timesheet_store.pay_period === undefined) return;
let new_period_number = (timesheet_store.pay_period.pay_period_no) + direction;
let new_year = timesheet_store.pay_period.pay_year;
if ( new_period_number > 26 || new_period_number < 1) {
new_period_number = 1;
new_year += direction;
}
await getPayPeriodOverviewsByDateOrYearAndNumber(new_year, new_period_number);
};
const getNextPayPeriodOverview = async () => {
await getNextOrPreviousPayPeriodOverview(NavigatorConstants.NEXT_PERIOD);
};
const getPreviousPayPeriodOverview = async () => {
await getNextOrPreviousPayPeriodOverview(NavigatorConstants.PREVIOUS_PERIOD);
};
const getTimesheetApprovalCSVReport = async ( report_filter_company: boolean[], report_filter_type: boolean[], year?: number, period_number?: number ) => { const getTimesheetApprovalCSVReport = async ( report_filter_company: boolean[], report_filter_type: boolean[], year?: number, period_number?: number ) => {
if (timesheet_store.pay_period === undefined) return;
const [ targo, solucom ] = report_filter_company; const [ targo, solucom ] = report_filter_company;
const [ shifts, expenses, holiday, vacation ] = report_filter_type; const [ shifts, expenses, holiday, vacation ] = report_filter_type;
const options = { const options = {
@ -34,7 +61,9 @@ export const useTimesheetApprovalApi = () => {
}; };
return { return {
getPayPeriodOverviewsByDate, getPayPeriodOverviewsByDateOrYearAndNumber,
getTimesheetApprovalCSVReport, getTimesheetApprovalCSVReport,
getNextPayPeriodOverview,
getPreviousPayPeriodOverview,
} }
}; };

View File

@ -1,92 +0,0 @@
export interface PayPeriodOverview {
email: string;
employee_name: string;
regular_hours: number;
evening_hours: number;
emergency_hours: number;
overtime_hours: number;
total_hours: number;
expenses: number;
mileage: number;
is_approved: boolean;
};
export interface PayPeriodOverviewResponse {
pay_period_no: number;
pay_year: number;
period_start: string;
period_end: string;
payday: string;
label: string;
employees_overview: PayPeriodOverview[];
}
export const default_pay_period_overview: PayPeriodOverview = {
email: '',
employee_name: '',
regular_hours: -1,
evening_hours: -1,
emergency_hours: -1,
overtime_hours: -1,
total_hours: -1,
expenses: -1,
mileage: -1,
is_approved: false
}
export const pay_period_overview_columns = [
{
name: 'employee_name',
label: 'timesheet_approvals.table.full_name',
field: 'employee_name',
sortable: true
},
{
name: 'email',
label: 'timesheet_approvals.table.email',
field: 'email',
sortable: true,
},
{
name: 'regular_hours',
label: 'shared.shift_type.regular',
field: 'regular_hours',
sortable: true,
},
{
name: 'evening_hours',
label: 'shared.shift_type.evening',
field: 'evening_hours',
sortable: true,
},
{
name: 'emergency_hours',
label: 'shared.shift_type.emergency',
field: 'emergency_hours',
sortable: true,
},
{
name: 'overtime_hours',
label: 'shared.shift_type.overtime',
field: 'overtime_hours',
sortable: true,
},
{
name: 'expenses',
label: 'timesheet_approvals.table.expenses',
field: 'expenses',
sortable: true,
},
{
name: 'mileage',
label: 'timesheet_approvals.table.mileage',
field: 'mileage',
sortable: true,
},
{
name: 'is_approved',
label: 'timesheet_approvals.table.is_approved',
field: 'is_approved',
sortable: true,
}
];

View File

@ -0,0 +1,129 @@
import type { QTableColumn } from "quasar";
/* eslint-disable */
export enum NavigatorConstants {
NEXT_PERIOD = 1,
PREVIOUS_PERIOD = -1,
}
export interface TimesheetOverview {
email: string;
employee_name: string;
regular_hours: number;
other_hours: {
evening_hours: number;
emergency_hours: number;
overtime_hours: number;
sick_hours: number;
holiday_hours: number;
vacation_hours: number;
};
total_hours: number;
expenses: number;
mileage: number;
is_approved: boolean;
}
export interface PayPeriodOverviewResponse {
pay_period_no: number;
pay_year: number;
period_start: string;
period_end: string;
payday: string;
label: string;
employees_overview: TimesheetOverview[];
}
export const default_pay_period_overview: TimesheetOverview = {
email: '',
employee_name: '',
regular_hours: -1,
other_hours: {
evening_hours: -1,
emergency_hours: -1,
overtime_hours: -1,
sick_hours: -1,
holiday_hours: -1,
vacation_hours: -1,
},
total_hours: -1,
expenses: -1,
mileage: -1,
is_approved: false
}
export const pay_period_overview_columns: QTableColumn[] = [
{
name: 'employee_name',
label: 'timesheet_approvals.table.full_name',
align: 'left',
field: 'employee_name',
sortable: true
},
{
name: 'email',
label: 'timesheet_approvals.table.email',
field: 'email',
sortable: true,
},
{
name: 'REGULAR',
label: 'shared.shift_type.regular',
field: 'regular_hours',
sortable: true,
},
{
name: 'EVENING',
label: 'shared.shift_type.evening',
field: row => row.other_hours.evening_hours,
sortable: true,
},
{
name: 'EMERGENCY',
label: 'shared.shift_type.emergency',
field: row => row.other_hours.emergency_hours,
sortable: true,
},
{
name: 'SICK',
label: 'shared.shift_type.sick',
field: row => row.other_hours.sick_hours,
sortable: true,
},
{
name: 'HOLIDAY',
label: 'shared.shift_type.holiday',
field: row => row.other_hours.holiday_hours,
sortable: true,
},
{
name: 'VACATION',
label: 'shared.shift_type.vacation',
field: row => row.other_hours.vacation_hours,
sortable: true,
},
{
name: 'OVERTIME',
label: 'shared.shift_type.overtime',
field: row => row.other_hours.overtime_hours,
sortable: true,
},
{
name: 'expenses',
label: 'timesheet_approvals.table.expenses',
field: 'expenses',
sortable: true,
},
{
name: 'mileage',
label: 'timesheet_approvals.table.mileage',
field: 'mileage',
sortable: true,
},
{
name: 'is_approved',
label: 'timesheet_approvals.table.is_approved',
field: 'is_approved',
sortable: true,
}
]

View File

@ -1,6 +1,6 @@
import { api } from "src/boot/axios"; import { api } from "src/boot/axios";
import type { TimesheetApprovalCSVReportFilters } from "src/modules/timesheet-approval/models/timesheet-approval-csv-report.models"; import type { TimesheetApprovalCSVReportFilters } from "src/modules/timesheet-approval/models/timesheet-approval-csv-report.models";
import type { PayPeriodOverviewResponse } from "src/modules/timesheet-approval/models/pay-period-overview.models"; import type { PayPeriodOverviewResponse } from "src/modules/timesheet-approval/models/timesheet-overview.models";
export const timesheetApprovalService = { export const timesheetApprovalService = {
getPayPeriodOverviewsBySupervisorEmail: async (year: number, period_number: number, supervisor_email: string): Promise<PayPeriodOverviewResponse> => { getPayPeriodOverviewsBySupervisorEmail: async (year: number, period_number: number, supervisor_email: string): Promise<PayPeriodOverviewResponse> => {

View File

@ -1,34 +0,0 @@
<script
setup
lang="ts"
>
import { useExpensesStore } from 'src/stores/expense-store';
const expense_store = useExpensesStore();
</script>
<template>
<q-item class="row justify-between">
<q-item-label
header
class="text-h6 col-auto"
>
{{ $t('timesheet.expense.title') }}
</q-item-label>
<q-item-section class="items-center col-auto">
<q-badge
lines="1"
class="q-pa-sm q-px-md"
:label="$t('timesheet.expense.total_amount') + ': ' + expense_store.pay_period_expenses_totals.amount.toFixed(2)"
/>
<q-separator spaced />
<q-badge
lines="2"
class="q-pa-sm q-px-md"
:label="$t('timesheet.expense.total_mileage') + ': ' + expense_store.pay_period_expenses_totals.mileage.toFixed(1)"
/>
</q-item-section>
</q-item>
</template>

View File

@ -1,151 +0,0 @@
<script
setup
lang="ts"
>
import { unwrapAndClone } from 'src/utils/unwrap-and-clone';
import { expenseTypeIcon } from 'src/modules/timesheets/utils/expense.util';
import { useExpensesStore } from 'src/stores/expense-store';
import { useTimesheetStore } from 'src/stores/timesheet-store';
import { useExpensesApi } from 'src/modules/timesheets/composables/api/use-expense-api';
import { default_expense, type Expense } from 'src/modules/timesheets/models/expense.models';
import { computed, inject } from 'vue';
const timesheet_store = useTimesheetStore();
const expenses_store = useExpensesStore();
const expenses_api = useExpensesApi();
const expenses_list = computed(() => timesheet_store.pay_period_details.weeks.flatMap(week =>
Object.values(week.expenses).flatMap(day => day.expenses)));
const employee_email = inject('employeeEmail', '');
const setExpenseToModify = (expense: Expense) => {
expenses_store.mode = 'update';
expenses_store.current_expense = expense;
expenses_store.initial_expense = unwrapAndClone(expense);
};
const requestExpenseDeletion = async (expense: Expense) => {
expenses_store.mode = 'delete';
expenses_store.initial_expense = expense;
expenses_store.current_expense = default_expense;
await expenses_api.deleteExpenseByEmployeeEmail(employee_email, expenses_store.initial_expense.date);
}
</script>
<template>
<!-- liste des dépenses pré existantes -->
<q-list
padding
class="rounded-borders"
>
<q-item-label
v-if="expenses_store.pay_period_expenses.expenses.length === 0"
class="text-italic q-px-sm"
>
{{ $t('timesheet.expense.empty_list') }}
</q-item-label>
<q-item
style="border: solid 1px lightgrey; border-radius: 7px;"
v-for="(expense, index) in expenses_list"
:key="index"
class="q-my-xs shadow-1"
:class="expenses_store.mode === 'update' ? 'bg-accent' : ''"
>
<!-- avatar type icon section -->
<q-item-section avatar>
<q-icon
:name="expenseTypeIcon(expense.type)"
color="primary"
/>
</q-item-section>
<!-- amount or mileage section -->
<q-item-section top>
<q-item-label v-if="String(expense.type).trim().toUpperCase() === 'MILEAGE'">
<template v-if="typeof expense.mileage === 'number'">
{{ expense.mileage?.toFixed(1) }} km
</template>
<template v-else>
{{ expense.amount.toFixed(2) }} $
</template>
</q-item-label>
<q-item-label v-else>
{{ expense.amount.toFixed(2) }} $
</q-item-label>
<!-- date label -->
<q-item-label
caption
lines="2"
>
{{ $d(new Date(expense.date), { year: 'numeric', month: 'short', day: 'numeric', weekday: 'short' })
}}
</q-item-label>
</q-item-section>
<!-- attachment file icon -->
<q-item-section side>
<q-btn
push
dense
size="md"
color="primary"
class="q-mx-lg"
icon="attach_file"
/>
</q-item-section>
<!-- comment section -->
<q-item-section top>
<q-item-label lines="1">
{{ $t('timesheet.expense.employee_comment') }}
</q-item-label>
<q-item-label
caption
lines="2"
>
{{ expense.comment }}
</q-item-label>
</q-item-section>
<!-- supervisor comment section -->
<q-item-section top>
<q-item-label lines="1">
{{ $t('timesheet.expense.supervisor_comment') }}
</q-item-label>
<q-item-label
v-if="expense.supervisor_comment"
caption
lines="2"
>
{{ expense.supervisor_comment }}
</q-item-label>
</q-item-section>
<q-item-section
v-if="!expenses_store.pay_period_expenses.is_approved && !expense.is_approved"
side
>
<q-btn
push
dense
size="xs"
color="primary"
icon="edit"
@click="setExpenseToModify(expense)"
/>
<q-btn
push
dense
size="xs"
color="negative"
icon="close"
@click="requestExpenseDeletion(expense)"
/>
</q-item-section>
</q-item>
</q-list>
</template>

View File

@ -2,40 +2,43 @@
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 { default_expense, EXPENSE_TYPE, TYPES_WITH_AMOUNT_ONLY } from 'src/modules/timesheets/models/expense.models'; import { empty_expense, EXPENSE_TYPE, TYPES_WITH_AMOUNT_ONLY } from 'src/modules/timesheets/models/expense.models';
import { makeExpenseRules } from 'src/modules/timesheets/utils/expense.util'; import { useExpenseRules } from 'src/modules/timesheets/utils/expense.util';
import { useExpensesApi } from 'src/modules/timesheets/composables/api/use-expense-api'; import { useExpensesApi } from 'src/modules/timesheets/composables/use-expense-api';
import { useTimesheetStore } from 'src/stores/timesheet-store';
const { t } = useI18n(); const { t } = useI18n();
const timesheet_store = useTimesheetStore();
const expenses_store = useExpensesStore(); const expenses_store = useExpensesStore();
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 = makeExpenseRules(t); const rules = useExpenseRules(t);
const cancelUpdateMode = () => { const cancelUpdateMode = () => {
expenses_store.current_expense = default_expense; expenses_store.current_expense = empty_expense;
expenses_store.initial_expense = default_expense; expenses_store.initial_expense = empty_expense;
expenses_store.mode = 'create';
} }
const requestExpenseCreationOrUpdate = async () => { const requestExpenseCreationOrUpdate = async () => {
if (expenses_store.mode === 'create') await expenses_api.createExpenseByEmployeeEmail(employee_email ?? '', expenses_store.current_expense.date); if (mode.value === '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 ?? '');
} }
</script> </script>
<template> <template>
<q-form <q-form
flat flat
v-if="!expenses_store.pay_period_expenses.is_approved" v-if="!timesheet_store.timesheets?.every(timesheet => timesheet.is_approved)"
@submit.prevent="requestExpenseCreationOrUpdate" @submit.prevent="requestExpenseCreationOrUpdate"
> >
<div class="text-subtitle2 q-py-sm"> <div class="text-subtitle2 q-py-sm">
@ -43,7 +46,7 @@
</div> </div>
<div <div
class="row justify-between rounded-5" class="row justify-between rounded-5"
:class="expenses_store.mode === 'update' ? 'bg-accent' : ''" :class="mode === 'update' ? 'bg-accent' : ''"
> >
<!-- date selection input --> <!-- date selection input -->
@ -87,11 +90,11 @@
map-options map-options
:label="$t('timesheet.expense.type')" :label="$t('timesheet.expense.type')"
:rules="[rules.typeRequired]" :rules="[rules.typeRequired]"
:option-label="label => $t(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)"> <template 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"
@ -174,7 +177,7 @@
<!-- add btn section --> <!-- add btn section -->
<div> <div>
<q-btn <q-btn
v-if="expenses_store.mode === 'update'" v-if="mode === 'update'"
flat flat
dense dense
size="sm" size="sm"

View File

@ -0,0 +1,44 @@
<script
setup
lang="ts"
>
/* eslint-disable */
import { useExpensesStore } from 'src/stores/expense-store';
const expense_store = useExpensesStore();
</script>
<template>
<q-item class="row justify-between items-center q-pa-none">
<q-item-label
header
class="text-h6 col q-pa-none"
>
{{ $t('timesheet.expense.title') }}
</q-item-label>
<!-- <q-item-section
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
no-wrap
class="col-auto items-center"
>
<q-badge
outline
class="q-py-xs q-px-md"
color="primary"
:label="$t('timesheet.expense.total_mileage') + ': ' + expense_store.pay_period_expenses?.total_mileage.toFixed(1) + ' km'"
/>
</q-item-section> -->
</q-item>
</template>

View File

@ -0,0 +1,203 @@
<script
setup
lang="ts"
>
/* eslint-disable */
import { computed, inject, ref } from 'vue';
import { unwrapAndClone } from 'src/utils/unwrap-and-clone';
import { useExpensesApi } from 'src/modules/timesheets/composables/use-expense-api';
import { useExpensesStore } from 'src/stores/expense-store';
import { useTimesheetStore } from 'src/stores/timesheet-store';
import { getExpenseIcon } from 'src/modules/timesheets/utils/expense.util';
import { useAuthStore } from 'src/stores/auth-store';
import { CAN_APPROVE_PAY_PERIODS } from 'src/modules/shared/models/user.models';
import { empty_expense, type Expense } from 'src/modules/timesheets/models/expense.models';
const { expense, horizontal = false } = defineProps<{
expense: Expense;
index: number;
horizontal?: boolean;
}>();
const timesheet_store = useTimesheetStore();
const expenses_store = useExpensesStore();
const auth_store = useAuthStore();
const expenses_api = useExpensesApi();
const is_approved = defineModel<boolean>({ required: true });
const is_selected = ref(false);
const refresh_key = ref(1);
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 () => {
// expenses_store.mode = 'delete';
expenses_store.initial_expense = expense;
expenses_store.current_expense = empty_expense;
await expenses_api.deleteExpenseByEmployeeEmail(employeeEmail, expenses_store.initial_expense.date);
}
function onExpenseClicked() {
if (is_authorized_to_approve.value) {
is_approved.value = !is_approved.value;
refresh_key.value += 1;
}
}
</script>
<template>
<transition
enter-active-class="animated pulse"
mode="out-in"
>
<q-item
:key="refresh_key"
:clickable="horizontal"
class="row col-4 q-ma-xs shadow-2"
:style="expenseItemStyle + approvedClass"
@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 -->
<q-item-section avatar>
<q-icon
:name="getExpenseIcon(expense.type)"
:color="expense.is_approved ? 'primary' : ($q.dark.isActive ? 'blue-grey-2' : 'grey-8')"
size="lg"
>
<q-badge
v-if="expense.type === 'ON_CALL'"
floating
class="q-pa-none rounded-50 bg-white z-top"
>
<q-icon
name="shield"
size="xs"
:color="expense.is_approved ? 'primary' : ($q.dark.isActive ? 'blue-grey-2' : 'grey-8')"
/>
</q-badge>
</q-icon>
</q-item-section>
<!-- amount or mileage section -->
<q-item-section class="col-auto">
<q-item-label v-if="String(expense.type).trim().toUpperCase() === 'MILEAGE'">
<template v-if="typeof expense.mileage === 'number'">
{{ expense.mileage?.toFixed(1) }} km
</template>
<template v-else>
${{ expense.amount.toFixed(2) }}
</template>
</q-item-label>
<q-item-label v-else>
${{ expense.amount.toFixed(2) }}
</q-item-label>
<!-- date label -->
<q-item-label
caption
lines="1"
>
<!-- {{ $d(new Date(expense.date), { year: 'numeric', month: 'short', day: 'numeric', weekday: 'short' }) }} -->
{{ expense.date }}
</q-item-label>
</q-item-section>
<q-space v-if="horizontal" />
<!-- attachment file icon -->
<q-item-section side>
<q-btn
push
dense
size="md"
color="primary"
class="q-mx-lg"
icon="attach_file"
/>
</q-item-section>
<!-- comment section -->
<q-item-section
v-if="!horizontal"
top
>
<q-item-label lines="1">
{{ $t('timesheet.expense.employee_comment') }}
</q-item-label>
<q-item-label
caption
lines="1"
>
{{ expense.comment }}
</q-item-label>
</q-item-section>
<!-- supervisor comment section -->
<q-item-section
v-if="expense.supervisor_comment && !horizontal"
top
>
<q-item-label lines="1">
{{ $t('timesheet.expense.supervisor_comment') }}
</q-item-label>
<q-item-label
v-if="expense.supervisor_comment"
caption
lines="2"
>
{{ expense.supervisor_comment }}
</q-item-label>
</q-item-section>
<q-item-section
side
class="q-pa-none"
>
<q-btn
push
dense
size="xs"
color="primary"
icon="edit"
class="q-mb-xs z-top"
@click.stop="setExpenseToModify"
/>
<q-btn
push
dense
size="xs"
color="negative"
icon="close"
class="z-top"
@click.stop="requestExpenseDeletion"
/>
</q-item-section>
</q-item>
</transition>
</template>

View File

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

View File

@ -3,9 +3,9 @@
lang="ts" lang="ts"
> >
import { useExpensesStore } from 'src/stores/expense-store'; import { useExpensesStore } from 'src/stores/expense-store';
import ExpenseCrudDialogList from 'src/modules/timesheets/components/expense-crud-dialog-list.vue'; import ExpenseDialogList from 'src/modules/timesheets/components/expense-dialog-list.vue';
import ExpenseCrudDialogForm from 'src/modules/timesheets/components/expense-crud-dialog-form.vue'; import ExpenseDialogForm from 'src/modules/timesheets/components/expense-dialog-form.vue';
import ExpenseCrudDialogHeader from 'src/modules/timesheets/components/expense-crud-dialog-header.vue'; import ExpenseDialogHeader from 'src/modules/timesheets/components/expense-dialog-header.vue';
const expense_store = useExpensesStore(); const expense_store = useExpensesStore();
</script> </script>
@ -32,11 +32,17 @@
{{ expenses_error }} {{ expenses_error }}
</q-banner> --> </q-banner> -->
<ExpenseCrudDialogHeader /> <ExpenseDialogHeader />
<ExpenseCrudDialogList /> <ExpenseDialogList />
<ExpenseCrudDialogForm /> <ExpenseDialogForm v-if="!expense_store.current_expense.is_approved" />
<q-icon
v-else
name="block"
color="negative"
size="lg"
/>
<q-separator spaced /> <q-separator spaced />

View File

@ -0,0 +1,81 @@
<script
setup
lang="ts"
>
import { type Shift, SHIFT_TYPES } from 'src/modules/timesheets/models/shift.models';
const shift = defineModel<Shift>({ required: true });
defineEmits<{
'onCommentBlur': [void];
}>();
</script>
<template>
<div class="row full-width justify-center">
<div class="col-sm-6 col-md-3 row q-mx-xs q-my-none">
<div class="col-auto column items-center">
<span
class="text-caption q-pa-none q-ma-none"
style="line-height: 0.7em; font-size: 0.7em;"
>{{ $t('timesheet.shift.types.REMOTE') }}</span>
<q-toggle
v-model="shift.is_remote"
class="q-pa-none q-ma-none"
/>
</div>
<q-select
v-model="shift.type"
options-dense
:options="SHIFT_TYPES"
:label="$t('timesheet.shift.types.label')"
class="col q-pa-none"
color="primary"
outlined
dense
square
hide-dropdown-icon
emit-value
map-options
/>
</div>
<div class="col-auto row q-mx-xs">
<q-input
v-model="shift.start_time"
:label="$t('timesheet.shift.fields.start')"
outlined
dense
square
inputmode="numeric"
mask="##:##"
class="col-auto q-mx-xs"
/>
<q-input
v-model="shift.end_time"
:label="$t('timesheet.shift.fields.end')"
outlined
dense
square
inputmode="numeric"
mask="##:##"
class="col-auto q-mx-xs"
/>
</div>
<q-input
v-model="shift.comment"
type="textarea"
autogrow
filled
dense
square
:label="$t('timesheet.shift.fields.header_comment')"
:counter="true"
:maxlength="512"
class="col-grow"
/>
</div>
</template>

View File

@ -1,173 +0,0 @@
<script
setup
lang="ts"
>
import { computed, ref } from 'vue';
import { useShiftStore } from 'src/stores/shift-store';
import { useShiftApi } from 'src/modules/timesheets/composables/api/use-shift-api';
import { SHIFT_TYPES } from 'src/modules/timesheets/models/shift.models';
const { date_iso, mode, current_shift, is_open, close } = useShiftStore();
const { upsertOrDeleteShiftByEmployeeEmail } = useShiftApi();
const { employeeEmail } = defineProps<{
employeeEmail: string;
}>();
const isSubmitting = ref(false);
const errorBanner = ref<string | null>(null);
const conflicts = ref<Array<{ start_time: string; end_time: string; type: string }>>([]);
const canSubmit = computed(() =>
mode === 'delete' ||
(current_shift.start_time.trim().length === 5 &&
current_shift.end_time.trim().length === 5 &&
current_shift.type !== undefined)
);
</script>
<template>
<q-dialog
v-model=" is_open"
persistent
transition-show="fade"
transition-hide="fade"
>
<q-card class="q-pa-md">
<div class="row items-center q-mb-sm">
<q-icon
name="schedule"
size="24px"
class="q-mr-sm"
/>
<div class="text-h6">
{{
mode === 'create'
? $t('timesheet.shift.actions.add')
: mode === 'update'
? $t('timesheet.shift.actions.edit')
: $t('timesheet.shift.actions.delete')
}}
</div>
<q-space />
<q-badge
outline
color="primary"
>
{{ date_iso }}
</q-badge>
</div>
<q-separator spaced />
<div
v-if="mode !== 'delete'"
class="column q-gutter-md"
>
<div class="row ">
<div class="col">
<q-input
v-model="current_shift.start_time"
:label="$t('timesheet.shift.fields.start')"
filled
dense
inputmode="numeric"
mask="##:##"
/>
</div>
<div class="col">
<q-input
v-model="current_shift.end_time"
:label="$t('timesheet.shift.fields.end')"
filled
dense
inputmode="numeric"
mask="##:##"
/>
</div>
</div>
<div class="row items-center">
<q-select
v-model="current_shift.type"
options-dense
:options="SHIFT_TYPES"
:label="$t('timesheet.shift.types.label')"
class="col"
color="primary"
filled
dense
hide-dropdown-icon
emit-value
map-options
/>
<q-toggle
v-model="current_shift.is_remote"
:label="$t('timesheet.shift.types.REMOTE')"
class="col-auto"
/>
</div>
<q-input
v-model="current_shift.comment"
type="textarea"
autogrow
filled
dense
:label="$t('timesheet.shift.fields.header_comment')"
:counter="true"
:maxlength="512"
/>
</div>
<div
v-else
class="q-pa-md"
>
{{ $t('timesheet.shift.actions.delete_confirmation_msg') }}
</div>
<div
v-if="errorBanner"
class="q-mt-md"
>
<q-banner
dense
class="bg-red-2 text-negative"
>{{ errorBanner }}</q-banner>
<div
v-if="conflicts.length"
class="q-mt-xs"
>
<div class="text-caption">Conflits :</div>
<ul class="q-pl-md q-mt-xs">
<li
v-for="(c, i) in conflicts"
:key="i"
>
{{ c.start_time }}{{ c.end_time }} ({{ c.type }})
</li>
</ul>
</div>
</div>
<q-separator spaced />
<div class="row justify-end q-gutter-sm">
<q-btn
flat
color="grey-8"
:label="$t('timesheet.cancel_button')"
@click="close"
/>
<q-btn
color="primary"
icon="save_alt"
:label="mode === 'delete' ? $t('timesheet.delete_button') : $t('timesheet.save_button')"
:loading="isSubmitting"
:disable="!canSubmit"
@click="upsertOrDeleteShiftByEmployeeEmail(employeeEmail)"
/>
</div>
</q-card>
</q-dialog>
</template>

View File

@ -1,33 +0,0 @@
<template>
<q-card-section
horizontal
class="text-uppercase text-center items-center q-pa-none"
>
<!-- shift row itself -->
<q-card-section class="col q-pa-none">
<q-card-section horizontal class="col q-pa-none">
<!-- punch-in timestamps -->
<q-card-section class="col q-pa-none">
<q-item-label class="text-weight-bolder text-primary" style="font-size: 0.7em;">
{{ $t('shared.misc.in') }}
</q-item-label>
</q-card-section>
<!-- arrows pointing to punch-out timestamps -->
<q-card-section class="col q-py-none q-px-sm">
</q-card-section>
<!-- punch-out timestamps -->
<q-card-section class="col q-pa-none">
<q-item-label class="text-weight-bolder text-primary" style="font-size: 0.7em;">
{{ $t('shared.misc.out') }}
</q-item-label>
</q-card-section>
<!-- comment button -->
<q-card-section class="col column q-pa-none">
</q-card-section>
</q-card-section>
</q-card-section>
</q-card-section>
</template>

View File

@ -10,10 +10,9 @@
const is_showing_legend = ref(false); const is_showing_legend = ref(false);
const legend: ShiftLegendItem[] = [ const legend: ShiftLegendItem[] = [
{ type: 'REGULAR', color: 'secondary', label_type: 'timesheet.shift.types.REGULAR', text_color: 'grey-8' }, { type: 'REGULAR', color: 'secondary', label_type: 'timesheet.shift.types.REGULAR' },
{ type: 'EVENING', color: 'warning', label_type: 'timesheet.shift.types.EVENING' }, { type: 'EVENING', color: 'warning', label_type: 'timesheet.shift.types.EVENING' },
{ type: 'EMERGENCY', color: 'amber-10', label_type: 'timesheet.shift.types.EMERGENCY' }, { type: 'EMERGENCY', color: 'amber-10', label_type: 'timesheet.shift.types.EMERGENCY' },
{ type: 'OVERTIME', color: 'negative', label_type: 'timesheet.shift.types.OVERTIME' },
{ type: 'VACATION', color: 'purple-10', label_type: 'timesheet.shift.types.VACATION' }, { type: 'VACATION', color: 'purple-10', label_type: 'timesheet.shift.types.VACATION' },
{ type: 'HOLIDAY', color: 'purple-5', label_type: 'timesheet.shift.types.HOLIDAY' }, { type: 'HOLIDAY', color: 'purple-5', label_type: 'timesheet.shift.types.HOLIDAY' },
{ type: 'SICK', color: 'grey-8', label_type: 'timesheet.shift.types.SICK' }, { type: 'SICK', color: 'grey-8', label_type: 'timesheet.shift.types.SICK' },
@ -35,7 +34,7 @@
dense dense
rounded rounded
color="primary" color="primary"
class="col-auto q-ma-sm" class="col-auto q-my-sm"
@click="is_showing_legend = !is_showing_legend" @click="is_showing_legend = !is_showing_legend"
> >
<template #default> <template #default>
@ -55,7 +54,7 @@
> >
<div <div
v-if="is_showing_legend" v-if="is_showing_legend"
class="q-pa-xs bg-white rounded-5 shadow-2 text-center q-ma-xs" class="q-py-xs bg-white rounded-5 shadow-2 text-center q-my-xs"
> >
<q-badge <q-badge
v-for="shift_type in shift_type_legend" v-for="shift_type in shift_type_legend"
@ -63,7 +62,7 @@
:color="shift_type.color" :color="shift_type.color"
:label="shift_type.label" :label="shift_type.label"
:text-color="shift_type.text_color || 'white'" :text-color="shift_type.text_color || 'white'"
class="q-px-md q-py-xs q-mx-xs q-my-none text-uppercase text-weight-bolder justify-center" class="q-pa-xs q-mx-xs q-my-none text-uppercase text-weight-bolder justify-center"
style="font-size: 0.8em;" style="font-size: 0.8em;"
/> />
</div> </div>

View File

@ -1,136 +1,197 @@
<script setup lang="ts"> <script
import { computed } from 'vue'; setup
import type { Shift } from 'src/modules/timesheets/models/shift.models'; 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, dense = false } = defineProps<{ const shift = defineModel<Shift>('shift', { required: true });
shift: Shift;
const { dense = false } = defineProps<{
dense?: boolean; dense?: boolean;
}>(); }>();
const emit = defineEmits<{ defineEmits<{
'save-comment': [comment: string, shift: Shift]; 'saveComment': [comment: string, shift_id: number];
'request-update': [shift: Shift]; 'requestUpdate': [shift_id: number];
'request-delete': [shift: Shift]; 'requestDelete': [void];
}>(); }>();
const has_comment = computed(() => { const time_picker_model = ref('');
const comment = shift.comment ?? ''; const is_showing_time_picker = ref(false);
return typeof comment === 'string' && comment.trim().length > 0; const select_ref = useTemplateRef<QSelect>('select');
})
const comment_icon = computed(() => (has_comment.value ? 'announcement' : 'chat_bubble_outline'));
const comment_color = computed(() => (has_comment.value ? 'primary' : 'grey-8'));
const hour_font_size = computed(() => dense ? '0.9em' : '1.5em' )
const get_shift_color = (type: string): string => {
switch (type) {
case 'REGULAR': return 'secondary'; const options: { label: string, value: ShiftType, icon: string, icon_color: string }[] = [
case 'EVENING': return 'warning'; { label: t('timesheet.shift.types.REGULAR'), value: 'REGULAR', icon: 'wb_sunny', icon_color: '' },
case 'EMERGENCY': return 'amber-10'; { label: t('timesheet.shift.types.EVENING'), value: 'EVENING', icon: 'bedtime', icon_color: 'indigo-8' },
case 'OVERTIME': return 'negative'; { label: t('timesheet.shift.types.EMERGENCY'), value: 'EMERGENCY', icon: 'ring_volume', icon_color: 'red-8' },
case 'VACATION': return 'purple-10'; { label: t('timesheet.shift.types.VACATION'), value: 'VACATION', icon: 'beach_access', icon_color: 'yellow-8' },
case 'HOLIDAY': return 'purple-5'; { label: t('timesheet.shift.types.HOLIDAY'), value: 'HOLIDAY', icon: 'forest', icon_color: 'green-8' },
case 'SICK': return 'grey-8'; { label: t('timesheet.shift.types.SICK'), value: 'SICK', icon: 'medication_liquid', icon_color: 'cyan-8' },
default: return 'transparent'; ];
}
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;
}; };
const get_text_color = (type: string): string => { onMounted(() => {
switch (type) { if (ui_store.focus_next_component) {
case 'REGULAR': return 'grey-8'; select_ref.value?.focus();
case '': return 'grey-5'; select_ref.value?.showPopup();
default: return 'white'; ui_store.focus_next_component = false;
} }
} });
const onClickUpdate = (type: string) => {
if (type !== '') { emit('request-update', shift) };
}
const onClickDelete = () => emit('request-delete', shift);
</script> </script>
<template> <template>
<q-card-section
horizontal
class="q-pa-none text-uppercase text-center items-center rounded-10"
:class="shift.type"
style="line-height: 1;"
@click.stop="onClickUpdate(shift.type)"
>
<!-- punch-in timestamps -->
<q-card-section class="q-pa-none col">
<q-item-label
class="text-weight-bolder q-pa-xs rounded-5"
:class="'bg-' + get_shift_color(shift.type) + ' text-' + get_text_color(shift.type)"
:style="'font-size: ' + hour_font_size + '; line-height: 80% !important;'"
>
{{ shift.start_time }}
</q-item-label>
</q-card-section>
<!-- arrows pointing to punch-out timestamps -->
<q-card-section
horizontal
class="items-center justify-center q-mx-sm col"
>
<div <div
v-for="icon_data, index in [ v-if="shift.shift_id !== 0"
{ transform: 'transform: translateX(5px);', color: 'accent' }, class="col row flex-center text-uppercase rounded-10"
{ transform: 'transform: translateX(-5px);', color: 'primary' }]" >
:key="index" <!-- 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 <q-icon
v-if="shift.type" :name="scope.opt.icon"
name="double_arrow" :color="scope.opt.icon_color"
:color="icon_data.color" size="sm"
size="24px" class="col-auto q-mx-xs"
:style="icon_data.transform"
/> />
<span
v-if="$q.screen.gt.md"
style="line-height: 0.9em;"
class="col ellipsis"
>{{ scope.opt.label }}</span>
</div> </div>
</q-card-section> </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 --> <!-- punch-out timestamps -->
<q-card-section class="q-pa-none col"> <q-input
<q-item-label v-model="shift.end_time"
class="text-weight-bolder text-white q-pa-xs rounded-5" dense
:class="'bg-' + get_shift_color(shift.type) + ' text-' + get_text_color(shift.type)" type="time"
style="font-size: 1.5em; line-height: 80% !important;" 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
> >
{{ shift.end_time }} <template #label>
</q-item-label> <span
</q-card-section> class="text-weight-bolder"
style="font-size: 0.95em;"
>{{ $t('shared.misc.out') }}</span>
</template>
<!-- comment and expenses buttons --> <template #append>
<q-card-section class="col q-pa-none text-right">
<!-- comment btn -->
<q-icon
v-if="shift.type"
:name="comment_icon"
:color="comment_color"
class="q-pa-none q-mx-xs"
size="sm"
/>
<!-- expenses btn -->
<q-btn <q-btn
v-if="shift.type" 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 flat
dense dense
color='grey-8' :icon="shift.comment ? 'chat' : 'chat_bubble_outline'"
icon="attach_money" :text-color="shift.comment ? 'primary' : 'grey-8'"
class="q-pa-none q-mx-xs" class="col-auto q-ma-none q-pl-md full-height"
/> />
<!-- delete btn -->
<q-btn <q-btn
v-if="shift.type"
push
dense dense
size="sm" flat
color="red-6" round
icon="close" unelevated
class="q-ml-xs" tabindex="-1"
@click.stop="onClickDelete" icon="cancel"
color="negative"
class="q-pa-none q-mr-xs"
@click="$emit('requestDelete')"
/> />
</q-card-section> </div>
</q-card-section> </div>
</template> </template>

View File

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

View File

@ -3,48 +3,61 @@
lang="ts" lang="ts"
> >
import ShiftList from 'src/modules/timesheets/components/shift-list.vue'; import ShiftList from 'src/modules/timesheets/components/shift-list.vue';
import ShiftCrudDialog from 'src/modules/timesheets/components/shift-crud-dialog.vue'; import ExpenseDialog from 'src/modules/timesheets/components/expense-dialog.vue';
import ExpenseCrudDialog from 'src/modules/timesheets/components/expense-crud-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 ShiftListLegend from 'src/modules/timesheets/components/shift-list-legend.vue';
import { useTimesheetStore } from 'src/stores/timesheet-store'; import { useTimesheetStore } from 'src/stores/timesheet-store';
import { useTimesheetApi } from 'src/modules/timesheets/composables/api/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';
const { open } = useExpensesStore(); const { open } = useExpensesStore();
const shift_api = useShiftApi();
const { employeeEmail, dense = false } = defineProps<{ const { employeeEmail, dense = false } = defineProps<{
employeeEmail: string; employeeEmail: string;
dense?: boolean; dense?: boolean;
}>(); }>();
const { is_loading } = useTimesheetStore(); const timesheet_store = useTimesheetStore();
const { getPayPeriodDetailsByDate, getPreviousPayPeriodDetails, getNextPayPeriodDetails } = useTimesheetApi(); const timesheet_api = useTimesheetApi();
provide('employeeEmail', employeeEmail); provide('employeeEmail', employeeEmail);
</script> </script>
<template> <template>
<div class="column flex-center full-width">
<q-dialog
v-model="timesheet_store.is_loading"
transition-show="jump-down"
transition-hide="jump-down"
>
<q-card class="q-pa-xl rounded-200 bg-white frosted-glass">
<q-spinner-radio
color="primary"
size="20vh"
/>
</q-card>
</q-dialog>
<q-card <q-card
flat flat
class="q-mt-md bg-secondary full-width" class="transparent full-width"
> >
<q-inner-loading
:showing="is_loading"
color="primary"
/>
<q-card-section <q-card-section
v-if="!dense"
:horizontal="$q.screen.gt.sm" :horizontal="$q.screen.gt.sm"
class="q-px-lg items-center" class="q-px-md items-center q-mb-md"
:class="$q.screen.lt.md ? 'column' : ''" :class="$q.screen.lt.md ? 'column' : ''"
> >
<!-- navigation btn --> <!-- navigation btn -->
<PayPeriodNavigator <PayPeriodNavigator
@date-selected="getPayPeriodDetailsByDate" v-if="!dense"
@pressed-previous-button="getPreviousPayPeriodDetails" @date-selected="timesheet_api.getTimesheetsByDate(employeeEmail)"
@pressed-next-button="getNextPayPeriodDetails" @pressed-previous-button="timesheet_api.getTimesheetsByCurrentPayPeriod(employeeEmail)"
@pressed-next-button="timesheet_api.getTimesheetsByCurrentPayPeriod(employeeEmail)"
/> />
<!-- mobile expenses button --> <!-- mobile expenses button -->
@ -56,13 +69,25 @@
icon="receipt_long" icon="receipt_long"
:label="$t('timesheet.expense.open_btn')" :label="$t('timesheet.expense.open_btn')"
class="q-mt-sm" class="q-mt-sm"
@click="open(employeeEmail)" @click="open"
/> />
<!-- shift's colored legend --> <!-- shift's colored legend -->
<ShiftListLegend :is-loading="false" /> <!-- <ShiftListLegend :is-loading="false" /> -->
<q-space /> <q-space />
<!-- save timesheet changes button -->
<q-btn
v-if="$q.screen.gt.sm"
push
rounded
:disable="timesheet_store.is_loading"
color="primary"
icon="upload"
:label="$t('shared.label.save')"
class="q-mr-md"
@click="shift_api.saveShiftChanges"
/>
<!-- desktop expenses button --> <!-- desktop expenses button -->
<q-btn <q-btn
@ -72,17 +97,13 @@
color="primary" color="primary"
icon="receipt_long" icon="receipt_long"
:label="$t('timesheet.expense.open_btn')" :label="$t('timesheet.expense.open_btn')"
@click="open(employeeEmail)" @click="open"
/> />
</q-card-section> </q-card-section>
<q-card-section :horizontal="$q.screen.gt.sm"> <ShiftList :dense="dense" />
<ShiftList :dense="dense"/>
</q-card-section>
</q-card> </q-card>
<ExpenseDialog />
<ExpenseCrudDialog /> </div>
<ShiftCrudDialog :employee-email="employeeEmail" />
</template> </template>

View File

@ -1,41 +0,0 @@
import { normalizeObject } from "src/utils/normalize-object";
import { useExpensesStore } from "src/stores/expense-store";
import { expense_validation_schema } from "src/modules/timesheets/models/expense.validation";
import type { Expense, UpsertExpense } from "src/modules/timesheets/models/expense.models";
export const useExpensesApi = () => {
const expenses_store = useExpensesStore();
const toUpsertExpense = (obj: {
old_expense?: Expense;
new_expense?: Expense;
}) => obj as UpsertExpense;
const createExpenseByEmployeeEmail = async (employee_email: string, date: string): Promise<void> => {
const upsert_expense = toUpsertExpense({
new_expense: normalizeObject(expenses_store.current_expense, expense_validation_schema),
});
await expenses_store.upsertOrDeleteExpensesByEmployeeEmail(employee_email, date, upsert_expense);
};
const updateExpenseByEmployeeEmail = async (employee_email: string, date: string): Promise<void> => {
const upsert_expense = toUpsertExpense({
old_expense: normalizeObject(expenses_store.initial_expense, expense_validation_schema),
new_expense: normalizeObject(expenses_store.current_expense, expense_validation_schema),
});
await expenses_store.upsertOrDeleteExpensesByEmployeeEmail(employee_email, date, upsert_expense);
};
const deleteExpenseByEmployeeEmail = async (employee_email: string, date: string): Promise<void> => {
const upsert_expense = toUpsertExpense({
old_expense: normalizeObject(expenses_store.initial_expense, expense_validation_schema),
});
await expenses_store.upsertOrDeleteExpensesByEmployeeEmail(employee_email, date, upsert_expense);
};
return {
createExpenseByEmployeeEmail,
updateExpenseByEmployeeEmail,
deleteExpenseByEmployeeEmail,
};
};

View File

@ -1,85 +0,0 @@
import { unwrapAndClone } from "src/utils/unwrap-and-clone";
import { TIME_FORMAT_PATTERN } from "src/modules/timesheets/constants/shift.constants";
import { GenericApiError } from "src/modules/timesheets/models/expense.validation";
import { useShiftStore } from "src/stores/shift-store";
import { default_shift, type Shift, type UpsertShift } from "src/modules/timesheets/models/shift.models";
import { deepEqual } from "src/utils/deep-equal";
export const useShiftApi = () => {
const shift_store = useShiftStore();
const normalizeShiftPayload = (shift: Shift): Shift => {
const comment = shift.comment?.trim() || undefined;
return {
date: shift.date,
start_time: shift.start_time,
end_time: shift.end_time,
type: shift.type,
is_approved: false,
is_remote: shift.is_remote,
comment: comment,
};
};
const parseHHMM = (s: string): [number, number] => {
const m = /^(\d{2}):(\d{2})$/.exec(s);
if (!m) {
throw new GenericApiError({ status_code: 400, message: `Invalid time format: ${s}. Expected HH:MM.` });
}
const h = Number(m[1]);
const min = Number(m[2]);
if (Number.isNaN(h) || Number.isNaN(min) || h < 0 || h > 23 || min < 0 || min > 59) {
throw new GenericApiError({ status_code: 400, message: `Invalid time value: ${s}.` })
}
return [h, min];
};
const toMinutes = (hhmm: string): number => {
const [h, m] = parseHHMM(hhmm);
return h * 60 + m;
};
const validateShift = (shift: Shift, label: 'old_shift' | 'new_shift') => {
if (!TIME_FORMAT_PATTERN.test(shift.start_time) || !TIME_FORMAT_PATTERN.test(shift.end_time)) {
throw new GenericApiError({
status_code: 400,
message: `Invalid time format in ${label}. Expected HH:MM`,
context: { [label]: shift }
});
}
if (toMinutes(shift.end_time) <= toMinutes(shift.start_time)) {
throw new GenericApiError({
status_code: 400,
message: `Invalid time range in ${label}. The End time must be after the Start time`,
context: { [label]: shift }
});
}
};
const upsertOrDeleteShiftByEmployeeEmail = async (employee_email: string): Promise<void> => {
const flat_upsert_shift: UpsertShift = {
...(deepEqual(shift_store.initial_shift, default_shift) ? { old_shift: unwrapAndClone(shift_store.initial_shift) } : {}),
...(deepEqual(shift_store.current_shift, default_shift) ? { new_shift: unwrapAndClone(shift_store.current_shift) } : {}),
};
const normalized_upsert_shift: UpsertShift = {
...(flat_upsert_shift.old_shift ? { old_shift: normalizeShiftPayload(flat_upsert_shift.old_shift) } : {}),
...(flat_upsert_shift.new_shift ? { new_shift: normalizeShiftPayload(flat_upsert_shift.new_shift) } : {}),
};
if (normalized_upsert_shift.old_shift) validateShift(normalized_upsert_shift.old_shift, 'old_shift');
if (normalized_upsert_shift.new_shift) validateShift(normalized_upsert_shift.new_shift, 'new_shift');
await shift_store.upsertOrDeleteShiftByEmployeeEmail(employee_email, normalized_upsert_shift);
};
return {
upsertOrDeleteShiftByEmployeeEmail,
};
}

View File

@ -1,53 +0,0 @@
import { useAuthStore } from "src/stores/auth-store";
import { useTimesheetStore } from "src/stores/timesheet-store"
export const useTimesheetApi = () => {
const timesheet_store = useTimesheetStore();
const auth_store = useAuthStore();
const NEXT = 1;
const PREVIOUS = -1;
const getPayPeriodDetailsByDate = async (date_string: string, employee_email?: string) => {
const success = await timesheet_store.getPayPeriodByDateOrYearAndNumber(date_string);
if (success) {
await timesheet_store.getPayPeriodDetailsByEmployeeEmail(employee_email ?? auth_store.user.email)
}
}
const getNextOrPreviousPayPeriodDetails = async (direction: number, employee_email?: string) => {
const { pay_period } = timesheet_store;
let new_number = pay_period.pay_period_no + direction;
let new_year = pay_period.pay_year;
if (new_number > 26) {
new_number = 1;
new_year += 1;
}
if (new_number < 1) {
new_number = 26;
new_year -= 1;
}
const success = await timesheet_store.getPayPeriodByDateOrYearAndNumber(new_year, new_number);
if (success) {
await timesheet_store.getPayPeriodDetailsByEmployeeEmail(employee_email ?? auth_store.user.email);
}
};
const getNextPayPeriodDetails = async (employee_email?: string) => {
await getNextOrPreviousPayPeriodDetails(NEXT, employee_email ?? auth_store.user.email);
}
const getPreviousPayPeriodDetails = async (employee_email?: string) => {
await getNextOrPreviousPayPeriodDetails(PREVIOUS, employee_email ?? auth_store.user.email);
}
return {
getPayPeriodDetailsByDate,
getNextPayPeriodDetails,
getPreviousPayPeriodDetails,
};
};

View File

@ -0,0 +1,28 @@
/* eslint-disable */
import { normalizeObject } from "src/utils/normalize-object";
import { useExpensesStore } from "src/stores/expense-store";
import { expense_validation_schema } from "src/modules/timesheets/models/expense-validation.models";
import type { Expense } from "src/modules/timesheets/models/expense.models";
export const useExpensesApi = () => {
const expenses_store = useExpensesStore();
const createExpenseByEmployeeEmail = async (employee_email: string, date: string): Promise<void> => {
// await expenses_store.upsertOrDeleteExpensesByEmployeeEmail(employee_email, date, upsert_expense);
};
const updateExpenseByEmployeeEmail = async (employee_email: string, date: string): Promise<void> => {
// await expenses_store.upsertOrDeleteExpensesByEmployeeEmail(employee_email, date, upsert_expense);
};
const deleteExpenseByEmployeeEmail = async (employee_email: string, date: string): Promise<void> => {
// await expenses_store.upsertOrDeleteExpensesByEmployeeEmail(employee_email, date, upsert_expense);
};
return {
createExpenseByEmployeeEmail,
updateExpenseByEmployeeEmail,
deleteExpenseByEmployeeEmail,
};
};

View File

@ -1,36 +0,0 @@
import { ref, computed } from "vue";
import type { Expense } from "src/modules/timesheets/models/expense.models";
import type { ExpenseType } from "src/modules/timesheets/models/expense.models";
export const useExpenseDraft = (initial?: Partial<Expense>) => {
const DEFAULT_TYPE: ExpenseType = 'EXPENSES';
const draft = ref<Partial<Expense>>({
date: '',
type: DEFAULT_TYPE,
comment: '',
...(initial ?? {}),
});
const reset = () => {
draft.value = {
date: '',
type: DEFAULT_TYPE,
comment: '',
}
}
const setType = (value: ExpenseType) => {
draft.value.type = value;
if(value === 'MILEAGE') {
delete draft.value.amount;
} else {
delete draft.value.mileage;
}
};
const showMileage = computed(()=> (draft.value.type as string) === 'MILEAGE');
const showAmount = computed(()=> !showMileage.value);
return { draft, setType, reset, showMileage, showAmount };
}

View File

@ -1,21 +0,0 @@
import { ref } from "vue";
import type { QForm } from "quasar"
export const useExpenseForm = () => {
const formRef = ref<QForm | null>(null);
const triedSubmit = ref(false);
const validateAnd = async (fn: ()=> void | Promise<void>) => {
triedSubmit.value = true;
const ok = await formRef.value?.validate(true);
if(!ok) return false;
await fn();
return true;
};
return {
formRef,
validateAnd,
};
}

View File

@ -1,59 +0,0 @@
// import { ref, type Ref } from "vue";
// import { normalizeObject } from "src/utils/normalize-object";
// import { normExpenseType } from "../utils/expense.util";
// import type { Expense, PayPeriodExpenses } from "src/modules/timesheets/models/expense.models";
// import { useExpensesStore } from "src/stores/expense-store";
// import { unwrapAndClone } from "src/utils/unwrap-and-clone";
// import { expense_validation_schema } from "src/modules/timesheets/models/expense.validation";
// const expenses_store = useExpensesStore();
// export const useExpenseItems = () => {
// let expenses = unwrapAndClone(expenses_store.pay_period_expenses.expenses.map(normalizeExpense));
// const normalizePayload = (expense: Expense): Expense => {
// const exp = normalizeObject(expense, expense_validation_schema);
// const out: Expense = {
// date: exp.date,
// type: exp.type as ExpenseType,
// comment: exp.comment || '',
// };
// if(typeof exp.amount === 'number') out.amount = exp.amount;
// if(typeof exp.mileage === 'number') out.mileage = exp.mileage;
// return out;
// }
// const addFromDraft = () => {
// const candidate: Expense = normalizeExpense({
// date: draft.date,
// type: normExpenseType(draft.type),
// ...(typeof draft.amount === 'number' ? { amount: draft.amount }: {}),
// ...(typeof draft.mileage === 'number' ? { mileage: draft.mileage }: {}),
// comment: String(draft.comment ?? '').trim(),
// } as Expense);
// validateExpenseUI(candidate, 'expense_draft');
// expenses = [ ...expenses, candidate];
// };
// const removeAt = (index: number) => {
// if(index < 0 || index >= expenses.length) return;
// expenses = expenses.filter((_,i)=> i !== index);
// };
// const validateAll = () => {
// for (const expense of expenses) {
// validateExpenseUI(expense, 'expense_item');
// }
// };
// const payload = () => expenses.map(normalizeExpense);
// return {
// expenses,
// addFromDraft,
// removeAt,
// validateAll,
// payload,
// };
// };

View File

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

View File

@ -0,0 +1,38 @@
import { useAuthStore } from "src/stores/auth-store";
import { useTimesheetStore } from "src/stores/timesheet-store"
export const useTimesheetApi = () => {
const timesheet_store = useTimesheetStore();
const auth_store = useAuthStore();
const getTimesheetsByDate = async (date_string: string, employee_email?: string) => {
timesheet_store.is_loading = true;
const success = await timesheet_store.getPayPeriodByDateOrYearAndNumber(date_string);
if (success) {
await timesheet_store.getTimesheetsByEmployeeEmail(employee_email ?? auth_store.user?.email ?? '');
timesheet_store.is_loading = false;
}
timesheet_store.is_loading = false;
}
const getTimesheetsByCurrentPayPeriod = async (employee_email?: string) => {
if (timesheet_store.pay_period === undefined) return false;
timesheet_store.is_loading = true;
const success = await timesheet_store.getPayPeriodByDateOrYearAndNumber(timesheet_store.pay_period.pay_year, timesheet_store.pay_period.pay_period_no );
if (success) {
await timesheet_store.getTimesheetsByEmployeeEmail(employee_email ?? auth_store.user?.email ?? '');
timesheet_store.is_loading = false;
}
timesheet_store.is_loading = false;
};
return {
getTimesheetsByDate,
getTimesheetsByCurrentPayPeriod,
};
};

View File

@ -1 +0,0 @@
export const DATE_FORMAT_PATTERN = /^\d{4}-\d{2}-\d{2}$/;

View File

@ -1,2 +0,0 @@
export const TIME_FORMAT_PATTERN = /^\d{2}:\d{2}$/;
export const DATE_FORMAT_PATTERN = /^\d{4}-\d{2}-\d{2}$/;

View File

@ -43,6 +43,7 @@ export class ExpensesApiError extends ApiError {
}; };
export const expense_validation_schema: Normalizer<Expense> = { export const expense_validation_schema: Normalizer<Expense> = {
id: v => typeof v === 'number' ? v : -1,
date: v => typeof v === 'string' ? v.trim() : '1970-01-01', date: v => typeof v === 'string' ? v.trim() : '1970-01-01',
type: v => EXPENSE_TYPE.includes(v as ExpenseType) ? v as ExpenseType : "EXPENSES", type: v => EXPENSE_TYPE.includes(v as ExpenseType) ? v as ExpenseType : "EXPENSES",
amount: v => typeof v === "number" ? v : -1, amount: v => typeof v === "number" ? v : -1,

View File

@ -1,37 +1,22 @@
export type ExpenseType = 'PER_DIEM' | 'MILEAGE' | 'EXPENSES' | 'PRIME_GARDE'; export type ExpenseType = 'PER_DIEM' | 'MILEAGE' | 'EXPENSES' | 'ON_CALL';
export const EXPENSE_TYPE: ExpenseType[] = ['PER_DIEM', 'MILEAGE', 'EXPENSES', 'PRIME_GARDE',]; export const EXPENSE_TYPE: ExpenseType[] = ['PER_DIEM', 'MILEAGE', 'EXPENSES', 'ON_CALL',];
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', 'PRIME_GARDE',]; export const TYPES_WITH_AMOUNT_ONLY: Readonly<ExpenseType[]> = ['PER_DIEM', 'EXPENSES', 'ON_CALL',];
export interface Expense { export interface Expense {
date: string; id: number;
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 type ExpenseTotals = {
amount: number;
mileage: number;
reimburseable_total?: number;
}; };
export interface PayPeriodExpenses { export const empty_expense: Expense = {
is_approved: boolean; id: -1,
expenses: Expense[];
totals?: ExpenseTotals;
}
export interface UpsertExpense {
old_expense: Expense;
new_expense: Expense;
}
export const default_expense: Expense = {
date: '', date: '',
type: 'EXPENSES', type: 'EXPENSES',
amount: 0, amount: 0,
@ -39,7 +24,24 @@ export const default_expense: Expense = {
is_approved: false, is_approved: false,
}; };
export const default_pay_period_expenses: PayPeriodExpenses = { export const test_expenses: Expense[] = [
{
id: 201,
date: '2025-01-06',
type: 'EXPENSES',
amount: 15.5,
comment: 'Lunch receipt',
is_approved: false, is_approved: false,
expenses: [], },
} {
id: 202,
date: '2025-01-07',
type: 'MILEAGE',
amount: 0,
mileage: 32.4,
comment: 'Travel to client site',
is_approved: true,
},
];

View File

@ -1,78 +0,0 @@
import type { Shift } from "./shift.models";
import { default_expense, type Expense } from "src/modules/timesheets/models/expense.models";
export type Week<T> = {
sun: T;
mon: T;
tue: T;
wed: T;
thu: T;
fri: T;
sat: T;
};
export interface PayPeriodDetails {
weeks: PayPeriodDetailsWeek[];
employee_full_name: string;
}
export interface PayPeriodDetailsWeek {
is_approved: boolean;
shifts: Week<PayPeriodDetailsWeekDayShifts>
expenses: Week<PayPeriodDetailsWeekDayExpenses>;
}
export interface PayPeriodDetailsWeekDayShifts {
shifts: Shift[];
regular_hours: number;
evening_hours: number;
emergency_hours: number;
overtime_hours: number;
total_hours: number;
short_date: string;
break_duration?: number;
}
export interface PayPeriodDetailsWeekDayExpenses {
expenses: Expense[];
total_expenses: number;
total_mileage: number;
}
const makeWeek = <T>(factory: ()=> T): Week<T> => ({
sun: factory(),
mon: factory(),
tue: factory(),
wed: factory(),
thu: factory(),
fri: factory(),
sat: factory(),
});
const emptyDailySchedule = (): PayPeriodDetailsWeekDayShifts => ({
shifts: [],
regular_hours: 0,
evening_hours: 0,
emergency_hours: 0,
overtime_hours: 0,
total_hours: 0,
short_date: "",
break_duration: 0,
});
const emptyDailyExpenses = (): PayPeriodDetailsWeekDayExpenses => ({
expenses: [default_expense,],
total_expenses: -1,
total_mileage: -1,
});
export const defaultPayPeriodDetailsWeek = (): PayPeriodDetailsWeek => ({
is_approved: false,
shifts: makeWeek(emptyDailySchedule),
expenses: makeWeek(emptyDailyExpenses),
});
export const default_pay_period_details: PayPeriodDetails = {
weeks: [ defaultPayPeriodDetailsWeek(), ],
employee_full_name: "",
}

View File

@ -1,16 +1,13 @@
export const SHIFT_TYPES = [ export const SHIFT_TYPES: ShiftType[] = [
'REGULAR', 'REGULAR',
'EVENING', 'EVENING',
'EMERGENCY', 'EMERGENCY',
'OVERTIME',
'HOLIDAY', 'HOLIDAY',
'VACATION', 'VACATION',
'SICK' 'SICK'
]; ];
export type ShiftType = 'REGULAR' | 'EVENING' | 'EMERGENCY' | 'OVERTIME' | 'HOLIDAY' | 'VACATION' | 'SICK' ; export type ShiftType = 'REGULAR' | 'EVENING' | 'EMERGENCY' | 'HOLIDAY' | 'VACATION' | 'SICK';
export type UpsertAction = 'create' | 'update' | 'delete';
export type ShiftLegendItem = { export type ShiftLegendItem = {
type: ShiftType; type: ShiftType;
@ -19,32 +16,31 @@ export type ShiftLegendItem = {
text_color?: string; text_color?: string;
}; };
export interface Shift { export class Shift {
date: string; shift_id: number;
timesheet_id: number;
date: string; //YYYY-MM-DD
type: ShiftType; type: ShiftType;
start_time: string; start_time: string; //HH:mm:ss
end_time: string; end_time: string; //HH:mm:ss
comment: string | undefined; comment: string | undefined;
is_approved: boolean; is_approved: boolean;
is_remote: boolean; is_remote: boolean;
constructor() {
this.shift_id = -1;
this.timesheet_id = -1;
this.date = '';
this.type = 'REGULAR';
this.start_time = '';
this.end_time = '';
this.comment = undefined;
this.is_approved = false;
this.is_remote = false;
}
} }
export interface UpsertShiftsResponse { export interface NewShift {
action: UpsertAction; timesheet_id: number;
day: Shift[]; shifts: Shift[];
} }
export interface UpsertShift {
old_shift?: Shift | undefined;
new_shift?: Shift | undefined;
}
export const default_shift: Readonly<Shift> = {
date: '',
start_time: '--:--',
end_time: '--:--',
type: 'REGULAR',
comment: '',
is_approved: false,
is_remote: false,
};

View File

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

View File

@ -1,6 +0,0 @@
export type PayPeriodLabel = {
start_date: string;
end_date: string;
};
export type UpsertAction = 'created' | 'updated' | 'deleted';

View File

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

View File

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

View File

@ -1,16 +1,9 @@
import { api } from "src/boot/axios"; import { api } from "src/boot/axios";
import type { UpsertShift } from "src/modules/timesheets/models/shift.models";
import type { PayPeriod } from "src/modules/shared/models/pay-period.models"; import type { PayPeriod } from "src/modules/shared/models/pay-period.models";
import type { PayPeriodDetails } from "src/modules/timesheets/models/pay-period-details.models"; import type { TimesheetResponse } from "src/modules/timesheets/models/timesheet.models";
import type { PayPeriodOverview } from "src/modules/timesheet-approval/models/pay-period-overview.models"; import type { TimesheetOverview } from "src/modules/timesheet-approval/models/timesheet-overview.models";
import type { Expense, PayPeriodExpenses, UpsertExpense } from "src/modules/timesheets/models/expense.models";
export const timesheetService = { export const timesheetService = {
getPayPeriodDetailsByEmployeeEmail: async (email: string): Promise<PayPeriodDetails> => {
const response = await api.get(`/timesheets/${encodeURIComponent(email)}`);
return response.data;
},
getPayPeriodByDate: async (date_string: string): Promise<PayPeriod> => { getPayPeriodByDate: async (date_string: string): Promise<PayPeriod> => {
const response = await api.get(`pay-periods/date/${date_string}`); const response = await api.get(`pay-periods/date/${date_string}`);
return response.data; return response.data;
@ -21,30 +14,13 @@ export const timesheetService = {
return response.data; return response.data;
}, },
getPayPeriodOverviewsBySupervisorEmail: async (year: number, period_number: number, supervisor_email: string): Promise<PayPeriodOverview[]> => { getTimesheetOverviewsByPayPeriodAndSupervisorEmail: async (year: number, period_number: number, supervisor_email: string): Promise<TimesheetOverview[]> => {
const response = await api.get(`pay-periods/${year}/${period_number}/${supervisor_email}`); const response = await api.get(`pay-periods/${year}/${period_number}/${supervisor_email}`);
return response.data; return response.data;
}, },
getPayPeriodDetailsByPayPeriodAndEmployeeEmail: async (year: number, period_no: number, email: string): Promise<PayPeriodDetails> => { getTimesheetsByPayPeriodAndEmployeeEmail: async (employee_email: string, year: number, period_number: number): Promise<TimesheetResponse> => {
const response = await api.get('timesheets', { params: { year, period_no, email, } }); const response = await api.get('timesheets', { params: { employee_email, year, period_number } });
return response.data; return response.data;
}, },
getExpensesByPayPeriodAndEmployeeEmail: async (email: string, year: string, period_number: string): Promise<PayPeriodExpenses> => {
const response = await api.get(`/expenses/${email}/${year}/${period_number}`);
return response.data;
},
upsertOrDeleteShiftsByDateAndEmployeeEmail: async (email: string, payload: UpsertShift[], date: string): Promise<PayPeriodDetails> => {
const response = await api.put(`/shifts/upsert/${email}/${date}`, payload);
return response.data;
},
upsertOrDeleteExpensesByPayPeriodAndEmployeeEmail: async (email: string, date: string, payload: UpsertExpense): Promise<Expense[]> => {
const headers = { 'Content-Type': 'application/json' }
const response = await api.put(`/expenses/upsert/${email}/${date}`, payload, { headers });
return response.data;
}
}; };

View File

@ -1,46 +1,21 @@
import type { Expense, ExpenseTotals } from "src/modules/timesheets/models/expense.models"; import type { ExpenseType } from "src/modules/timesheets/models/expense.models";
//------------------ normalization / icons ------------------ export const getExpenseIcon = (type: ExpenseType) => {
export const normExpenseType = (type: unknown): string => switch (type) {
typeof type === 'string' ? type.trim().toUpperCase() : ''; case 'MILEAGE': return 'time_to_leave';
case 'EXPENSES': return 'receipt_long';
const icon_map: Record<string,string> = { case 'PER_DIEM': return 'hotel';
MILEAGE: 'time_to_leave', case 'ON_CALL': return 'phone_android';
EXPENSES: 'receipt_long', default: return 'help_outline';
PER_DIEM: 'hotel', }
PRIME_GARDE: 'admin_panel_settings',
}; };
export const expenseTypeIcon = (type: unknown): string => { export const useExpenseRules = (t: (_key: string) => string) => {
const t = normExpenseType(type);
return (
icon_map[t.toLowerCase()] ??
icon_map[t.replace('-','_').toLowerCase()] ??
'help_outline'
);
};
//------------------ totals ------------------
export const computeExpenseTotals = (items: readonly Expense[]): ExpenseTotals =>
items.reduce<ExpenseTotals>(
(acc, e) => ({
amount: acc.amount + (Number(e.amount) || 0),
mileage: acc.mileage + (Number(e.mileage) || 0),
}),
{ amount: 0, mileage: 0 }
);
//------------------ Quasar :rules=[] ------------------
export const makeExpenseRules = (t: (_key: string) => string) => {
const isPresent = (val: unknown) => val !== undefined && val !== null && val !== ''; const isPresent = (val: unknown) => val !== undefined && val !== null && val !== '';
const typeRequired = (val: unknown) => (!!val) || t('timesheet.expense.errors.type_required'); const typeRequired = (val: unknown) => (!!val) || t('timesheet.expense.errors.type_required');
const amountRequired = (val: unknown) => (isPresent(val)) || t('timesheet.expense.errors.amount_required_for_type'); const amountRequired = (val: unknown) => (isPresent(val)) || t('timesheet.expense.errors.amount_required_for_type');
const mileageRequired = (val: unknown) => (isPresent(val)) || t('timesheet.expense.errors.mileage_required_for_type'); const mileageRequired = (val: unknown) => (isPresent(val)) || t('timesheet.expense.errors.mileage_required_for_type');
const commentRequired = (val: unknown) => (typeof val === 'string' ? val.trim().length > 0 : false) || t('timesheet.expense.errors.comment_required');
const commentRequired = (val: unknown) => (typeof val === 'string' ? val.trim().length > 0 : false ) || t('timesheet.expense.errors.comment_required');
return { return {
typeRequired, typeRequired,

View File

@ -1,130 +0,0 @@
// import { COMMENT_MAX_LENGTH, DATE_FORMAT_PATTERN } from "src/modules/timesheets/constants/expense.constants";
// import { ExpensesValidationError } from "src/modules/timesheets/models/expense.validation";
// import { type Expense, type ExpenseType, TYPES_WITH_AMOUNT_ONLY, TYPES_WITH_MILEAGE_ONLY } from "src/modules/timesheets/models/expense.models";
// //normalization helpers
// export const toNumOrUndefined = (value: unknown): number | undefined => {
// if(value === undefined || value === null || (typeof value === 'string' && value.trim() === '')) return undefined;
// const num = Number(value);
// return Number.isFinite(num) ? num : undefined;
// };
// export const normalizeComment = (input?: string): string | undefined => {
// if(typeof input === 'undefined' || input === null) return undefined;
// const trimmed = String(input).trim();
// return trimmed.length ? trimmed : undefined;
// };
// export const normalizeType = (input: string): string => (input ?? '').trim().toUpperCase();
// export const normalizeExpense = (expense: Expense): Expense => {
// const comment = normalizeComment(expense.comment);
// const amount = toNumOrUndefined(expense.amount);
// const mileage = toNumOrUndefined(expense.mileage);
// return {
// date: (expense.date ?? '').trim(),
// type: normalizeType(expense.type),
// ...(amount !== undefined ? { amount } : {}),
// ...(mileage !== undefined ? { mileage } : {}),
// ...(comment !== undefined ? { comment } : {}),
// ...(typeof expense.supervisor_comment === 'string' && expense.supervisor_comment.trim().length
// ? { supervisor_comment: expense.supervisor_comment.trim() }
// : {}),
// ...(typeof expense.is_approved === 'boolean' ? { is_approved: expense.is_approved }: {} ),
// };
// };
// //UI validation error messages
// export const validateExpenseUI = (raw: Expense, label: string = 'expense'): void => {
// const expense = normalizeExpense(raw);
// //Date input validation
// if(!DATE_FORMAT_PATTERN.test(expense.date)) {
// throw new ExpensesValidationError({
// status_code: 400,
// message: 'timesheet.expense.errors.date_required_or_invalid',
// context: { [label]: expense },
// });
// }
// //comment input validation
// if(!expense.comment) {
// throw new ExpensesValidationError({
// status_code: 400,
// message: 'timesheet.expense.errors.comment_required',
// context: { [label]: expense },
// })
// }
// if((expense.comment.length ?? 0) > COMMENT_MAX_LENGTH) {
// throw new ExpensesValidationError({
// status_code: 400,
// message: 'timesheet.expense.errors.comment_too_long',
// context: { [label]: { ...expense, comment_length: expense.comment?.length } },
// });
// }
// //amount input validation
// if(expense.amount !== undefined && expense.amount <= 0) {
// throw new ExpensesValidationError({
// status_code: 400,
// message: 'timesheet.expense.errors.amount_must_be_positive',
// context: { [label]: expense },
// });
// }
// //mileage input validation
// if(expense.mileage !== undefined && expense.mileage <= 0) {
// throw new ExpensesValidationError({
// status_code: 400,
// message: 'timesheet.expense.errors.mileage_must_be_positive',
// context: { [label]: expense },
// });
// }
// //cross origin amount/mileage validation
// const has_amount = typeof expense.amount === 'number' && expense.amount > 0;
// const has_mileage = typeof expense.mileage === 'number' && expense.mileage > 0;
// if(has_amount === has_mileage) {
// throw new ExpensesValidationError({
// status_code: 400,
// message: 'timesheet.expense.errors.amount_xor_mileage',
// context: { [label]: expense },
// });
// }
// //type constraint validation
// const type = expense.type as ExpenseType;
// if( TYPES_WITH_MILEAGE_ONLY.includes(type) && !has_mileage ) {
// throw new ExpensesValidationError({
// status_code: 400,
// message: 'timesheet.expense.errors.mileage_required_for_type',
// context: { [label]: expense },
// });
// }
// if(TYPES_WITH_AMOUNT_ONLY.includes(type) && !has_amount) {
// throw new ExpensesValidationError({
// status_code: 400,
// message: 'timesheet.expense.errors.amount_required_for_type',
// context: { [label]: expense },
// });
// }
// };
// <<<<<<< HEAD
// //totals per pay-period
// export const compute_expense_totals = (items: Expense[]) => items.reduce(
// (acc, raw) => {
// const expense = normalizeExpense(raw);
// if(typeof expense.amount === 'number' && expense.amount > 0) acc.amount += expense.amount;
// if(typeof expense.mileage === 'number' && expense.mileage > 0) acc.mileage += expense.mileage;
// return acc;
// },
// { amount: 0, mileage: 0 }
// );
// =======
// >>>>>>> 1bdbe021facc85fb50cff6c60053278695df6bdc

View File

@ -1,18 +0,0 @@
// import type { ShiftKey, ShiftPayload, ShiftSelectOption } from "../types/shift.types";
// export const toShiftPayload = (shift: any): ShiftPayload => ({
// start_time: String(shift.start_time),
// end_time: String(shift.end_time),
// type: String(shift.type).toUpperCase() as ShiftKey,
// is_remote: !!shift.is_remote,
// ...(shift.comment ? { comment: String(shift.comment) } : {}),
// });
// export const buildShiftOptions = (
// keys: readonly string[],
// t:(k: string) => string
// ): ShiftSelectOption[] =>
// keys.map((key) => ({
// value: key as any,
// label: t(`timesheet.shift.types.${key}`),
// }));

View File

@ -1,17 +0,0 @@
import type { PayPeriodLabel } from "src/modules/timesheets/models/ui.models";
export const formatPayPeriodLabel = (
raw_label: string | undefined,
locale: string,
extractDate: (_input: string, _mask: string) => Date,
opts: Intl.DateTimeFormatOptions
): PayPeriodLabel => {
const label = raw_label ?? '';
const dates = label.split('.');
if(dates.length < 2) return { start_date: '—', end_date:'—' };
const fmt = new Intl.DateTimeFormat(locale, opts);
const start = fmt.format(extractDate(dates[0]!, 'YYYY-MM-DD'));
const end = fmt.format(extractDate(dates[1]!, 'YYYY-MM-DD'));
return { start_date: start, end_date: end };
}

View File

@ -0,0 +1,47 @@
<script
setup
lang="ts"
>
import { Notify } from 'quasar';
const clickNotify = () => {
Notify.create({
message: 'You clicked the little click button!',
color: 'info'
})
}
</script>
<template>
<q-page
padding
class="q-pa-md row items-center justify-center"
>
<q-card class="shadow-2 col-9 dark-font">
<q-img src="src/assets/line-truck-1.jpg">
<div class="absolute-bottom text-h5">
Welcome to App Targo, !
</div>
</q-img>
<q-card-section class="text-center">
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.
</q-card-section>
<q-separator />
<q-card-actions align="center">
<q-btn
color="primary"
label="Click Me"
@click="clickNotify"
/>
</q-card-actions>
</q-card>
</q-page>
</template>

View File

@ -0,0 +1,18 @@
<script
setup
lang="ts"
>
import EmployeeListTable from 'src/modules/employee-list/components/employee-list-table.vue';
import EmployeeListAddModifyDialog from 'src/modules/employee-list/components/employee/employee-list-add-modify-dialog.vue';
import PageHeaderTemplate from 'src/modules/shared/components/page-header-template.vue';
</script>
<template>
<q-page class="column flex-center">
<EmployeeListAddModifyDialog />
<PageHeaderTemplate title="employee_list.page_header" />
<EmployeeListTable />
</q-page>
</template>

View File

@ -1,6 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import LoginConnectionPanel from 'src/modules/auth/components/login-connection-panel.vue'; import LoginConnectionPanel from 'src/modules/auth/components/login-connection-panel.vue';
import LoginDevBypass from 'src/modules/auth/components/login-dev-bypass.vue';
</script> </script>
<template> <template>
@ -11,9 +10,6 @@
<transition appear slow enter-active-class="animated zoomIn" leave-active-class="animated zoomOut"> <transition appear slow enter-active-class="animated zoomIn" leave-active-class="animated zoomOut">
<LoginConnectionPanel /> <LoginConnectionPanel />
</transition> </transition>
<!-- DEV TOOLS -->
<LoginDevBypass />
</q-page> </q-page>
</q-page-container> </q-page-container>
</q-layout> </q-layout>

View File

@ -1,22 +1,22 @@
<script setup lang="ts"> <script
import ProfileEmployee from 'src/modules/profile/pages/employee/profile-employee.vue'; setup
lang="ts"
>
import MenuEmployee from 'src/modules/profile/components/employee/menu-employee.vue';
import { useAuthStore } from 'src/stores/auth-store'; import { useAuthStore } from 'src/stores/auth-store';
import { type EmployeeProfile } from 'src/modules/employee-list/types/employee-profile-interface'; // import type { EmployeeProfile } from 'src/modules/employee-list/models/employee-profile.models';
const auth_store = useAuthStore(); const auth_store = useAuthStore();
const employee_roles = [ 'SUPERVISOR', 'EMPLOYEE', 'ADMIN', 'HR', 'ACCOUNTING' ]; const employee_roles = ['SUPERVISOR', 'EMPLOYEE', 'ADMIN', 'HR', 'ACCOUNTING'];
const { employeeProfile } = defineProps<{ // const employee_profile = defineModel<EmployeeProfile>({ required: true });
employeeProfile?: EmployeeProfile | undefined;
}>();
</script> </script>
<template> <template>
<q-page class="bg-secondary column items-center justify-center"> <q-page class="bg-secondary column items-center justify-center">
<ProfileEmployee <MenuEmployee
v-if="employee_roles.includes( auth_store.user.role.toUpperCase() )" v-if="employee_roles.includes(auth_store.user?.role.toUpperCase() ?? 'GUEST')"
class="col-auto" class="col-auto"
:employee-profile="employeeProfile"
/> />
</q-page> </q-page>
</template> </template>

View File

@ -1,14 +0,0 @@
<script setup lang="ts">
import SupervisorCrewTable from '../components/supervisor/supervisor-crew-table.vue';
import EmployeeListAddModifyDialog from '../components/employee/employee-list-add-modify-dialog.vue';
</script>
<template>
<q-page>
<EmployeeListAddModifyDialog />
<div class="text-h4 row justify-center q-py-sm q-mt-lg text-uppercase text-weight-bolder">
{{ $t('employee_list.page_header') }}
</div>
<SupervisorCrewTable />
</q-page>
</template>

View File

@ -1,73 +0,0 @@
<script
setup
lang="ts"
>
import { useQuasar } from 'quasar';
import type { QVueGlobals } from 'quasar';
const q: QVueGlobals = useQuasar();
const clickNotify = () => {
q.notify({
message: 'Nick pinged you.',
})
}
</script>
<template>
<q-page
padding
class="q-pa-md row items-center justify-center"
>
<q-card class="shadow-2 col-9 dark-font">
<q-img src="src/assets/line-truck-1.jpg">
<div class="absolute-bottom text-h5">
Welcome to App Targo!
</div>
</q-img>
<q-card-section class="text-center">
Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium,
totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta
sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia
consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est, qui
dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora
incidunt ut labore et dolore magnam aliquam quaerat voluptatem. Ut enim ad minima veniam, quis nostrum
exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? Quis autem
vel eum iure reprehenderit qui in ea voluptate velit esse quam nihil molestiae consequatur, vel illum
qui
dolorem eum fugiat quo voluptas nulla pariatur?
</q-card-section>
<q-card-section class="text-center">
At vero eos et accusamus et iusto odio dignissimos ducimus qui blanditiis praesentium voluptatum
deleniti atque corrupti quos dolores et quas molestias excepturi sint occaecati cupiditate non
provident, similique sunt in culpa qui officia deserunt mollitia animi, id est laborum et dolorum fuga.
Et harum quidem rerum facilis est et expedita distinctio. Nam libero tempore, cum soluta nobis est
eligendi optio cumque nihil impedit quo minus id quod maxime placeat facere possimus, omnis voluptas
assumenda est, omnis dolor repellendus. Temporibus autem quibusdam et aut officiis debitis aut rerum
necessitatibus saepe eveniet ut et voluptates repudiandae sint et molestiae non recusandae. Itaque earum
rerum hic tenetur a sapiente delectus, ut aut reiciendis voluptatibus maiores alias consequatur aut
perferendis doloribus asperiores repellat.
</q-card-section>
<q-card-section class="text-center">
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.
</q-card-section>
<q-separator />
<q-card-actions align="center">
<q-btn
color="primary"
label="Click Me"
@click="clickNotify"
/>
</q-card-actions>
</q-card>
</q-page>
</template>

View File

@ -5,7 +5,7 @@
import { useTimesheetStore } from 'src/stores/timesheet-store'; import { useTimesheetStore } from 'src/stores/timesheet-store';
import PageHeaderTemplate from 'src/modules/shared/components/page-header-template.vue'; import PageHeaderTemplate from 'src/modules/shared/components/page-header-template.vue';
import OverviewList from 'src/modules/timesheet-approval/components/overview-list.vue'; import OverviewList from 'src/modules/timesheet-approval/components/overview-list.vue';
import DetailscrudDialog from 'src/modules/timesheet-approval/components/details-crud-dialog.vue'; import DetailsDialog from 'src/modules/timesheet-approval/components/details-dialog.vue';
const timesheet_approval_api = useTimesheetApprovalApi(); const timesheet_approval_api = useTimesheetApprovalApi();
const timesheet_store = useTimesheetStore(); const timesheet_store = useTimesheetStore();
@ -18,7 +18,7 @@
}; };
onMounted( async () => { onMounted( async () => {
await timesheet_approval_api.getPayPeriodOverviewsByDate(date.formatDate( new Date(), 'YYYY-MM-DD')); await timesheet_approval_api.getPayPeriodOverviewsByDateOrYearAndNumber(date.formatDate( new Date(), 'YYYY-MM-DD'));
}); });
</script> </script>
@ -29,16 +29,16 @@
> >
<PageHeaderTemplate <PageHeaderTemplate
title="timesheet_approvals.page_title" title="timesheet_approvals.page_title"
:start-date="timesheet_store.pay_period.period_start" :start-date="timesheet_store.pay_period?.period_start ?? ''"
:end-date="timesheet_store.pay_period.period_end" :end-date="timesheet_store.pay_period?.period_end ?? ''"
/> />
<DetailscrudDialog <DetailsDialog
v-model:dialog="is_details_dialog_open" v-model:dialog="is_details_dialog_open"
:employee-email="employee_email" :employee-email="employee_email"
:is-loading="timesheet_store.is_loading" :is-loading="timesheet_store.is_loading"
:employee-overview="timesheet_store.current_pay_period_overview" :employee-overview="timesheet_store.current_pay_period_overview"
:timesheet-details="timesheet_store.pay_period_details" :timesheets="timesheet_store.timesheets"
/> />
<OverviewList @clickedDetailsButton="onDetailsClicked"/> <OverviewList @clickedDetailsButton="onDetailsClicked"/>

View File

@ -5,7 +5,7 @@
import { date } from 'quasar'; import { date } from 'quasar';
import { onMounted } from 'vue'; import { onMounted } from 'vue';
import { useAuthStore } from 'src/stores/auth-store'; import { useAuthStore } from 'src/stores/auth-store';
import { useTimesheetApi } from 'src/modules/timesheets/composables/api/use-timesheet-api'; import { useTimesheetApi } from 'src/modules/timesheets/composables/use-timesheet-api';
import { useTimesheetStore } from 'src/stores/timesheet-store'; import { useTimesheetStore } from 'src/stores/timesheet-store';
import PageHeaderTemplate from 'src/modules/shared/components/page-header-template.vue'; import PageHeaderTemplate from 'src/modules/shared/components/page-header-template.vue';
import TimesheetWrapper from 'src/modules/timesheets/components/timesheet-wrapper.vue'; import TimesheetWrapper from 'src/modules/timesheets/components/timesheet-wrapper.vue';
@ -15,7 +15,7 @@
const timesheet_api = useTimesheetApi(); const timesheet_api = useTimesheetApi();
onMounted(async () => { onMounted(async () => {
await timesheet_api.getPayPeriodDetailsByDate(date.formatDate(new Date(), 'YYYY-MM-DD')); await timesheet_api.getTimesheetsByDate(date.formatDate(new Date(), 'YYYY-MM-DD'));
}); });
</script> </script>
@ -23,15 +23,21 @@
<template> <template>
<q-page <q-page
padding padding
class="q-pa-md bg-secondary" class="column q-pa-md bg-secondary items-center"
> >
<PageHeaderTemplate <PageHeaderTemplate
:title="$t('timesheet.page_header')" :title="'timesheet.page_header'"
:start-date="timesheet_store.pay_period.period_start" :start-date="timesheet_store.pay_period?.period_start ?? ''"
:end-date="timesheet_store.pay_period.period_end" :end-date="timesheet_store.pay_period?.period_end ?? ''"
class="col-auto"
/> />
<TimesheetWrapper :employee-email="user.email" /> <div
class="col"
:style="$q.screen.gt.sm ? 'width: 90vw' : ''"
>
<TimesheetWrapper :employee-email="user?.email ?? ''" />
</div>
</q-page> </q-page>
</template> </template>

View File

@ -2,6 +2,7 @@ import { defineRouter } from '#q-app/wrappers';
import { createMemoryHistory, createRouter, createWebHashHistory, createWebHistory, } from 'vue-router'; import { createMemoryHistory, createRouter, createWebHashHistory, createWebHistory, } from 'vue-router';
import routes from './routes'; import routes from './routes';
import { useAuthStore } from 'src/stores/auth-store'; import { useAuthStore } from 'src/stores/auth-store';
import { RouteNames } from 'src/router/router-constants';
/* /*
* If not building with SSR mode, you can * If not building with SSR mode, you can
@ -27,10 +28,12 @@ export default defineRouter(function (/* { store, ssrContext } */) {
history: createHistory(process.env.VUE_ROUTER_BASE), history: createHistory(process.env.VUE_ROUTER_BASE),
}); });
Router.beforeEach((destinationPage) => { Router.beforeEach(async (destinationPage) => {
const authStore = useAuthStore(); const authStore = useAuthStore();
const result = await authStore.getProfile() ?? { status: 400, message: 'unknown error occured' };
if (destinationPage.meta.requiresAuth && !authStore.isAuthorizedUser) { if ((destinationPage.meta.requiresAuth && !authStore.isAuthorizedUser) || (result.status >= 400 && destinationPage.name !== RouteNames.LOGIN)) {
console.log('no user account found');
return { name: 'login' }; return { name: 'login' };
} }
}) })

View File

@ -6,5 +6,5 @@ export enum RouteNames {
TIMESHEET_APPROVALS = 'timesheet-approvals', TIMESHEET_APPROVALS = 'timesheet-approvals',
EMPLOYEE_LIST = 'employee-list', EMPLOYEE_LIST = 'employee-list',
PROFILE = 'user/profile', PROFILE = 'user/profile',
TIMESHEET_TEMP = 'timesheet-temp' TIMESHEET = 'timesheet'
} }

View File

@ -10,7 +10,7 @@ const routes: RouteRecordRaw[] = [
{ {
path: '', path: '',
name: RouteNames.DASHBOARD, name: RouteNames.DASHBOARD,
component: () => import('src/pages/test-page.vue'), component: () => import('src/pages/dashboard-page.vue'),
}, },
{ {
path: 'timesheet-approvals', path: 'timesheet-approvals',
@ -20,11 +20,11 @@ const routes: RouteRecordRaw[] = [
{ {
path: 'employees', path: 'employees',
name: RouteNames.EMPLOYEE_LIST, name: RouteNames.EMPLOYEE_LIST,
component: () => import('src/pages/supervisor-crew-page.vue'), component: () => import('src/pages/employee-list-page.vue'),
}, },
{ {
path: 'timesheet-temp', path: 'timesheet',
name: RouteNames.TIMESHEET_TEMP, name: RouteNames.TIMESHEET,
component: () => import('src/pages/timesheet-page.vue') component: () => import('src/pages/timesheet-page.vue')
}, },
{ {
@ -38,7 +38,7 @@ const routes: RouteRecordRaw[] = [
{ {
path: '/v1/login', path: '/v1/login',
name: RouteNames.LOGIN, name: RouteNames.LOGIN,
component: () => import('src/modules/auth/pages/auth-login.vue'), component: () => import('src/pages/login-page.vue'),
meta: { requiresAuth: false }, meta: { requiresAuth: false },
}, },

View File

@ -1,47 +1,67 @@
import { computed, ref } from "vue"; import { computed, ref } from "vue";
import { defineStore } from "pinia"; import { defineStore } from "pinia";
import { AuthService } from "../modules/auth/services/services-auth"; import { AuthService } from "../modules/auth/services/services-auth";
import type { User } from "src/modules/shared/models/user.models"; import { CAN_APPROVE_PAY_PERIODS, type User } from "src/modules/shared/models/user.models";
import { useRouter } from "vue-router";
export type CompanyRole = 'guest' | 'supervisor' | 'accounting' | 'human_resources' | 'employee'; import { Notify } from "quasar";
const TestUsers: Record<CompanyRole, User> = {
guest: { firstName: 'Unknown', lastName: 'Unknown', email: 'guest@guest.com', role: 'guest' },
supervisor: { firstName: 'User', lastName: 'Test', email: 'user@targointernet.com', role: 'supervisor' },
accounting: { firstName: 'Robin', lastName: 'Clark', email: 'user5@example.test', role: 'supervisor' },
human_resources: { firstName: 'Robin', lastName: 'Clark', email: 'user5@example.test', role: 'supervisor' },
employee: { firstName: 'Robin', lastName: 'Clark', email: 'user5@example.test', role: 'supervisor' },
}
export const useAuthStore = defineStore('auth', () => { export const useAuthStore = defineStore('auth', () => {
const user = ref<User>(TestUsers.guest); const user = ref<User>();
const authError = ref(""); const authError = ref("");
const isAuthorizedUser = computed(() => user.value.role !== 'guest'); const isAuthorizedUser = computed(() => CAN_APPROVE_PAY_PERIODS.includes(user.value?.role ?? 'GUEST'));
const router = useRouter();
const login = () => { const login = () => {
//TODO: manage customer login process //TODO: manage customer login process
}; };
const oidcLogin = () => { const oidcLogin = () => {
const oidcPopup = AuthService.oidcLogin(); window.addEventListener('message', (event) => {
if (!oidcPopup) { void handleAuthMessage(event);
authError.value = "You have popups blocked on this website!"; });
}
const oidc_popup = window.open(`${import.meta.env.VITE_TARGO_BACKEND_AUTH_URL}auth/v1/login`, 'authPopup', 'width=600,height=800');
if (!oidc_popup)
Notify.create({
message: "You have popups blocked on this website!",
color: 'negative',
textColor: 'white',
});
}; };
const logout = () => { const logout = () => {
user.value = TestUsers.guest; user.value = undefined;
}; };
const setUser = (bypassRole: string) => { const handleAuthMessage = async (event: MessageEvent) => {
if (bypassRole in TestUsers) { if (event.data.type === 'authSuccess') {
user.value = TestUsers[bypassRole as CompanyRole]; try {
await getProfile();
await router.push('/');
} catch (error) {
console.error('failed to login: ', error);
} }
else { } else {
user.value = TestUsers.guest; Notify.create({
message: "You have popups blocked on this website!",
color: 'negative',
textColor: 'white',
});
} }
}; };
return { user, authError, isAuthorizedUser, login, oidcLogin, logout, setUser }; const getProfile = async (): Promise<{ status: number, message: string }> => {
try {
const new_user = await AuthService.getProfile();
user.value = new_user;
return { status: 200, message: 'profile retrieved successfully' };
} catch (error) {
console.error('error while retrieving profile: ', error);
}
return { status: 400, message: 'unknown error occured' };
}
return { user, authError, isAuthorizedUser, login, oidcLogin, logout, getProfile };
}); });

View File

@ -1,12 +1,11 @@
import { ref } from "vue"; import { ref } from "vue";
import { defineStore } from "pinia"; import { defineStore } from "pinia";
import { EmployeeListService } from "src/modules/employee-list/services/services-employee-list"; import { EmployeeListService } from "src/modules/employee-list/services/employee-list-service";
import { default_employee_profile, type EmployeeProfile } from "src/modules/employee-list/types/employee-profile-interface"; import { default_employee_profile, type EmployeeProfile } from "src/modules/employee-list/models/employee-profile.models";
import type { EmployeeListTableItem } from "src/modules/employee-list/types/employee-list-table-interface";
export const useEmployeeStore = defineStore('employee', () => { export const useEmployeeStore = defineStore('employee', () => {
const employee = ref<EmployeeProfile>( default_employee_profile ); const employee = ref<EmployeeProfile>( default_employee_profile );
const employeeList = ref<EmployeeListTableItem[]>([]); const employeeList = ref<EmployeeProfile[]>([]);
const isShowingEmployeeAddModifyWindow = ref<boolean>(false); const isShowingEmployeeAddModifyWindow = ref<boolean>(false);
const isLoadingEmployeeProfile = ref(false); const isLoadingEmployeeProfile = ref(false);
const isLoadingEmployeeList = ref(false); const isLoadingEmployeeList = ref(false);

View File

@ -1,23 +1,17 @@
import { computed, ref } from "vue"; import { ref } from "vue";
import { defineStore } from "pinia"; import { defineStore } from "pinia";
import { useTimesheetStore } from "src/stores/timesheet-store"; import { ExpensesApiError, type GenericApiError } from "src/modules/timesheets/models/expense-validation.models";
import { default_expense, default_pay_period_expenses, type UpsertExpense, type Expense, type PayPeriodExpenses } from "src/modules/timesheets/models/expense.models"; import { empty_expense, test_expenses, type Expense } from "src/modules/timesheets/models/expense.models";
import { timesheetService } from "src/modules/timesheets/services/timesheet-service"; import { ExpenseService } from "src/modules/timesheets/services/expense-service";
import { ExpensesApiError, type GenericApiError } from "src/modules/timesheets/models/expense.validation";
import { computeExpenseTotals } from "src/modules/timesheets/utils/expense.util";
import type { UpsertAction } from "src/modules/timesheets/models/shift.models";
export const useExpensesStore = defineStore('expenses', () => { export const useExpensesStore = defineStore('expenses', () => {
const timesheet_store = useTimesheetStore();
const is_open = ref(false); const is_open = ref(false);
const is_loading = ref(false); const is_loading = ref(false);
const mode = ref<UpsertAction>('create'); const pay_period_expenses = ref<Expense[]>(test_expenses);
const pay_period_expenses = ref<PayPeriodExpenses>(default_pay_period_expenses); const current_expense = ref<Expense>(empty_expense);
const pay_period_expenses_totals = computed(() => computeExpenseTotals(pay_period_expenses.value.expenses)) const initial_expense = ref<Expense>(empty_expense);
const current_expense = ref<Expense>(default_expense);
const initial_expense = ref<Expense>(default_expense);
const error = ref<string | null>(null); const error = ref<string | null>(null);
// const setErrorFrom = (err: unknown) => { // const setErrorFrom = (err: unknown) => {
@ -25,14 +19,14 @@ export const useExpensesStore = defineStore('expenses', () => {
// error.value = e?.message || 'Unknown error'; // error.value = e?.message || 'Unknown error';
// }; // };
const open = async (employee_email: string): Promise<void> => { const open = (): void => {
is_open.value = true; is_open.value = true;
is_loading.value = true; is_loading.value = true;
error.value = null; error.value = null;
current_expense.value = default_expense; current_expense.value = empty_expense;
initial_expense.value = default_expense; initial_expense.value = empty_expense;
await getPayPeriodExpensesByEmployeeEmail(employee_email); // await getPayPeriodExpensesByTimesheetId(timesheet_id);
is_loading.value = false; is_loading.value = false;
} }
@ -41,16 +35,12 @@ export const useExpensesStore = defineStore('expenses', () => {
is_open.value = false; is_open.value = false;
}; };
const getPayPeriodExpensesByEmployeeEmail = async (employee_email: string): Promise<void> => { const getPayPeriodExpensesByTimesheetId = async (timesheet_id: number): Promise<void> => {
is_loading.value = true; is_loading.value = true;
error.value = null; error.value = null;
try { try {
const expenses = await timesheetService.getExpensesByPayPeriodAndEmployeeEmail( const expenses = await ExpenseService.getExpensesByTimesheetId(timesheet_id);
encodeURIComponent(employee_email),
encodeURIComponent(timesheet_store.pay_period.pay_year),
encodeURIComponent(timesheet_store.pay_period.pay_period_no),
);
pay_period_expenses.value = expenses; pay_period_expenses.value = expenses;
} catch (err: unknown) { } catch (err: unknown) {
if (typeof err === 'object') { if (typeof err === 'object') {
@ -72,21 +62,16 @@ export const useExpensesStore = defineStore('expenses', () => {
} }
}; };
const upsertOrDeleteExpensesByEmployeeEmail = async (employee_email: string, date: string, expense: UpsertExpense): Promise<void> => { const upsertOrDeleteExpensesById = async (expense_id: number): Promise<void> => {
is_loading.value = true; is_loading.value = true;
error.value = null; error.value = null;
try { try {
const updated_expenses = await timesheetService.upsertOrDeleteExpensesByPayPeriodAndEmployeeEmail( await ExpenseService.upsertOrDeleteExpenseById(expense_id);
encodeURIComponent(employee_email), // TODO: Save response data into proper ref
encodeURIComponent(date),
expense,
);
console.log('updated expenses received: ', updated_expenses)
pay_period_expenses.value.expenses = updated_expenses;
} catch (err) { } catch (err) {
// setErrorFrom(err); // setErrorFrom(err);
console.log('error doing some expense thing: ', err) console.error(err);
} finally { } finally {
is_loading.value = false; is_loading.value = false;
} }
@ -95,15 +80,13 @@ export const useExpensesStore = defineStore('expenses', () => {
return { return {
is_open, is_open,
is_loading, is_loading,
mode,
pay_period_expenses, pay_period_expenses,
pay_period_expenses_totals,
current_expense, current_expense,
initial_expense, initial_expense,
error, error,
open, open,
getPayPeriodExpensesByEmployeeEmail, getPayPeriodExpensesByTimesheetId,
upsertOrDeleteExpensesByEmployeeEmail, upsertOrDeleteExpensesById,
close, close,
}; };
}); });

View File

@ -1,80 +1,71 @@
import { ref } from "vue"; import { ref } from "vue";
import { defineStore } from "pinia"; import { defineStore } from "pinia";
import { unwrapAndClone } from "src/utils/unwrap-and-clone"; import { ShiftService } from "src/modules/timesheets/services/shift-service";
import { timesheetService } from "src/modules/timesheets/services/timesheet-service";
import { useTimesheetStore } from "src/stores/timesheet-store"; import { useTimesheetStore } from "src/stores/timesheet-store";
import { default_shift, type UpsertAction, type Shift, type UpsertShift } from "src/modules/timesheets/models/shift.models"; import { Notify } from "quasar";
export const useShiftStore = defineStore('shift', () => {
const is_open = ref(false);
const mode = ref<UpsertAction>('create');
const date_iso = ref<string>('');
const current_shift = ref<Shift>(default_shift);
const initial_shift = ref<Shift>(default_shift);
export const useShiftStore = defineStore('shift_store', () => {
const timesheet_store = useTimesheetStore(); const timesheet_store = useTimesheetStore();
const shift_error = ref();
const open = (next_mode: UpsertAction, date: string, current: Shift, initial: Shift) => { const deleteShiftById = async (shift_id: number): Promise<boolean> => {
mode.value = next_mode; try {
date_iso.value = date; await ShiftService.deleteShiftById(shift_id);
current_shift.value = current; // new shift return true;
initial_shift.value = initial; // old shift } catch (error) {
is_open.value = true; console.error('DEV ERROR || error while deleting shift: ', error);
}; return false;
const openCreate = (date: string) => {
open('create', date, default_shift, default_shift);
};
const openUpdate = (date: string, shift: Shift) => {
open('update', date, shift, unwrapAndClone(shift));
};
const openDelete = (date: string, shift: Shift) => {
open('delete', date, default_shift, shift);
} }
const close = () => {
is_open.value = false;
mode.value = 'create';
date_iso.value = '';
current_shift.value = default_shift;
initial_shift.value = default_shift;
}; };
const upsertOrDeleteShiftByEmployeeEmail = async (employee_email: string, upsert_shift: UpsertShift) => { const createNewShifts = async (): Promise<boolean> => {
const encoded_email = encodeURIComponent(employee_email); if (timesheet_store.timesheets === undefined) return false;
const encoded_date = encodeURIComponent(current_shift.value.date);
try { try {
const result = await timesheetService.upsertOrDeleteShiftsByDateAndEmployeeEmail(encoded_email, [ upsert_shift, ], encoded_date); const new_shifts = timesheet_store.timesheets.flatMap(week => week.days).flatMap(day => day.shifts).filter(shift => shift.shift_id < 0);
timesheet_store.pay_period_details = result;
} catch (err) { if (new_shifts?.length > 0) {
console.log('error doing thing: ', err) const response = await ShiftService.createNewShifts(new_shifts);
// const status_code: number = err?.response?.status ?? 500; if (response.status <= 200) {
// const data = err?.response?.data ?? {}; return true;
// throw new GenericApiError({
// status_code,
// error_code: data.error_code,
// message: data.message || data.error || err.message,
// context: data.context,
// });
} finally {
close();
} }
} }
console.log('No new shifts to save');
Notify.create('no new shifts to save')
return false;
} catch (error) {
console.error('Error creating new shifts: ', error);
return false;
}
};
const updateShifts = async (): Promise<boolean> => {
if (timesheet_store.timesheets === undefined) return false;
try {
const existing_shifts = timesheet_store.timesheets.flatMap(week => week.days).flatMap(day => day.shifts).filter(shift => shift.shift_id > 0);
if (existing_shifts?.length > 0) {
const response = await ShiftService.updateShifts(existing_shifts);
if (response.status <= 200) {
return true;
}
}
console.log('No shifts to update');
Notify.create('no shifts to update')
return false;
} catch (error) {
console.error('Error updating shifts: ', error);
return false;
}
}
return { return {
is_open, shift_error,
mode, deleteShiftById,
date_iso, createNewShifts,
current_shift, updateShifts,
initial_shift, }
openCreate,
openUpdate,
openDelete,
close,
upsertOrDeleteShiftByEmployeeEmail,
};
}) })

View File

@ -1,28 +1,24 @@
import { ref } from 'vue';
import { defineStore } from 'pinia'; import { defineStore } from 'pinia';
import { computed, ref } from 'vue'; import { useAuthStore } from 'src/stores/auth-store';
import { withLoading } from 'src/utils/store-helpers';
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 { default_pay_period_overview, type PayPeriodOverview } from "src/modules/timesheet-approval/models/pay-period-overview.models"; import type { TimesheetOverview } from "src/modules/timesheet-approval/models/timesheet-overview.models";
import { default_pay_period, type PayPeriod } from 'src/modules/shared/models/pay-period.models'; import type { PayPeriod } from 'src/modules/shared/models/pay-period.models';
import { default_pay_period_details, type PayPeriodDetails } from 'src/modules/timesheets/models/pay-period-details.models'; import type { Timesheet } from 'src/modules/timesheets/models/timesheet.models';
import type { TimesheetApprovalCSVReportFilters } from 'src/modules/timesheet-approval/models/timesheet-approval-csv-report.models'; import type { TimesheetApprovalCSVReportFilters } from 'src/modules/timesheet-approval/models/timesheet-approval-csv-report.models';
export const useTimesheetStore = defineStore('timesheet', () => { export const useTimesheetStore = defineStore('timesheet', () => {
const auth_store = useAuthStore();
const is_loading = ref<boolean>(false); const is_loading = ref<boolean>(false);
const pay_period = ref<PayPeriod>(default_pay_period); const pay_period = ref<PayPeriod>();
const pay_period_overviews = ref<PayPeriodOverview[]>([default_pay_period_overview,]); const pay_period_overviews = ref<TimesheetOverview[]>([]);
const current_pay_period_overview = ref<PayPeriodOverview>(default_pay_period_overview); const current_pay_period_overview = ref<TimesheetOverview>();
const pay_period_details = ref<PayPeriodDetails>(default_pay_period_details); const timesheets = ref<Timesheet[]>();
const pay_period_report = ref(); const pay_period_report = ref();
const is_calendar_limit = computed(() =>
pay_period.value.pay_year === 2024 &&
pay_period.value.pay_period_no <= 1
);
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> => {
is_loading.value = true;
try { try {
if (typeof date_or_year === 'string') { if (typeof date_or_year === 'string') {
pay_period.value = await timesheetService.getPayPeriodByDate(date_or_year); pay_period.value = await timesheetService.getPayPeriodByDate(date_or_year);
@ -30,33 +26,31 @@ export const useTimesheetStore = defineStore('timesheet', () => {
else if (typeof date_or_year === 'number' && period_number) { else if (typeof date_or_year === 'number' && period_number) {
pay_period.value = await timesheetService.getPayPeriodByYearAndPeriodNumber(date_or_year, period_number); pay_period.value = await timesheetService.getPayPeriodByYearAndPeriodNumber(date_or_year, period_number);
} }
else pay_period.value = default_pay_period; else pay_period.value = undefined;
is_loading.value = false;
return true; return true;
} catch (error) { } catch (error) {
console.error('Could not get current pay period: ', error); console.error('Could not get current pay period: ', error);
pay_period.value = default_pay_period; pay_period.value = undefined;
pay_period_overviews.value = [default_pay_period_overview,]; pay_period_overviews.value = [];
//TODO: More in-depth error-handling here //TODO: More in-depth error-handling here
is_loading.value = false;
return false; return false;
} }
}; };
const getPayPeriodOverviewsBySupervisorEmail = async (pay_year: number, period_number: number, supervisor_email: string): Promise<boolean> => { const getTimesheetOverviewsByPayPeriod = async (pay_year: number, period_number: number, supervisor_email?: string): Promise<boolean> => {
is_loading.value = true; is_loading.value = true;
try { try {
const response = await timesheetApprovalService.getPayPeriodOverviewsBySupervisorEmail(pay_year, period_number, supervisor_email); const response = await timesheetApprovalService.getPayPeriodOverviewsBySupervisorEmail(pay_year, period_number, supervisor_email ?? auth_store.user?.email ?? '');
pay_period_overviews.value = response.employees_overview; pay_period_overviews.value = response.employees_overview;
is_loading.value = false; is_loading.value = false;
return true; return true;
} catch (error) { } catch (error) {
console.error('There was an error retrieving Employee Pay Period overviews: ', error); console.error('There was an error retrieving Employee Pay Period overviews: ', error);
pay_period_overviews.value = [default_pay_period_overview,]; pay_period_overviews.value = [];
// TODO: More in-depth error-handling here // TODO: More in-depth error-handling here
is_loading.value = false; is_loading.value = false;
@ -64,27 +58,23 @@ export const useTimesheetStore = defineStore('timesheet', () => {
} }
}; };
const getPayPeriodDetailsByEmployeeEmail = async (employee_email: string) => { const getTimesheetsByEmployeeEmail = async (employee_email: string) => {
is_loading.value = true; is_loading.value = true;
if (pay_period.value === undefined) return;
try { try {
const response = await timesheetService.getPayPeriodDetailsByPayPeriodAndEmployeeEmail( const response = await timesheetService.getTimesheetsByPayPeriodAndEmployeeEmail(employee_email, pay_period.value.pay_year, pay_period.value.pay_period_no);
pay_period.value.pay_year, timesheets.value = response.timesheets;
pay_period.value.pay_period_no,
employee_email
);
pay_period_details.value = response;
console.log('pay period details: ', response, pay_period_details.value.employee_full_name)
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);
// TODO: More in-depth error-handling here // TODO: More in-depth error-handling here
pay_period_details.value = default_pay_period_details; timesheets.value = [];
is_loading.value = false; is_loading.value = false;
} }
}; };
const getPayPeriodReportByYearAndPeriodNumber = async (year: number, period_number: number, report_filters?: TimesheetApprovalCSVReportFilters) => { const getPayPeriodReportByYearAndPeriodNumber = async (year: number, period_number: number, report_filters?: TimesheetApprovalCSVReportFilters) => {
return withLoading(is_loading.value, async () => {
try { try {
const response = await timesheetApprovalService.getPayPeriodReportByYearAndPeriodNumber( const response = await timesheetApprovalService.getPayPeriodReportByYearAndPeriodNumber(
year, year,
@ -92,7 +82,6 @@ export const useTimesheetStore = defineStore('timesheet', () => {
report_filters report_filters
); );
pay_period_report.value = response; pay_period_report.value = response;
return true; return true;
} catch (error) { } catch (error) {
console.error('There was an error retrieving the report CSV: ', error); console.error('There was an error retrieving the report CSV: ', error);
@ -100,19 +89,17 @@ export const useTimesheetStore = defineStore('timesheet', () => {
} }
return false; return false;
});
}; };
return { return {
is_loading, is_loading,
is_calendar_limit,
pay_period, pay_period,
pay_period_overviews, pay_period_overviews,
current_pay_period_overview, current_pay_period_overview,
pay_period_details, timesheets,
getPayPeriodByDateOrYearAndNumber, getPayPeriodByDateOrYearAndNumber,
getPayPeriodOverviewsBySupervisorEmail, getTimesheetOverviewsByPayPeriod,
getPayPeriodDetailsByEmployeeEmail, getTimesheetsByEmployeeEmail,
getPayPeriodReportByYearAndPeriodNumber, getPayPeriodReportByYearAndPeriodNumber,
}; };
}); });

View File

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