fix(all): More changes to UI:

Timesheet: fix UI spaces with scrolling, change ui to not show preset apply if no preset set to employee. Layout Drawer: fix display of options according to user permissions, fix highlight of menu item to match current route name. Employee list: add functionality to prevent users without user management permissions to see or edit user info and prevent seeing inactive users, add remote to shifts for preset editor, add hover effect to employee items when management mode to visually hint at clickable item.
This commit is contained in:
Nicolas Drolet 2026-01-02 17:26:20 -05:00
parent 20fcc0206c
commit f738a5872a
20 changed files with 141 additions and 146 deletions

View File

@ -13,7 +13,7 @@
const DRAWER_BUTTONS: { i18n_key: string, icon: string, route: RouteNames, required_module?: UserModuleAccess }[] = [
{ i18n_key: 'nav_bar.home', icon: "home", route: RouteNames.DASHBOARD, required_module: ModuleNames.DASHBOARD },
{ i18n_key: 'nav_bar.timesheet_approvals', icon: "event_available", route: RouteNames.TIMESHEET_APPROVALS, required_module: ModuleNames.TIMESHEETS_APPROVAL },
{ i18n_key: 'nav_bar.employee_list', icon: "groups", route: RouteNames.EMPLOYEE_LIST },
{ i18n_key: 'nav_bar.employee_list', icon: "groups", route: RouteNames.EMPLOYEE_LIST, required_module: ModuleNames.EMPLOYEE_LIST },
{ i18n_key: 'nav_bar.timesheet', icon: "punch_clock", route: RouteNames.TIMESHEET, required_module: ModuleNames.TIMESHEETS },
{ i18n_key: 'nav_bar.profile', icon: "account_box", route: RouteNames.PROFILE, required_module: ModuleNames.PERSONAL_PROFILE },
{ i18n_key: 'nav_bar.help', icon: "contact_support", route: RouteNames.HELP },
@ -26,7 +26,6 @@
const is_mini = ref(true);
const onClickDrawerPage = (page_name: RouteNames) => {
ui_store.current_page = page_name;
is_mini.value = true;
router.push({ name: page_name }).catch(error => {
@ -43,7 +42,7 @@
};
onMounted(() => {
if(q.platform.is.mobile) {
if (q.platform.is.mobile) {
ui_store.is_left_drawer_open = false;
}
})
@ -66,22 +65,26 @@
v-for="button, index in DRAWER_BUTTONS"
:key="index"
v-show="button.required_module ?? true"
class="row items-center full-width q-py-sm cursor-pointer"
:class="ui_store.current_page === button.route ? ($q.dark.isActive ? 'bg-green-10' : 'bg-green-2') : ''"
@click="onClickDrawerPage(button.route)"
>
<q-icon
:name="button.icon"
color="accent"
size="lg"
class="col-auto q-pl-sm"
/>
<div
class="col text-uppercase text-weight-bold text-h6 q-pl-sm"
:class="$q.platform.is.mobile ? '' : 'q-mini-drawer-hide'"
v-if="button.required_module ? auth_store.user?.user_module_access.includes(button.required_module) : true"
class="row items-center full-width q-py-sm cursor-pointer"
:class="$router.currentRoute.value.name === button.route ? ($q.dark.isActive ? 'bg-green-10' : 'bg-green-2') : ''"
>
{{ $t(button.i18n_key) }}
<q-icon
:name="button.icon"
color="accent"
size="lg"
class="col-auto q-pl-sm"
/>
<div
class="col text-uppercase text-weight-bold text-h6 q-pl-sm"
:class="$q.platform.is.mobile ? '' : 'q-mini-drawer-hide'"
>
{{ $t(button.i18n_key) }}
</div>
</div>
</div>

View File

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

View File

@ -19,6 +19,7 @@ import { ref } from 'vue';
arrows
:autoplay="9001"
control-color="accent"
control-type="outline"
class="bg-dark full-width rounded-15 shadow-18"
>
<!-- welcome slide -->

View File

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

View File

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

View File

@ -66,6 +66,28 @@
>{{ scope.opt.label }}</span>
</div>
</template>
<template #after>
<q-toggle
v-model="shift.is_remote"
dense
keep-color
size="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>

View File

@ -40,14 +40,13 @@
}>();
const is_showing_filters = ref(false);
const search_string = ref('');
const overview_rows = computed(() => timesheet_store.pay_period_overviews.filter(overview => overview));
const overview_filters = ref<PayPeriodOverviewFilters>({
is_showing_inactive: false,
is_showing_team_only: false,
supervisors: [],
name_search_string: search_string.value,
name_search_string: '',
});
const onClickedDetails = async (row: TimesheetApprovalOverview) => {
@ -106,7 +105,7 @@
:no-data-label="$t('shared.error.no_data_found')"
:no-results-label="$t('shared.error.no_search_results')"
:loading-label="$t('shared.label.loading')"
:style="`max-height: ${maxHeight}px;`"
:style="overview_rows.length > 0 ? `max-height: ${maxHeight - (timesheet_store.is_approval_grid_mode ? 0 : 20)}px;` : ''"
@row-click="(_evt, row: TimesheetApprovalOverview) => onClickedDetails(row)"
>
<template #top>
@ -158,7 +157,7 @@
/>
<QTableFilters
v-model:search="search_string"
v-model:search="overview_filters.name_search_string"
class="col-auto q-mb-sm"
/>
@ -279,7 +278,7 @@
<!-- Template for custome failed-to-load state -->
<template #no-data="{ message, filter }">
<div class="full-width column items-center text-accent q-gutter-sm">
<div v-if="!timesheet_store.is_loading" class="full-width column items-center text-accent">
<q-icon
size="4em"
:name="filter ? 'filter_alt_off' : 'error_outline'"

View File

@ -12,7 +12,7 @@
<template>
<div
v-if="$q.platform.is.mobile && $q.screen.width < $q.screen.height"
class="col-auto row items-start q-px-sm q-pt-sm full-width"
class="row items-start q-px-sm q-pt-sm full-width"
>
<!-- per timesheet -->
<div

View File

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

View File

@ -5,7 +5,7 @@
import ShiftListDay from 'src/modules/timesheets/components/shift-list-day.vue';
import ShiftListDateWidget from 'src/modules/timesheets/components/shift-list-date-widget.vue';
import { date } from 'quasar';
import { date, useQuasar } from 'quasar';
import { computed, ref, watch } from 'vue';
import { useUiStore } from 'src/stores/ui-store';
import { useTimesheetStore } from 'src/stores/timesheet-store';
@ -17,6 +17,7 @@
const CURRENT_DATE_STRING = new Date().toISOString().slice(0, 10);
const { extractDate } = date;
const q = useQuasar();
const ui_store = useUiStore();
const timesheet_store = useTimesheetStore();
@ -68,7 +69,7 @@
};
watch(currentDayComponentWatcher, () => {
if (currentDayComponent.value && timesheet_page.value) {
if (currentDayComponent.value && timesheet_page.value && q.platform.is.mobile) {
console.log('setting scroll position to offsetTop of currentDayComponent: ', currentDayComponent.value[0]!.offsetTop);
timesheet_page.value.setScrollPosition('vertical', currentDayComponent.value[0]!.offsetTop, 800);
return;
@ -78,7 +79,7 @@
<template>
<div
class="col column fit relative-position"
class="column fit relative-position"
:style="$q.platform.is.mobile && $q.screen.width < $q.screen.height ? 'margin-bottom: 40px' : ''"
v-touch-swipe="value => handleSwipe(value.direction, value.distance ?? { x: 0, y: 0 })"
>
@ -91,7 +92,7 @@
>
<!-- Show if no timesheets found (further than one month from present) -->
<div
v-if="timesheet_store.timesheets.length < 1"
v-if="timesheet_store.timesheets.length < 1 && !timesheet_store.is_loading"
class="col-auto column flex-center fit q-py-lg"
style="min-height: 20vh;"
>
@ -123,7 +124,7 @@
leave-active-class="animated fadeOutUp"
>
<q-btn
v-if="!$q.platform.is.mobile && timesheet.days.every(day => day.shifts.length < 1)"
v-if="!$q.platform.is.mobile && timesheet.days.every(day => day.shifts.length < 1) && timesheet_store.has_timesheet_preset"
:disable="!timesheet.days.every(day => day.shifts.length < 1)"
flat
dense

View File

@ -8,7 +8,7 @@
import PayPeriodNavigator from 'src/modules/shared/components/pay-period-navigator.vue';
import TimesheetErrorWidget from 'src/modules/timesheets/components/timesheet-error-widget.vue';
import LoadingOverlay from 'src/modules/shared/components/loading-overlay.vue';
import ShiftListWeeklyOverview from 'src/modules/timesheets/components/mobile/shift-list-weekly-overview.vue';
import ShiftListWeeklyOverviewMobile from 'src/modules/timesheets/components/mobile/shift-list-weekly-overview-mobile.vue';
import { computed, onMounted } from 'vue';
import { useShiftApi } from 'src/modules/timesheets/composables/use-shift-api';
@ -67,7 +67,10 @@
/>
<!-- label for approval mode to delimit that this is the timesheet -->
<span v-if="mode === 'approval'" class="col-auto text-uppercase text-bold text-h5"> {{ $t('timesheet.page_header') }}</span>
<span
v-if="mode === 'approval'"
class="col-auto text-uppercase text-bold text-h5"
> {{ $t('timesheet.page_header') }}</span>
<q-space v-if="$q.screen.width > $q.screen.height" />
@ -100,9 +103,12 @@
<TimesheetErrorWidget class="col-auto" />
<!-- mobile weekly overview widget -->
<ShiftListWeeklyOverview />
<ShiftListWeeklyOverviewMobile class="col-auto" />
<ShiftList :mode="mode" />
<ShiftList
:mode="mode"
class="col"
/>
<q-btn
v-if="$q.platform.is.mobile && $q.screen.width < $q.screen.height"

View File

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

View File

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

View File

@ -5,6 +5,7 @@ export const TIME_FORMAT_PATTERN = /^(\d{2}:\d{2})?$/;
export const DATE_FORMAT_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
export interface TimesheetResponse {
has_preset_schedule: boolean;
employee_fullname: string;
timesheets: Timesheet[];
}
@ -42,78 +43,3 @@ export interface TotalExpenses {
on_call: number;
mileage: number;
}
// export const test_timesheets: Timesheet[] = [
// {
// timehsid: 1,
// is_approved: false,
// weekly_hours: { regular: 8, evening: 0, emergency: 0, overtime: 0, vacation: 0, holiday: 0, sick: 0, absent: 0 },
// weekly_expenses: { expenses: 15.5, mileage: 0 },
// days: [
// {
// date: '2025-10-18',
// daily_hours: { regular: 8, evening: 0, emergency: 0, overtime: 0, vacation: 0, holiday: 0, sick: 0, absent: 0 },
// daily_expenses: { expenses: 15.5, mileage: 0 },
// shifts: [
// { id: 101, date: '2025-01-06', type: 'REGULAR', start_time: '08:00', end_time: '12:00', comment: 'blah', is_approved: false, is_remote: false, },
// { id: 102, date: '2025-01-06', type: 'REGULAR', start_time: '13:00', end_time: '17:00', comment: undefined, is_approved: false, is_remote: false, },
// ],
// expenses: [
// { id: 201, date: '2025-01-06', type: 'EXPENSES', amount: 15.5, comment: 'Lunch receipt', is_approved: false, },
// ],
// },
// ],
// },
// {
// id: 2,
// is_approved: true,
// weekly_hours: {
// regular: 0,
// evening: 0,
// emergency: 0,
// overtime: 8,
// vacation: 0,
// holiday: 0,
// sick: 0,
// absent: 0,
// },
// weekly_expenses: {
// expenses: 0,
// mileage: 32.4,
// },
// days: [
// {
// date: '2025-10-27',
// daily_hours: {
// regular: 0,
// evening: 0,
// emergency: 0,
// overtime: 8,
// vacation: 0,
// holiday: 0,
// sick: 0,
// absent: 0,
// },
// daily_expenses: {
// expenses: 0,
// mileage: 32.4,
// },
// shifts: [
// { id: 101, date: '2025-10-27', type: 'REGULAR', start_time: '08:00', end_time: '12:00', comment: undefined, is_approved: false, is_remote: false, },
// { id: 102, date: '2025-10-27', type: 'REGULAR', start_time: '13:00', end_time: '17:00', comment: undefined, is_approved: false, is_remote: false, },
// ],
// expenses: [
// {
// id: 202,
// date: '2025-10-27',
// type: 'MILEAGE',
// amount: 0,
// mileage: 32.4,
// comment: 'Travel to client site',
// is_approved: true,
// },
// ],
// },
// ],
// },
// ];

View File

@ -62,7 +62,7 @@
<iframe
title="Environment Canada Weather"
height="400px"
src="https://weather.gc.ca/wxlink/wxlink.html?coords=45.159%2C-73.676&lang=e"
src="https://weather.gc.ca/wxlink/wxlink.html?coords=45.159%2C-73.676&lang=f"
allowtransparency="true"
style="border: 0;"
class="col-auto"

View File

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

View File

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

View File

@ -20,14 +20,14 @@
const headerComponent = ref<HTMLElement | null>(null);
const table_max_height = computed(() => {
const height = page_height.value - (headerComponent.value?.clientHeight ?? 0) - 20;
console.log('offset height of header: ', headerComponent.value?.clientHeight);
console.log('height calculated: ', height);
const height = page_height.value - (headerComponent.value?.offsetHeight ?? 0);
return height;
});
const tableStyleFunction = (offset: number, height: number) => {
page_height.value = height - offset;
return { minHeight: height - offset + 'px' };
};
onMounted(async () => {
@ -49,7 +49,7 @@
<OverviewReport />
<div
class="column items-center scroll q-pa-sm"
class="column items-center scroll q-px-sm full-width"
style="min-height: inherit;"
>
<div
@ -63,7 +63,7 @@
/>
</div>
<div class="col">
<div class="col-grow full-width">
<OverviewList :max-height="table_max_height" />
</div>
</div>

View File

@ -15,7 +15,6 @@
<template>
<q-page
padding
class="column bg-secondary items-center"
>
<PageHeaderTemplate

View File

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