Merge branch 'main' of git.targo.ca:Targo/targo_frontend into dev/matthieu/help-page

This commit is contained in:
Matthieu Haineault 2025-12-22 15:57:47 -05:00
commit ae374d0888
43 changed files with 1439 additions and 845 deletions

View File

@ -2,7 +2,7 @@
@each $size in (1, 2, 3, 4, 5, 10, 15, 20, 25, 50, 75, 100, 200) { @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;
} }
} }
.text-fb-blue { .text-fb-blue {

View File

@ -107,6 +107,12 @@ export default {
}, },
}, },
error :{
not_found_header: "page not found",
not_found_description: "You may have entered the wrong URL, or you may not have access to this page",
go_back: "go back",
},
login: { login: {
page_header: "account login", page_header: "account login",
email: "e-mail", email: "e-mail",
@ -230,6 +236,10 @@ export default {
timesheet: { timesheet: {
page_header: "Timesheet", page_header: "Timesheet",
week: "week",
total_hours: "total hours: ",
current_shifts: "shifts worked",
apply_preset: "auto-fill",
apply_preset_day: "Apply schedule to day", apply_preset_day: "Apply schedule to day",
apply_preset_week: "Apply schedule to week", apply_preset_week: "Apply schedule to week",
nav_button: { nav_button: {
@ -294,7 +304,7 @@ export default {
SHIFT_OVERLAP: "An overlaps occured between 2 or more shifts", SHIFT_OVERLAP: "An overlaps occured between 2 or more shifts",
SHIFT_OVERLAP_SHORT: "Overlap", SHIFT_OVERLAP_SHORT: "Overlap",
INVALID_SHIFT: "A shift contains missing or corrupted data", INVALID_SHIFT: "A shift contains missing or corrupted data",
SHIFT_TIME_REQUIRED: "Time required", SHIFT_TIME_REQUIRED: "Time missing",
SHIFT_TYPE_REQUIRED: "Shift type required", SHIFT_TYPE_REQUIRED: "Shift type required",
SHIFT_NOT_FOUND: "Shift missing or deleted", SHIFT_NOT_FOUND: "Shift missing or deleted",
PAY_PERIOD_NOT_FOUND: "No pay period matching given dates", PAY_PERIOD_NOT_FOUND: "No pay period matching given dates",
@ -308,15 +318,6 @@ export default {
timesheet_approvals: { timesheet_approvals: {
page_title: "Validation cartes de temps", page_title: "Validation cartes de temps",
table: {
full_name: "full name",
email: "email address",
is_approved: "approval",
expenses: "expenses",
mileage: "mileage",
verified: "approved",
unverified: "pending",
},
chart: { chart: {
hours_worked_title: "hours worked", hours_worked_title: "hours worked",
expenses_title: "expenses accrued", expenses_title: "expenses accrued",
@ -329,6 +330,16 @@ export default {
expenses: "expenses", expenses: "expenses",
options: "options", options: "options",
}, },
table: {
full_name: "full name",
email: "email address",
is_approved: "approval",
expenses: "expenses",
mileage: "mileage",
verified: "approved",
unverified: "pending",
inactive: "inactive",
},
tooltip: { tooltip: {
button_detailed_view: "detailed view", button_detailed_view: "detailed view",
}, },

View File

@ -107,6 +107,12 @@ export default {
}, },
}, },
error :{
not_found_header: "page introuvable",
not_found_description: "Vous avez possiblement entré une mauvaise addresse URL, ou vous n'avez pas accès à cette section du site",
go_back: "retour en arrière",
},
login: { login: {
page_header: "connexion au compte", page_header: "connexion au compte",
email: "courriel", email: "courriel",
@ -231,6 +237,10 @@ export default {
timesheet: { timesheet: {
page_header: "Carte de temps", page_header: "Carte de temps",
week: "semaine",
total_hours: "heures totales: ",
current_shifts: "quarts entrées",
apply_preset: "auto-remplir",
apply_preset_day: "Appliquer horaire pour la journée", apply_preset_day: "Appliquer horaire pour la journée",
apply_preset_week: "Appliquer horaire pour la semaine", apply_preset_week: "Appliquer horaire pour la semaine",
nav_button: { nav_button: {
@ -295,7 +305,7 @@ export default {
SHIFT_OVERLAP: "Il y a un chevauchement entre deux ou plusieurs quarts", SHIFT_OVERLAP: "Il y a un chevauchement entre deux ou plusieurs quarts",
SHIFT_OVERLAP_SHORT: "Chevauchement", SHIFT_OVERLAP_SHORT: "Chevauchement",
INVALID_SHIFT: "Un quart de travail contient des données manquantes ou corrompues", INVALID_SHIFT: "Un quart de travail contient des données manquantes ou corrompues",
SHIFT_TIME_REQUIRED: "Heure requise", SHIFT_TIME_REQUIRED: "Heures manquantes",
SHIFT_TYPE_REQUIRED: "Type requis", SHIFT_TYPE_REQUIRED: "Type requis",
SHIFT_NOT_FOUND: "Quart de travail manquant ou supprimé", SHIFT_NOT_FOUND: "Quart de travail manquant ou supprimé",
PAY_PERIOD_NOT_FOUND: "Aucune période de paie ne correspond aux dates fournies", PAY_PERIOD_NOT_FOUND: "Aucune période de paie ne correspond aux dates fournies",
@ -309,15 +319,6 @@ export default {
timesheet_approvals: { timesheet_approvals: {
page_title: "Validation cartes de temps", page_title: "Validation cartes de temps",
table: {
full_name: "nom complet",
email: "courriel",
is_approved: "approuvé",
expenses: "dépenses",
mileage: "kilométrage",
verified: "approuvé",
unverified: "à vérifier",
},
chart: { chart: {
hours_worked_title: "heures travaillées", hours_worked_title: "heures travaillées",
expenses_title: "dépenses encourues" expenses_title: "dépenses encourues"
@ -330,6 +331,16 @@ export default {
expenses: "dépenses", expenses: "dépenses",
options: "options", options: "options",
}, },
table: {
full_name: "nom complet",
email: "courriel",
is_approved: "approuvé",
expenses: "dépenses",
mileage: "kilométrage",
verified: "approuvé",
unverified: "à vérifier",
inactive: "inactif",
},
tooltip: { tooltip: {
button_detailed_view: "vue détaillée", button_detailed_view: "vue détaillée",
}, },

View File

@ -3,7 +3,7 @@
setup setup
> >
import { useUiStore } from 'src/stores/ui-store'; import { useUiStore } from 'src/stores/ui-store';
import HeaderBarNotification from './main-layout-header-bar-notification.vue'; // import HeaderBarNotification from './main-layout-header-bar-notification.vue';
const uiStore = useUiStore(); const uiStore = useUiStore();
</script> </script>
@ -29,7 +29,7 @@
</q-btn> </q-btn>
</q-toolbar-title> </q-toolbar-title>
<q-item class="q-pa-none"> <q-item class="q-pa-none">
<HeaderBarNotification /> <!-- <HeaderBarNotification /> -->
</q-item> </q-item>
</q-toolbar> </q-toolbar>
</q-header> </q-header>

View File

@ -7,7 +7,7 @@
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'; import { ModuleNames } from 'src/modules/shared/models/user.models';
const auth_store = useAuthStore(); const auth_store = useAuthStore();
const ui_store = useUiStore(); const ui_store = useUiStore();
@ -32,155 +32,109 @@
<template> <template>
<q-drawer <q-drawer
v-model="ui_store.is_left_drawer_open" v-model="ui_store.is_left_drawer_open"
:persistent="!ui_store.is_mobile_mode" :persistent="!$q.platform.is.mobile"
mini-to-overlay mini-to-overlay
elevated elevated
side="left" side="left"
:mini="is_mini" :mini="is_mini"
@mouseenter="is_mini = false" @mouseenter="is_mini = false"
@mouseleave="is_mini = true" @mouseleave="is_mini = true"
class="bg-dark z-max" class="bg-dark z-max column no-wrap"
:class="!$q.platform.is.mobile && is_mini ? 'items-center' : 'items-start'"
> >
<q-scroll-area class="fit"> <!-- Home -->
<q-list> <q-btn
<!-- Home --> flat
<q-item dense
v-ripple no-wrap
clickable size="lg"
side icon="home"
@click="goToPageName(RouteNames.DASHBOARD)" :label="!$q.platform.is.mobile && is_mini ? '' : $t('nav_bar.home')"
> class="col-auto text-uppercase text-weight-bold q-my-xs"
<q-item-section avatar> :class="!$q.platform.is.mobile && is_mini ? '': 'q-px-sm'"
<q-icon @click="goToPageName(RouteNames.DASHBOARD)"
name="home" />
color="accent"
size="lg"
/>
</q-item-section>
<q-item-section>
<q-item-label class="text-uppercase text-weight-bold">{{ $t('nav_bar.home') }}</q-item-label>
</q-item-section>
</q-item>
<!-- Timesheet Validation -- Supervisor and Accounting only --> <!-- Timesheet Validation -->
<q-item <q-btn
v-ripple v-if="auth_store.user?.user_module_access.includes(ModuleNames.TIMESHEETS_APPROVAL)"
clickable flat
side dense
@click="goToPageName(RouteNames.TIMESHEET_APPROVALS)" no-wrap
v-if="CAN_APPROVE_PAY_PERIODS.includes(auth_store.user?.role ?? 'GUEST')" size="lg"
> icon="event_available"
<q-item-section avatar> :label="!$q.platform.is.mobile && is_mini ? '' : $t('nav_bar.timesheet_approvals')"
<q-icon class="col-auto text-uppercase text-weight-bold q-my-xs"
name="event_available" :class="!$q.platform.is.mobile && is_mini ? '': 'q-px-sm'"
color="accent" @click="goToPageName(RouteNames.TIMESHEET_APPROVALS)"
size="lg" />
/>
</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-section>
</q-item>
<!-- Employee List -- Supervisor, Accounting and HR only --> <!-- Employee List -->
<q-item <q-btn
v-ripple v-if="auth_store.user?.user_module_access.includes(ModuleNames.EMPLOYEE_LIST)"
clickable flat
side dense
@click="goToPageName(RouteNames.EMPLOYEE_LIST)" no-wrap
v-if="CAN_APPROVE_PAY_PERIODS.includes(auth_store.user?.role ?? 'GUEST')" size="lg"
> icon="groups"
<q-item-section avatar> :label="!$q.platform.is.mobile && is_mini ? '' : $t('nav_bar.employee_list')"
<q-icon class="col-auto text-uppercase text-weight-bold q-my-xs"
name="groups" :class="!$q.platform.is.mobile && is_mini ? '': 'q-px-sm'"
color="accent" @click="goToPageName(RouteNames.EMPLOYEE_LIST)"
size="lg" />
/>
</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-section>
</q-item>
<!-- Employee Timesheet temp -- Employee, Supervisor, Accounting only --> <!-- Employee Timesheet -->
<q-item <q-btn
v-ripple v-if="auth_store.user?.user_module_access.includes(ModuleNames.TIMESHEETS)"
clickable flat
side dense
@click="goToPageName(RouteNames.TIMESHEET)" no-wrap
v-if="CAN_APPROVE_PAY_PERIODS.includes(auth_store.user?.role ?? 'GUEST')" size="lg"
> icon="punch_clock"
<q-item-section avatar> :label="!$q.platform.is.mobile && is_mini ? '' : $t('nav_bar.timesheet')"
<q-icon class="col-auto text-uppercase text-weight-bold q-my-xs"
name="punch_clock" :class="!$q.platform.is.mobile && is_mini ? '': 'q-px-sm'"
color="accent" @click="goToPageName(RouteNames.TIMESHEET)"
size="lg" />
/>
</q-item-section>
<q-item-section>
<q-item-label class="text-uppercase text-weight-bold">{{ $t('nav_bar.timesheet')
}}</q-item-label>
</q-item-section>
</q-item>
<!-- Profile --> <!-- Profile -->
<q-item <q-btn
v-ripple v-if="auth_store.user?.user_module_access.includes(ModuleNames.PERSONAL_PROFILE)"
clickable flat
side dense
@click="goToPageName(RouteNames.PROFILE)" no-wrap
> size="lg"
<q-item-section avatar> icon="account_box"
<q-icon :label="!$q.platform.is.mobile && is_mini ? '' : $t('nav_bar.profile')"
name="account_box" class="col-auto text-uppercase text-weight-bold q-my-xs"
color="accent" :class="!$q.platform.is.mobile && is_mini ? '': 'q-px-sm'"
size="lg" @click="goToPageName(RouteNames.PROFILE)"
/> />
</q-item-section>
<q-item-section>
<q-item-label class="text-uppercase text-weight-bold">{{ $t('nav_bar.profile') }}</q-item-label>
</q-item-section>
</q-item>
<!-- Help --> <!-- Help -->
<q-item <q-btn
v-ripple flat
clickable dense
@click="goToPageName('help')" no-wrap
> size="lg"
<q-item-section avatar> icon="contact_support"
<q-icon :label="!$q.platform.is.mobile && is_mini ? '' : $t('nav_bar.help')"
name="contact_support" class="col-auto text-uppercase text-weight-bold q-my-xs"
color="accent" :class="!$q.platform.is.mobile && is_mini ? '': 'q-px-sm'"
size="lg" @click="goToPageName(RouteNames.HELP)"
/> />
</q-item-section>
<q-item-section>
<q-item-label class="text-uppercase text-weight-bold">{{ $t('nav_bar.help') }}</q-item-label>
</q-item-section>
</q-item>
</q-list>
<!-- Logout --> <!-- Logout -->
<q-item <q-btn
v-ripple flat
clickable dense
@click="handleLogout" no-wrap
class="absolute-bottom" size="lg"
> icon="exit_to_app"
<q-item-section avatar> :label="!$q.platform.is.mobile && is_mini ? '' : $t('nav_bar.logout')"
<q-icon class="col-auto text-uppercase text-weight-bold q-my-xs"
name="exit_to_app" :class="!$q.platform.is.mobile && is_mini ? 'absolute-bottom': 'absolute-bottom-left'"
color="accent" @click="handleLogout"
size="lg" />
/>
</q-item-section>
<q-item-section>
<q-item-label class="text-uppercase text-weight-bold">{{ $t('nav_bar.logout') }}</q-item-label>
</q-item-section>
</q-item>
</q-scroll-area>
</q-drawer> </q-drawer>
</template> </template>

View File

@ -2,16 +2,15 @@
lang="ts" lang="ts"
setup setup
> >
import { RouterView } from 'vue-router';
import HeaderBar from 'src/layouts/components/main-layout-header-bar.vue'; import HeaderBar from 'src/layouts/components/main-layout-header-bar.vue';
import FooterBar from 'src/layouts/components/main-layout-footer-bar.vue'; import FooterBar from 'src/layouts/components/main-layout-footer-bar.vue';
import LeftDrawer from 'src/layouts/components/main-layout-left-drawer.vue'; import LeftDrawer from 'src/layouts/components/main-layout-left-drawer.vue';
import { useUiStore } from 'src/stores/ui-store';
import { onMounted, watch, ref } from 'vue'; import { onMounted, watch, ref } from 'vue';
import { useI18n } from 'vue-i18n'; import { RouterView } from 'vue-router';
import { useUiStore } from 'src/stores/ui-store';
const { t } = useI18n();
const ui_store = useUiStore(); const ui_store = useUiStore();
const user_preferences = ref(ui_store.user_preferences); const user_preferences = ref(ui_store.user_preferences);
@ -23,7 +22,7 @@
watch(user_preferences, async () => { watch(user_preferences, async () => {
if (ui_store.user_preferences.id !== -1) { if (ui_store.user_preferences.id !== -1) {
await ui_store.updateUserPreferences(t); await ui_store.updateUserPreferences();
return return
} }
await ui_store.getUserPreferences(); await ui_store.getUserPreferences();

View File

@ -5,12 +5,13 @@
import { ref } from 'vue'; import { ref } from 'vue';
import { unwrapAndClone } from 'src/utils/unwrap-and-clone'; import { unwrapAndClone } from 'src/utils/unwrap-and-clone';
import { useEmployeeStore } from 'src/stores/employee-store'; import { useEmployeeStore } from 'src/stores/employee-store';
import { employee_access_options, type ModuleAccessPreset, type ModuleAccessName, employee_access_presets, getEmployeeAccessOptionIcon } from 'src/modules/employee-list/models/employee-profile.models'; import { employee_access_options, type ModuleAccessPreset, employee_access_presets, getEmployeeAccessOptionIcon } from 'src/modules/employee-list/models/employee-profile.models';
import type { UserModuleAccess } from 'src/modules/shared/models/user.models';
const employee_store = useEmployeeStore(); const employee_store = useEmployeeStore();
const preset_preview = ref<ModuleAccessPreset>(); const preset_preview = ref<ModuleAccessPreset>();
const toggleInSelected = (value: ModuleAccessName) => { const toggleInSelected = (value: UserModuleAccess) => {
const i = employee_store.employee.user_module_access.indexOf(value); const i = employee_store.employee.user_module_access.indexOf(value);
if (i === -1) employee_store.employee.user_module_access.push(value); if (i === -1) employee_store.employee.user_module_access.push(value);
else employee_store.employee.user_module_access.splice(i, 1); else employee_store.employee.user_module_access.splice(i, 1);
@ -21,7 +22,7 @@
} }
const getPreviewBackgroundColor = (name: ModuleAccessName) => { const getPreviewBackgroundColor = (name: UserModuleAccess) => {
if (employee_access_presets[preset_preview.value!].includes(name)) { if (employee_access_presets[preset_preview.value!].includes(name)) {
if (!employee_store.employee.user_module_access.includes(name)) return 'bg-info text-white'; if (!employee_store.employee.user_module_access.includes(name)) return 'bg-info text-white';
@ -33,7 +34,7 @@
return 'bg-dark'; return 'bg-dark';
}; };
const getBackgroundColor = (name: ModuleAccessName) => { const getBackgroundColor = (name: UserModuleAccess) => {
if (employee_store.employee.user_module_access.includes(name)) return 'bg-accent text-white'; if (employee_store.employee.user_module_access.includes(name)) return 'bg-accent text-white';
return 'bg-dark'; return 'bg-dark';
@ -142,8 +143,12 @@
class="row full-width cursor-pointer flex-center q-pa-sm rounded-5 no-wrap shadow-5" class="row full-width cursor-pointer flex-center q-pa-sm rounded-5 no-wrap shadow-5"
:class="preset_preview !== undefined ? getPreviewBackgroundColor(option.value) : getBackgroundColor(option.value)" :class="preset_preview !== undefined ? getPreviewBackgroundColor(option.value) : getBackgroundColor(option.value)"
@click="toggleInSelected(option.value)" @click="toggleInSelected(option.value)"
> >
<q-icon :name="getEmployeeAccessOptionIcon(option.value)" size="sm" class="q-mr-sm"/> <q-icon
:name="getEmployeeAccessOptionIcon(option.value)"
size="sm"
class="q-mr-sm"
/>
<span class="text-uppercase text-weight-bold non-selectable"> <span class="text-uppercase text-weight-bold non-selectable">
{{ $t('employee_management.module_access.' + option.value) }} {{ $t('employee_management.module_access.' + option.value) }}
</span> </span>

View File

@ -48,8 +48,7 @@ export const useEmployeeListApi = () => {
// Build a backend-compatible SchedulePreset instance // Build a backend-compatible SchedulePreset instance
const backend_preset = new SchedulePreset( const backend_preset = new SchedulePreset(
preset.id, preset.id,
preset.name, preset.name,
preset.is_default,
preset_shifts preset_shifts
); );

View File

@ -1,6 +1,6 @@
import type { QSelectOption, QTableColumn } from "quasar"; import type { QSelectOption, QTableColumn } from "quasar";
import type { UserModuleAccess } from "src/modules/shared/models/user.models";
export type ModuleAccessName = 'dashboard' | 'employee_list' | 'employee_management' | 'personal_profile' | 'timesheets' | 'timesheets_approval';
export type ModuleAccessPreset = 'admin' | 'supervisor' | 'employee' | 'none'; export type ModuleAccessPreset = 'admin' | 'supervisor' | 'employee' | 'none';
export type CompanyNames = 'Targo' | 'Solucom'; export type CompanyNames = 'Targo' | 'Solucom';
@ -18,7 +18,7 @@ export class EmployeeProfile {
residence: string; residence: string;
birth_date: string; birth_date: string;
is_supervisor: boolean; is_supervisor: boolean;
user_module_access: ModuleAccessName[]; user_module_access: UserModuleAccess[];
preset_id?: number | null; preset_id?: number | null;
constructor() { constructor() {
@ -100,7 +100,7 @@ export const employee_list_columns: QTableColumn<EmployeeProfile>[] = [
}, },
]; ];
export const employee_access_options: QSelectOption<ModuleAccessName>[] = [ export const employee_access_options: QSelectOption<UserModuleAccess>[] = [
{ label: 'dashboard', value: 'dashboard' }, { label: 'dashboard', value: 'dashboard' },
{ label: 'employee_list', value: 'employee_list' }, { label: 'employee_list', value: 'employee_list' },
{ label: 'personal_profile', value: 'personal_profile' }, { label: 'personal_profile', value: 'personal_profile' },
@ -109,14 +109,14 @@ export const employee_access_options: QSelectOption<ModuleAccessName>[] = [
{ label: 'timesheets_approval', value: 'timesheets_approval' }, { label: 'timesheets_approval', value: 'timesheets_approval' },
] ]
export const employee_access_presets: Record<ModuleAccessPreset, ModuleAccessName[]> = { export const employee_access_presets: Record<ModuleAccessPreset, UserModuleAccess[]> = {
'admin' : ['dashboard', 'employee_list', 'employee_management', 'personal_profile', 'timesheets', 'timesheets_approval'], 'admin' : ['dashboard', 'employee_list', 'employee_management', 'personal_profile', 'timesheets', 'timesheets_approval'],
'supervisor' : ['dashboard', 'employee_list', 'personal_profile', 'timesheets', 'timesheets_approval'], 'supervisor' : ['dashboard', 'employee_list', 'personal_profile', 'timesheets', 'timesheets_approval'],
'employee' : ['dashboard', 'timesheets', 'personal_profile', 'employee_list'], 'employee' : ['dashboard', 'timesheets', 'personal_profile', 'employee_list'],
'none' : [], 'none' : [],
} }
export const getEmployeeAccessOptionIcon = (module: ModuleAccessName): string => { export const getEmployeeAccessOptionIcon = (module: UserModuleAccess): string => {
switch (module) { switch (module) {
case 'dashboard': return 'home'; case 'dashboard': return 'home';
case 'employee_list' : return 'groups'; case 'employee_list' : return 'groups';

View File

@ -9,13 +9,11 @@ export type PresetManagerMode = 'create' | 'update' | 'copy' | 'delete';
export class SchedulePreset { export class SchedulePreset {
id: number; id: number;
name: string; name: string;
is_default: boolean;
shifts: SchedulePresetShift[]; shifts: SchedulePresetShift[];
constructor(id?: number, name?: string, is_default?: boolean, shifts?: SchedulePresetShift[]) { constructor(id?: number, name?: string, shifts?: SchedulePresetShift[]) {
this.id = id ?? -1; this.id = id ?? -1;
this.name = name ?? 'default'; this.name = name ?? 'default';
this.is_default = is_default ?? false;
this.shifts = shifts ?? []; this.shifts = shifts ?? [];
} }
} }
@ -27,7 +25,7 @@ export class SchedulePresetShift {
start_time: string; start_time: string;
end_time: string; end_time: string;
is_remote: boolean; is_remote: boolean;
constructor(weekday: Weekday) { constructor(weekday: Weekday) {
this.preset_id = -1; this.preset_id = -1;
this.week_day = weekday; this.week_day = weekday;
@ -41,13 +39,11 @@ export class SchedulePresetShift {
export class SchedulePresetFrontend { export class SchedulePresetFrontend {
id: number; id: number;
name: string; name: string;
is_default: boolean;
weekdays: WeekdayPresetShifts[]; weekdays: WeekdayPresetShifts[];
constructor(schedule_preset?: SchedulePreset) { constructor(schedule_preset?: SchedulePreset) {
this.id = schedule_preset?.id ?? -1; this.id = schedule_preset?.id ?? -1;
this.name = schedule_preset?.name ?? ''; this.name = schedule_preset?.name ?? '';
this.is_default = schedule_preset?.is_default ?? false;
this.weekdays = WEEKDAYS.map(day => ({ this.weekdays = WEEKDAYS.map(day => ({
day, day,
is_error: false, is_error: false,

View File

@ -25,7 +25,7 @@
<div> <div>
<q-card <q-card
flat flat
class="rounded-5 bg-transparent q-pa-none" class="rounded-5 bg-transparent q-pa-none fit"
> >
<MenuTemplate <MenuTemplate
:first-name="employee_profile.first_name === '' ? auth_store.user?.first_name ?? '' : employee_profile.first_name" :first-name="employee_profile.first_name === '' ? auth_store.user?.first_name ?? '' : employee_profile.first_name"

View File

@ -11,18 +11,15 @@
</script> </script>
<template> <template>
<div class="q-pa-md column fit"> <div class="q-px-md column no-wrap">
<div <div style="transform: translate(10px, 12px);">
class="col-auto"
style="transform: translate(10px, 12px);"
>
<span class="text-uppercase text-weight-bold text-accent bg-dark q-px-sm"> <span class="text-uppercase text-weight-bold text-accent bg-dark q-px-sm">
{{ $t('profile.preferences.display_options') }} {{ $t('profile.preferences.display_options') }}
</span> </span>
</div> </div>
<div <div
class="col-auto justify-center content-center q-mb-sm q-pa-sm rounded-5" class="col-auto justify-center content-center q-mb-sm q-pa-md rounded-5"
:class="ui_store.is_mobile_mode ? 'column' : 'row'" :class="ui_store.is_mobile_mode ? 'column' : 'row'"
style="border: 1px solid var(--q-accent);" style="border: 1px solid var(--q-accent);"
> >
@ -32,12 +29,12 @@
clickable clickable
dense dense
v-ripple v-ripple
class="col rounded-5 q-ma-sm shadow-4" class="col row rounded-5 q-ma-sm shadow-4"
:class="mode.quasar_value === $q.dark.mode ? 'bg-accent text-white text-weight-bolder' : ''" :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" @click="ui_store.user_preferences.is_dark_mode = mode.value"
> >
<q-item-section avatar> <q-item-section avatar>
<q-icon <q-icon
:name="mode.icon" :name="mode.icon"
size="md" size="md"
:color="mode.quasar_value === $q.dark.mode ? 'white' : ''" :color="mode.quasar_value === $q.dark.mode ? 'white' : ''"

View File

@ -16,7 +16,7 @@
<template> <template>
<div <div
class="column flex-center" class="column flex-center fit"
> >
<MenuHeader <MenuHeader
:user-first-name="firstName" :user-first-name="firstName"
@ -41,7 +41,7 @@
<q-card <q-card
class="col" class="col"
:class="$q.screen.lt.sm ? 'full-width' : 'q-ml-sm'" :class="$q.platform.is.mobile ? 'fit' : 'q-ml-sm'"
> >
<q-tab-panels <q-tab-panels
v-model="current_menu" v-model="current_menu"
@ -49,8 +49,8 @@
vertical vertical
transition-prev="jump-up" transition-prev="jump-up"
transition-next="jump-up" transition-next="jump-up"
class="rounded-5" class="rounded-5 q-py-sm"
style="height: 50vh;" :style="$q.platform.is.mobile ? '' : 'height: 50vh;'"
> >
<slot name="panels"></slot> <slot name="panels"></slot>
</q-tab-panels> </q-tab-panels>

View File

@ -9,11 +9,11 @@
dense dense
rounded rounded
debounce="300" debounce="300"
class="right-rounded"
:label="$t('shared.label.search')" :label="$t('shared.label.search')"
label-color="accent"
bg-color="white"
color="accent" color="accent"
bg-color="white"
label-color="accent"
class="text-primary"
> >
<template #prepend> <template #prepend>
<q-icon <q-icon

View File

@ -3,13 +3,18 @@ export interface User {
last_name: string; last_name: string;
email: string; email: string;
role: UserRole; role: UserRole;
user_module_access: UserModuleAccess;
} }
export type UserRole = 'ADMIN' |'SUPERVISOR' | 'HR' | 'ACCOUNTING' | 'EMPLOYEE' | 'DEALER' | 'CUSTOMER' | 'GUEST'; export type UserRole = 'ADMIN' | 'SUPERVISOR' | 'HR' | 'ACCOUNTING' | 'EMPLOYEE' | 'DEALER' | 'CUSTOMER' | 'GUEST';
export const CAN_APPROVE_PAY_PERIODS: UserRole[] = [ export const ModuleNames = {
'ADMIN', DASHBOARD: 'dashboard',
'SUPERVISOR', EMPLOYEE_LIST: 'employee_list',
'HR', EMPLOYEE_MANAGEMENT: 'employee_management',
'ACCOUNTING', PERSONAL_PROFILE: 'personal_profile',
] TIMESHEETS: 'timesheets',
TIMESHEETS_APPROVAL: 'timesheets_approval',
} as const;
export type UserModuleAccess = typeof ModuleNames[keyof typeof ModuleNames];

View File

@ -4,6 +4,7 @@
> >
/* eslint-disable */ /* eslint-disable */
import { onMounted, ref } from 'vue'; import { onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { colors } from 'quasar'; import { colors } from 'quasar';
import { useQuasar } from 'quasar'; import { useQuasar } from 'quasar';
import { Doughnut } from 'vue-chartjs'; import { Doughnut } from 'vue-chartjs';
@ -11,6 +12,7 @@
import { useTimesheetStore } from 'src/stores/timesheet-store'; import { useTimesheetStore } from 'src/stores/timesheet-store';
const $q = useQuasar(); const $q = useQuasar();
const { t } = useI18n();
ChartJS.register(Title, Tooltip, Legend, ArcElement, CategoryScale, LinearScale); ChartJS.register(Title, Tooltip, Legend, ArcElement, CategoryScale, LinearScale);
ChartJS.defaults.font.family = '"Roboto", sans-serif'; ChartJS.defaults.font.family = '"Roboto", sans-serif';
@ -20,10 +22,10 @@
const timesheet_store = useTimesheetStore(); const timesheet_store = useTimesheetStore();
const shift_type_labels = ref<string[]>([ const shift_type_labels = ref<string[]>([
timesheet_store.current_pay_period_overview!.regular_hours.toString() + 'h', t('shared.shift_type.regular'),
timesheet_store.current_pay_period_overview!.other_hours.evening_hours.toString() + 'h', t('shared.shift_type.evening'),
timesheet_store.current_pay_period_overview!.other_hours.emergency_hours.toString() + 'h', t('shared.shift_type.emergency'),
timesheet_store.current_pay_period_overview!.other_hours.overtime_hours.toString() + 'h', t('shared.shift_type.overtime'),
]); ]);
const shift_type_totals = ref<ChartDataset<'doughnut'>[]>([]); const shift_type_totals = ref<ChartDataset<'doughnut'>[]>([]);

View File

@ -25,36 +25,34 @@
@show="is_dialog_open = true" @show="is_dialog_open = true"
@hide="is_dialog_open = false" @hide="is_dialog_open = false"
> >
<q-card <div
class="shadow-12 rounded-15 bg-secondary hide-scrollbar" class="column bg-secondary hide-scrollbar shadow-12 rounded-15 q-pa-sm no-wrap"
:style="($q.screen.lt.md ? '' : 'width:80vw !important;') + ($q.dark.isActive ? ' border: 2px solid var(--q-accent)' : '')" :style="($q.screen.lt.md ? '' : 'width:80vw !important;') + ($q.dark.isActive ? ' border: 2px solid var(--q-accent)' : '')"
> >
<!-- employee name --> <!-- employee name -->
<q-card-section class="text-h4 text-weight-bolder text-center text-uppercase q-px-none q-py-sm"> <div class="col-auto text-h4 text-weight-bolder text-center text-uppercase q-px-none q-py-sm">
<span>{{ timesheet_store.selected_employee_name }}</span> <span>{{ timesheet_store.selected_employee_name }}</span>
</q-card-section> </div>
<!-- employee pay period details using chart --> <!-- employee pay period details using chart -->
<q-card-section <div
v-if="is_dialog_open" v-if="is_dialog_open && !$q.platform.is.mobile"
:horizontal="!$q.screen.lt.md" class="col-4 q-px-md no-wrap"
class="q-px-md rounded-10 no-wrap" :class="$q.platform.is.mobile ? 'column' : 'row'"
> >
<DetailsDialogChartHoursWorked class="col" /> <DetailsDialogChartHoursWorked class="col" />
<DetailsDialogChartShiftTypes class="col q-ma-lg" /> <DetailsDialogChartShiftTypes class="col" />
<DetailsDialogChartExpenses class="col" /> <DetailsDialogChartExpenses class="col" />
</q-card-section> </div>
<q-card-section> <div class="col-auto">
<ExpenseDialogList /> <ExpenseDialogList />
</q-card-section> </div>
<!-- list of shifts --> <!-- list of shifts -->
<q-card-section class="col-auto"> <div class="col column no-wrap">
<TimesheetWrapper mode="approval" /> <TimesheetWrapper mode="approval" class="col"/>
</q-card-section> </div>
<q-separator /> </div>
</q-card>
</q-dialog> </q-dialog>
</template> </template>

View File

@ -0,0 +1,36 @@
<script
setup
lang="ts"
>
import type { PayPeriodOverviewFilters } from 'src/modules/timesheet-approval/models/timesheet-overview.models';
const filters = defineModel<PayPeriodOverviewFilters>('filters', { required: true })
</script>
<template>
<div class="column text-weight-medium">
<q-separator
color="accent"
size="5px"
class="col-auto"
/>
<div class="col row">
<q-checkbox
v-model="filters.is_showing_inactive"
size="lg"
color="accent"
label="show inactive"
class="col"
/>
<q-checkbox
v-model="filters.is_showing_team_only"
size="lg"
val="team"
color="accent"
label="show team only"
class="col"
/>
</div>
</div>
</template>

View File

@ -13,7 +13,20 @@
const emit = defineEmits<{ const emit = defineEmits<{
'clickDetails': [overview: TimesheetOverview]; 'clickDetails': [overview: TimesheetOverview];
'clickApprovalAll' : [is_approved: boolean];
}>(); }>();
const getMinutes = (hours: number) => {
const minutes_percent = hours - Math.floor(hours);
const minutes = Math.round(minutes_percent * 60);
return minutes > 1 ? minutes.toString() : '0';
}
const getHoursMinutesString = (hours: number): string => {
const flat_hours = Math.floor(hours);
const minutes = Math.round((hours - flat_hours) * 60);
return `${flat_hours}h ${minutes > 1 ? minutes : ''}`
}
</script> </script>
<template> <template>
@ -24,16 +37,20 @@
> >
<q-card <q-card
class="rounded-10 shadow-5" class="rounded-10 shadow-5"
:style="`animation-delay: ${index / 15}s;`" :style="`animation-delay: ${index / 15}s; opacity: ${row.is_active ? '1' : '0.5'}; transform: scale(${row.is_active ? '1' : '0.9'})`"
> >
<!-- Card header with employee name and details button--> <!-- Card header with employee name and details button-->
<q-card-section <q-card-section
horizontal horizontal
class="q-py-none q-px-sm q-ma-none justify-between items-center bg-primary text-white" class="q-py-xs q-px-sm q-ma-none justify-between items-center bg-primary text-white"
> >
<div> <div>
<span class="text-h5 text-uppercase text-weight-medium text-accent q-mr-xs">{{ row.employee_name.split(' ')[0] <span
}}</span> class="text-h5 text-uppercase text-weight-medium q-mr-xs"
:class="row.is_active ? 'text-accent' : 'text-negative'"
>
{{ row.employee_name.split(' ')[0] }}
</span>
<span class="text-uppercase text-weight-light">{{ row.employee_name.split(' ')[1] }}</span> <span class="text-uppercase text-weight-light">{{ row.employee_name.split(' ')[1] }}</span>
</div> </div>
@ -45,7 +62,7 @@
unelevated unelevated
class="col-auto q-pa-none q-ma-none" class="col-auto q-pa-none q-ma-none"
color="accent" color="accent"
icon="work_history" icon="las la-chart-pie"
@click="emit('clickDetails', row)" @click="emit('clickDetails', row)"
> >
<q-tooltip <q-tooltip
@ -55,6 +72,11 @@
> >
{{ $t('timesheet_approvals.tooltip.button_detailed_view') }} {{ $t('timesheet_approvals.tooltip.button_detailed_view') }}
</q-tooltip> </q-tooltip>
<q-icon
name="las la-chart-bar"
color="accent"
/>
</q-btn> </q-btn>
</q-card-section> </q-card-section>
@ -67,13 +89,13 @@
<div class="col column"> <div class="col column">
<span <span
class="text-weight-bold text-uppercase q-pa-none q-my-none" class="text-weight-bold text-uppercase q-pa-none q-my-none"
:class="row.regular_hours > 80 ? 'text-negative' : 'text-accent'" :class="row.regular_hours > 80 || !row.is_active ? 'text-negative' : 'text-accent'"
> {{ > {{
$t('shared.shift_type.regular') }} </span> $t('shared.shift_type.regular') }} </span>
<span <span
class="text-weight-bolder text-h3 q-py-none" class="text-weight-bolder text-h3 q-py-none"
:class="row.regular_hours > 80 ? 'text-negative' : ''" :class="row.regular_hours > 80 || !row.is_active ? 'text-negative' : ''"
> {{ row.regular_hours }} </span> > {{ getHoursMinutesString(row.regular_hours) }} </span>
<q-separator class="q-mr-sm" /> <q-separator class="q-mr-sm" />
</div> </div>
@ -88,11 +110,13 @@
<span <span
class="text-weight-bold text-accent text-uppercase q-pa-none q-my-none" class="text-weight-bold text-accent text-uppercase q-pa-none q-my-none"
style="font-size: 0.7em;" style="font-size: 0.7em;"
> {{ $t(`shared.shift_type.${index.replace('_hours', '')}`) }} </span> >
{{ $t(`shared.shift_type.${index.replace('_hours', '')}`) }}
</span>
<span <span
class="text-weight-bolder q-pa-none q-mb-xs" class="text-weight-bolder q-pa-none q-mb-xs"
style="font-size: 1.2em; line-height: 1em;" style="font-size: 1.2em; line-height: 1em;"
> {{ hour_type }} </span> > {{ getHoursMinutesString(hour_type) }} </span>
</div> </div>
</div> </div>
</div> </div>
@ -133,30 +157,53 @@
<q-card-section <q-card-section
horizontal horizontal
class="justify-between items-center text-weight-bold q-pa-none" class="justify-between items-center text-weight-bold q-pa-none"
:class="row.is_approved ? 'text-white bg-accent' : 'bg-dark text-accent'" :class="row.is_active ? (row.is_approved ? 'text-white bg-accent' : 'bg-dark') : 'bg-transparent'"
> >
<div class="col-auto"> <div
<span class="text-uppercase text-h6 q-ml-sm text-weight-bolder"> {{ row.total_hours }} </span> v-if="row.is_active"
<span class="text-uppercase text-weight-bold text-caption q-ml-xs"> total </span> class="col row full-width"
>
<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>
<span class="text-uppercase text-weight-medium text-caption">H</span>
<span class="text-h6 q-ml-sm text-weight-bolder">{{ getMinutes(row.total_hours) }}</span>
<span class="text-uppercase text-weight-medium text-caption">M</span>
</div>
<div class="col-auto q-py-xs q-px-md">
<q-checkbox
v-model="modelApproval"
dense
left-label
keep-color
size="lg"
checked-icon="lock"
unchecked-icon="lock_open"
:color="row.is_approved ? 'white' : 'accent'"
:label="row.is_approved ? $t('timesheet_approvals.table.verified') : $t('timesheet_approvals.table.unverified')"
class="text-uppercase"
:class="row.is_approved ? '' : 'text-accent'"
@update:model-value="value => $emit('clickApprovalAll', value)"
/>
</div>
</div> </div>
<div <div
class="col-auto q-py-xs q-px-md" v-else
style="border: 1px solid var(--q-accent);" class="col row flex-center q-px-sm full-width"
> >
<q-checkbox <q-icon
v-model="modelApproval" name="block"
dense color="negative"
left-label class="col-auto"
keep-color
size="lg" size="lg"
checked-icon="lock"
unchecked-icon="lock_open"
:color="row.is_approved ? 'white' : 'accent'"
:label="row.is_approved ? $t('timesheet_approvals.table.verified') : $t('timesheet_approvals.table.unverified')"
class="text-uppercase"
:class="row.is_approved ? '' : 'text-accent'"
/> />
<span class="col q-pl-sm text-uppercase text-weight-bold text-h5">{{
$t('timesheet_approvals.table.inactive') }}</span>
</div> </div>
</q-card-section> </q-card-section>
</q-card> </q-card>

View File

@ -1,37 +1,68 @@
<script setup lang="ts"> <script
/* eslint-disable */ setup
import { computed, ref } from 'vue'; lang="ts"
import OverviewListItem from 'src/modules/timesheet-approval/components/overview-list-item.vue'; >
import LoadingOverlay from 'src/modules/shared/components/loading-overlay.vue'; /* eslint-disable */
import { useTimesheetStore } from 'src/stores/timesheet-store'; import OverviewListItem from 'src/modules/timesheet-approval/components/overview-list-item.vue';
import { overview_column_names, pay_period_overview_columns, type TimesheetOverview } from 'src/modules/timesheet-approval/models/timesheet-overview.models'; import LoadingOverlay from 'src/modules/shared/components/loading-overlay.vue';
import QTableFilters from 'src/modules/shared/components/q-table-filters.vue';
import PayPeriodNavigator from 'src/modules/shared/components/pay-period-navigator.vue';
import OverviewListFilters from 'src/modules/timesheet-approval/components/overview-list-filters.vue';
const timesheet_store = useTimesheetStore(); import { computed, ref } from 'vue';
import { useTimesheetStore } from 'src/stores/timesheet-store';
const visible_columns = ref<string[]>([ import { useTimesheetApprovalApi } from 'src/modules/timesheet-approval/composables/use-timesheet-approval-api';
overview_column_names.REGULAR, import { overview_column_names, pay_period_overview_columns, PayPeriodOverviewFilters, type TimesheetOverview } from 'src/modules/timesheet-approval/models/timesheet-overview.models';
overview_column_names.EVENING,
overview_column_names.EMERGENCY,
overview_column_names.SICK,
overview_column_names.VACATION,
overview_column_names.HOLIDAY,
overview_column_names.OVERTIME,
overview_column_names.IS_APPROVED,
]);
const overview_rows = computed(() => timesheet_store.pay_period_overviews[0]?.regular_hours === -1 ?
[] :
timesheet_store.pay_period_overviews
)
const onClickedDetails = async (row: TimesheetOverview) => {
timesheet_store.current_pay_period_overview = row;
await timesheet_store.getTimesheetsByOptionalEmployeeEmail(row.email);
timesheet_store.is_details_dialog_open = true;
};
const timesheet_store = useTimesheetStore();
const timesheet_approval_api = useTimesheetApprovalApi();
const visible_columns = ref<string[]>([
overview_column_names.REGULAR,
overview_column_names.EVENING,
overview_column_names.EMERGENCY,
overview_column_names.SICK,
overview_column_names.VACATION,
overview_column_names.HOLIDAY,
overview_column_names.OVERTIME,
overview_column_names.IS_APPROVED,
]);
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,
});
const onClickedDetails = async (row: TimesheetOverview) => {
timesheet_store.current_pay_period_overview = row;
await timesheet_store.getTimesheetsByOptionalEmployeeEmail(row.email);
timesheet_store.is_details_dialog_open = true;
};
const onClickApproveAll = async (email: string, is_approved: boolean) => {
await timesheet_approval_api.toggleTimesheetsApprovalByEmployeeEmail(email, is_approved);
}
const filterEmployeeRows = (rows: readonly TimesheetOverview[], terms: PayPeriodOverviewFilters): TimesheetOverview[] => {
let result = [...rows];
if (!terms.is_showing_inactive) {
result = result.filter(row => row.is_active);
}
// if (terms.name_search_string) {
// result = result.filter(row => row.employee_name.includes(terms.name_search_string ?? ''));
// }
return result;
};
</script> </script>
<template> <template>
@ -49,10 +80,12 @@ const onClickedDetails = async (row: TimesheetOverview) => {
:rows="overview_rows" :rows="overview_rows"
:columns="pay_period_overview_columns" :columns="pay_period_overview_columns"
row-key="email" row-key="email"
:filter="timesheet_store.search_filter"
:grid="timesheet_store.is_approval_grid_mode" :grid="timesheet_store.is_approval_grid_mode"
:dense="timesheet_store.is_approval_grid_mode" :dense="timesheet_store.is_approval_grid_mode"
hide-pagination hide-pagination
:pagination="{ sortBy: 'is_active' }"
:filter="overview_filters"
:filter-method="filterEmployeeRows"
color="accent" color="accent"
:rows-per-page-options="[0]" :rows-per-page-options="[0]"
card-container-class="justify-center" card-container-class="justify-center"
@ -63,6 +96,87 @@ const onClickedDetails = async (row: TimesheetOverview) => {
: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')"
> >
<template #top>
<div class="column full-width">
<div
class="col-auto row items-start full-width q-px-lg"
:class="($q.platform.is.mobile ? 'column flex-center' : 'row q-mt-md') + (timesheet_store.is_approval_grid_mode ? '' : ' q-mb-md')"
>
<PayPeriodNavigator
@date-selected="timesheet_approval_api.getTimesheetOverviews"
@pressed-next-button="timesheet_approval_api.getTimesheetOverviews"
@pressed-previous-button="timesheet_approval_api.getTimesheetOverviews"
:class="$q.platform.is.mobile ? 'q-mb-sm' : ''"
style="height: 40px;"
/>
<q-space />
<div
class="col-auto row no-wrap items-start"
:class="$q.platform.is.mobile ? 'q-mb-md' : ''"
>
<q-btn-toggle
v-model="timesheet_store.is_approval_grid_mode"
push
rounded
color="white"
text-color="accent"
toggle-color="accent"
class="col-auto"
:class="$q.platform.is.mobile ? 'q-mb-sm' : 'q-mr-sm'"
:options="[
{ icon: 'grid_view', value: true },
{ icon: 'view_list', value: false },
]"
style="height: 40px;"
/>
<q-btn
push
rounded
icon="download"
color="accent"
:label="$q.screen.lt.md ? '' : $t('shared.label.download')"
class="col-auto q-mr-sm"
style="height: 40px;"
@click="timesheet_store.is_report_dialog_open = true"
/>
<QTableFilters
v-model:search="search_string"
class="col-auto q-mb-xs"
/>
<q-btn
flat
icon="filter_alt"
color="white"
:label="$q.platform.is.mobile ? '' : $t('shared.label.filter')"
class="col q-ml-sm self-stretch bg-accent"
style="border-radius: 5px 5px 0 0;"
@click="is_showing_filters = !is_showing_filters"
/>
</div>
</div>
<q-slide-transition>
<OverviewListFilters
v-if="is_showing_filters"
v-model:filters="overview_filters"
class="q-mx-lg col-auto"
/>
</q-slide-transition>
<q-separator
color="accent"
size="5px"
class="q-mx-lg"
/>
</div>
</template>
<template #header="props"> <template #header="props">
<q-tr <q-tr
:props="props" :props="props"
@ -119,7 +233,7 @@ const onClickedDetails = async (row: TimesheetOverview) => {
{{ props.value.split(' ')[0] }} {{ props.value.split(' ')[0] }}
</span> </span>
<span class="text-uppercase text-weight-light">{{ props.value.split(' ')[1] <span class="text-uppercase text-weight-light">{{ props.value.split(' ')[1]
}}</span> }}</span>
</div> </div>
<span v-else>{{ props.value }}</span> <span v-else>{{ props.value }}</span>
</div> </div>
@ -134,7 +248,8 @@ const onClickedDetails = async (row: TimesheetOverview) => {
:key="props.row.email + timesheet_store.pay_period?.pay_period_no" :key="props.row.email + timesheet_store.pay_period?.pay_period_no"
:index="props.rowIndex" :index="props.rowIndex"
:row="props.row" :row="props.row"
@click-details="overview => onClickedDetails(overview)" @click-details="onClickedDetails"
@click-approval-all="is_approved => onClickApproveAll(props.row.email, is_approved)"
/> />
</template> </template>

View File

@ -26,6 +26,20 @@ export const useTimesheetApprovalApi = () => {
timesheet_store.is_loading = false; timesheet_store.is_loading = false;
}; };
const toggleTimesheetsApprovalByEmployeeEmail = async (email: string, approval_status: boolean) => {
timesheet_store.is_loading = true;
const success = await timesheet_store.getTimesheetsByOptionalEmployeeEmail(email);
if (success) {
const approval_success = await timesheet_store.toggleTimesheetsApprovalByEmployeeEmail(email, approval_status);
const overview = timesheet_store.pay_period_overviews.find(overview => overview.email === email);
if (overview && approval_success) overview.is_approved = approval_status;
}
timesheet_store.is_loading = false;
};
const getTimesheetApprovalCSVReport = async (report_filter_company: boolean[], report_filter_type: boolean[]) => { const getTimesheetApprovalCSVReport = async (report_filter_company: boolean[], report_filter_type: boolean[]) => {
if (timesheet_store.pay_period === undefined) return; if (timesheet_store.pay_period === undefined) return;
@ -40,6 +54,7 @@ export const useTimesheetApprovalApi = () => {
return { return {
getTimesheetOverviewsByDate, getTimesheetOverviewsByDate,
toggleTimesheetsApprovalByEmployeeEmail,
getTimesheetApprovalCSVReport, getTimesheetApprovalCSVReport,
getTimesheetOverviews, getTimesheetOverviews,
} }

View File

@ -1,8 +1,9 @@
import type { QTableColumn } from "quasar"; import type { QTableColumn } from "quasar";
export interface TimesheetOverview { export class TimesheetOverview {
email: string; email: string;
employee_name: string; employee_name: string;
is_active: boolean;
regular_hours: number; regular_hours: number;
other_hours: { other_hours: {
evening_hours: number; evening_hours: number;
@ -16,6 +17,25 @@ export interface TimesheetOverview {
expenses: number; expenses: number;
mileage: number; mileage: number;
is_approved: boolean; is_approved: boolean;
constructor() {
this.email = '';
this.employee_name = 'John Doe';
this.is_active = true;
this.regular_hours = 0;
this.other_hours = {
evening_hours: 0,
emergency_hours: 0,
overtime_hours: 0,
sick_hours: 0,
holiday_hours: 0,
vacation_hours: 0,
}
this.total_hours = 0;
this.expenses = 0;
this.mileage = 0;
this.is_approved = false;
};
} }
export interface PayPeriodOverviewResponse { export interface PayPeriodOverviewResponse {
@ -28,22 +48,11 @@ export interface PayPeriodOverviewResponse {
employees_overview: TimesheetOverview[]; employees_overview: TimesheetOverview[];
} }
export const default_pay_period_overview: TimesheetOverview = { export interface PayPeriodOverviewFilters {
email: '', is_showing_inactive: boolean;
employee_name: '', is_showing_team_only: boolean;
regular_hours: -1, supervisors: string[];
other_hours: { name_search_string: string | number | null;
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 overview_column_names = { export const overview_column_names = {
@ -58,7 +67,8 @@ export const overview_column_names = {
OVERTIME: 'OVERTIME', OVERTIME: 'OVERTIME',
EXPENSES: 'expenses', EXPENSES: 'expenses',
MILEAGE: 'mileage', MILEAGE: 'mileage',
IS_APPROVED: 'is_approved', IS_APPROVED: 'is_approved',
IS_ACTIVE: 'is_active',
} }
export const pay_period_overview_columns: QTableColumn[] = [ export const pay_period_overview_columns: QTableColumn[] = [
@ -145,5 +155,11 @@ export const pay_period_overview_columns: QTableColumn[] = [
label: 'timesheet_approvals.table.is_approved', label: 'timesheet_approvals.table.is_approved',
field: 'is_approved', field: 'is_approved',
sortable: true, sortable: true,
},
{
name: overview_column_names.IS_ACTIVE,
label: 'timesheet_approvals.table.is_active',
field: 'is_active',
sortable: true,
} }
] ]

View File

@ -1,4 +1,5 @@
import { api } from "src/boot/axios"; import { api } from "src/boot/axios";
import type { BackendResponse } from "src/modules/shared/models/backend-response.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";
import type { PayPeriodOverviewResponse } from "src/modules/timesheet-approval/models/timesheet-overview.models"; import type { PayPeriodOverviewResponse } from "src/modules/timesheet-approval/models/timesheet-overview.models";
@ -12,4 +13,9 @@ export const timesheetApprovalService = {
const response = await api.get(`exports/csv/${year}/${period_number}`, { params: filters, responseType: 'arraybuffer' }); const response = await api.get(`exports/csv/${year}/${period_number}`, { params: filters, responseType: 'arraybuffer' });
return response; return response;
}, },
updateTimesheetsApprovalStatus: async (email: string, timesheet_ids: number[], is_approved: boolean): Promise<BackendResponse<{shifts: number, expenses: number}>> => {
const response = await api.patch<BackendResponse<{shifts: number, expenses: number}>>('pay-periods/pay-period-approval', { email, timesheet_ids, is_approved});
return response.data;
},
}; };

View File

@ -101,6 +101,7 @@
v-model="is_navigator_open" v-model="is_navigator_open"
transition-show="jump-right" transition-show="jump-right"
transition-hide="jump-right" transition-hide="jump-right"
class="z-top"
> >
<q-date <q-date
v-model="expenses_store.current_expense.date" v-model="expenses_store.current_expense.date"

View File

@ -56,15 +56,16 @@
> >
<template #header> <template #header>
<div class="row items-center"> <div class="row items-center">
<span class="col-auto text-uppercase text-weight-bold text-h6 q-ml-lg q-mr-sm">
{{ $t('timesheet.expense.add_expense') }}
</span>
<q-icon <q-icon
v-if="expense_store.mode !== 'create'" name="add_circle_outline"
name="las la-plus-square"
size="md" size="md"
class="col-auto" class="col-auto"
:class="expense_store.is_showing_create_form ? 'invisible' : ''"
/> />
<span class="col-auto text-uppercase text-weight-bold text-h6 q-ml-xs q-mr-sm">
{{ $t('timesheet.expense.add_expense') }}
</span>
</div> </div>
</template> </template>

View File

@ -94,6 +94,7 @@
v-model="is_navigator_open" v-model="is_navigator_open"
transition-show="jump-right" transition-show="jump-right"
transition-hide="jump-right" transition-hide="jump-right"
class="z-top"
> >
<q-date <q-date
v-model="expenses_store.current_expense.date" v-model="expenses_store.current_expense.date"
@ -217,12 +218,12 @@
class="col-auto" class="col-auto"
/> />
<q-dialog v-model="is_showing_comment_dialog_mobile"> <q-dialog v-model="is_showing_comment_dialog_mobile" class="z-top">
<q-card class="full-width bg-secondary rounded-10"> <q-card class="full-width bg-primary rounded-10">
<q-card-section class="q-pa-none"> <q-card-section class="q-pa-none">
<span <span
class="text-weight-bold text-accent text-uppercase text-caption" class="text-weight-bold text-accent text-uppercase text-caption q-mx-md"
style="font-size: 1.5em;" style="font-size: 1.2em;"
> >
{{ $t('timesheet.expense.employee_comment') }} {{ $t('timesheet.expense.employee_comment') }}
</span> </span>
@ -231,14 +232,13 @@
<q-card-section class="q-pa-none bg-primary rounded-10"> <q-card-section class="q-pa-none bg-primary rounded-10">
<q-input <q-input
v-model="expenses_store.current_expense.comment" v-model="expenses_store.current_expense.comment"
standout="bg-blue-grey-9" filled
dense dense
hide-bottom-space hide-bottom-space
color="primary" color="accent"
type="textarea" type="textarea"
:maxlength="COMMENT_MAX_LENGTH" :maxlength="COMMENT_MAX_LENGTH"
lazy-rules="ondemand" class="bg-white no-border"
:rules="[rules.commentRequired]"
> >
</q-input> </q-input>
</q-card-section> </q-card-section>

View File

@ -0,0 +1,317 @@
<script
setup
lang="ts"
>
import { computed, onMounted, ref } from 'vue';
import { QSelect, QInput } from 'quasar';
import { useUiStore } from 'src/stores/ui-store';
import { SHIFT_OPTIONS } from 'src/modules/timesheets/utils/shift.util';
import type { Shift } from 'src/modules/timesheets/models/shift.models';
const ui_store = useUiStore();
const COMMENT_LENGTH_MAX = 280;
const shift = defineModel<Shift>('shift', { required: true });
const shift_type_selected = ref(SHIFT_OPTIONS.find(option => option.value == shift.value.type));
const select_ref = ref<QSelect | null>(null);
const is_showing_comment_popup = ref(false);
const comment_length = computed(() => shift.value.comment?.length ?? 0);
const error_message = ref('');
const { errorMessage = undefined, dense = false, hasShiftAfter = false, isTimesheetApproved = false } = defineProps<{
dense?: boolean;
hasShiftAfter?: boolean;
isTimesheetApproved?: boolean;
errorMessage?: string | undefined;
}>();
const emit = defineEmits<{
'requestDelete': [void];
'onTimeFieldBlur': [void];
}>();
const onBlurShiftTypeSelect = () => {
if (shift_type_selected.value === undefined) {
shift.value.type = 'REGULAR';
shift.value.id = 0;
emit('requestDelete');
}
};
const onTimeFieldBlur = (time_string: string) => {
if (time_string.length < 1 || !time_string) {
shift.value.has_error = true;
error_message.value = 'timesheet.errors.SHIFT_TIME_REQUIRED';
} else {
shift.value.has_error = false;
error_message.value = '';
emit('onTimeFieldBlur');
}
}
const getCommentCounterColor = (comment_length: number) => {
if (comment_length < 200) return 'primary';
if (comment_length < 250) return 'warning';
return 'negative';
};
onMounted(() => {
if (ui_store.focus_next_component) {
select_ref.value?.focus();
select_ref.value?.showPopup();
shift_type_selected.value = undefined;
ui_store.focus_next_component = false;
}
if (errorMessage)
error_message.value = errorMessage;
});
</script>
<template>
<div class="row q-px-xs">
<div class="col column">
<div class="col row items-center text-uppercase q-px-xs rounded-5">
<!-- comment button -->
<q-btn
v-if="ui_store.is_mobile_mode && !dense"
:icon="shift.comment ? 'chat' : 'chat_bubble_outline'"
:text-color="shift.comment ? ((shift.is_approved && isTimesheetApproved) ? 'white' : 'accent') : 'grey-5'"
class="col-auto full-height q-mx-xs rounded-5 shadow-1"
@click="is_showing_comment_popup = true"
>
<q-dialog v-model="is_showing_comment_popup">
<q-input
color="white"
v-model="shift.comment"
dense
:readonly="(shift.is_approved || isTimesheetApproved)"
autofocus
counter
bottom-slots
stack-label
:label="$t('timesheet.shift.fields.header_comment')"
:maxlength="COMMENT_LENGTH_MAX"
:class="(shift.is_approved || isTimesheetApproved) ? 'cursor-not-allowed' : ''"
>
<template #append>
<q-icon name="edit" />
</template>
<template #counter>
<div class="row flex-center">
<q-space />
<q-knob
v-model="comment_length"
readonly
:max="COMMENT_LENGTH_MAX"
size="1.6em"
:thickness="0.4"
:color="getCommentCounterColor(comment_length)"
track-color="grey-4"
class="col-auto q-mr-xs"
/>
<span
:class="'col-auto text-weight-bolder text-' + getCommentCounterColor(comment_length)"
>{{ 280 - comment_length }}</span>
</div>
</template>
</q-input>
</q-dialog>
</q-btn>
<!-- shift type -->
<q-select
ref="select"
v-model="shift_type_selected"
:standout="$q.dark.isActive ? 'bg-blue-grey-3' : 'bg-blue-grey-9'"
dense
:borderless="(shift.is_approved && isTimesheetApproved)"
:readonly="(shift.is_approved && isTimesheetApproved)"
options-dense
hide-dropdown-icon
:menu-offset="[0, 10]"
menu-anchor="bottom middle"
menu-self="top middle"
:options="SHIFT_OPTIONS"
class="col rounded-5 bg-dark"
:class="!shift.is_approved && !isTimesheetApproved ? '' : 'inset-shadow'"
popup-content-class="text-uppercase text-weight-bold text-center rounded-5"
:style="shift.is_approved ? 'background-color: #0a7d32 !important;' : ''"
popup-content-style="border: 2px solid var(--q-accent)"
@blur="onBlurShiftTypeSelect"
@update:model-value="option => shift.type = option.value"
>
<template #selected-item="scope">
<div
class="row items-center text-weight-bold q-ma-none q-pa-none no-wrap ellipsis full-width"
:class="ui_store.is_mobile_mode ? 'full-height' : ''"
:tabindex="scope.tabindex"
>
<q-icon
:name="scope.opt.icon"
:color="scope.opt.icon_color"
size="sm"
class="col-auto"
:class="shift.is_approved ? 'q-mx-xs' : 'q-mr-xs'"
/>
<span
style="line-height: 1.2em;"
class="col-auto ellipsis"
:class="!shift.is_approved ? '' : 'text-white'"
>
{{ $t(scope.opt.label) }}
</span>
</div>
</template>
<template #after>
<q-icon
v-if="shift.is_approved"
:name="shift.is_remote ? 'las la-laptop' : 'las la-building'"
size="1.2em"
color="white"
class="q-mr-sm"
>
<q-tooltip
anchor="top middle"
self="bottom middle"
:offset="[0, 10]"
:hide-delay="1000"
class="text-uppercase text-weight-bold text-white bg-primary"
>
{{ shift.is_remote ? $t('timesheet.shift.types.REMOTE') :
$t('timesheet.shift.types.OFFICE') }}
</q-tooltip>
</q-icon>
<q-toggle
v-else
v-model="shift.is_remote"
:disable="shift.is_approved"
dense
keep-color
size="3em"
color="accent"
icon="las la-building"
checked-icon="las la-laptop"
>
<q-tooltip
anchor="top middle"
self="bottom middle"
:offset="[0, 10]"
:hide-delay="1000"
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>
<div class="col row items-start text-uppercase rounded-5 q-pa-xs">
<!-- punch in field -->
<div class="col q-pr-xs">
<q-input
v-model="shift.start_time"
dense
:borderless="(shift.is_approved && isTimesheetApproved)"
:readonly="(shift.is_approved && isTimesheetApproved)"
type="time"
:standout="$q.dark.isActive ? 'bg-blue-grey-3' : 'bg-blue-grey-9'"
label-slot
lazy-rules
no-error-icon
hide-bottom-space
:error="shift.has_error"
:error-message="errorMessage || error_message !== '' ? $t(errorMessage ?? error_message) : ''"
:label-color="!shift.is_approved ? 'accent' : 'white'"
class="rounded-5 bg-dark"
:class="(shift.id === -2 ? 'bg-negative ' : ' ') + (!shift.is_approved && !isTimesheetApproved ? '' : 'cursor-not-allowed inset-shadow')"
:input-class="'text-weight-medium ' + (shift.id === -2 ? 'text-white ' : ' ') + (shift.is_approved ? 'text-white cursor-not-allowed q-px-sm' : '')"
input-style="font-size: 1.2em;"
:style="shift.is_approved ? 'background-color: #0a7d32 !important;' : ''"
@blur="onTimeFieldBlur(shift.start_time)"
>
<template #label>
<span
class="text-weight-bolder"
:class="shift.is_approved ? ' q-ml-md' : ''"
style="font-size: 0.95em;"
>{{ $t('shared.misc.in') }}</span>
</template>
</q-input>
</div>
<!-- punch out field -->
<div class="col">
<q-input
v-model="shift.end_time"
standout
dense
:borderless="(shift.is_approved && isTimesheetApproved)"
:readonly="(shift.is_approved && isTimesheetApproved)"
type="time"
label-slot
no-error-icon
hide-bottom-space
:error="shift.has_error"
:error-message="errorMessage || error_message !== '' ? $t(errorMessage ?? error_message) : ''"
:label-color="!shift.is_approved ? 'accent' : 'white'"
:input-class="'text-weight-medium ' + (shift.id === -2 ? 'text-white ' : ' ') + (shift.is_approved ? 'text-white cursor-not-allowed q-px-sm' : '')"
input-style="font-size: 1.2em;"
class="rounded-5 bg-dark"
:class="(shift.id === -2 ? 'bg-negative ' : ' ') + (shift.is_approved ? 'cursor-not-allowed q-px-xs transparent inset-shadow' : (isTimesheetApproved ? 'inset-shadow' : ''))"
:style="shift.is_approved ? 'background-color: #0a7d32 !important;' : ''"
@blur="onTimeFieldBlur(shift.end_time)"
>
<template #label>
<span
class="text-weight-bolder"
:class="shift.is_approved ? ' q-ml-md' : ''"
style="font-size: 0.95em;"
>{{ $t('shared.misc.out') }}</span>
</template>
</q-input>
</div>
</div>
</div>
<div class="col-auto">
<q-btn
v-if="!shift.is_approved"
flat
dense
color="negative"
icon="las la-trash"
size="lg"
class="full-height"
@click="$emit('requestDelete')"
/>
</div>
<q-separator
v-if="hasShiftAfter"
spaced
class="q-mx-md col-12"
/>
</div>
</template>
<style scoped>
:deep(.q-field--error) {
background-color: var(--q-negative) !important;
}
:deep(.q-field--error .q-field__bottom) {
color: white;
font-weight: 900;
border-radius: 0 0 5px 5px;
padding-top: 0;
align-items: center;
}
</style>

View File

@ -0,0 +1,91 @@
<script
setup
lang="ts"
>
import { useTimesheetApi } from 'src/modules/timesheets/composables/use-timesheet-api';
import { useTimesheetStore } from 'src/stores/timesheet-store';
const timesheet_store = useTimesheetStore();
const timesheet_api = useTimesheetApi();
</script>
<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"
>
<!-- per timesheet -->
<div
v-for="timesheet, timesheet_index in timesheet_store.timesheets"
:key="timesheet_index"
class="col column flex-center q-pa-sm"
>
<!-- container -->
<div
class="rounded-5 relative-position q-px-sm q-pt-sm q-pb-xs full-width shadow-4"
style="border: 1px solid var(--q-accent);"
>
<!-- label for week number -->
<div
class="self-start text-uppercase text-weight-bolder text-accent bg-secondary absolute-top-left q-px-xs"
style="font-size: 0.8em; top: -7px; left: 10px; line-height: 1em;"
>{{ $t('timesheet.week') + ` ${timesheet_index + 1}` }}</div>
<!-- hours worked in the week -->
<div class="col-auto row">
<span class="text-weight-bolder text-uppercase text-accent text-caption q-mr-sm">{{
$t('timesheet.total_hours') }}</span>
<span>{{
(timesheet.weekly_hours.regular +
timesheet.weekly_hours.evening +
timesheet.weekly_hours.emergency +
timesheet.weekly_hours.overtime).toFixed(2)
}}</span>
</div>
<!-- label for current shifts preview -->
<div
class="col-auto full-width text-center text-weight-medium text-caption text-uppercase q-mt-xs"
style="font-size: 0.65em; line-height: 1.2em;"
> {{ $t('timesheet.current_shifts') }}</div>
<!-- preview of current number of shifts -->
<div
class="col row flex-center"
style="height: 20px;"
>
<div
v-for="day, day_index in timesheet.days"
:key="day_index"
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'"
: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'"
>
<q-icon
v-if="day.shifts.every(shift => shift.is_approved) && day.shifts.length > 0"
name="check"
class="q-pa-none"
/>
</q-badge>
</div>
</div>
</div>
<!-- button to apply weekly schedule preset -->
<div class="col-auto flex-center row q-pt-xs full-width">
<q-btn
v-if="timesheet.days.every(day => day.shifts.length < 1)"
push
dense
color="accent"
:label="$t('timesheet.apply_preset')"
class="full-width"
@click="timesheet_api.applyPreset(timesheet.timesheet_id)"
/>
</div>
</div>
</div>
</template>

View File

@ -2,55 +2,42 @@
setup setup
lang="ts" lang="ts"
> >
/* eslint-disable*/ import { onMounted, ref } from 'vue';
import { onBeforeUnmount, onMounted, ref, useTemplateRef, watch, nextTick } from 'vue';
import { useI18n } from 'vue-i18n';
import { QSelect, QInput } from 'quasar'; import { QSelect, QInput } from 'quasar';
import { Shift, type ShiftOption } from 'src/modules/timesheets/models/shift.models';
import { useUiStore } from 'src/stores/ui-store'; import { useUiStore } from 'src/stores/ui-store';
import { useShiftRules, SHIFT_OPTIONS } from 'src/modules/timesheets/utils/shift.util'; import { SHIFT_OPTIONS } from 'src/modules/timesheets/utils/shift.util';
import type { Shift } from 'src/modules/timesheets/models/shift.models';
let timer: NodeJS.Timeout;
const { t } = useI18n();
const ui_store = useUiStore(); const ui_store = useUiStore();
const COMMENT_LENGTH_MAX = 280; const COMMENT_LENGTH_MAX = 280;
const shift = defineModel<Shift>('shift', { required: true }); const shift = defineModel<Shift>('shift', { required: true });
const shift_type_selected = ref(SHIFT_OPTIONS.find(option => option.value == shift.value.type)); const shift_type_selected = ref(SHIFT_OPTIONS.find(option => option.value == shift.value.type));
const select_ref = useTemplateRef<QSelect>('select'); const select_ref = ref<QSelect | null>(null);
const start_time_ref = useTemplateRef<QInput>('start_time'); const error_message = ref('');
const end_time_ref = useTemplateRef<QInput>('end_time');
const { dayShifts = [], dense = false, hasShiftAfter = false, isTimesheetApproved = false } = defineProps<{ const { errorMessage = undefined, isTimesheetApproved = false } = defineProps<{
dayShifts: Shift[];
dense?: boolean; dense?: boolean;
hasShiftAfter?: boolean;
isTimesheetApproved?: boolean; isTimesheetApproved?: boolean;
errorMessage?: string | undefined;
}>(); }>();
const shift_rules = useShiftRules(t('timesheet.errors.SHIFT_TIME_REQUIRED'), t('timesheet.errors.SHIFT_OVERLAP_SHORT'), dayShifts);
const emit = defineEmits<{ const emit = defineEmits<{
'saveComment': [comment: string, shift_id: number];
'requestDelete': [void]; 'requestDelete': [void];
'onTimeFieldBlur': [void]; 'onTimeFieldBlur': [void];
}>(); }>();
const onBlurShiftTypeSelect = () => { const onTimeFieldBlur = (time_string: string) => {
if (shift_type_selected.value === undefined) { if (time_string.length < 1 || !time_string) {
shift.value.type = 'REGULAR'; shift.value.has_error = true;
shift.value.id = 0; error_message.value = 'timesheet.errors.SHIFT_TIME_REQUIRED'
emit('requestDelete'); } else {
} shift.value.has_error = false;
}; error_message.value = '';
emit('onTimeFieldBlur');
const slideDeleteShift = async (reset: () => void) => { }
timer = setTimeout(() => { }
reset();
emit('requestDelete');
}, 200);
};
const getCommentCounterColor = (comment_length: number) => { const getCommentCounterColor = (comment_length: number) => {
if (comment_length < 200) return 'primary'; if (comment_length < 200) return 'primary';
@ -65,43 +52,193 @@
shift_type_selected.value = undefined; shift_type_selected.value = undefined;
ui_store.focus_next_component = false; ui_store.focus_next_component = false;
} }
});
onBeforeUnmount(() => { if (errorMessage)
clearTimeout(timer); error_message.value = errorMessage;
}); });
watch(() => [start_time_ref.value?.hasError, end_time_ref.value?.hasError], ([start_error, end_error]) => {
shift.value.has_error = (start_error || end_error) ?? false;
})
</script> </script>
<template> <template>
<q-slide-item <div :class="ui_store.is_mobile_mode ? 'column' : 'row'">
right-color="negative" <div
class="rounded-5 transparent" class="row items-center text-uppercase rounded-5"
:class="ui_store.is_mobile_mode ? 'q-my-md' : 'q-mr-xs'" :class="ui_store.is_mobile_mode ? 'col q-mb-xs q-px-xs' : 'col-4'"
@right="details => slideDeleteShift(details.reset)"
>
<template
#right
v-if="ui_store.is_mobile_mode"
> >
<q-icon name="delete" /> <!-- shift type -->
</template> <q-select
ref="select"
<div :class="ui_store.is_mobile_mode ? 'column' : 'row'"> v-model="shift_type_selected"
<div :standout="$q.dark.isActive ? 'bg-blue-grey-3' : 'bg-blue-grey-9'"
class="row items-center text-uppercase rounded-5" dense
:class="ui_store.is_mobile_mode ? 'col q-mb-xs q-px-xs' : 'col-4'" :borderless="(shift.is_approved && isTimesheetApproved)"
:readonly="(shift.is_approved && isTimesheetApproved)"
:options-dense="!ui_store.is_mobile_mode"
hide-dropdown-icon
:menu-offset="[0, 10]"
menu-anchor="bottom middle"
menu-self="top middle"
:options="SHIFT_OPTIONS"
class="col rounded-5 q-mx-xs bg-dark"
:class="!shift.is_approved && !isTimesheetApproved ? '' : 'inset-shadow'"
popup-content-class="text-uppercase text-weight-bold text-center rounded-5"
:style="shift.is_approved ? 'background-color: #0a7d32 !important;' : ''"
popup-content-style="border: 2px solid var(--q-accent)"
@update:model-value="option => shift.type = option.value"
> >
<!-- mobile comment button --> <template #selected-item="scope">
<div
class="row items-center text-weight-bold q-ma-none q-pa-none no-wrap ellipsis full-width"
:class="ui_store.is_mobile_mode ? 'full-height' : ''"
:tabindex="scope.tabindex"
>
<q-icon
:name="scope.opt.icon"
:color="scope.opt.icon_color"
size="sm"
class="col-auto"
:class="shift.is_approved ? 'q-mx-xs' : 'q-mr-xs'"
/>
<span
style="line-height: 1.2em;"
class="col-auto ellipsis"
:class="!shift.is_approved ? '' : 'text-white'"
>
{{ $t(scope.opt.label) }}
</span>
</div>
</template>
<template #after>
<q-icon
v-if="shift.is_approved"
:name="shift.is_remote ? 'las la-laptop' : 'las la-building'"
size="1.2em"
color="white"
class="q-mr-sm"
>
<q-tooltip
anchor="top middle"
self="bottom middle"
:offset="[0, 10]"
class="text-uppercase text-weight-bold text-white bg-primary"
>
{{ shift.is_remote ? $t('timesheet.shift.types.REMOTE') :
$t('timesheet.shift.types.OFFICE') }}
</q-tooltip>
</q-icon>
<q-toggle
v-else
v-model="shift.is_remote"
:disable="shift.is_approved"
dense
keep-color
size="3em"
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>
<div class="col row items-start text-uppercase rounded-5 q-pa-xs">
<!-- punch in field -->
<q-input
ref="start_time"
v-model="shift.start_time"
dense
:borderless="(shift.is_approved && isTimesheetApproved)"
:readonly="(shift.is_approved && isTimesheetApproved)"
type="time"
:standout="$q.dark.isActive ? 'bg-blue-grey-3' : 'bg-blue-grey-9'"
label-slot
lazy-rules
no-error-icon
hide-bottom-space
:error="shift.has_error"
:error-message="errorMessage || error_message !== '' ? $t(errorMessage ?? error_message) : ''"
:label-color="!shift.is_approved ? 'accent' : 'white'"
class="col rounded-5 bg-dark q-mx-xs"
:class="(shift.id === -2 ? 'bg-negative ' : ' ') + (!shift.is_approved && !isTimesheetApproved ? '' : 'cursor-not-allowed inset-shadow')"
:input-class="'text-weight-medium ' + (shift.id === -2 ? 'text-white ' : ' ') + (shift.is_approved ? 'text-white cursor-not-allowed q-px-sm' : '')"
input-style="font-size: 1.2em;"
:style="shift.is_approved ? 'background-color: #0a7d32 !important;' : ''"
@blur="onTimeFieldBlur(shift.start_time)"
>
<template #label>
<span
class="text-weight-bolder"
:class="shift.is_approved ? ' q-ml-md' : ''"
style="font-size: 0.95em;"
>{{ $t('shared.misc.in') }}</span>
</template>
</q-input>
<!-- punch out field -->
<q-input
ref="end_time"
v-model="shift.end_time"
:standout="$q.dark.isActive ? 'bg-blue-grey-3' : 'bg-blue-grey-9'"
dense
:borderless="(shift.is_approved && isTimesheetApproved)"
:readonly="(shift.is_approved && isTimesheetApproved)"
type="time"
label-slot
no-error-icon
hide-bottom-space
:error="shift.has_error"
:error-message="errorMessage || error_message !== '' ? $t(errorMessage ?? error_message) : ''"
:label-color="!shift.is_approved ? 'accent' : 'white'"
:input-class="'text-weight-medium ' + (shift.id === -2 ? 'text-white ' : ' ') + (shift.is_approved ? 'text-white cursor-not-allowed q-px-sm' : '')"
input-style="font-size: 1.2em;"
class="col rounded-5 bg-dark q-mx-xs"
:class="(shift.id === -2 ? 'bg-negative ' : ' ') + (shift.is_approved ? 'cursor-not-allowed q-px-xs transparent inset-shadow' : (isTimesheetApproved ? 'inset-shadow' : ''))"
:style="shift.is_approved ? 'background-color: #0a7d32 !important;' : ''"
@blur="onTimeFieldBlur(shift.end_time)"
>
<template #label>
<span
class="text-weight-bolder"
:class="shift.is_approved ? ' q-ml-md' : ''"
style="font-size: 0.95em;"
>{{ $t('shared.misc.out') }}</span>
</template>
</q-input>
<!-- comment and delete buttons -->
<div
class="row full-height"
:class="ui_store.is_mobile_mode ? 'col-12' : 'col-auto flex-center'"
>
<!-- desktop comment button -->
<q-btn <q-btn
v-if="ui_store.is_mobile_mode && !dense" v-if="!ui_store.is_mobile_mode"
push
dense
:color="shift.is_approved ? 'white' : 'accent'"
:icon="shift.comment ? 'chat' : 'chat_bubble_outline'" :icon="shift.comment ? 'chat' : 'chat_bubble_outline'"
:text-color="shift.comment ? ((shift.is_approved && isTimesheetApproved) ? 'white' : 'accent') : 'grey-5'" :text-color="shift.is_approved ? 'accent' : 'white'"
class="col-auto full-height q-mx-xs rounded-5 shadow-1" class="col"
:class="ui_store.is_mobile_mode ? 'q-mt-xs bg-dark' : ''"
> >
<q-badge
v-if="shift.comment"
floating
rounded
color="negative"
/>
<q-popup-edit <q-popup-edit
v-model="shift.comment" v-model="shift.comment"
:title="$t('timesheet.shift.fields.header_comment')" :title="$t('timesheet.shift.fields.header_comment')"
@ -113,13 +250,13 @@
color="white" color="white"
v-model="scope.value" v-model="scope.value"
dense dense
:readonly="(shift.is_approved || isTimesheetApproved)" :readonly="shift.is_approved"
autofocus autofocus
counter counter
bottom-slots bottom-slots
:maxlength="COMMENT_LENGTH_MAX" :maxlength="COMMENT_LENGTH_MAX"
class="q-pb-lg" class="q-pb-lg"
:class="(shift.is_approved || isTimesheetApproved) ? 'cursor-not-allowed' : ''" :class="shift.is_approved ? 'cursor-not-allowed' : ''"
@keyup.enter="scope.set" @keyup.enter="scope.set"
> >
<template #append> <template #append>
@ -148,248 +285,23 @@
</q-popup-edit> </q-popup-edit>
</q-btn> </q-btn>
<!-- shift type --> <q-btn
<q-select v-if="!shift.is_approved"
ref="select" flat
v-model="shift_type_selected"
:standout="$q.dark.isActive ? 'bg-blue-grey-3' : 'bg-blue-grey-9'"
dense dense
:borderless="(shift.is_approved && isTimesheetApproved)" :disable="shift.is_approved"
:readonly="(shift.is_approved && isTimesheetApproved)" tabindex="-1"
:options-dense="!ui_store.is_mobile_mode" icon="las la-trash"
hide-dropdown-icon text-color="negative"
:menu-offset="[0, 10]" class="col"
menu-anchor="bottom middle" size="1.2em"
menu-self="top middle" :class="shift.is_approved ? 'invisible' : ''"
:options="SHIFT_OPTIONS" @click="$emit('requestDelete')"
class="col rounded-5 q-mx-xs bg-dark"
:class="!shift.is_approved && !isTimesheetApproved ? '' : 'inset-shadow'"
popup-content-class="text-uppercase text-weight-bold text-center rounded-5"
:style="shift.is_approved ? 'background-color: #0a7d32 !important;' : ''"
popup-content-style="border: 2px solid var(--q-accent)"
@blur="onBlurShiftTypeSelect"
@update:model-value="option => shift.type = option.value"
> >
<template #selected-item="scope"> </q-btn>
<div
class="row items-center text-weight-bold q-ma-none q-pa-none no-wrap ellipsis full-width"
:class="ui_store.is_mobile_mode ? 'full-height' : ''"
:tabindex="scope.tabindex"
>
<q-icon
:name="scope.opt.icon"
:color="scope.opt.icon_color"
size="sm"
class="col-auto"
:class="shift.is_approved ? 'q-mx-xs': 'q-mr-xs'"
/>
<span
style="line-height: 1.2em;"
class="col-auto ellipsis"
:class="!shift.is_approved ? '' : 'text-white'"
>
{{ $t(scope.opt.label) }}
</span>
</div>
</template>
<template #after>
<q-icon
v-if="shift.is_approved"
:name="shift.is_remote ? 'las la-laptop' : 'las la-building'"
size="1.2em"
color="white"
class="q-mr-sm"
>
<q-tooltip
anchor="top middle"
self="bottom middle"
:offset="[0, 10]"
class="text-uppercase text-weight-bold text-white bg-primary"
>
{{ shift.is_remote ? $t('timesheet.shift.types.REMOTE') :
$t('timesheet.shift.types.OFFICE') }}
</q-tooltip>
</q-icon>
<q-toggle
v-else
v-model="shift.is_remote"
:disable="shift.is_approved"
dense
keep-color
size="3em"
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>
<div class="col row items-start text-uppercase rounded-5 q-pa-xs">
<!-- punch in field -->
<q-input
ref="start_time"
v-model="shift.start_time"
dense
:borderless="(shift.is_approved && isTimesheetApproved)"
:readonly="(shift.is_approved && isTimesheetApproved)"
type="time"
:standout="$q.dark.isActive ? 'bg-blue-grey-3' : 'bg-blue-grey-9'"
label-slot
lazy-rules
no-error-icon
hide-bottom-space
:rules="[shift_rules.isTimeRequiredRule, shift_rules.isShiftOverlapRule]"
:label-color="!shift.is_approved ? 'accent' : 'white'"
class="col rounded-5 bg-dark"
:class="(shift.id === -2 ? 'bg-negative ' : ' ') + (ui_store.is_mobile_mode ? 'q-mr-xs ' : 'q-mx-xs ') + (!shift.is_approved && !isTimesheetApproved ? '' : 'cursor-not-allowed inset-shadow')"
:input-class="'text-weight-medium ' + (shift.id === -2 ? 'text-white ' : ' ') + (shift.is_approved ? 'text-white cursor-not-allowed q-px-sm' : '')"
input-style="font-size: 1.2em;"
:style="shift.is_approved ? 'background-color: #0a7d32 !important;' : ''"
@blur="emit('onTimeFieldBlur')"
>
<template #label>
<span
class="text-weight-bolder"
:class="shift.is_approved ? ' q-ml-md' : ''"
style="font-size: 0.95em;"
>{{ $t('shared.misc.in') }}</span>
</template>
</q-input>
<!-- punch out field -->
<q-input
ref="end_time"
v-model="shift.end_time"
:standout="$q.dark.isActive ? 'bg-blue-grey-3' : 'bg-blue-grey-9'"
dense
:borderless="(shift.is_approved && isTimesheetApproved)"
:readonly="(shift.is_approved && isTimesheetApproved)"
type="time"
label-slot
lazy-rules
no-error-icon
hide-bottom-space
:rules="[shift_rules.isTimeRequiredRule, shift_rules.isShiftOverlapRule]"
:label-color="!shift.is_approved ? 'accent' : 'white'"
:input-class="'text-weight-medium ' + (shift.id === -2 ? 'text-white ' : ' ') + (shift.is_approved ? 'text-white cursor-not-allowed q-px-sm' : '')"
input-style="font-size: 1.2em;"
class="col rounded-5 bg-dark"
:class="(shift.id === -2 ? 'bg-negative ' : ' ') + (ui_store.is_mobile_mode ? 'q-mr-xs ' : 'q-mx-xs ') + (shift.is_approved ? 'cursor-not-allowed q-px-xs transparent inset-shadow' : (isTimesheetApproved ? 'inset-shadow' : ''))"
:style="shift.is_approved ? 'background-color: #0a7d32 !important;' : ''"
@blur="emit('onTimeFieldBlur')"
>
<template #label>
<span
class="text-weight-bolder"
:class="shift.is_approved ? ' q-ml-md' : ''"
style="font-size: 0.95em;"
>{{ $t('shared.misc.out') }}</span>
</template>
</q-input>
<!-- comment and delete buttons -->
<div class="row full-height" :class="ui_store.is_mobile_mode ? 'col-12' : 'col-auto flex-center'">
<!-- desktop comment button -->
<q-btn
v-if="!ui_store.is_mobile_mode"
push
dense
:color="shift.is_approved ? 'white' : 'accent'"
:icon="shift.comment ? 'chat' : 'chat_bubble_outline'"
:text-color="shift.is_approved ? 'accent' : 'white'"
class="col"
:class="ui_store.is_mobile_mode ? 'q-mt-xs bg-dark' : ''"
>
<q-badge
v-if="shift.comment"
floating
rounded
color="negative"
/>
<q-popup-edit
v-model="shift.comment"
:title="$t('timesheet.shift.fields.header_comment')"
auto-save
v-slot="scope"
class="bg-dark"
>
<q-input
color="white"
v-model="scope.value"
dense
:readonly="shift.is_approved"
autofocus
counter
bottom-slots
:maxlength="COMMENT_LENGTH_MAX"
class="q-pb-lg"
:class="shift.is_approved ? 'cursor-not-allowed' : ''"
@keyup.enter="scope.set"
>
<template #append>
<q-icon name="edit" />
</template>
<template #counter>
<div class="row flex-center">
<q-space />
<q-knob
:model-value="scope.value?.length"
readonly
:max="COMMENT_LENGTH_MAX"
size="1.6em"
:thickness="0.4"
:color="getCommentCounterColor(scope.value?.length ?? 0)"
track-color="grey-4"
class="col-auto q-mr-xs"
/>
<span
:class="'col-auto text-weight-bolder text-' + getCommentCounterColor(scope.value?.length ?? 0)"
>{{ 280 - (scope.value?.length ?? 0) }}</span>
</div>
</template>
</q-input>
</q-popup-edit>
</q-btn>
<q-btn
v-if="!ui_store.is_mobile_mode && !shift.is_approved"
flat
dense
:disable="shift.is_approved"
tabindex="-1"
icon="las la-trash"
text-color="negative"
class="col"
size="1.2em"
:class="shift.is_approved ? 'invisible' : ''"
@click="$emit('requestDelete')"
>
</q-btn>
</div>
</div> </div>
</div> </div>
</q-slide-item> </div>
<q-separator
v-if="hasShiftAfter && ui_store.is_mobile_mode"
spaced
color="accent"
class="q-mx-md"
/>
</template> </template>
<style scoped> <style scoped>

View File

@ -3,15 +3,18 @@
lang="ts" lang="ts"
> >
import ShiftListDayRow from 'src/modules/timesheets/components/shift-list-day-row.vue'; import ShiftListDayRow from 'src/modules/timesheets/components/shift-list-day-row.vue';
import ShiftListDayRowMobile from 'src/modules/timesheets/components/mobile/shift-list-day-row-mobile.vue';
import { ref } from 'vue'; import { ref } from 'vue';
import { useShiftApi } from 'src/modules/timesheets/composables/use-shift-api'; import { useShiftApi } from 'src/modules/timesheets/composables/use-shift-api';
import { useTimesheetApi } from 'src/modules/timesheets/composables/use-timesheet-api'; import { useTimesheetApi } from 'src/modules/timesheets/composables/use-timesheet-api';
import type { Shift } from 'src/modules/timesheets/models/shift.models'; import type { Shift } from 'src/modules/timesheets/models/shift.models';
import type { TimesheetDay } from 'src/modules/timesheets/models/timesheet.models'; import type { TimesheetDay } from 'src/modules/timesheets/models/timesheet.models';
import { isShiftOverlap } from 'src/modules/timesheets/utils/shift.util';
const shift_api = useShiftApi(); const shift_api = useShiftApi();
const timesheet_api = useTimesheetApi(); const timesheet_api = useTimesheetApi();
const shift_error_message = ref<string | undefined>();
const { day, dense = false, approved = false } = defineProps<{ const { day, dense = false, approved = false } = defineProps<{
timesheetId: number; timesheetId: number;
@ -35,6 +38,15 @@
} }
await shift_api.deleteShiftById(shift.id); await shift_api.deleteShiftById(shift.id);
}; };
const onTimeFieldBlur = () => {
const is_error = isShiftOverlap(day.shifts);
day.shifts.map(shift => shift.has_error = is_error);
if (is_error)
shift_error_message.value = 'timesheet.errors.SHIFT_OVERLAP_SHORT';
else
shift_error_message.value = undefined;
}
</script> </script>
<template> <template>
@ -69,15 +81,31 @@
</q-btn> </q-btn>
</transition> </transition>
<ShiftListDayRow <div
v-for="shift, shift_index in day.shifts" v-for="shift, shift_index in day.shifts"
:key="shift_index" :key="shift_index"
v-model:shift="day.shifts[shift_index]!" class="col-auto"
:day-shifts="day.shifts" >
:is-timesheet-approved="approved" <ShiftListDayRowMobile
:dense="dense" v-if="$q.platform.is.mobile"
:has-shift-after="shift_index < day.shifts.length - 1" v-model:shift="day.shifts[shift_index]!"
@request-delete="deleteCurrentShift(shift)" :is-timesheet-approved="approved"
/> :error-message="shift_error_message"
:dense="dense"
:has-shift-after="shift_index < day.shifts.length - 1"
@request-delete="deleteCurrentShift(shift)"
@on-time-field-blur="onTimeFieldBlur()"
/>
<ShiftListDayRow
v-else
v-model:shift="day.shifts[shift_index]!"
:is-timesheet-approved="approved"
:error-message="shift_error_message"
@request-delete="deleteCurrentShift(shift)"
@on-time-field-blur="onTimeFieldBlur()"
/>
</div>
</div> </div>
</template> </template>

View File

@ -20,6 +20,10 @@
const timesheet_store = useTimesheetStore(); const timesheet_store = useTimesheetStore();
const timesheet_api = useTimesheetApi(); const timesheet_api = useTimesheetApi();
const { mode = 'normal'} = defineProps<{
mode: 'normal' | 'approval';
}>();
const mobile_animation_direction = ref('fadeInLeft'); const mobile_animation_direction = ref('fadeInLeft');
const animation_style = computed(() => ui_store.is_mobile_mode ? mobile_animation_direction.value : 'fadeInDown'); const animation_style = computed(() => ui_store.is_mobile_mode ? mobile_animation_direction.value : 'fadeInDown');
@ -47,23 +51,26 @@
return day.shifts.every(shift => shift.is_approved === true); return day.shifts.every(shift => shift.is_approved === true);
} }
const handleSwipe = async (direction: 'left' | 'up' | 'down' | 'right' | undefined, distance: {x?: number, y?: number}) => { const handleSwipe = async (direction: 'left' | 'up' | 'down' | 'right' | undefined, distance: { x?: number, y?: number }) => {
mobile_animation_direction.value = direction === 'left' ? 'fadeInRight' : 'fadeInLeft'; mobile_animation_direction.value = direction === 'left' ? 'fadeInRight' : 'fadeInLeft';
if (distance.x && Math.abs(distance.x) > 10 ) { if (distance.x && Math.abs(distance.x) > 10) {
await timesheet_api.getTimesheetsBySwiping( direction === 'left' ? 1 : -1 ) await timesheet_api.getTimesheetsBySwiping(direction === 'left' ? 1 : -1)
} }
} }
</script> </script>
<template> <template>
<div class="col column fit relative-position" v-touch-swipe="value => handleSwipe(value.direction, value.distance ?? {x: 0, y: 0})"> <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 })"
>
<q-scroll-area <q-scroll-area
ref="timesheet_page" ref="timesheet_page"
:horizontal-offset="[0, 3]" :horizontal-offset="[0, 3]"
class="absolute-full hide-scrollbar q-mt-sm" class="absolute-full hide-scrollbar"
:thumb-style="{ opacity: '0' }" :thumb-style="{ opacity: '0' }"
:bar-style="{ opacity: '0' }" :bar-style="{ opacity: '0' }"
style="min-height: 50vh;"
> >
<!-- Show if no timesheets found (further than one month from present) --> <!-- Show if no timesheets found (further than one month from present) -->
<div <div
@ -91,7 +98,7 @@
<div <div
v-for="timesheet, timesheet_index of timesheet_store.timesheets" v-for="timesheet, timesheet_index of timesheet_store.timesheets"
:key="timesheet.timesheet_id" :key="timesheet.timesheet_id"
class="col fit" class="col column fit flex-center"
> >
<transition <transition
appear appear
@ -99,13 +106,12 @@
leave-active-class="animated fadeOutUp" leave-active-class="animated fadeOutUp"
> >
<q-btn <q-btn
v-if="!$q.platform.is.mobile" v-if="!$q.platform.is.mobile && timesheet.days.every(day => day.shifts.length < 1)"
:disable="!timesheet.days.every(day => day.shifts.length < 1)" :disable="!timesheet.days.every(day => day.shifts.length < 1)"
flat flat
dense dense
:label="$t('timesheet.apply_preset_week')" :label="$t('timesheet.apply_preset_week')"
class="text-uppercase text-weight-bold text-accent q-mx-lg q-py-none rounded-5" class="col-auto text-uppercase text-weight-bold text-accent q-mx-lg q-py-none rounded-5"
:class="timesheet.days.every(day => day.shifts.length < 1) ? '' : 'invisible'"
@click="timesheet_api.applyPreset(timesheet.timesheet_id)" @click="timesheet_api.applyPreset(timesheet.timesheet_id)"
> >
<q-icon <q-icon
@ -123,21 +129,20 @@
<div <div
v-for="day, day_index in timesheet.days" v-for="day, day_index in timesheet.days"
:key="day.date" :key="day.date"
class="col-auto row rounded-10 q-ma-sm shadow-10" class="col-auto row q-pa-sm fit"
:style="`animation-delay: ${day_index / 15}s;`" :style="`animation-delay: ${day_index / 15}s;`"
> >
<div <div
v-if="$q.platform.is.mobile && ($q.screen.width < $q.screen.height)" v-if="$q.platform.is.mobile && ($q.screen.width < $q.screen.height)"
class="col column full-width" class="col column full-width q-px-md q-py-sm"
> >
<q-card <q-card
class="rounded-10" class="mobile-rounded-10 shadow-12"
:class="(getDayApproval(day) || timesheet.is_approved) ? 'bg-accent' : 'bg-dark'" :class="(getDayApproval(day) || timesheet.is_approved) ? 'bg-accent' : 'bg-dark'"
:style="ui_store.is_mobile_mode ? ((getDayApproval(day) || timesheet.is_approved) ? 'border: 6px inset var(--q-accent)' : 'border: 1px solid var(--q-accent);') : ''"
> >
<q-card-section <q-card-section
class="text-weight-bolder text-uppercase text-h6 q-py-xs" class="text-weight-bolder text-uppercase text-h6 q-py-sm text-center relative-position"
:class="(getDayApproval(day) || timesheet.is_approved) ? 'bg-accent text-white' : 'bg-primary text-white'" :class="(getDayApproval(day) || timesheet.is_approved) ? 'bg-accent text-white' : 'bg-primary text-white'"
style="line-height: 1em;" style="line-height: 1em;"
> >
@ -145,6 +150,15 @@
weekday: 'long', day: 'numeric', month: weekday: 'long', day: 'numeric', month:
'long' 'long'
}) }}</span> }) }}</span>
<q-icon
v-if="(getDayApproval(day) || timesheet.is_approved)"
name="verified"
size="3em"
color="white"
class="absolute-top-left z-top"
style="top: -0.2em; left: 0px;"
/>
</q-card-section> </q-card-section>
<q-card-section <q-card-section
@ -162,49 +176,29 @@
/> />
</q-card-section> </q-card-section>
<q-card-actions class="q-pa-none"> <q-card-section class="q-pa-none">
<q-btn <q-btn
v-if="!(getDayApproval(day) || timesheet.is_approved)" v-if="!(getDayApproval(day) || timesheet.is_approved)"
square square
dense
size="xl"
color="accent" color="accent"
icon="more_time" icon="more_time"
class="full-width" class="full-width"
style="border-radius: 0 0 5px 5px;" style="border-radius: 0 0 10px 10px;"
@click="addNewShift(day.shifts, day.date, timesheet.timesheet_id)" @click="addNewShift(day.shifts, day.date, timesheet.timesheet_id)"
/> />
</q-card-actions> </q-card-section>
<q-badge
v-if="(getDayApproval(day) || timesheet.is_approved)"
floating
class="transparent q-pa-none rounded-50"
style="transform: translate(15px, -5px);"
>
<q-icon
name="verified"
size="5em"
color="white"
/>
</q-badge>
</q-card> </q-card>
</div> </div>
<div <div
v-else v-else
class="col row full-width" class="col row full-width rounded-10 ellipsis shadow-10"
:class="(getDayApproval(day) || timesheet.is_approved) ? 'rounded-10 bg-accent' : ''"
> >
<transition
appear
enter-active-class="animated fadeInDown"
leave-active-class="animated fadeOutUp"
>
</transition>
<!-- List of shifts -->
<div <div
class="col row bg-dark" class="col row"
:class="(getDayApproval(day) || timesheet.is_approved) ? 'bg-transparent' : ''" :class="(getDayApproval(day) || timesheet.is_approved) ? 'bg-accent' : 'bg-dark'"
style="border-radius: 10px 0 0 10px;"
> >
<!-- Date block --> <!-- Date block -->
<ShiftListDateWidget <ShiftListDateWidget
@ -231,17 +225,19 @@
color="white" color="white"
size="xl" size="xl"
class="full-height" class="full-height"
:class="(getDayApproval(day) || timesheet.is_approved) ? 'bg-accent' : ''"
/> />
<q-btn <q-btn
v-else v-else
:dense="!ui_store.is_mobile_mode" :dense="!$q.platform.is.mobile"
square
icon="more_time" icon="more_time"
size="lg" size="lg"
color="accent" color="accent"
text-color="white" text-color="white"
class="full-height" class="full-height"
:class="$q.screen.lt.md ? 'q-px-xs ' : ' '" :class="$q.platform.is.mobile ? 'q-px-xs' : ''"
style="border-radius: 0 10px 10px 0;"
@click="addNewShift(day.shifts, day.date, timesheet.timesheet_id)" @click="addNewShift(day.shifts, day.date, timesheet.timesheet_id)"
/> />
</div> </div>
@ -250,29 +246,47 @@
</transition-group> </transition-group>
</div> </div>
</div> </div>
<q-page-sticky
position="bottom-right"
:offset="[0, -35]"
class="z-top"
>
<transition
appear
enter-active-class="animated zoomIn"
leave-active-class="animated zoomOut"
>
<q-btn
v-if="scroll_y > 400"
fab
icon="las la-chevron-up"
color="white"
text-color="accent"
class="shadow-12"
@click="timesheet_page!.setScrollPosition('vertical', 0, 300)"
/>
</transition>
</q-page-sticky>
</q-scroll-area> </q-scroll-area>
<q-page-sticky
v-if="mode === 'normal'"
position="bottom-right"
:offset="$q.screen.width > $q.screen.height ? [15, 15] : [15, 65]"
class="z-top"
>
<transition
appear
enter-active-class="animated zoomIn"
leave-active-class="animated zoomOut"
>
<q-btn
v-if="scroll_y > 400"
fab
icon="las la-chevron-up"
color="white"
text-color="accent"
class="shadow-12"
@click="timesheet_page!.setScrollPosition('vertical', 0, 300)"
/>
</transition>
</q-page-sticky>
</div> </div>
</template> </template>
<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 {
border-radius: #{$size}px #{$size}px 0 0 !important;
}
.mobile-rounded-#{$size} > div:last-child {
border-radius: 0 0 #{$size}px #{$size}px !important;
}
}
</style>

View File

@ -8,6 +8,7 @@
import PayPeriodNavigator from 'src/modules/shared/components/pay-period-navigator.vue'; import PayPeriodNavigator from 'src/modules/shared/components/pay-period-navigator.vue';
import TimesheetErrorWidget from 'src/modules/timesheets/components/timesheet-error-widget.vue'; import TimesheetErrorWidget from 'src/modules/timesheets/components/timesheet-error-widget.vue';
import LoadingOverlay from 'src/modules/shared/components/loading-overlay.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 { computed, onMounted } from 'vue'; import { computed, onMounted } from 'vue';
import { useShiftApi } from 'src/modules/timesheets/composables/use-shift-api'; import { useShiftApi } from 'src/modules/timesheets/composables/use-shift-api';
@ -30,7 +31,8 @@
}>(); }>();
onMounted(async () => { onMounted(async () => {
await timesheet_api.getTimesheetsByDate(date.formatDate(new Date(), 'YYYY-MM-DD')); if (mode === 'normal')
await timesheet_api.getTimesheetsByDate(date.formatDate(new Date(), 'YYYY-MM-DD'));
}); });
</script> </script>
@ -38,9 +40,10 @@
<div class="column items-center full-height"> <div class="column items-center full-height">
<LoadingOverlay v-model="timesheet_store.is_loading" /> <LoadingOverlay v-model="timesheet_store.is_loading" />
<!-- top menu -->
<div <div
class="col-auto row items-center full-width" class="col-auto row items-center full-width"
:class="$q.platform.is.mobile && ($q.screen.width < $q.screen.height) ? 'justify-between' : 'q-mt-md q-px-md'" :class="$q.platform.is.mobile && ($q.screen.width < $q.screen.height) ? 'justify-between' : 'q-pb-sm q-px-md'"
> >
<!-- navigation btn --> <!-- navigation btn -->
<PayPeriodNavigator <PayPeriodNavigator
@ -63,20 +66,10 @@
@click="expenses_store.open" @click="expenses_store.open"
/> />
<q-space v-if="!$q.platform.is.mobile" /> <!-- label for approval mode to delimit that this is the timesheet -->
<span class="col-auto text-uppercase text-bold text-h5"> {{ $t('timesheet.page_header') }}</span>
<!-- desktop save timesheet changes button --> <q-space v-if="$q.screen.width > $q.screen.height" />
<q-btn
v-if="mode === 'normal' && !is_timesheets_approved && !$q.platform.is.mobile"
push
rounded
:disable="timesheet_store.is_loading || has_shift_errors"
:color="timesheet_store.is_loading || has_shift_errors ? 'grey-5' : 'accent'"
icon="upload"
:label="$t('shared.label.save')"
:class="$q.platform.is.mobile && ($q.screen.width < $q.screen.height) ? 'full-width' : 'q-mr-md'"
@click="shift_api.saveShiftChanges"
/>
<!-- desktop expenses button --> <!-- desktop expenses button -->
<q-btn <q-btn
@ -88,25 +81,45 @@
:label="$t('timesheet.expense.open_btn')" :label="$t('timesheet.expense.open_btn')"
@click="expenses_store.open" @click="expenses_store.open"
/> />
<!-- desktop save timesheet changes button -->
<q-btn
v-if="!is_timesheets_approved && $q.screen.width > $q.screen.height"
push
rounded
:disable="timesheet_store.is_loading || has_shift_errors"
:color="timesheet_store.is_loading || has_shift_errors ? 'grey-5' : 'accent'"
icon="upload"
:label="$t('shared.label.save')"
:class="$q.platform.is.mobile && ($q.screen.width < $q.screen.height) ? 'full-width' : 'q-ml-md'"
@click="shift_api.saveShiftChanges"
/>
</div> </div>
<TimesheetErrorWidget class="col-auto"/> <!-- error message widget for potential backend-provided errors -->
<TimesheetErrorWidget class="col-auto" />
<ShiftList />
<!-- mobile weekly overview widget -->
<ShiftListWeeklyOverview />
<ShiftList :mode="mode" />
<q-btn <q-btn
v-if="mode === 'approval' || $q.platform.is.mobile && $q.screen.width < $q.screen.height" v-if="$q.platform.is.mobile && $q.screen.width < $q.screen.height"
push square
rounded
:disable="timesheet_store.is_loading" :disable="timesheet_store.is_loading"
size="lg"
color="accent" color="accent"
icon="upload" icon="upload"
:label="$t('shared.label.save')" :label="$t('shared.label.save')"
class="col-auto" class="col-auto absolute-bottom shadow-up-10 z-top"
:class="$q.platform.is.mobile && $q.screen.width < $q.screen.height ? 'full-width q-mt-sm' : 'q-mr-md'" style="height: 50px;"
@click="shift_api.saveShiftChanges" @click="shift_api.saveShiftChanges"
/> />
<ExpenseDialog :is-approved="is_timesheets_approved" /> <ExpenseDialog
:is-approved="is_timesheets_approved"
class="z-top"
/>
</div> </div>
</template> </template>

View File

@ -1,4 +1,4 @@
import { date, patterns, type ValidationRule } from "quasar"; import { date } from "quasar";
import type { SchedulePresetShift } from "src/modules/employee-list/models/schedule-presets.models"; import type { SchedulePresetShift } from "src/modules/employee-list/models/schedule-presets.models";
import type { Shift, ShiftOption } from "src/modules/timesheets/models/shift.models"; import type { Shift, ShiftOption } from "src/modules/timesheets/models/shift.models";
@ -26,16 +26,6 @@ export const isShiftOverlap = (shifts: Shift[] | SchedulePresetShift[]): boolean
return false; return false;
}; };
export const useShiftRules = (time_required_error: string, overlap_error_string: string, day_shifts: Shift[]) => {
const isTimeRequiredRule: ValidationRule<string> = (time_string: string) => (!!time_string && patterns.testPattern.time(time_string)) || time_required_error;
const isShiftOverlapRule: ValidationRule<string> = (_time_string: string) => !isShiftOverlap(day_shifts) || overlap_error_string;
return {
isTimeRequiredRule,
isShiftOverlapRule
};
};
export const SHIFT_OPTIONS: ShiftOption[] = [ export const SHIFT_OPTIONS: ShiftOption[] = [
{ label: 'timesheet.shift.types.REGULAR', value: 'REGULAR', icon: 'wb_sunny', icon_color: 'accent' }, { label: 'timesheet.shift.types.REGULAR', value: 'REGULAR', icon: 'wb_sunny', icon_color: 'accent' },
{ label: 'timesheet.shift.types.EVENING', value: 'EVENING', icon: 'bedtime', icon_color: 'orange-5' }, { label: 'timesheet.shift.types.EVENING', value: 'EVENING', icon: 'bedtime', icon_color: 'orange-5' },

View File

@ -1,19 +1,43 @@
<script setup lang="ts">
import { useRouter } from 'vue-router';
const router = useRouter();
</script>
<template> <template>
<q-layout view="hHh lpR fFf"> <q-layout view="hHh lpR fFf">
<q-page-container> <q-page-container>
<q-page padding class="column justify-center items-center bg-secondary"> <q-page class="row justify-center bg-secondary">
<q-card class="col-shrink rounded-20"> <div class=" column justify-center" :class="$q.platform.is.mobile ? 'col-11' : 'col-8'">
<q-img src="src/assets/line-truck-1.jpg" height="20vh"> <div class="column rounded-20 q-pa-xs bg-accent" :class="$q.platform.is.mobile ? 'col-5' : 'col-4'">
<div <div class="col">
class="absolute-bottom text-h4 text-center text-weight-bolder justify-center items-center row"> <q-img src="src/assets/line-truck-1.jpg" fit="cover" class="relative-position fit border-radius-inherit">
<div class="q-pr-md text-primary text-h3 text-weight-bolder">404</div> <div class="absolute-bottom text-center column flex-center">
PAGE NOT FOUND <div class="q-pr-md text-white text-h2 text-weight-bolder">404</div>
<div class="q-pr-md text-white text-h5 text-weight-bold">{{
$t('error.not_found_header')
}}</div>
</div>
</q-img>
</div> </div>
</q-img>
<q-card-section class="text-center text-h5 text-primary"> <div class="col-auto text-center text-h6 text-weight-light bg-dark q-pa-md">
{{ $t('notFoundPage.pageText') }} <div>{{ $t('error.not_found_description') }}</div>
</q-card-section> </div>
</q-card> </div>
<div class="col-auto row self-center q-py-md">
<q-btn
push
dense
color="accent"
:label="$t('error.go_back')"
class="col-auto q-px-md q-py-xs"
@click="router.go(-2)"
/>
</div>
</div>
</q-page> </q-page>
</q-page-container> </q-page-container>
</q-layout> </q-layout>

View File

@ -21,10 +21,11 @@ import { onMounted } from 'vue';
</script> </script>
<template> <template>
<q-page class="bg-secondary row items-center justify-center"> <q-page class="bg-secondary row items-center justify-center fit">
<MenuEmployee <MenuEmployee
v-if="employee_roles.includes(auth_store.user?.role.toUpperCase() ?? 'GUEST')" v-if="employee_roles.includes(auth_store.user?.role.toUpperCase() ?? 'GUEST')"
class="col-xs-12 col-md-10 col-lg-7 col-xl-5" class="col-xs-12 col-md-10 col-lg-7 col-xl-5"
:class="$q.platform.is.mobile ? 'self-stretch' : ''"
/> />
</q-page> </q-page>
</template> </template>

View File

@ -1,22 +1,24 @@
<script setup lang="ts"> <script
/* eslint-disable */ setup
import { onMounted } from 'vue'; lang="ts"
import { date } from 'quasar'; >
import { useTimesheetApprovalApi } from 'src/modules/timesheet-approval/composables/use-timesheet-approval-api'; /* eslint-disable */
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 DetailsDialog from 'src/modules/timesheet-approval/components/details-dialog.vue';
import DetailsDialog from 'src/modules/timesheet-approval/components/details-dialog.vue'; import OverviewReport from 'src/modules/timesheet-approval/components/overview-report.vue';
import QTableFilters from 'src/modules/shared/components/q-table-filters.vue';
import PayPeriodNavigator from 'src/modules/shared/components/pay-period-navigator.vue';
import OverviewReport from 'src/modules/timesheet-approval/components/overview-report.vue';
const timesheet_approval_api = useTimesheetApprovalApi(); import { date } from 'quasar';
const timesheet_store = useTimesheetStore(); import { onMounted } from 'vue';
import { useTimesheetApprovalApi } from 'src/modules/timesheet-approval/composables/use-timesheet-approval-api';
import { useTimesheetStore } from 'src/stores/timesheet-store';
onMounted(async () => { const timesheet_approval_api = useTimesheetApprovalApi();
await timesheet_approval_api.getTimesheetOverviewsByDate(date.formatDate(new Date(), 'YYYY-MM-DD')); const timesheet_store = useTimesheetStore();
});
onMounted(async () => {
await timesheet_approval_api.getTimesheetOverviewsByDate(date.formatDate(new Date(), 'YYYY-MM-DD'));
});
</script> </script>
<template> <template>
@ -37,50 +39,8 @@ onMounted(async () => {
:timesheets="timesheet_store.timesheets" :timesheets="timesheet_store.timesheets"
/> />
<div <OverviewReport />
class="col-auto full-width q-px-lg"
:class="($q.screen.lt.md ? 'column flex-center' : 'row') + (timesheet_store.is_approval_grid_mode ? ' q-mb-sm' : ' q-mb-md')"
>
<PayPeriodNavigator
@date-selected="timesheet_approval_api.getTimesheetOverviews"
@pressed-next-button="timesheet_approval_api.getTimesheetOverviews"
@pressed-previous-button="timesheet_approval_api.getTimesheetOverviews"
:class="$q.screen.lt.md ? 'q-mb-sm' : ''"
/>
<q-space />
<q-btn-toggle
v-model="timesheet_store.is_approval_grid_mode"
push
rounded
color="white"
text-color="accent"
toggle-color="accent"
:class="$q.screen.lt.md ? 'q-mb-sm' : 'q-mr-md'"
:options="[
{ icon: 'grid_view', value: true },
{ icon: 'view_list', value: false },
]"
/>
<div
class="col-auto row no-wrap flex-center"
:class="$q.screen.lt.md ? 'q-mb-md' : ''"
>
<q-btn
push
rounded
icon="download"
color="accent"
:label="$q.screen.lt.md ? '' : $t('shared.label.download')"
class="col-auto q-mr-sm"
@click="timesheet_store.is_report_dialog_open = true"
/>
<QTableFilters v-model:search="timesheet_store.search_filter" />
</div>
</div>
<OverviewReport/>
<OverviewList class="col" /> <OverviewList class="col" />
</q-page> </q-page>
</template> </template>

View File

@ -16,7 +16,7 @@
<template> <template>
<q-page <q-page
padding padding
class="column q-pa-md bg-secondary items-center" class="column bg-secondary items-center"
> >
<PageHeaderTemplate <PageHeaderTemplate
:title="'timesheet.page_header'" :title="'timesheet.page_header'"

View File

@ -3,6 +3,7 @@ import { createMemoryHistory, createRouter, createWebHashHistory, createWebHisto
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'; import { RouteNames } from 'src/router/router-constants';
import type { UserModuleAccess } from 'src/modules/shared/models/user.models';
/* /*
* If not building with SSR mode, you can * If not building with SSR mode, you can
@ -28,14 +29,19 @@ export default defineRouter(function (/* { store, ssrContext } */) {
history: createHistory(process.env.VUE_ROUTER_BASE), history: createHistory(process.env.VUE_ROUTER_BASE),
}); });
Router.beforeEach(async (destinationPage) => { Router.beforeEach(async (destination_page) => {
const authStore = useAuthStore(); const auth_store = useAuthStore();
const result = await authStore.getProfile() ?? { status: 400, message: 'unknown error occured' }; const result = await auth_store.getProfile() ?? { status: 400, message: 'unknown error occured' };
if ((destinationPage.meta.requiresAuth && !authStore.isAuthorizedUser) || (result.status >= 400 && destinationPage.name !== RouteNames.LOGIN)) { if (destination_page.meta.requires_auth && !auth_store.user || (result.status >= 400 && destination_page.name !== RouteNames.LOGIN)) {
console.error('no user account found'); console.error('no user account found');
return { name: 'login' }; return { name: 'login' };
} }
if (destination_page.meta.required_module && auth_store.user) {
if (!auth_store.user.user_module_access.includes(destination_page.meta.required_module as UserModuleAccess))
return {name: 'error'};
}
}) })
return Router; return Router;

View File

@ -8,4 +8,6 @@ export enum RouteNames {
PROFILE = 'personal_profile', PROFILE = 'personal_profile',
TIMESHEET = 'timesheets', TIMESHEET = 'timesheets',
HELP = 'help', HELP = 'help',
ERROR = 'error',
} }

View File

@ -1,36 +1,42 @@
import type { RouteRecordRaw } from 'vue-router'; import type { RouteRecordRaw } from 'vue-router';
import { RouteNames } from './router-constants'; import { RouteNames } from './router-constants';
import { ModuleNames } from 'src/modules/shared/models/user.models';
const routes: RouteRecordRaw[] = [ const routes: RouteRecordRaw[] = [
{ {
path: '/', path: '/',
component: () => import('src/layouts/main-layout.vue'), component: () => import('src/layouts/main-layout.vue'),
meta: { requiresAuth: true }, meta: { requires_auth: true },
children: [ children: [
{ {
path: '', path: '',
name: RouteNames.DASHBOARD, name: RouteNames.DASHBOARD,
component: () => import('src/pages/dashboard-page.vue'), component: () => import('src/pages/dashboard-page.vue'),
meta: { required_module: ModuleNames.DASHBOARD }
}, },
{ {
path: 'timesheet-approvals', path: 'timesheet-approvals',
name: RouteNames.TIMESHEET_APPROVALS, name: RouteNames.TIMESHEET_APPROVALS,
component: () => import('src/pages/timesheet-approval-page.vue'), component: () => import('src/pages/timesheet-approval-page.vue'),
meta: { required_module: ModuleNames.TIMESHEETS_APPROVAL }
}, },
{ {
path: 'employees', path: 'employees',
name: RouteNames.EMPLOYEE_LIST, name: RouteNames.EMPLOYEE_LIST,
component: () => import('src/pages/employee-list-page.vue'), component: () => import('src/pages/employee-list-page.vue'),
meta: { required_module: ModuleNames.EMPLOYEE_LIST }
}, },
{ {
path: 'timesheet', path: 'timesheet',
name: RouteNames.TIMESHEET, name: RouteNames.TIMESHEET,
component: () => import('src/pages/timesheet-page.vue') component: () => import('src/pages/timesheet-page.vue'),
meta: { required_module: ModuleNames.TIMESHEETS },
}, },
{ {
path: 'user/profile', path: 'user/profile',
name: RouteNames.PROFILE, name: RouteNames.PROFILE,
component: () => import('src/pages/profile-page.vue'), component: () => import('src/pages/profile-page.vue'),
meta: { required_module: ModuleNames.PERSONAL_PROFILE },
}, },
{ {
path: 'help', path: 'help',
@ -44,22 +50,23 @@ const routes: RouteRecordRaw[] = [
path: '/v1/login', path: '/v1/login',
name: RouteNames.LOGIN, name: RouteNames.LOGIN,
component: () => import('src/pages/login-page.vue'), component: () => import('src/pages/login-page.vue'),
meta: { requiresAuth: false }, meta: { requires_auth: false },
}, },
{ {
path: '/login-success', path: '/login-success',
name: RouteNames.LOGIN_SUCCESS, name: RouteNames.LOGIN_SUCCESS,
component: () => import('src/modules/auth/pages/auth-login-popup-success.vue'), component: () => import('src/modules/auth/pages/auth-login-popup-success.vue'),
meta: { requiresAuth: false }, meta: { requires_auth: false },
}, },
// Always leave this as last one, // Always leave this as last one,
// but you can also remove it // but you can also remove it
{ {
path: '/:catchAll(.*)*', path: '/:catchAll(.*)*',
name: RouteNames.ERROR,
component: () => import('src/pages/error-page.vue'), component: () => import('src/pages/error-page.vue'),
meta: { requiresAuth: false }, meta: { requires_auth: false },
}, },
]; ];

View File

@ -1,16 +1,13 @@
import { computed, ref } from "vue"; import { ref } from "vue";
import { Notify } from "quasar";
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 { CAN_APPROVE_PAY_PERIODS, type User } from "src/modules/shared/models/user.models";
import { useRouter } from "vue-router"; import { useRouter } from "vue-router";
import { Notify } from "quasar"; import type { User } from "src/modules/shared/models/user.models";
import type { ModuleAccessName } from "src/modules/employee-list/models/employee-profile.models";
export const useAuthStore = defineStore('auth', () => { export const useAuthStore = defineStore('auth', () => {
const user = ref<User>(); const user = ref<User>();
const user_module_access = ref<ModuleAccessName>();
const authError = ref(""); const authError = ref("");
const isAuthorizedUser = computed(() => CAN_APPROVE_PAY_PERIODS.includes(user.value?.role ?? 'GUEST'));
const router = useRouter(); const router = useRouter();
const login = () => { const login = () => {
@ -64,15 +61,13 @@ export const useAuthStore = defineStore('auth', () => {
return { status: 400, message: 'unknown error occured' }; return { status: 400, message: 'unknown error occured' };
} }
return { return {
user, user,
authError, authError,
isAuthorizedUser, login,
user_module_access, oidcLogin,
login, logout,
oidcLogin, getProfile
logout,
getProfile
}; };
}); });

View File

@ -23,7 +23,6 @@ export const useTimesheetStore = defineStore('timesheet', () => {
const is_details_dialog_open = ref(false); const is_details_dialog_open = ref(false);
const selected_employee_name = ref<string>(); const selected_employee_name = ref<string>();
const current_pay_period_overview = ref<TimesheetOverview>(); const current_pay_period_overview = ref<TimesheetOverview>();
const search_filter = ref<string | number | null>('');
const is_approval_grid_mode = ref<boolean>(true); const is_approval_grid_mode = ref<boolean>(true);
const pay_period_report = ref(); const pay_period_report = ref();
@ -86,8 +85,8 @@ export const useTimesheetStore = defineStore('timesheet', () => {
} }
}; };
const getTimesheetsByOptionalEmployeeEmail = async (employee_email?: string) => { const getTimesheetsByOptionalEmployeeEmail = async (employee_email?: string): Promise<boolean> => {
if (pay_period.value === undefined) return; if (pay_period.value === undefined) return false;
is_loading.value = true; is_loading.value = true;
let response; let response;
@ -97,7 +96,7 @@ export const useTimesheetStore = defineStore('timesheet', () => {
} else { } else {
response = await timesheetService.getTimesheetsByPayPeriodAndOptionalEmail(pay_period.value.pay_year, pay_period.value.pay_period_no); response = await timesheetService.getTimesheetsByPayPeriodAndOptionalEmail(pay_period.value.pay_year, pay_period.value.pay_period_no);
} }
if (response.success && response.data) { if (response.success && response.data) {
selected_employee_name.value = response.data.employee_fullname; selected_employee_name.value = response.data.employee_fullname;
timesheets.value = response.data.timesheets; timesheets.value = response.data.timesheets;
@ -107,15 +106,38 @@ export const useTimesheetStore = defineStore('timesheet', () => {
timesheets.value = []; timesheets.value = [];
initial_timesheets.value = []; initial_timesheets.value = [];
} }
is_loading.value = false; is_loading.value = false;
return response.success;
} 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
timesheets.value = []; timesheets.value = [];
is_loading.value = false; is_loading.value = false;
return false;
} }
}; };
const toggleTimesheetsApprovalByEmployeeEmail = async (email: string, approval_status: boolean): Promise<boolean> => {
try {
const timesheet_ids = timesheets.value.map(timesheet => timesheet.timesheet_id);
// Backend returns the amount of shifts and expenses successfully updated, could be useful for error handling???
// const shift_expense_count = timesheets.value.reduce((timesheets_sum, timesheet) => {
// return timesheets_sum + timesheet.days.reduce((day_sum, day) => {
// return day_sum + day.shifts.length + day.expenses.length
// }, 0);
// }, 0);
const response = await timesheetApprovalService.updateTimesheetsApprovalStatus(email, timesheet_ids, approval_status);
return response.success;
} catch (error) {
console.error("couldn't approve timesheets for employee: ", error);
}
return false;
};
const getPayPeriodReport = async (report_filters: TimesheetApprovalCSVReportFilters) => { const getPayPeriodReport = async (report_filters: TimesheetApprovalCSVReportFilters) => {
try { try {
if (!pay_period.value) return false; if (!pay_period.value) return false;
@ -147,7 +169,6 @@ export const useTimesheetStore = defineStore('timesheet', () => {
is_report_dialog_open, is_report_dialog_open,
is_approval_grid_mode, is_approval_grid_mode,
is_details_dialog_open, is_details_dialog_open,
search_filter,
pay_period, pay_period,
pay_period_overviews, pay_period_overviews,
current_pay_period_overview, current_pay_period_overview,
@ -160,6 +181,7 @@ export const useTimesheetStore = defineStore('timesheet', () => {
getPayPeriodByDateOrYearAndNumber, getPayPeriodByDateOrYearAndNumber,
getTimesheetOverviews, getTimesheetOverviews,
getTimesheetsByOptionalEmployeeEmail, getTimesheetsByOptionalEmployeeEmail,
toggleTimesheetsApprovalByEmployeeEmail,
getPayPeriodReport, getPayPeriodReport,
openReportDialog, openReportDialog,
closeReportDialog, closeReportDialog,

View File

@ -1,9 +1,9 @@
import { useI18n } from 'vue-i18n';
import { defineStore } from 'pinia'; import { defineStore } from 'pinia';
import { Notify, LocalStorage, useQuasar, Dark } from 'quasar';
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
import { LocalStorage, useQuasar, Dark } from 'quasar';
import { Preferences } from 'src/modules/profile/models/preferences.models'; import { Preferences } from 'src/modules/profile/models/preferences.models';
import { ProfileService } from 'src/modules/profile/services/profile-service'; import { ProfileService } from 'src/modules/profile/services/profile-service';
import { useI18n, type ComposerTranslation } from 'vue-i18n';
export const useUiStore = defineStore('ui', () => { export const useUiStore = defineStore('ui', () => {
@ -44,7 +44,7 @@ export const useUiStore = defineStore('ui', () => {
} }
}; };
const updateUserPreferences = async (t: ComposerTranslation) => { const updateUserPreferences = async () => {
try { try {
if (user_preferences.value.id === -1) return; if (user_preferences.value.id === -1) return;
@ -53,13 +53,11 @@ export const useUiStore = defineStore('ui', () => {
Object.assign(user_preferences.value, response.data); Object.assign(user_preferences.value, response.data);
LocalStorage.setItem('user_preferences', response.data); LocalStorage.setItem('user_preferences', response.data);
setPreferences(); setPreferences();
Notify.create({ message: t('profile.preferences.update_successful'), color: 'accent' });
return; return;
} }
} catch (error) { } catch (error) {
console.error('Could not update user preferences: ', error); console.error('Could not update user preferences: ', error);
} }
Notify.create({ message: t('profile.preferences.update_failed'), color: 'negative' })
}; };
const setPreferences = () => { const setPreferences = () => {