feat(approvals): add both weeks to modal, time grid and mouseover labels, buttons on employee card. Minor DRYing in various files.

This commit is contained in:
Nicolas Drolet 2025-08-26 17:02:08 -04:00
parent 6248cb3354
commit 81e4fd3ed0
12 changed files with 193 additions and 72 deletions

View File

@ -127,6 +127,8 @@ export default defineConfig((ctx) => {
// animations: 'all', // --- includes all animations // animations: 'all', // --- includes all animations
// https://v2.quasar.dev/options/animations // https://v2.quasar.dev/options/animations
animations: [ animations: [
'fadeIn',
'fadeOut',
'fadeInUp', 'fadeInUp',
'zoomIn', 'zoomIn',
'zoomOut', 'zoomOut',

View File

@ -327,10 +327,8 @@ export default {
consumedVacationTotalValidation: 'Consumed with vacation must be positive.', consumedVacationTotalValidation: 'Consumed with vacation must be positive.',
maxVacationPerYearValidation: 'Max Vacation Per Year must be positive.', maxVacationPerYearValidation: 'Max Vacation Per Year must be positive.',
resteVacationTotal: 'Rest of vacation', resteVacationTotal: 'Rest of vacation',
validateToolTip: 'Validate', tooltipTimeline: 'Daily breakdown',
unvalidateToolTip: 'Unvalidate', tooltipTimesheet: 'Open timesheet',
lockToolTip: 'Lock the week',
unlockToolTip: 'Unlock the week',
}, },
shiftColumns: { shiftColumns: {
title: 'Shifts', title: 'Shifts',

View File

@ -374,10 +374,8 @@ export default {
consumedVacationTotalValidation: 'Vacances utilisées doit être positif', consumedVacationTotalValidation: 'Vacances utilisées doit être positif',
maxVacationPerYearValidation: 'Maximum vacances annuel doit être positif.', maxVacationPerYearValidation: 'Maximum vacances annuel doit être positif.',
resteVacationTotal: 'Reste des vacances', resteVacationTotal: 'Reste des vacances',
validateToolTip: 'Valider', tooltipTimeline: 'Vue journalière',
unvalidateToolTip: 'Invalider', tooltipTimesheet: 'Feuille de temps',
lockToolTip: 'Verrouiller la semaine',
unlockToolTip: 'Déverrouiller la semaine',
}, },
usersListPage: { usersListPage: {
tableHeader: 'Répertoire du personnel', tableHeader: 'Répertoire du personnel',

View File

@ -5,7 +5,7 @@ export interface EmployeeProfile {
company_name: number; company_name: number;
job_title: string; job_title: string;
email: string; email: string;
phone_number: number; phone_number: string;
first_work_day: string; first_work_day: string;
last_work_day: string; last_work_day: string;
residence: string; residence: string;

View File

@ -1,9 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from "vue"; import { computed, ref } from "vue";
import type { Shift } from "src/modules/timesheets/types/timesheet-shift-interface"; import type { Shift } from "src/modules/timesheets/types/timesheet-shift-interface";
import { date } from 'quasar'; import { date } from 'quasar';
const totalMinutes = 24 * 60; const total_minutes = 24 * 60;
const props = defineProps<{ const props = defineProps<{
weekdayShifts: (Shift | null)[]; weekdayShifts: (Shift | null)[];
}>(); }>();
@ -17,29 +17,55 @@
return props.weekdayShifts return props.weekdayShifts
.filter((s): s is Shift => s !== null) // skip null shifts .filter((s): s is Shift => s !== null) // skip null shifts
.map((s) => { .map((s) => {
const start = toMinutes(s.start_time); const start = toMinutes(s.start);
const end = toMinutes(s.end_time); const end = toMinutes(s.end);
const start_percent = (start / totalMinutes) * 100; const hover = ref<boolean>(false);
const width_percent = ((end - start) / totalMinutes) * 100; const start_percent = (start / total_minutes) * 100;
const width_percent = ((end - start) / total_minutes) * 100;
return { start_percent, width_percent }; return { start_percent, width_percent, s, hover };
}); });
}); });
</script> </script>
<template> <template>
<div class="relative full-width bg-grey-4 rounded-10" style="height: 8px; margin: 7px 0;"> <div class="relative bg-grey-5 rounded-10 no-wrap" style="height: 4px; margin: 6px 0;">
<div <div
v-for="(bar, i) in bars" v-for="(bar, index) in bars"
:key="i" :key="index"
class="absolute bg-primary" class="absolute bg-primary no-wrap"
:style="{ :style="{
left: bar.start_percent + '%', left: bar.start_percent + '%',
width: bar.width_percent + '%', width: bar.width_percent + '%',
height: '14px', height: '10px',
transform: 'translateY(-3px)', transform: 'translateY(-3px)',
}" }"
></div> @mouseenter="bar.hover.value = true"
@mouseleave="bar.hover.value = false"
>
<!-- hoverable timestamps -->
<transition-group
appear
enter-active-class="animated fadeIn"
leave-active-class="animated fadeOut"
>
<q-badge
v-if="bar.hover.value"
style="transform: translate(-110%, -40%);"
>
{{ bar.s.start }}
</q-badge>
<q-badge
v-if="bar.hover.value"
floating style="transform: translate(100%, 5%);"
>
{{ bar.s.end }}
</q-badge>
</transition-group>
<!-- total hours worked per day -->
<!-- <q-badge> {{ bar.s.end }}</q-badge> -->
</div>
</div> </div>
</template> </template>

View File

@ -4,14 +4,18 @@
const props = defineProps<{ const props = defineProps<{
isLoading: boolean; isLoading: boolean;
employeeName: string;
employeeDetails: PayPeriodEmployeeDetails | undefined; employeeDetails: PayPeriodEmployeeDetails | undefined;
}>(); }>();
</script> </script>
<template> <template>
<q-card class="q-pa-sm bg-white shadow-5 full-width"> <q-card class="q-pa-md bg-white shadow-12">
<!-- loader --> <!-- loader -->
<q-card-section v-if="props.isLoading"> <q-card-section
v-if="props.isLoading"
class="text-center"
>
<q-spinner <q-spinner
color="primary" color="primary"
size="5em" size="5em"
@ -22,27 +26,86 @@
</div> </div>
</q-card-section> </q-card-section>
<!-- employee name -->
<q-card-section class="text-h5 text-weight-bolder text-center full-width text-primary q-pt-none">
{{ props.employeeName }}
</q-card-section>
<!-- employee timesheet details --> <!-- employee timesheet details -->
<q-card-section <q-card-section v-if="!props.isLoading" class="q-pa-none">
v-if="!props.isLoading" <div class="relative column col no-wrap bg-transparent">
class="column" <div class="row text-center full-width text-grey-5 text-weight-bolder text-caption no-wrap">
> <div class="col"></div>
<div class="absolute-full row justify-center text-center text-primary text-weight-bolder text-caption q-py-none q-my-none q-px-xl q-mx-sm"> <div class="">4</div>
<q-space /> <div class="col"></div>
<div class="col">4</div> <div class="">8</div>
<div class="col">8</div> <div class="col"></div>
<div class="col">12</div> <div class="">12</div>
<div class="col">4</div> <div class="col"></div>
<div class="col">8</div> <div class="">4</div>
<q-space /> <div class="col"></div>
<div class="">8</div>
<div class="col"></div>
</div> </div>
<ShiftPreviewBar <ShiftPreviewBar
v-for="(shifts, index) in employeeDetails?.week1.shifts" v-for="(shifts, index) in employeeDetails?.week1.shifts"
:key="index" :key="index"
:weekday-shifts="shifts" :weekday-shifts="shifts"
class="q-mt-xs" class="q-mt-xs z-top"
/> />
<!-- {{ employeeDetails }} --> </div>
<q-separator class="q-mx-xl q-my-sm" style="height: 3px;"/>
<div class="relative column col">
<ShiftPreviewBar
v-for="(shifts, index) in employeeDetails?.week2.shifts"
:key="index"
:weekday-shifts="shifts"
class="q-mt-xs z-top"
/>
<div class="row text-center full-width text-grey-5 text-caption no-wrap">
<div class="col"></div>
<div class="">4</div>
<div class="col"></div>
<div class="">8</div>
<div class="col"></div>
<div class="">12</div>
<div class="col"></div>
<div class="">4</div>
<div class="col"></div>
<div class="">8</div>
<div class="col"></div>
</div>
</div>
<div class="column absolute-full q-py-lg" style="z-index: 0;">
<div class="row col">
<div class="col"></div>
<q-separator vertical />
<div class="col"></div>
<q-separator vertical />
<div class="col"></div>
<q-separator vertical />
<div class="col"></div>
<q-separator vertical />
<div class="col"></div>
<q-separator vertical />
<div class="col"></div>
</div>
<div class="row col q-mt-lg">
<div class="col"></div>
<q-separator vertical />
<div class="col"></div>
<q-separator vertical />
<div class="col"></div>
<q-separator vertical />
<div class="col"></div>
<q-separator vertical />
<div class="col"></div>
<q-separator vertical />
<div class="col"></div>
</div>
</div>
</q-card-section> </q-card-section>
</q-card> </q-card>
</template> </template>

View File

@ -1,12 +1,18 @@
<script setup lang="ts"> <script setup lang="ts">
import type { PayPeriodOverviewEmployee } from 'src/modules/timesheet-approval/types/timesheet-approval-pay-period-overview-employee-interface'; import type { PayPeriodOverviewEmployee } from 'src/modules/timesheet-approval/types/timesheet-approval-pay-period-overview-employee-interface';
interface TableColumn { type TableColumn = {
name: string; name: string;
label: string; label: string;
value: unknown; value: unknown;
}; };
type CardButton = {
icon: string;
label: string;
onClick: () => void;
};
const props = defineProps<{ const props = defineProps<{
cols: TableColumn[]; cols: TableColumn[];
row: PayPeriodOverviewEmployee; row: PayPeriodOverviewEmployee;
@ -17,15 +23,20 @@
clickDetails: [email: string]; clickDetails: [email: string];
'update:modelValue': [value: boolean | null]; 'update:modelValue': [value: boolean | null];
}>(); }>();
const card_buttons: CardButton[] = [
{ icon: 'work_history', label: 'timeSheetValidations.tooltipTimeline', onClick: () => emit('clickDetails', props.row.email) },
{ icon: 'open_in_new', label: 'timeSheetValidations.tooltipTimesheet', onClick: () => emit('clickDetails', props.row.email) }
];
</script> </script>
<template> <template>
<div class="q-px-sm q-pb-sm col-xs-12 col-sm-6 col-md-4 col-lg-4 col-xl-3 grid-style-transition"> <div class="q-px-sm q-pb-sm q-mt-sm col-xs-12 col-sm-6 col-md-4 col-lg-4 col-xl-3 grid-style-transition">
<q-card class="rounded-10"> <q-card class="rounded-10">
<!-- Card header with employee name and details button--> <!-- Card header with employee name and details button-->
<q-card-section <q-card-section
horizontal horizontal
class="q-py-none q-pl-md" class="q-py-none q-pl-md relative"
> >
<div class="text-primary text-h5 text-weight-bolder q-pt-xs overflow-hidden"> <div class="text-primary text-h5 text-weight-bolder q-pt-xs overflow-hidden">
{{ props.row.employee_name }} {{ props.row.employee_name }}
@ -33,16 +44,27 @@
<q-space /> <q-space />
<!-- Button to get full timesheet details --> <!-- Buttons to view detailed shifts or view employee timesheet -->
<q-btn <q-btn
flat flat
unelevated unelevated
rounded square
class="q-py-none" dense
v-for="(button, index) in card_buttons"
:key="index"
class="q-py-none bg-white q-my-xs"
color="primary" color="primary"
icon="open_in_full" :icon="button.icon"
@click="emit('clickDetails', props.row.email)" @click="button.onClick"
/> >
<q-tooltip
anchor="top middle"
self="center middle"
class="bg-primary uppercase text-weight-bold"
>
{{$t(button.label)}}
</q-tooltip>
</q-btn>
</q-card-section> </q-card-section>
<q-separator <q-separator
@ -82,7 +104,7 @@
<q-item <q-item
dense dense
class="column ellipsis " class="column ellipsis "
v-for="col in props.cols.slice(2, 5)" v-for="col in props.cols.slice(3, 6)"
:key="col.label" :key="col.label"
> >
<q-item-label <q-item-label
@ -109,7 +131,7 @@
<q-item <q-item
dense dense
class="column" class="column"
v-for="col in props.cols.slice(5, )" v-for="col in props.cols.slice(6, )"
:key="col.label" :key="col.label"
> >
<q-item-label <q-item-label

View File

@ -23,6 +23,7 @@
const filter = ref<string | number | null>(''); const filter = ref<string | number | null>('');
const original_approvals = ref<Record<string, boolean>>({}); const original_approvals = ref<Record<string, boolean>>({});
const is_showing_details = ref<boolean>(false); const is_showing_details = ref<boolean>(false);
const clicked_employee_name = ref<string>('');
const columns = computed((): QTableColumn<PayPeriodOverviewEmployee>[] => [ const columns = computed((): QTableColumn<PayPeriodOverviewEmployee>[] => [
{ {
@ -87,9 +88,9 @@
await timesheet_approval_api.getPayPeriodOverviewByDate(date_string); await timesheet_approval_api.getPayPeriodOverviewByDate(date_string);
} }
const onClickedDetails = async (email: string) => { const onClickedDetails = async (email: string, name: string) => {
clicked_employee_name.value = name;
is_showing_details.value = true; is_showing_details.value = true;
console.log('employee email is: ', email);
await timesheet_approval_api.getTimesheetsByPayPeriodAndEmail(email); await timesheet_approval_api.getTimesheetsByPayPeriodAndEmail(email);
} }
@ -99,7 +100,6 @@
const approvals = timesheet_store.pay_period_overview_employees.map(emp => [emp.email, emp.is_approved]); const approvals = timesheet_store.pay_period_overview_employees.map(emp => [emp.email, emp.is_approved]);
original_approvals.value = Object.fromEntries(approvals); original_approvals.value = Object.fromEntries(approvals);
console.log(timesheet_store.pay_period_overview_employees);
}) })
</script> </script>
@ -111,7 +111,9 @@
> >
<TimesheetApprovalEmployeeDetails <TimesheetApprovalEmployeeDetails
:is-loading="timesheet_store.is_loading" :is-loading="timesheet_store.is_loading"
:employee-name="clicked_employee_name"
:employee-details="timesheet_store.pay_period_employee_details" :employee-details="timesheet_store.pay_period_employee_details"
style="min-width: 300px;"
/> />
</q-dialog> </q-dialog>
<div class="q-pa-md"> <div class="q-pa-md">
@ -158,7 +160,7 @@
:cols="props.cols" :cols="props.cols"
:row="props.row" :row="props.row"
:initial-state="props.row.is_approved" :initial-state="props.row.is_approved"
@click-details="onClickedDetails" @click-details="email => onClickedDetails(email, props.row.employee_name)"
/> />
</template> </template>

View File

@ -18,6 +18,13 @@
const start_date = new Intl.DateTimeFormat(locale.value, date_options).format(date.extractDate(dates[0] as string, 'YYYY-MM-DD')); const start_date = new Intl.DateTimeFormat(locale.value, date_options).format(date.extractDate(dates[0] as string, 'YYYY-MM-DD'));
const end_date = new Intl.DateTimeFormat(locale.value, date_options).format(date.extractDate(dates[1] as string, 'YYYY-MM-DD')); const end_date = new Intl.DateTimeFormat(locale.value, date_options).format(date.extractDate(dates[1] as string, 'YYYY-MM-DD'));
if ( dates.length === 1 ) {
return {
start_date: '—',
end_date: '—'
}
}
return { start_date, end_date }; return { start_date, end_date };
}); });
</script> </script>

View File

@ -1,7 +1,7 @@
import type { TimesheetDetailsWeek } from "src/modules/timesheets/types/timesheet-details-interface"; import type { TimesheetDetailsWeek } from "src/modules/timesheets/types/timesheet-details-interface";
export interface PayPeriodEmployeeDetails { export interface PayPeriodEmployeeDetails {
is_approved: boolean; // is_approved: boolean;
week1: TimesheetDetailsWeek; week1: TimesheetDetailsWeek;
week2: TimesheetDetailsWeek; week2: TimesheetDetailsWeek;
}; };

View File

@ -1,5 +1,5 @@
export interface Shift { export interface Shift {
is_approved: boolean; is_approved: boolean;
start_time: string; start: string;
end_time: string; end: string;
} }

View File

@ -31,6 +31,8 @@ export const useTimesheetStore = defineStore('timesheet', () => {
return true; return true;
} catch(error){ } catch(error){
console.error('Could not get current pay period: ', error ); console.error('Could not get current pay period: ', error );
current_pay_period.value = default_pay_period;
pay_period_overview_employees.value = [];
//TODO: More in-depth error-handling here //TODO: More in-depth error-handling here
} }
@ -50,6 +52,8 @@ export const useTimesheetStore = defineStore('timesheet', () => {
return true; return true;
} catch(error){ } catch(error){
console.error('Could not get current pay period: ', error ); console.error('Could not get current pay period: ', error );
current_pay_period.value = default_pay_period;
pay_period_overview_employees.value = [];
//TODO: More in-depth error-handling here //TODO: More in-depth error-handling here
} }
@ -87,7 +91,6 @@ export const useTimesheetStore = defineStore('timesheet', () => {
employee_email employee_email
); );
pay_period_employee_details.value = response; pay_period_employee_details.value = response;
} 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);
pay_period_employee_details.value = MOCK_DATA_TIMESHEET_DETAILS; pay_period_employee_details.value = MOCK_DATA_TIMESHEET_DETAILS;
@ -115,11 +118,11 @@ const MOCK_DATA_TIMESHEET_DETAILS = {
is_approved: true, is_approved: true,
shifts: { shifts: {
sun: [], sun: [],
mon: [ { is_approved: true, start_time: '08:00', end_time: '12:00' }, { is_approved: true, start_time: '13:00', end_time: '17:00' } ], mon: [ { is_approved: true, start: '08:00', end: '12:00' }, { is_approved: true, start: '13:00', end: '17:00' } ],
tue: [ { is_approved: true, start_time: '08:00', end_time: '11:45' }, { is_approved: true, start_time: '12:45', end_time: '17:00' } ], tue: [ { is_approved: true, start: '08:00', end: '11:45' }, { is_approved: true, start: '12:45', end: '17:00' } ],
wed: [ { is_approved: true, start_time: '08:00', end_time: '12:00' }, { is_approved: true, start_time: '13:00', end_time: '17:00' } ], wed: [ { is_approved: true, start: '08:00', end: '12:00' }, { is_approved: true, start: '13:00', end: '17:00' } ],
thu: [ { is_approved: false, start_time: '13:00', end_time: '17:00' } ], thu: [ { is_approved: false, start: '13:00', end: '17:00' } ],
fri: [ { is_approved: true, start_time: '08:00', end_time: '12:00' }, { is_approved: true, start_time: '13:00', end_time: '17:00' } ], fri: [ { is_approved: true, start: '08:00', end: '12:00' }, { is_approved: true, start: '13:00', end: '17:00' } ],
sat: [] sat: []
}, },
expenses: { expenses: {