Merge pull request 'dev/nicolas/timesheet-approval-staging-prep' (#37) from dev/nicolas/timesheet-approval-staging-prep into main

Reviewed-on: Targo/targo_frontend#37
This commit is contained in:
Nicolas 2026-01-06 09:16:55 -05:00
commit 07b52c854f
53 changed files with 1036 additions and 748 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

BIN
src/assets/info-pannes.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 938 KiB

After

Width:  |  Height:  |  Size: 578 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 151 KiB

After

Width:  |  Height:  |  Size: 150 KiB

View File

@ -4,8 +4,9 @@ export default {
welcome_title: "Welcome to the new Targo Application!",
welcome_message: "Development is complete and the application is live! Things have remained mostly the same, but with a new coat of paint, a more streamlined user experience, and most importantly, drastically improved security and optimization!",
help_title: "We have a help page!",
help_message: "We did our best to keep the app intuitive with as few clicks and changes as possible, but it's not always perfect! We made this page to explain every part of the app if you any of it ever feels confusing.",
help_message: "We've modernized the app while trying to make as few functional changes as possible, but if there's ever any part of the site that leaves you scratching your head, feel free to check out the help page.",
},
useful_links: "useful links",
},
help: {
label: "Centre d'aide",
@ -207,6 +208,7 @@ export default {
modify: "modify",
close: "close",
download: "download",
open: "open",
},
misc: {
or: "or",
@ -243,6 +245,9 @@ export default {
page_header: "Timesheet",
week: "week",
total_hours: "total hours: ",
total_expenses: "total expenses: ",
vacation_available: "vacation time available: ",
sick_available: "sick time available: ",
current_shifts: "shifts worked",
apply_preset: "auto-fill",
apply_preset_day: "Apply schedule to day",
@ -329,6 +334,7 @@ export default {
},
print_report: {
title: "Download options",
description: "Choose what to include in the report",
company: "companies",
type: "type",
shifts: "shifts",
@ -345,7 +351,7 @@ export default {
unverified: "pending",
inactive: "inactive",
filter_active: "show only active employees",
filter_team: "",
filter_team: "show my team only",
},
tooltip: {
button_detailed_view: "detailed view",

View File

@ -2,10 +2,11 @@ export default {
dashboard: {
carousel: {
welcome_title: "Bienvenue dans la nouvelle application Targo!",
welcome_message: "Le développement est terminé et l'application est officiellement en ligne! Les fonctionnalités demeurent grandement intactes comparé à l'ancienne version, mise à part une nouvelle couche de peinture, une expérience utilisateur plus intuitive et surtout une sécurité et optimization drastiquement amélioriés!",
welcome_message: "La nouvelle application est officiellement en ligne ! Plus performante et plus sécuritaire, elle conserve lessentiel avec un design rafraîchie.",
help_title: "Nous avons une page d'aide!",
help_message: "Nous avons fait notre possible pour rendre l'application plus intuitive et facile d'accès en suivant les tendances modernes, mais il y a toujours place à l'amélioration! La page d'aide est là pour vous si jamais nous avons raté la cible et qu'une partie du site semble nébuleux.",
help_message: "Lapplication a été pensée pour être plus intuitive et moderne. En cas de doute, la page daide est à votre disposition.",
},
useful_links: "liens utiles",
},
help: {
label: "Centre d'aide",
@ -207,7 +208,8 @@ export default {
update: "mettre à jour",
modify: "modifier",
close: "fermer",
download: "téléchargement",
download: "télécharger",
open: "ouvrir",
},
misc: {
or: "ou",
@ -244,6 +246,9 @@ export default {
page_header: "Carte de temps",
week: "semaine",
total_hours: "heures totales: ",
total_expenses: "dépenses totales: ",
vacation_available: "vacances disponibles: ",
sick_available: "congés maladie disponible: ",
current_shifts: "quarts entrées",
apply_preset: "auto-remplir",
apply_preset_day: "Appliquer horaire pour la journée",
@ -330,6 +335,7 @@ export default {
},
print_report: {
title: "options de téléchargement",
description: "Choisissez ce qui sera inclu dans le rapport",
company: "compagnies",
type: "types de données",
shifts: "quarts de travail",
@ -345,8 +351,8 @@ export default {
verified: "approuvé",
unverified: "à vérifier",
inactive: "inactif",
filter_active: "",
filter_team: "",
filter_active: "montrer les employés inactifs",
filter_team: "montrer mon équipe seulement",
},
tooltip: {
button_detailed_view: "vue détaillée",

View File

@ -2,21 +2,34 @@
setup
lang="ts"
>
import { onMounted, ref } from 'vue';
import { useQuasar } from 'quasar';
import { useRouter } from 'vue-router';
import { useAuthStore } from 'src/stores/auth-store';
import { useUiStore } from 'src/stores/ui-store';
import { ref } from 'vue';
import { RouteNames } from 'src/router/router-constants';
import { ModuleNames } from 'src/modules/shared/models/user.models';
import { ModuleNames, type UserModuleAccess } from 'src/modules/shared/models/user.models';
const DRAWER_BUTTONS: { i18n_key: string, icon: string, route: RouteNames, required_module?: UserModuleAccess }[] = [
{ i18n_key: 'nav_bar.home', icon: "home", route: RouteNames.DASHBOARD, required_module: ModuleNames.DASHBOARD },
{ i18n_key: 'nav_bar.timesheet_approvals', icon: "event_available", route: RouteNames.TIMESHEET_APPROVALS, required_module: ModuleNames.TIMESHEETS_APPROVAL },
{ i18n_key: 'nav_bar.employee_list', icon: "groups", route: RouteNames.EMPLOYEE_LIST, required_module: ModuleNames.EMPLOYEE_LIST },
{ i18n_key: 'nav_bar.timesheet', icon: "punch_clock", route: RouteNames.TIMESHEET, required_module: ModuleNames.TIMESHEETS },
{ i18n_key: 'nav_bar.profile', icon: "account_box", route: RouteNames.PROFILE, required_module: ModuleNames.PERSONAL_PROFILE },
{ i18n_key: 'nav_bar.help', icon: "contact_support", route: RouteNames.HELP },
]
const q = useQuasar();
const auth_store = useAuthStore();
const ui_store = useUiStore();
const router = useRouter();
const is_mini = ref(true);
const goToPageName = (page_name: string) => {
router.push({ name: page_name }).catch(err => {
console.error('Error with Vue Router: ', err);
const onClickDrawerPage = (page_name: RouteNames) => {
is_mini.value = true;
router.push({ name: page_name }).catch(error => {
console.error('failed to reach page: ', error);
});
};
@ -26,7 +39,13 @@
router.push({ name: 'login' }).catch(err => {
console.error('could not log you out: ', err);
})
};
onMounted(() => {
if (q.platform.is.mobile) {
ui_store.is_left_drawer_open = false;
}
})
</script>
<template>
@ -39,102 +58,56 @@
:mini="is_mini"
@mouseenter="is_mini = false"
@mouseleave="is_mini = true"
class="bg-dark z-max column no-wrap"
:class="!$q.platform.is.mobile && is_mini ? 'items-center' : 'items-start'"
class="bg-dark z-max"
>
<!-- Home -->
<q-btn
flat
dense
no-wrap
<q-scroll-area class="column fit">
<div
v-for="button, index in DRAWER_BUTTONS"
:key="index"
v-show="button.required_module ?? true"
@click="onClickDrawerPage(button.route)"
>
<div
v-if="button.required_module ? auth_store.user?.user_module_access.includes(button.required_module) : true"
class="row items-center full-width q-py-sm cursor-pointer"
:class="$router.currentRoute.value.name === button.route ? ($q.dark.isActive ? 'bg-green-10' : 'bg-green-2') : ''"
>
<q-icon
:name="button.icon"
color="accent"
size="lg"
icon="home"
:label="!$q.platform.is.mobile && is_mini ? '' : $t('nav_bar.home')"
class="col-auto text-uppercase text-weight-bold q-my-xs"
:class="!$q.platform.is.mobile && is_mini ? '': 'q-px-sm'"
@click="goToPageName(RouteNames.DASHBOARD)"
class="col-auto q-pl-sm"
/>
<!-- Timesheet Validation -->
<q-btn
v-if="auth_store.user?.user_module_access.includes(ModuleNames.TIMESHEETS_APPROVAL)"
flat
dense
no-wrap
size="lg"
icon="event_available"
:label="!$q.platform.is.mobile && is_mini ? '' : $t('nav_bar.timesheet_approvals')"
class="col-auto text-uppercase text-weight-bold q-my-xs"
:class="!$q.platform.is.mobile && is_mini ? '': 'q-px-sm'"
@click="goToPageName(RouteNames.TIMESHEET_APPROVALS)"
/>
<div
class="col text-uppercase text-weight-bold text-h6 q-pl-sm"
:class="$q.platform.is.mobile ? '' : 'q-mini-drawer-hide'"
>
{{ $t(button.i18n_key) }}
</div>
</div>
</div>
<!-- Employee List -->
<q-btn
v-if="auth_store.user?.user_module_access.includes(ModuleNames.EMPLOYEE_LIST)"
flat
dense
no-wrap
size="lg"
icon="groups"
:label="!$q.platform.is.mobile && is_mini ? '' : $t('nav_bar.employee_list')"
class="col-auto text-uppercase text-weight-bold q-my-xs"
:class="!$q.platform.is.mobile && is_mini ? '': 'q-px-sm'"
@click="goToPageName(RouteNames.EMPLOYEE_LIST)"
/>
<q-separator spaced />
<!-- Employee Timesheet -->
<q-btn
v-if="auth_store.user?.user_module_access.includes(ModuleNames.TIMESHEETS)"
flat
dense
no-wrap
size="lg"
icon="punch_clock"
:label="!$q.platform.is.mobile && is_mini ? '' : $t('nav_bar.timesheet')"
class="col-auto text-uppercase text-weight-bold q-my-xs"
:class="!$q.platform.is.mobile && is_mini ? '': 'q-px-sm'"
@click="goToPageName(RouteNames.TIMESHEET)"
/>
<!-- Profile -->
<q-btn
v-if="auth_store.user?.user_module_access.includes(ModuleNames.PERSONAL_PROFILE)"
flat
dense
no-wrap
size="lg"
icon="account_box"
:label="!$q.platform.is.mobile && is_mini ? '' : $t('nav_bar.profile')"
class="col-auto text-uppercase text-weight-bold q-my-xs"
:class="!$q.platform.is.mobile && is_mini ? '': 'q-px-sm'"
@click="goToPageName(RouteNames.PROFILE)"
/>
<!-- Help -->
<q-btn
flat
dense
no-wrap
size="lg"
icon="contact_support"
:label="!$q.platform.is.mobile && is_mini ? '' : $t('nav_bar.help')"
class="col-auto text-uppercase text-weight-bold q-my-xs"
:class="!$q.platform.is.mobile && is_mini ? '': 'q-px-sm'"
@click="goToPageName(RouteNames.HELP)"
/>
<!-- Logout -->
<q-btn
flat
dense
no-wrap
size="lg"
icon="exit_to_app"
:label="!$q.platform.is.mobile && is_mini ? '' : $t('nav_bar.logout')"
class="col-auto text-uppercase text-weight-bold q-my-xs"
:class="!$q.platform.is.mobile && is_mini ? 'absolute-bottom': 'absolute-bottom-left'"
<div
class="row items-center full-width cursor-pointer q-py-sm"
@click="handleLogout"
>
<q-icon
name="exit_to_app"
color="accent"
size="lg"
class="col-auto q-pl-sm"
/>
<div
class="col text-uppercase text-weight-bold text-h6 q-pl-sm"
:class="$q.platform.is.mobile ? '' : 'q-mini-drawer-hide'"
>
{{ $t('nav_bar.logout') }}
</div>
</div>
</q-scroll-area>
</q-drawer>
</template>

View File

@ -32,10 +32,13 @@
<template>
<q-layout view="hHh lpR fFf">
<HeaderBar />
<LeftDrawer />
<q-page-container>
<router-view class="q-pa-sm bg-secondary" />
<router-view />
</q-page-container>
<FooterBar />
</q-layout>
</template>

View File

@ -1,25 +1,31 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useAuthApi } from 'src/modules/auth/composables/use-auth-api';
import LoginRockPaperScissor from 'src/modules/auth/components/login-rock-paper-scissor.vue';
<script
setup
lang="ts"
>
import { computed } from 'vue';
import { useAuthApi } from 'src/modules/auth/composables/use-auth-api';
import LoginRockPaperScissor from 'src/modules/auth/components/login-rock-paper-scissor.vue';
const auth_api = useAuthApi();
const auth_api = useAuthApi();
const email = defineModel<string>('email', { default: '', });
// const is_remembered = ref<boolean>(false);
const is_employee_email = computed(() => email.value.includes('@targ'));
const is_game_time = computed(() => email.value.includes('allumette'));
const email = defineModel<string>('email', { default: '', });
// const is_remembered = ref<boolean>(false);
const is_employee_email = computed(() => email.value.includes('@targ'));
const is_game_time = computed(() => email.value.includes('allumette'));
</script>
<template>
<q-card class="rounded-15 shadow-10 full-width">
<q-card-section class="text-center bg-primary q-pa-lg">
<q-card
bordered
class="rounded-15 shadow-10 full-width"
>
<div class="text-center bg-primary q-pa-lg">
<q-img
src="/src/assets/logo-targo-white.svg"
ratio="4.6"
fit="contain"
/>
</q-card-section>
</div>
<div class="q-pt-sm q-px-xl q-pb-lg ">
<q-card-section class="text-center text-uppercase">

View File

@ -0,0 +1,42 @@
<script
setup
lang="ts"
>
const { imageSource = "", title = "", description = "", route = "" } = defineProps<{
imageSource?: string,
title?: string,
description?: string,
route?: string,
}>();
const onClickExternalShortcut = () => {
window.open(route, '_blank')?.focus();
}
</script>
<template>
<q-card
class="shortcut-card cursor-pointer shadow-12"
@click="onClickExternalShortcut"
>
<q-img
:src="imageSource"
fit="contain"
>
<div class="absolute-bottom text-uppercase text-weight-bolder text-center">{{ title }}</div>
</q-img>
<q-card-section v-if="description">
<span>{{ description }}</span>
</q-card-section>
</q-card>
</template>
<style
lang="sass"
scoped
>
.shortcut-card
width: 100%
max-width: 250px
</style>

View File

@ -0,0 +1,72 @@
<script
setup
lang="ts"
>
import { RouteNames } from 'src/router/router-constants';
import { ref } from 'vue';
const slide = ref<string>('welcome');
</script>
<template>
<q-carousel
v-model="slide"
transition-prev="jump-right"
transition-next="jump-left"
swipeable
animated
infinite
arrows
:autoplay="9001"
control-color="accent"
control-type="outline"
class="bg-dark full-width rounded-15 shadow-18"
>
<!-- welcome slide -->
<q-carousel-slide
name="welcome"
class="q-pa-none fit"
>
<div class="column fit">
<q-img
src="src/assets/targo_building.png"
position="50% 25%"
fit="cover"
class="col-9"
>
<div class="absolute-bottom text-h6 text-uppercase text-weight-light">
{{ $t('dashboard.carousel.welcome_title') }}
</div>
</q-img>
<div class="col column flex-center q-px-md">
<span class="col-auto text-center">{{ $t('dashboard.carousel.welcome_message') }}</span>
</div>
</div>
</q-carousel-slide>
<!-- help page slide -->
<q-carousel-slide
name="tv"
class="q-pa-none cursor-pointer"
@click="$router.push(RouteNames.HELP)"
>
<div class="column fit">
<q-img
src="src/assets/targo_help_banner.png"
position="50% 25%"
fit="none"
class="col-9"
>
<div class="absolute-bottom text-h6 text-uppercase text-weight-light">
{{ $t('dashboard.carousel.help_title') }}
</div>
</q-img>
<div class="col column flex-center q-px-md">
<span class="col-auto text-center">{{ $t('dashboard.carousel.help_message') }}</span>
</div>
</div>
</q-carousel-slide>
</q-carousel>
</template>

View File

@ -22,7 +22,7 @@
const getPresetOptions = (): { label: string, value: number }[] => {
const options = schedule_preset_store.schedule_presets.map(preset => { return { label: preset.name, value: preset.id } });
options.push({ label: '', value: -1 });
options.push({ label: 'Aucun', value: -1 });
return options;
};

View File

@ -58,21 +58,21 @@
name="form"
icon="las la-id-card"
:label="$q.screen.lt.sm ? '' : $t('employee_management.details_label')"
class="rounded-25 q-ma-xs"
class="rounded-25 q-ma-xs bg-dark"
style="border: 2px solid var(--q-accent);"
/>
<q-tab
name="access"
icon="las la-key"
:label="$q.screen.lt.sm ? '' : $t('employee_management.access_label')"
class="rounded-25 q-ma-xs"
class="rounded-25 q-ma-xs bg-dark"
style="border: 2px solid var(--q-accent);"
/>
<q-tab
name="schedule"
icon="calendar_month"
:label="$q.screen.lt.sm ? '' : $t('employee_management.schedule_label')"
class="rounded-25 q-ma-xs"
class="rounded-25 q-ma-xs bg-dark"
style="border: 2px solid var(--q-accent);"
/>
</q-tabs>

View File

@ -2,20 +2,30 @@
setup
lang="ts"
>
import { useQuasar } from 'quasar';
import type { EmployeeProfile } from 'src/modules/employee-list/models/employee-profile.models';
import { ref } from 'vue';
// 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
// return first_name.charAt(0) + last_name.charAt(0);
// };
const q = useQuasar();
const is_mouseover = ref(false);
const { row, index = -1 } = defineProps<{
const { row, index = -1, isManagement = false } = defineProps<{
row: EmployeeProfile
index?: number
isManagement?: boolean;
}>()
const emit = defineEmits<{
defineEmits<{
onProfileClick: [email: string]
}>();
const getItemStyle = (): string => {
const active_style = row.last_work_day === null ? '' : 'opacity: 0.6;';
const dark_style = q.dark.isActive ? 'border: 2px solid var(--q-accent);' : '';
const hover_style = isManagement ? (is_mouseover.value ? `transform: scale(1.1); z-index: 2;` :'transform: scale(1) skew(0)') : '';
return `${active_style} ${dark_style} ${hover_style}`;
}
</script>
<template>
@ -24,10 +34,13 @@
:style="`animation-delay: ${index / 25}s;`"
>
<div
class="column col no-wrap cursor-pointer bg-dark rounded-15 shadow-12"
class="column col no-wrap bg-dark rounded-15 shadow-12"
:class="isManagement ? 'cursor-pointer item-mouse-hover' : ''"
style="max-width: 230px; height: 275px;"
:style="(row.last_work_day === null ? ' ' : 'opacity: 0.6; ') + ($q.dark.isActive ? ' border: 2px solid var(--q-accent)' : '')"
@click="emit('onProfileClick', row.email)"
:style="getItemStyle()"
@click="$emit('onProfileClick', row.email)"
@mouseenter="is_mouseover = true"
@mouseleave="is_mouseover = false"
>
<div class="col-auto column flex-center q-pt-md">
<q-avatar
@ -67,3 +80,9 @@
</div>
</div>
</template>
<style lang="css" scoped>
.item-mouse-hover {
transition: all 0.2s ease-out;
}
</style>

View File

@ -7,13 +7,18 @@
import { onMounted, ref } from 'vue';
import { date, type QTableColumn } from 'quasar';
import { useUiStore } from 'src/stores/ui-store';
import { useAuthStore } from 'src/stores/auth-store';
import { useEmployeeStore } from 'src/stores/employee-store';
import { useTimesheetStore } from 'src/stores/timesheet-store';
import { employee_list_columns, type EmployeeProfile, type EmployeeListFilters } from 'src/modules/employee-list/models/employee-profile.models';
const ui_store = useUiStore();
const auth_store = useAuthStore();
const employee_store = useEmployeeStore();
const timesheet_store = useTimesheetStore();
const ui_store = useUiStore();
const is_management = auth_store.user?.user_module_access.includes('employee_management') ?? false;
const visible_columns = ref<(keyof EmployeeProfile)[]>(['first_name', 'email', 'job_title', 'last_work_day']);
const table_grid_container = ref<HTMLElement | null>(null);
@ -86,16 +91,33 @@
:visible-columns="visible_columns"
>
<template #top>
<div class="row full-width q-mb-sm">
<div class="row flex-center full-width q-mb-sm">
<q-btn
push
v-if="is_management"
rounded
color="accent"
icon="las la-user-edit"
:label="$t('shared.label.add')"
class="text-uppercase"
class="text-uppercase q-py-sm"
@click.stop="_evt => employee_store.openAddModifyDialog()"
/>
<q-checkbox
v-if="is_management"
v-model="filters.hide_inactive_users"
color="accent"
:label="$t('employee_management.filter.hide_terminated')"
class="text-uppercase q-ml-md text-weight-medium q-px-sm"
>
<q-icon
name="las la-user-times"
color="negative"
size="sm"
class="q-px-sm"
/>
</q-checkbox>
<q-space />
<q-btn-toggle
@ -131,15 +153,6 @@
</template>
</q-input>
</div>
<div class="row">
<q-space />
<q-checkbox
v-model="filters.hide_inactive_users"
color="accent"
:label="$t('employee_management.filter.hide_terminated')"
/>
</div>
</template>
<template #header="props">
@ -170,7 +183,8 @@
:key="props.rowIndex"
:row="props.row"
:index="props.rowIndex"
@on-profile-click="employee_store.openAddModifyDialog"
:is-management="is_management"
@on-profile-click="email => is_management ? employee_store.openAddModifyDialog(email) : ''"
/>
</transition>
</template>
@ -178,7 +192,7 @@
<template #body-cell="scope">
<q-td
:props="scope"
@click="employee_store.openAddModifyDialog(scope.row.email)"
@click="is_management ? employee_store.openAddModifyDialog(scope.row.email) : ''"
>
<transition
appear

View File

@ -66,6 +66,28 @@
>{{ scope.opt.label }}</span>
</div>
</template>
<template #after>
<q-toggle
v-model="shift.is_remote"
dense
keep-color
size="2.5em"
color="accent"
icon="las la-building"
checked-icon="las la-laptop"
>
<q-tooltip
anchor="top middle"
self="bottom middle"
:offset="[0, 10]"
class="text-uppercase text-weight-medium text-white bg-accent"
>
{{ shift.is_remote ? $t('timesheet.shift.types.REMOTE') :
$t('timesheet.shift.types.OFFICE') }}
</q-tooltip>
</q-toggle>
</template>
</q-select>
</div>

View File

@ -1,40 +1,43 @@
<script setup lang="ts">
//default images
// import default_dashboard from 'src/assets/help-ss/default-dashboard.png';
import default_personal_profile from 'src/assets/help-ss/default-personnal_profile.png';
import default_timesheet from 'src/assets/help-ss/default-timesheet.png';
import default_employee_list from 'src/assets/help-ss/default-employee-list.png';
import default_employee_management from 'src/assets/help-ss/default-employee-management.png';
import default_validation_page from 'src/assets/help-ss/default-validation-page.png';
<script
setup
lang="ts"
>
//default images
// import default_dashboard from 'src/assets/help-ss/default-dashboard.png';
import default_personal_profile from 'src/assets/help-ss/default-personnal_profile.png';
import default_timesheet from 'src/assets/help-ss/default-timesheet.png';
import default_employee_list from 'src/assets/help-ss/default-employee-list.png';
import default_employee_management from 'src/assets/help-ss/default-employee-management.png';
import default_validation_page from 'src/assets/help-ss/default-validation-page.png';
const default_images: Record<UserModuleAccess, string> = {
const default_images: Record<UserModuleAccess, string> = {
dashboard: '',
personal_profile: default_personal_profile,
timesheets: default_timesheet,
employee_list: default_employee_list,
employee_management: default_employee_management,
timesheets_approval: default_validation_page,
};
};
import type { HelpModuleOptions } from 'src/modules/help/models/help-module.model';
import type { UserModuleAccess } from 'src/modules/shared/models/user.models';
import { ref } from 'vue';
import type { HelpModuleOptions } from 'src/modules/help/models/help-module.model';
import type { UserModuleAccess } from 'src/modules/shared/models/user.models';
import { ref } from 'vue';
const props = defineProps<{
const props = defineProps<{
help_module: UserModuleAccess;
options: HelpModuleOptions[];
moduleIndex: number;
}>();
}>();
const help_module = props.help_module;
const current_path = ref<string>(default_images[help_module]);
const help_module = props.help_module;
const current_path = ref<string>(default_images[help_module]);
const switchSide = (index: number) => {
const switchSide = (index: number) => {
if (index % 2 !== 0) {
return false
}
return true
}
}
</script>
@ -45,14 +48,15 @@ const switchSide = (index: number) => {
>
<!-- Card Header -->
<div
class="row col-auto text-h5 q-pa-md text-primary bg-secondary"
:class="switchSide(moduleIndex) ? 'justify-start' : 'justify-end'"
class="row col-auto text-h4 text-weight-thin q-py-md q-px-xl bg-secondary"
:class="switchSide(moduleIndex) ? 'justify-end' : 'justify-start'"
>
{{ ($t(`help.tutorial.${help_module}.title`)).toUpperCase() }}
</div>
<!-- Card Body -->
<!-- Card Body. The visual support image will alternate left and right -->
<div class="row col full-width q-px-none">
<!-- Object and descriptions zone -->
<!-- Left-sided visual support -->
<div
class="col flex-center row"
v-if="moduleIndex % 2 !== 0"
@ -63,16 +67,17 @@ const switchSide = (index: number) => {
leave-active-class="animated fade-out"
>
<q-img
class="image-wrapper"
:src="current_path"
loading="lazy"
fit="contain"
class="rounded-10 image-wrapper"
>
</q-img>
</transition>
</div>
<div class="col column q-mx-sm">
<!-- Area with expandable help items -->
<div class="col column q-px-md">
<q-expansion-item
v-for="option, index in options"
:key="index"
@ -112,7 +117,8 @@ const switchSide = (index: number) => {
</div>
</q-expansion-item>
</div>
<!-- images of the related selected option -->
<!-- right-sided visual support -->
<div
class="col flex-center row"
v-if="moduleIndex % 2 === 0"
@ -123,11 +129,10 @@ const switchSide = (index: number) => {
leave-active-class="animated fade-out"
>
<q-img
class="image-wrapper"
:src="current_path"
loading="lazy"
fit="contain"
style="width: 150%;"
class="rounded-10 image-wrapper"
>
</q-img>
</transition>

View File

@ -23,17 +23,20 @@
:class="ui_store.is_mobile_mode ? 'column' : 'row'"
style="border: 1px solid var(--q-accent);"
>
<q-item
<div
v-for="mode of dark_mode_options"
:key="mode.label"
class="col q-pa-sm"
>
<q-item
clickable
dense
v-ripple
class="col row rounded-5 q-ma-sm shadow-4"
class="rounded-5 shadow-4 q-py-xs"
:class="(mode.quasar_value === $q.dark.mode ? 'bg-accent text-white text-weight-bolder' : '') + ($q.platform.is.mobile ? ' full-width q-py-xs' : '')"
@click="ui_store.user_preferences.is_dark_mode = mode.value"
>
<q-item-section avatar>
<q-item-section side>
<q-icon
:name="mode.icon"
size="md"
@ -41,8 +44,8 @@
/>
</q-item-section>
<q-item-section class="text-uppercase justify-center">
<q-item-label> {{ $t(mode.label) }}</q-item-label>
<q-item-section>
<q-item-label class="text-uppercase justify-center">{{ $t(mode.label) }}</q-item-label>
</q-item-section>
<q-item-section side>
@ -54,6 +57,7 @@
</q-item-section>
</q-item>
</div>
</div>
<div
class="col-auto row text-uppercase text-weight-bold text-accent"

View File

@ -20,8 +20,10 @@
<div class="column text-uppercase text-center text-weight-bolder text-h4">
<span
v-if="!$q.platform.is.mobile"
class="col q-mt-lg"
>{{ $t(title) }}</span>
class="col q-pt-lg"
>
{{ $t(title) }}
</span>
<transition
enter-active-class="animated fadeInDown"

View File

@ -96,6 +96,7 @@
v-model="calendar_date"
color="primary"
today-btn
no-unset
mask="YYYY-MM-DD"
:options="date => date >= PAY_PERIOD_DATE_LIMIT"
@update:model-value="onDateSelected"

View File

@ -37,7 +37,7 @@
<!-- employee pay period details using chart -->
<div
v-if="is_dialog_open && !$q.platform.is.mobile"
class="col-4 q-px-md no-wrap"
class="col-auto q-px-md no-wrap"
:class="$q.platform.is.mobile ? 'column' : 'row'"
>
<DetailsDialogChartHoursWorked class="col" />
@ -50,8 +50,8 @@
</div>
<!-- list of shifts -->
<div class="col column no-wrap">
<TimesheetWrapper mode="approval" class="col"/>
<div class="col-auto column no-wrap">
<TimesheetWrapper mode="approval" class="col-auto"/>
</div>
</div>
</q-dialog>

View File

@ -15,17 +15,18 @@
keep-color
size="lg"
color="accent"
label="show inactive"
:label="$t('timesheet_approvals.table.filter_active')"
class="col"
:class="filters.is_showing_inactive ? 'text-accent text-weight-bolder' : 'text-white text-weight-medium'"
/>
<q-checkbox
v-model="filters.is_showing_team_only"
keep-color
size="lg"
val="team"
color="accent"
label="show team only"
:label="$t('timesheet_approvals.table.filter_team')"
class="col"
:class="filters.is_showing_team_only ? 'text-accent text-weight-bolder' : 'text-white text-weight-medium'"
/>

View File

@ -154,7 +154,6 @@ import { getHoursMinutesStringFromHoursFloat, getMinutes } from 'src/utils/date-
>
<div
class="col text-uppercase"
:class="row.total_hours > 80 || !row.is_active ? 'text-negative' : ''"
>
<span class="text-h6 q-ml-sm text-weight-bolder">{{ 'Total : ' + Math.floor(row.total_hours)
}}</span>

View File

@ -35,15 +35,18 @@
'is_approved',
]);
const { maxHeight } = defineProps<{
maxHeight: number;
}>();
const is_showing_filters = ref(false);
const search_string = ref('');
const overview_rows = computed(() => timesheet_store.pay_period_overviews.filter(overview => overview));
const overview_filters = ref<PayPeriodOverviewFilters>({
is_showing_inactive: false,
is_showing_team_only: false,
supervisors: [],
name_search_string: search_string.value,
name_search_string: '',
});
const onClickedDetails = async (row: TimesheetApprovalOverview) => {
@ -80,31 +83,30 @@
</script>
<template>
<div class="q-px-md full-height">
<div class="full-width">
<LoadingOverlay v-model="timesheet_store.is_loading" />
<q-table
:key="timesheet_store.is_approval_grid_mode ? 'grid' : 'list'"
:visible-columns="VISIBLE_COLUMNS"
dense
row-key="email"
color="accent"
hide-pagination
:rows="overview_rows"
:columns="pay_period_overview_columns"
row-key="email"
:visible-columns="VISIBLE_COLUMNS"
:grid="timesheet_store.is_approval_grid_mode"
:dense="timesheet_store.is_approval_grid_mode"
hide-pagination
:pagination="{ sortBy: 'is_active' }"
:filter="overview_filters"
:filter-method="filterEmployeeRows"
color="accent"
:rows-per-page-options="[0]"
card-container-class="justify-center"
class="bg-transparent"
:class="timesheet_store.is_approval_grid_mode ? '' : 'sticky-header-table no-shadow'"
card-container-class="justify-center"
table-class="q-pa-none q-mx-md rounded-10 bg-dark shadow-15 hide-scrollbar"
:no-data-label="$t('shared.error.no_data_found')"
:no-results-label="$t('shared.error.no_search_results')"
:loading-label="$t('shared.label.loading')"
:style="$q.platform.is.mobile ? '' : 'max-height: 70vh;'"
:style="overview_rows.length > 0 ? `max-height: ${maxHeight - (timesheet_store.is_approval_grid_mode ? 0 : 20)}px;` : ''"
@row-click="(_evt, row: TimesheetApprovalOverview) => onClickedDetails(row)"
>
<template #top>
<div class="column full-width">
@ -155,7 +157,7 @@
/>
<QTableFilters
v-model:search="search_string"
v-model:search="overview_filters.name_search_string"
class="col-auto q-mb-sm"
/>
@ -219,7 +221,7 @@
:key="props.rowIndex + (timesheet_store.pay_period?.pay_period_no ?? 0)"
class="rounded-5"
style="font-size: 1.2em;"
:style="`animation-delay: ${props.rowIndex / 30}s;`"
:style="`animation-delay: ${props.rowIndex / 15}s; opacity: ${props.row.is_active ? '1' : '0.5'};`"
>
<transition
v-if="props.col.name === 'is_approved'"
@ -233,13 +235,16 @@
:icon="props.value ? 'lock' : 'lock_open'"
:color="props.value ? 'white' : 'grey-5'"
class="rounded-5 "
:class="props.value ? 'bg-accent' : ''"
@click.stop="onClickApproveAll(props.row.email, props.row.is_approved)"
:class="props.value ? (props.row.is_active ? 'bg-accent' : 'bg-negative') : ''"
@click.stop="onClickApproveAll(props.row.email, !props.row.is_approved)"
/>
</transition>
<div v-else-if="props.col.name === 'employee_first_name'">
<span class="text-h5 text-uppercase text-accent q-mr-xs">
<span
class="text-h5 text-uppercase q-mr-xs"
:class="props.row.is_active ? 'text-accent' : 'text-negative'"
>
{{ props.value }}
</span>
<span class="text-uppercase text-weight-light">
@ -273,7 +278,7 @@
<!-- Template for custome failed-to-load state -->
<template #no-data="{ message, filter }">
<div class="full-width column items-center text-accent q-gutter-sm">
<div v-if="!timesheet_store.is_loading" class="full-width column items-center text-accent">
<q-icon
size="4em"
:name="filter ? 'filter_alt_off' : 'error_outline'"
@ -305,4 +310,7 @@
tbody
scroll-margin-top: 48px
.q-table__grid-content
overflow: auto
</style>

View File

@ -1,38 +1,41 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { useTimesheetStore } from 'src/stores/timesheet-store';
import { TimesheetApprovalCSVReportFilters } from 'src/modules/timesheet-approval/models/timesheet-approval-csv-report.models';
<script
setup
lang="ts"
>
import { computed, ref, watch } from 'vue';
import { useTimesheetStore } from 'src/stores/timesheet-store';
import { TimesheetApprovalCSVReportFilters } from 'src/modules/timesheet-approval/models/timesheet-approval-csv-report.models';
const timesheet_store = useTimesheetStore();
const report_filter_options = ref<TimesheetApprovalCSVReportFilters>(new TimesheetApprovalCSVReportFilters);
const timesheet_store = useTimesheetStore();
const report_filter_options = ref<TimesheetApprovalCSVReportFilters>(new TimesheetApprovalCSVReportFilters);
const selected_report_filters = ref<(keyof TimesheetApprovalCSVReportFilters)[]>(
const selected_report_filters = ref<(keyof TimesheetApprovalCSVReportFilters)[]>(
Object.entries(report_filter_options.value).filter(([_key, value]) => value).map(([key]) => key as keyof TimesheetApprovalCSVReportFilters)
);
);
interface ReportOptions {
interface ReportOptions {
label: string;
value: keyof TimesheetApprovalCSVReportFilters;
};
};
const company_options: ReportOptions[] = [
const company_options: ReportOptions[] = [
{ label: 'Targo', value: 'targo' },
{ label: 'Solucom', value: 'solucom' },
];
];
const type_options: ReportOptions[] = [
const type_options: ReportOptions[] = [
{ label: 'timesheet_approvals.print_report.shifts', value: 'shifts' },
{ label: 'timesheet_approvals.print_report.expenses', value: 'expenses' },
{ label: 'shared.shift_type.holiday', value: 'holiday' },
{ label: 'shared.shift_type.vacation', value: 'vacation' },
];
];
const is_download_button_enable = computed(() =>
const is_download_button_enable = computed(() =>
company_options.map(option => option.value).some(option => selected_report_filters.value.includes(option)) &&
type_options.map(option => option.value).some(option => selected_report_filters.value.includes(option))
);
);
const onClickedDownload = async () => {
const onClickedDownload = async () => {
try {
const data = await timesheet_store.getPayPeriodReport(report_filter_options.value);
@ -57,28 +60,47 @@ const onClickedDownload = async () => {
} catch (error) {
console.error(`An error occured during the CSV download: `, error)
}
}
}
watch(selected_report_filters, (new_values) => {
watch(selected_report_filters, (new_values) => {
Object.keys(report_filter_options.value).forEach(key => {
const typed_key = key as keyof TimesheetApprovalCSVReportFilters;
report_filter_options.value[typed_key] = new_values.includes(key as keyof TimesheetApprovalCSVReportFilters);
});
});
});
</script>
<template>
<q-dialog v-model="timesheet_store.is_report_dialog_open">
<div class="bg-secondary full-width shadow-24 rounded-10 column">
<div class="shadow-1 bg-primary text-accent text-weight-bold text-center text-uppercase">
<span> {{ $t('timesheet_approvals.print_report.title') }}</span>
<div
class="column bg-secondary shadow-24 rounded-10"
:style="$q.dark.isActive ? 'border: 2px solid var(--q-accent)' : ''"
>
<!-- main header -->
<div class="col-auto bg-primary text-accent text-weight-bolder text-center text-uppercase text-h6 q-py-xs z-top">
{{ $t('timesheet_approvals.print_report.title') }}
</div>
<div class="row q-py-md q-px-lg">
<div class="col-auto full-width shadow-1 row bg-dark q-py-xs q-px-lg rounded-10">
<span class="col q-px-sm q-pt-xs text-weight-bolder text-accent text-uppercase col-3">
<!-- info blurb -->
<div class="col-auto row flex-center full-width bg-dark shadow-2">
<q-icon
name="info_outline"
size="sm"
class="col-auto q-mr-xs"
/>
<span class="col-auto text-weight-light q-mr-sm">
{{ $t('timesheet_approvals.print_report.description') }}
</span>
</div>
<!-- company header -->
<span class="col-auto q-px-sm q-pt-md text-weight-medium text-accent text-uppercase">
{{ $t('timesheet_approvals.print_report.company') }}
</span>
<div class="row bordered-primary col-auto full-width">
<!-- company options -->
<div class="col row text-uppercase full-width q-px-md">
<div
v-for="company, index in company_options"
:key="index"
@ -88,23 +110,24 @@ watch(selected_report_filters, (new_values) => {
v-model="selected_report_filters"
left-label
color="white"
class="q-px-md shadow-4 rounded-25 full-width"
dense
:class="selected_report_filters.includes(company.value) ? 'bg-accent text-white' : 'bg-dark'"
:label="$t(company.label)"
:val="company.value"
checked-icon="download"
unchecked-icon="highlight_off"
class="q-px-md q-py-xs shadow-4 rounded-25 full-width"
:class="selected_report_filters.includes(company.value) ? 'bg-accent text-white text-bold' : 'bg-dark'"
/>
</div>
</div>
</div>
<div class="row q-py-md">
<div class="col-auto full-width shadow-1 row bg-dark q-px-lg rounded-10 q-pb-sm">
<span class="col q-px-sm q-pt-xs text-weight-bolder text-accent text-uppercase col-3">
<!-- shift type header -->
<span class="col-auto q-px-sm q-pt-md text-weight-medium text-uppercase text-accent">
{{ $t('timesheet_approvals.print_report.options') }}
</span>
<div class=" row bordered-primary col-auto full-width">
<!-- shift type options -->
<div class="col row text-uppercase full-width q-px-md q-pb-md">
<div
v-for="type, index in type_options"
:key="index"
@ -114,30 +137,27 @@ watch(selected_report_filters, (new_values) => {
v-model="selected_report_filters"
left-label
color="white"
class="q-px-md shadow-4 rounded-25 full-width"
dense
:class="selected_report_filters.includes(type.value) ? 'bg-accent text-white' : 'bg-dark'"
:label="$t(type.label)"
:val="type.value"
checked-icon="download"
unchecked-icon="highlight_off"
:label="$t(type.label)"
class="q-px-md q-py-xs shadow-4 rounded-25 full-width"
:class="selected_report_filters.includes(type.value) ? 'bg-accent text-white text-bold' : 'bg-white text-primary'"
/>
</div>
</div>
</div>
</div>
</div>
<div class="column">
<!-- download button -->
<q-btn
:disable="!is_download_button_enable"
square
size="md"
icon="download"
:color="is_download_button_enable ? 'accent' : 'grey-5'"
:label="$t('shared.label.download')"
class="col-auto q-py-sm shadow-up-2"
@click="onClickedDownload()"
/>
</div>
</div>
</q-dialog>
</template>

View File

@ -209,6 +209,18 @@
</q-tooltip>
</q-toggle>
</template>
<template #option="scope">
<q-item>
<q-item-section avatar>
<q-icon :name="scope.opt.icon" />
</q-item-section>
<q-item-section class="text-left">
{{ $t(scope.label) }}
</q-item-section>
</q-item>
</template>
</q-select>
</div>

View File

@ -12,7 +12,7 @@
<template>
<div
v-if="$q.platform.is.mobile && $q.screen.width < $q.screen.height"
class="col-auto row items-start q-px-sm q-pt-sm full-width"
class="row items-start q-px-sm q-pt-sm full-width"
>
<!-- per timesheet -->
<div
@ -60,7 +60,7 @@
class="col row flex-center"
>
<q-badge
:color="day.shifts.length > 0 ? (day.shifts.every(shift => shift.is_approved) ? 'accent shadow-2' : 'dark shadow-2') : 'blue-grey-5'"
:color="day.shifts.length > 0 ? (day.shifts.every(shift => shift.is_approved) ? 'accent shadow-2' : 'white shadow-2') : 'blue-grey-5'"
:class="day.shifts.length > 0 ? (day.shifts.every(shift => shift.is_approved) ? 'q-px-xs' : 'q-pa-sm') : ''"
:style="day.shifts.length > 0 ? '' : 'opacity: 0.5'"
>

View File

@ -149,6 +149,20 @@
</q-tooltip>
</q-toggle>
</template>
<template #option="scope">
<q-item clickable v-bind="scope.itemProps">
<q-item-section avatar>
<q-icon
:name="scope.opt.icon"
/>
</q-item-section>
<q-item-section class="text-left">
{{ $t(scope.label) }}
</q-item-section>
</q-item>
</template>
</q-select>
</div>

View File

@ -6,14 +6,16 @@
import ShiftListDayRowMobile from 'src/modules/timesheets/components/mobile/shift-list-day-row-mobile.vue';
import { ref } from 'vue';
import { useTimesheetStore } from 'src/stores/timesheet-store';
import { useShiftApi } from 'src/modules/timesheets/composables/use-shift-api';
import { useTimesheetApi } from 'src/modules/timesheets/composables/use-timesheet-api';
import type { Shift } from 'src/modules/timesheets/models/shift.models';
import type { TimesheetDay } from 'src/modules/timesheets/models/timesheet.models';
import { isShiftOverlap } from 'src/modules/timesheets/utils/shift.util';
import { isShiftOverlap } from 'src/modules/timesheets/utils/shift.util';
const shift_api = useShiftApi();
const timesheet_api = useTimesheetApi();
const timesheet_store = useTimesheetStore();
const shift_error_message = ref<string | undefined>();
const { day, dense = false, approved = false } = defineProps<{
@ -63,7 +65,7 @@ import { isShiftOverlap } from 'src/modules/timesheets/utils/shift.util';
leave-active-class="animated zoomOut fast"
>
<q-btn
v-if="!$q.platform.is.mobile && day.shifts.length < 1 && preset_mouseover"
v-if="!$q.platform.is.mobile && day.shifts.length < 1 && preset_mouseover && timesheet_store.has_timesheet_preset"
:disable="day.shifts.length > 0"
flat
dense

View File

@ -0,0 +1,71 @@
<script
setup
lang="ts"
>
import { useAuthStore } from 'src/stores/auth-store';
import { getHoursMinutesStringFromHoursFloat } from 'src/utils/date-and-time-utils';
const { mode = 'totals', totalHours = 0, vacationHours = 0, sickHours = 0, totalExpenses = 0 } = defineProps<{
mode: 'total-hours' | 'off-hours';
totalHours?: number;
vacationHours?: number;
sickHours?: number;
totalExpenses?: number;
}>();
const auth_store = useAuthStore();
const is_management = auth_store.user?.user_module_access.includes('timesheets_approval');
</script>
<template>
<div
class="column full-width shadow-4 rounded-5 q-pa-sm"
style="border: 1px solid var(--q-accent);"
>
<div
v-if="mode === 'total-hours'"
class="col column full-width"
>
<div class="col row full-width">
<span class="col-auto text-uppercase text-caption text-bold text-accent">
{{ $t('timesheet.total_hours') }}
</span>
<span class="col text-right">{{ getHoursMinutesStringFromHoursFloat(totalHours) }}</span>
</div>
<div class="col row full-width">
<span class="col-auto text-uppercase text-caption text-bold text-accent">
{{ $t('timesheet.total_expenses') }}
</span>
<span class="col text-right">{{ totalExpenses }}$</span>
</div>
</div>
<div
v-else
class="col column full-width"
>
<div class="col row full-width">
<span class="col-auto text-uppercase text-caption text-bold text-accent">
{{ $t('timesheet.vacation_available') }}
</span>
<span class="col text-right">{{ Math.floor(vacationHours / 8) }}</span>
</div>
<div
v-if="is_management"
class="col row full-width"
>
<span class="col-auto text-uppercase text-caption text-bold text-accent">
{{ $t('timesheet.sick_available') }}
</span>
<span class="col text-right">{{ Math.floor(sickHours / 8) }}</span>
</div>
</div>
</div>
</template>

View File

@ -5,22 +5,25 @@
import ShiftListDay from 'src/modules/timesheets/components/shift-list-day.vue';
import ShiftListDateWidget from 'src/modules/timesheets/components/shift-list-date-widget.vue';
import { date } from 'quasar';
import { computed, ref } from 'vue';
import { date, useQuasar } from 'quasar';
import { computed, ref, watch } from 'vue';
import { useUiStore } from 'src/stores/ui-store';
import { useTimesheetStore } from 'src/stores/timesheet-store';
import { Shift } from 'src/modules/timesheets/models/shift.models';
import { useTimesheetApi } from 'src/modules/timesheets/composables/use-timesheet-api';
import type { QScrollArea } from 'quasar';
import type { QScrollArea, TouchSwipeValue } from 'quasar';
import type { TimesheetDay } from 'src/modules/timesheets/models/timesheet.models';
const CURRENT_DATE_STRING = new Date().toISOString().slice(0, 10);
const { extractDate } = date;
const q = useQuasar();
const ui_store = useUiStore();
const timesheet_store = useTimesheetStore();
const timesheet_api = useTimesheetApi();
const { mode = 'normal'} = defineProps<{
const { mode = 'normal' } = defineProps<{
mode: 'normal' | 'approval';
}>();
@ -28,7 +31,12 @@
const animation_style = computed(() => ui_store.is_mobile_mode ? mobile_animation_direction.value : 'fadeInDown');
const timesheet_page = ref<QScrollArea | null>(null);
const scroll_y = computed(() => timesheet_page.value?.getScrollPosition().top ?? 0)
const currentDayComponent = ref<HTMLElement[] | null>(null);
const currentDayComponentWatcher = ref(currentDayComponent);
const scroll_y = computed(() => timesheet_page.value?.getScrollPosition().top ?? 0);
const timesheet_container = ref<HTMLElement | null>(null);
const scroll_area_height = ref(0);
const addNewShift = (day_shifts: Shift[], date: string, timesheet_id: number) => {
ui_store.focus_next_component = true;
@ -44,37 +52,54 @@
const shifts_without_deleted_shift = day.shifts.filter(shift => shift.id !== 0);
day.shifts = shifts_without_deleted_shift;
}
}
};
const getDayApproval = (day: TimesheetDay) => {
if (day.shifts.length < 1) return false;
return day.shifts.every(shift => shift.is_approved === true);
};
const handleSwipe: TouchSwipeValue = (details) => {
mobile_animation_direction.value = details.direction === 'left' ? 'fadeInRight' : 'fadeInLeft';
if (details.distance && details.distance.x && Math.abs(details.distance.x) > 10) {
timesheet_api.getTimesheetsBySwiping(details.direction === 'left' ? 1 : -1).catch(error => console.error(error));
}
};
const getMobileDayRef = (iso_date_string: string): string => {
return iso_date_string === CURRENT_DATE_STRING ? 'currentDayComponent' : '';
};
watch(currentDayComponentWatcher, () => {
if (currentDayComponent.value && timesheet_page.value && q.platform.is.mobile) {
console.log('setting scroll position to offsetTop of currentDayComponent: ', currentDayComponent.value[0]!.offsetTop);
timesheet_page.value.setScrollPosition('vertical', currentDayComponent.value[0]!.offsetTop, 800);
return;
}
const handleSwipe = async (direction: 'left' | 'up' | 'down' | 'right' | undefined, distance: { x?: number, y?: number }) => {
mobile_animation_direction.value = direction === 'left' ? 'fadeInRight' : 'fadeInLeft';
if (distance.x && Math.abs(distance.x) > 10) {
await timesheet_api.getTimesheetsBySwiping(direction === 'left' ? 1 : -1)
}
if (timesheet_container.value !== null && mode === 'approval') {
scroll_area_height.value = timesheet_container.value.offsetHeight
}
})
</script>
<template>
<div
class="col column fit relative-position"
:style="$q.platform.is.mobile ? 'margin-bottom: 40px' : ''"
v-touch-swipe="value => handleSwipe(value.direction, value.distance ?? { x: 0, y: 0 })"
class="column fit relative-position"
:style="$q.platform.is.mobile && $q.screen.width < $q.screen.height ? 'margin-bottom: 40px' : ''"
v-touch-swipe="handleSwipe"
>
<q-scroll-area
ref="timesheet_page"
:horizontal-offset="[0, 3]"
class="absolute-full hide-scrollbar"
class="col absolute-full hide-scrollbar"
:style="mode === 'approval' ? `height: ${scroll_area_height}px;` : ''"
:thumb-style="{ opacity: '0' }"
:bar-style="{ opacity: '0' }"
>
<!-- Show if no timesheets found (further than one month from present) -->
<div
v-if="timesheet_store.timesheets.length < 1"
v-if="timesheet_store.timesheets.length < 1 && !timesheet_store.is_loading"
class="col-auto column flex-center fit q-py-lg"
style="min-height: 20vh;"
>
@ -92,6 +117,7 @@
<!-- Else show timesheets if found -->
<div
v-else
ref="timesheet_container"
class="col fit"
:class="$q.platform.is.mobile ? 'column' : 'row'"
>
@ -106,7 +132,7 @@
leave-active-class="animated fadeOutUp"
>
<q-btn
v-if="!$q.platform.is.mobile && timesheet.days.every(day => day.shifts.length < 1)"
v-if="!$q.platform.is.mobile && timesheet.days.every(day => day.shifts.length < 1) && timesheet_store.has_timesheet_preset"
:disable="!timesheet.days.every(day => day.shifts.length < 1)"
flat
dense
@ -129,9 +155,11 @@
<div
v-for="day, day_index in timesheet.days"
:key="day.date"
:ref="getMobileDayRef(day.date)"
class="col-auto row q-pa-sm fit"
:style="`animation-delay: ${day_index / 15}s;`"
>
<!-- mobile version in portrait mode -->
<div
v-if="$q.platform.is.mobile && ($q.screen.width < $q.screen.height)"
class="col column full-width q-px-md q-py-sm"
@ -192,6 +220,7 @@
</q-card>
</div>
<!-- desktop version -->
<div
v-else
class="col row full-width rounded-10 ellipsis shadow-10"
@ -274,19 +303,21 @@
</template>
<style scoped lang="scss">
<style
scoped
lang="scss"
>
@each $size in (1, 2, 3, 4, 5, 10, 15, 20, 25, 50, 75, 100, 200) {
.mobile-rounded-#{$size} {
border-radius: #{$size}px !important;
}
.mobile-rounded-#{$size} > div:first-child {
.mobile-rounded-#{$size}>div:first-child {
border-radius: #{$size}px #{$size}px 0 0 !important;
}
.mobile-rounded-#{$size} > div:last-child {
.mobile-rounded-#{$size}>div:last-child {
border-radius: 0 0 #{$size}px #{$size}px !important;
}
}
</style>

View File

@ -4,11 +4,13 @@
>
/* eslint-disable */
import ShiftList from 'src/modules/timesheets/components/shift-list.vue';
import LoadingOverlay from 'src/modules/shared/components/loading-overlay.vue';
import ExpenseDialog from 'src/modules/timesheets/components/expense-dialog.vue';
import PageHeaderTemplate from 'src/modules/shared/components/page-header-template.vue';
import PayPeriodNavigator from 'src/modules/shared/components/pay-period-navigator.vue';
import TimesheetErrorWidget from 'src/modules/timesheets/components/timesheet-error-widget.vue';
import LoadingOverlay from 'src/modules/shared/components/loading-overlay.vue';
import ShiftListWeeklyOverview from 'src/modules/timesheets/components/mobile/shift-list-weekly-overview.vue';
import ShiftListWeeklyOverview from 'src/modules/timesheets/components/shift-list-weekly-overview.vue';
import ShiftListWeeklyOverviewMobile from 'src/modules/timesheets/components/mobile/shift-list-weekly-overview-mobile.vue';
import { computed, onMounted } from 'vue';
import { useShiftApi } from 'src/modules/timesheets/composables/use-shift-api';
@ -37,9 +39,41 @@
</script>
<template>
<div class="column items-center full-height">
<div class="column items-center full-height relative-position no-wrap">
<LoadingOverlay v-model="timesheet_store.is_loading" />
<!-- label for approval mode to delimit that this is the timesheet -->
<span
v-if="mode === 'approval'"
class="col-auto text-uppercase text-bold text-h5"
>
{{ $t('timesheet.page_header') }}
</span>
<!-- weekly overview -->
<div class="col-auto row q-px-lg full-width">
<!-- supervisor weekly overview -->
<div class="col-xs-6 col-md-4 col-xl-3 q-pa-md">
<ShiftListWeeklyOverview mode="total-hours" />
</div>
<PageHeaderTemplate
v-if="mode === 'normal'"
:title="'timesheet.page_header'"
:start-date="timesheet_store.pay_period?.period_start ?? ''"
:end-date="timesheet_store.pay_period?.period_end ?? ''"
class="col"
/>
<q-space v-else />
<!-- employee weekly overview -->
<div class="col-xs-6 col-md-4 col-xl-3 q-pa-md">
<ShiftListWeeklyOverview mode="off-hours" />
</div>
</div>
<!-- top menu -->
<div
class="col-auto row items-center full-width"
@ -49,7 +83,7 @@
<PayPeriodNavigator
v-if="mode === 'normal'"
class="col-auto"
@date-selected="date_value => timesheet_api.getTimesheetsByDate(date_value)"
@date-selected="timesheet_api.getTimesheetsByDate"
@pressed-previous-button="timesheet_api.getTimesheetsByCurrentPayPeriod"
@pressed-next-button="timesheet_api.getTimesheetsByCurrentPayPeriod"
/>
@ -66,9 +100,6 @@
@click="expenses_store.open"
/>
<!-- label for approval mode to delimit that this is the timesheet -->
<span v-if="mode === 'approval'" class="col-auto text-uppercase text-bold text-h5"> {{ $t('timesheet.page_header') }}</span>
<q-space v-if="$q.screen.width > $q.screen.height" />
<!-- desktop expenses button -->
@ -100,9 +131,13 @@
<TimesheetErrorWidget class="col-auto" />
<!-- mobile weekly overview widget -->
<ShiftListWeeklyOverview />
<ShiftListWeeklyOverviewMobile class="col-auto" />
<ShiftList :mode="mode" />
<ShiftList
:mode="mode"
:class="mode === 'normal' ? 'col' : 'col-auto'"
:style="mode === 'normal' ? '' : 'min-height: 100vh'"
/>
<q-btn
v-if="$q.platform.is.mobile && $q.screen.width < $q.screen.height"
@ -112,7 +147,7 @@
color="accent"
icon="upload"
:label="$t('shared.label.save')"
class="col-auto absolute-bottom shadow-up-10 z-top"
class="col-auto absolute-bottom shadow-up-10"
style="height: 50px;"
@click="shift_api.saveShiftChanges"
/>

View File

@ -21,8 +21,8 @@ export const useShiftApi = () => {
const saveShiftChanges = async () => {
timesheet_store.is_loading = true;
const create_success = await shift_store.createNewShifts();
const update_success = await shift_store.updateShifts();
const create_success = await shift_store.createNewShifts();
if (create_success || update_success){
await timesheet_store.getTimesheetsByOptionalEmployeeEmail(auth_store.user?.email ?? '');

View File

@ -5,6 +5,7 @@ export const useTimesheetApi = () => {
const timesheet_store = useTimesheetStore();
const getTimesheetsByDate = async (date_string: string, employee_email?: string) => {
timesheet_store.timesheets = [];
timesheet_store.is_loading = true;
const success = await timesheet_store.getPayPeriodByDateOrYearAndNumber(date_string);

View File

@ -5,6 +5,7 @@ export const TIME_FORMAT_PATTERN = /^(\d{2}:\d{2})?$/;
export const DATE_FORMAT_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
export interface TimesheetResponse {
has_preset_schedule: boolean;
employee_fullname: string;
timesheets: Timesheet[];
}
@ -42,78 +43,3 @@ export interface TotalExpenses {
on_call: 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

@ -2,15 +2,14 @@
setup
lang="ts"
>
import { ref } from 'vue';
const slide = ref<string>('welcome');
import MainCarousel from 'src/modules/dashboard/components/main-carousel.vue';
import ShortcutCard from 'src/modules/dashboard/components/employee/shortcut-card.vue';
</script>
<template>
<q-page
padding
class="q-pa-md justify-center items-stretch"
class="q-pa-md justify-center items-stretch bg-secondary"
:class="$q.platform.is.mobile ? 'column' : 'row'"
>
<!-- left column -->
@ -19,71 +18,45 @@
</div>
<!-- center column -->
<div class="column col-xs-12 col-md-8 col-xl-6 flex-center q-pa-md">
<q-carousel
v-model="slide"
transition-prev="jump-right"
transition-next="jump-left"
swipeable
animated
navigation
infinite
:autoplay="9001"
control-color="accent"
class="col-auto bg-dark rounded-15 shadow-18"
:style="$q.platform.is.mobile ? 'height: 60vh;' : 'height: 50vh;'"
>
<!-- welcome slide -->
<q-carousel-slide
name="welcome"
class="q-pa-none q-pb-xl fit"
>
<div class="column fit">
<q-img
src="src/assets/targo_building.png"
height="30vh"
position="50% 25%"
fit="cover"
class="col-auto"
>
<div class="absolute-bottom text-h6 text-uppercase text-weight-light">
{{ $t('dashboard.carousel.welcome_title') }}
<div class="column col-xs-12 col-md-8 col-xl-6 items-center q-pa-md">
<div class="col-auto full-width q-py-md">
<MainCarousel />
</div>
</q-img>
<div class="column col q-mt-md q-px-md flex-center">
<span class="col-auto">{{ $t('dashboard.carousel.welcome_message') }}</span>
</div>
</div>
</q-carousel-slide>
<span class="col-auto text-uppercase text-weight-bold self-start q-pt-md">{{ $t('dashboard.useful_links') }}</span>
<!-- help page slide -->
<q-carousel-slide
name="tv"
class="q-pa-none q-pb-xl"
>
<div class="column fit">
<q-img
src="src/assets/targo_help_banner.png"
height="30vh"
position="50% 25%"
fit="none"
class="col-auto"
>
<div class="absolute-bottom text-h6 text-uppercase text-weight-light">
{{ $t('dashboard.carousel.help_title') }}
<div class="col row full-width justify-evenly items-start q-py-md">
<div class="col-3 q-pa-sm">
<ShortcutCard
image-source="src/assets/google_thumbnail.png"
title="Google Workspace"
route="https://mail.google.com/mail/u/0/#inbox"
/>
</div>
</q-img>
<div class="col column justify-center q-mt-md q-px-md">
<span class="col-auto">{{ $t('dashboard.carousel.help_message') }}</span>
<div class="col-3 q-pa-sm">
<ShortcutCard
image-source="src/assets/facturation_thumbnail.png"
title="Facturation"
route="https://facturation.targo.ca/facturation/accueil.php?menu=ticket_open"
/>
</div>
</div>
</q-carousel-slide>
</q-carousel>
<div class="col column">
<span class="col-auto text-h6 text-uppercase"> </span>
<div class="col-3 q-pa-sm">
<ShortcutCard
image-source="src/assets/map_targo_banner.png"
title="Map Targo"
route="https://map.targointernet.com/infrastructure/map.php"
/>
</div>
<div class="col-3 q-pa-sm">
<ShortcutCard
image-source="src/assets/info-pannes.png"
title="Info Pannes"
route="https://infopannes.solutions.hydroquebec.com/info-pannes/pannes/pannes-en-cours"
/>
</div>
</div>
</div>
@ -97,7 +70,7 @@
<iframe
title="Environment Canada Weather"
height="400px"
src="https://weather.gc.ca/wxlink/wxlink.html?coords=45.159%2C-73.676&lang=e"
src="https://weather.gc.ca/wxlink/wxlink.html?coords=45.159%2C-73.676&lang=f"
allowtransparency="true"
style="border: 0;"
class="col-auto"

View File

@ -17,7 +17,7 @@
</script>
<template>
<q-page class="column items-center justify-start">
<q-page class="column items-center bg-secondary">
<AddModifyDialog />
<PageHeaderTemplate title="employee_list.page_header" />

View File

@ -4,7 +4,7 @@
<template>
<q-layout view="hHh lpR fFf">
<q-page-container class="bg-dark">
<q-page-container class="bg-blue-grey-10">
<q-page class="row">
<q-img src="src/assets/village.png" fit="cover" :class="$q.screen.lt.md ? 'absolute-bottom' : 'absolute-right'" />
<transition appear slow enter-active-class="animated zoomIn" leave-active-class="animated zoomOut" class="col-xs-10 absolute-center">

View File

@ -9,13 +9,27 @@
import OverviewReport from 'src/modules/timesheet-approval/components/overview-report.vue';
import { date } from 'quasar';
import { onMounted } from 'vue';
import { computed, onMounted, ref } from 'vue';
import { useTimesheetApprovalApi } from 'src/modules/timesheet-approval/composables/use-timesheet-approval-api';
import { useTimesheetStore } from 'src/stores/timesheet-store';
const timesheet_approval_api = useTimesheetApprovalApi();
const timesheet_store = useTimesheetStore();
const page_height = ref(0);
const headerComponent = ref<HTMLElement | null>(null);
const table_max_height = computed(() => {
const height = page_height.value - (headerComponent.value?.clientHeight ?? 0);
return height;
});
const tableStyleFunction = (offset: number, height: number) => {
page_height.value = height - offset;
return { minHeight: height - offset + 'px' };
};
onMounted(async () => {
await timesheet_approval_api.getTimesheetOverviewsByDate(date.formatDate(new Date(), 'YYYY-MM-DD'));
});
@ -23,16 +37,9 @@
<template>
<q-page
padding
class="column q-pa-md bg-secondary"
class="bg-secondary"
:style-fn="tableStyleFunction"
>
<PageHeaderTemplate
title="timesheet_approvals.page_title"
:start-date="timesheet_store.pay_period?.period_start ?? ''"
:end-date="timesheet_store.pay_period?.period_end ?? ''"
class="col-auto"
/>
<DetailsDialog
:is-loading="timesheet_store.is_loading"
:employee-overview="timesheet_store.current_pay_period_overview"
@ -41,6 +48,24 @@
<OverviewReport />
<OverviewList class="col" />
<div
class="column items-center scroll q-px-sm full-width"
style="min-height: inherit;"
>
<div
ref="headerComponent"
class="col-auto"
>
<PageHeaderTemplate
title="timesheet_approvals.page_title"
:start-date="timesheet_store.pay_period?.period_start ?? ''"
:end-date="timesheet_store.pay_period?.period_end ?? ''"
/>
</div>
<div class="col-grow full-width">
<OverviewList :max-height="table_max_height" />
</div>
</div>
</q-page>
</template>

View File

@ -2,29 +2,19 @@
setup
lang="ts"
>
import PageHeaderTemplate from 'src/modules/shared/components/page-header-template.vue';
import TimesheetWrapper from 'src/modules/timesheets/components/timesheet-wrapper.vue';
import { useAuthStore } from 'src/stores/auth-store';
import { useTimesheetStore } from 'src/stores/timesheet-store';
const { user } = useAuthStore();
const timesheet_store = useTimesheetStore();
</script>
<template>
<q-page
padding
class="column bg-secondary items-center"
>
<PageHeaderTemplate
:title="'timesheet.page_header'"
:start-date="timesheet_store.pay_period?.period_start ?? ''"
:end-date="timesheet_store.pay_period?.period_end ?? ''"
class="col-auto"
/>
<div
class="col column fit"
:style="$q.platform.is.mobile && ($q.screen.width < $q.screen.height) ? '' : 'width: 90vw'"

View File

@ -1,12 +1,11 @@
export enum RouteNames {
LOGIN = 'login',
LOGIN_SUCCESS = 'login-success',
DASHBOARD = 'dashboard',
TIMESHEET_APPROVALS = 'timesheets_approval',
EMPLOYEE_LIST = 'employee_list',
EMPLOYEE_MANAGEMENT = 'employee_management',
PROFILE = 'personal_profile',
TIMESHEET = 'timesheets',
DASHBOARD = '/',
TIMESHEET_APPROVALS = 'timesheet-approvals',
EMPLOYEE_LIST = 'employees',
PROFILE = 'profile',
TIMESHEET = 'timesheet',
HELP = 'help',
ERROR = 'error',

View File

@ -33,7 +33,7 @@ const routes: RouteRecordRaw[] = [
meta: { required_module: ModuleNames.TIMESHEETS },
},
{
path: 'user/profile',
path: 'profile',
name: RouteNames.PROFILE,
component: () => import('src/pages/profile-page.vue'),
meta: { required_module: ModuleNames.PERSONAL_PROFILE },

View File

@ -22,6 +22,7 @@ export const useTimesheetStore = defineStore('timesheet', () => {
const is_details_dialog_open = ref(false);
const selected_employee_name = ref<string>();
const has_timesheet_preset = ref(false);
const current_pay_period_overview = ref<TimesheetApprovalOverview>();
const is_approval_grid_mode = ref<boolean>(true);
const pay_period_report = ref();
@ -98,6 +99,7 @@ export const useTimesheetStore = defineStore('timesheet', () => {
}
if (response.success && response.data) {
has_timesheet_preset.value = response.data.has_preset_schedule;
selected_employee_name.value = response.data.employee_fullname;
timesheets.value = response.data.timesheets;
initial_timesheets.value = unwrapAndClone(timesheets.value);
@ -174,6 +176,7 @@ export const useTimesheetStore = defineStore('timesheet', () => {
current_pay_period_overview,
pay_period_infos,
selected_employee_name,
has_timesheet_preset,
timesheets,
all_current_shifts,
initial_timesheets,

View File

@ -4,15 +4,17 @@ import { computed, ref } from 'vue';
import { LocalStorage, useQuasar, Dark } from 'quasar';
import { Preferences } from 'src/modules/profile/models/preferences.models';
import { ProfileService } from 'src/modules/profile/services/profile-service';
import { RouteNames } from 'src/router/router-constants';
export const useUiStore = defineStore('ui', () => {
const q = useQuasar();
const { locale } = useI18n();
const is_left_drawer_open = ref(false);
const is_left_drawer_open = ref(true);
const focus_next_component = ref(false);
const is_mobile_mode = computed(() => q.screen.lt.md);
const user_preferences = ref<Preferences>(new Preferences);
const current_page = ref<RouteNames>(RouteNames.DASHBOARD);
const toggleRightDrawer = () => {
@ -69,6 +71,7 @@ export const useUiStore = defineStore('ui', () => {
}
return {
current_page,
is_mobile_mode,
focus_next_component,
is_left_drawer_open,