feat(details): Adjust shift display UI, DRY code in details components, prep for modifying UI

This commit is contained in:
Nicolas Drolet 2025-09-08 16:11:48 -04:00
parent b8c9b8ed76
commit 072e0931a1
9 changed files with 262 additions and 232 deletions

View File

@ -26,5 +26,5 @@ $dark-page: #323232;
$positive: #21ba45;
$negative: #e6364b;
$info: #6bb9e7;
$warning: #eec964;
$warning: #e4a944;
$white: white;

View File

@ -1,5 +1,6 @@
<script setup lang="ts">
import { ref } from 'vue';
import { colors } from 'quasar';
import { Bar } from 'vue-chartjs';
import { useI18n } from 'vue-i18n';
import { Chart as ChartJS, Title, Tooltip, Legend, BarElement, CategoryScale, LinearScale, type ChartData, type ChartOptions, type Plugin, type ChartDataset } from 'chart.js';
@ -12,14 +13,11 @@
ChartJS.defaults.font.family = '"Roboto", sans-serif';
ChartJS.defaults.maintainAspectRatio = false;
const props = withDefaults(defineProps<{
const props = defineProps<{
rawData: PayPeriodEmployeeDetails | undefined;
options?: ChartOptions<"bar"> | undefined;
plugins?: Plugin<"bar">[] | undefined;
}>(), {
options: () => ({}),
plugins: () => [],
});
}>();
const hours_worked_labels = ref<string[]>([]);
const hours_worked_dataset = ref<ChartDataset<'bar'>[]>([]);
@ -28,34 +26,36 @@
if (props.rawData) {
const all_weeks = [props.rawData.week1, props.rawData.week2];
const all_days = all_weeks.flatMap( week => Object.values(week.shifts));
const regular_hours = all_days.map( day => day.regular_hours);
const evening_hours = all_days.map( day => day.evening_hours);
const emergency_hours = all_days.map( day => day.emergency_hours);
const overtime_hours = all_days.map( day => day.overtime_hours);
hours_worked_dataset.value = [
const datasetConfig = [
{
key: 'regular_hours',
label: t('timeSheetValidations.hoursWorkedRegular'),
data: regular_hours,
backgroundColor: getComputedStyle(document.body).getPropertyValue('--q-primary').trim(),
color: colors.getPaletteColor('green-5'),
},
{
key: 'evening_hours',
label: t('timeSheetValidations.hoursWorkedEvening'),
data: evening_hours,
backgroundColor: getComputedStyle(document.body).getPropertyValue('--q-info').trim(),
color: colors.getPaletteColor('green-9'),
},
{
key: 'emergency_hours',
label: t('timeSheetValidations.hoursWorkedEmergency'),
data: emergency_hours,
backgroundColor: getComputedStyle(document.body).getPropertyValue('--q-accent').trim(),
color: getComputedStyle(document.body).getPropertyValue('--q-warning').trim(),
},
{
key: 'overtime_hours',
label: t('timeSheetValidations.hoursWorkedOvertime'),
data: overtime_hours,
backgroundColor: getComputedStyle(document.body).getPropertyValue('--q-negative').trim(),
color: getComputedStyle(document.body).getPropertyValue('--q-negative').trim(),
},
]
hours_worked_labels.value = all_days.map( day => day.short_date);
] as const;
hours_worked_dataset.value = datasetConfig.map(cfg => ({
label: cfg.label,
data: all_days.map(day => day[ cfg.key ]),
backgroundColor: cfg.color,
}));
hours_worked_labels.value = all_days.map(day => day.short_date);
}
return {
@ -69,6 +69,31 @@
<template>
<Bar
:data="getHoursWorkedData()"
:options="props.options"
:options="({
indexAxis: $q.screen.lt.md? 'y' : 'x',
plugins: {
legend: {
labels: {
boxWidth: 15,
},
},
title: {
display: true,
text: t('timeSheetValidations.hoursWorkedChartTitle'),
color: '#616161'
}
},
scales: {
x: {
stacked: true,
},
y: {
stacked: true,
suggestedMin: 0,
suggestedMax: 10,
}
}
})"
/>
</template>

View File

@ -1,8 +1,9 @@
<script setup lang="ts">
/* eslint-disable */
import { ref } from 'vue';
import { Doughnut } from 'vue-chartjs';
import { colors } from 'quasar';
import { useI18n } from 'vue-i18n';
import { Doughnut } from 'vue-chartjs';
import { Chart as ChartJS, Title, Tooltip, Legend, ArcElement, CategoryScale, LinearScale, type ChartData, type ChartOptions, type Plugin, type ChartDataset } from 'chart.js';
import type { PayPeriodOverviewEmployee } from 'src/modules/timesheet-approval/types/timesheet-approval-pay-period-overview-employee-interface';
@ -12,14 +13,9 @@
ChartJS.defaults.font.family = '"Roboto", sans-serif';
ChartJS.defaults.maintainAspectRatio = false;
const props = withDefaults(defineProps<{
const props = defineProps<{
rawData: PayPeriodOverviewEmployee | undefined;
options?: ChartOptions<"doughnut"> | undefined;
plugins?: Plugin<"doughnut">[] | undefined;
}>(), {
options: () => ({}),
plugins: () => [],
});
}>();
const shift_type_labels = ref<string[]>([]);
const shift_type_totals = ref<ChartDataset<'doughnut'>[]>([{ data: [40, 0, 2, 5], }]);
@ -33,10 +29,10 @@
props.rawData.overtime_hours,
],
backgroundColor: [
getComputedStyle(document.body).getPropertyValue('--q-primary').trim(),
getComputedStyle(document.body).getPropertyValue('--q-info').trim(),
getComputedStyle(document.body).getPropertyValue('--q-accent').trim(),
getComputedStyle(document.body).getPropertyValue('--q-negative').trim(),
colors.getPaletteColor('green-5'), // Regular
colors.getPaletteColor('green-9'), // Evening
getComputedStyle(document.body).getPropertyValue('--q-warning').trim(), // Emergency
getComputedStyle(document.body).getPropertyValue('--q-negative').trim(), // Overtime
]
}];
@ -58,6 +54,14 @@
<template>
<Doughnut
:data="data"
:options="props.options"
:options="({
plugins:{
legend:{
labels:{
boxWidth: 15,
}
}
}
})"
/>
</template>

View File

@ -13,14 +13,9 @@
ChartJS.defaults.font.family = '"Roboto", sans-serif';
ChartJS.defaults.maintainAspectRatio = false;
const props = withDefaults(defineProps<{
const props = defineProps<{
rawData: PayPeriodEmployeeDetails | undefined;
options?: ChartOptions<"bar"> | undefined;
plugins?: Plugin<"bar">[] | undefined;
}>(), {
options: () => ({}),
plugins: () => [],
});
}>();
const expenses_dataset = ref<ChartDataset<'bar'>[]>([]);
const expenses_labels = ref<string[]>([]);
@ -71,6 +66,31 @@
<template>
<Bar
:data="getExpensesData()"
:options="props.options"
:options="({
indexAxis: $q.screen.lt.md? 'y' : 'x',
plugins: {
title: {
display: true,
text: t('timeSheetValidations.reportFilterExpenses'),
color: '#616161'
},
legend:{
labels:{
boxWidth: 15,
}
}
},
scales: {
x: {
stacked: true
},
y: {
suggestedMin: 0,
suggestedMax: 100,
stacked: true
}
}
})"
:style="$q.screen.lt.md ? 'min-height: 300px;': '' "
/>
</template>

View File

@ -1,59 +1,31 @@
<template>
<!-- This header component is placed in absolute top in order to keep the timestamps
below it vertically centered in the row. Since being absolute top makes it no longer
follow the alignment of the shift rows below it, it must containt the same elements
as them to remain aligned. -->
<q-card-section
horizontal
class="text-uppercase text-center items-center q-px-xs"
class="text-uppercase text-center items-center q-pa-none"
>
<!-- Date widget -->
<q-card-section class="q-px-xs q-py-none col-auto"> <div class="q-px-xs q-py-none" style="width: 75px;"></div></q-card-section>
<!-- shift row itself -->
<q-card-section class="col q-pa-none">
<q-card-section horizontal class="col q-px-xs">
<q-card-section horizontal class="col q-pa-none">
<!-- punch-in timestamps -->
<q-card-section class="col q-px-xs q-py-none">
<q-item-label class="text-weight-bolder text-primary">
<q-card-section class="col q-pa-none">
<q-item-label class="text-weight-bolder text-primary" style="font-size: 0.7em;">
{{ $t('shiftColumns.labelIn') }}
</q-item-label>
</q-card-section>
<!-- arrows pointing to punch-out timestamps -->
<q-card-section class="col-auto q-pa-none q-mx-sm">
<q-icon
name="double_arrow"
color="transparent"
size="24px"
style="transform: translateX(5px);"
/>
<q-icon
name="double_arrow"
color="transparent"
size="24px"
style="transform: translateX(-5px);"
/>
<q-card-section class="col q-py-none q-px-sm">
</q-card-section>
<!-- punch-out timestamps -->
<q-card-section class="col q-px-xs q-py-none">
<q-item-label class="text-weight-bolder text-primary">
<q-card-section class="col q-pa-none">
<q-item-label class="text-weight-bolder text-primary" style="font-size: 0.7em;">
{{ $t('shiftColumns.labelOut') }}
</q-item-label>
</q-card-section>
<!-- shift type badge -->
<q-card-section class="col column q-pa-none q-gutter-md"><div style="width: 80px;"></div></q-card-section>
<!-- comment button -->
<q-card-section class="col-auto column q-pa-none">
<q-btn
icon="chat_bubble_outline"
color="transparent"
flat
class="q-pa-sm col"
/>
<q-card-section class="col column q-pa-none">
</q-card-section>
</q-card-section>
</q-card-section>

View File

@ -1,113 +1,103 @@
<script setup lang="ts">
import { computed } from 'vue';
import { default_shift } from 'src/modules/timesheets/types/timesheet-shift-interface';
import type { TimesheetDetailsDailySchedule } from 'src/modules/timesheets/types/timesheet-details-interface';
import type { Shift } from 'src/modules/timesheets/types/timesheet-shift-interface';
const props = defineProps<{
dayData: TimesheetDetailsDailySchedule;
shift: Shift;
}>();
const shifts_or_placeholder = computed(() => {
return props.dayData.shifts.length > 0 ? props.dayData.shifts : [default_shift];
})
const getShiftColor = (type: string): string => {
switch(type) {
case 'REGULAR': return 'primary';
case 'EVENING': return 'info';
case 'EMERGENCY': return 'teal-14';
case 'REGULAR': return 'secondary';
case 'EVENING': return 'warning';
case 'EMERGENCY': return 'amber-10';
case 'OVERTIME': return 'negative';
case 'VACATION': return 'purple-10';
case 'HOLIDAY': return 'indigo-13';
case 'SICK': return 'grey-9';
case 'HOLIDAY': return 'purple-10';
case 'SICK': return 'grey-8';
default : return 'transparent';
}
};
const getTextColor = (type: string): string => {
switch(type) {
case 'REGULAR': return 'grey-8';
case '': return 'transparent';
default: return 'white';
}
}
</script>
<template>
<q-card-section
horizontal
class="q-pa-xs text-uppercase text-center items-center"
class="q-pa-none text-uppercase text-center items-center cursor-pointer rounded-10"
style="line-height: 1;"
>
<!-- punch-in timestamps -->
<q-card-section class="q-pa-none col">
<q-item-label
v-for="shift, index in props.dayData.shifts"
:key="index"
class="text-weight-bolder text-grey-8 bg-secondary q-pa-xs rounded-5"
style="font-size: 1.75em; line-height: 80% !important;"
class="text-weight-bolder q-pa-xs rounded-5"
:class="'bg-' + getShiftColor(props.shift.type) + ' text-' + getTextColor(props.shift.type)"
style="font-size: 1.5em; line-height: 80% !important;"
>
{{ shift.start_time }}
</q-item-label>
<q-item-label v-if="props.dayData.shifts.length === 0"
class="text-weight-bolder text-grey-5"
style="font-size: 1.75em; line-height: 80% !important;"
>
---
{{ props.shift.start_time }}
</q-item-label>
</q-card-section>
<!-- arrows pointing to punch-out timestamps -->
<q-card-section
horizontal class="items-center justify-center q-ma-sm col-auto">
<q-icon
name="double_arrow"
:color=" props.dayData.shifts.length > 0 ? 'accent' : 'transparent'"
size="24px"
style="transform: translateX(5px);"
/>
<q-icon
name="double_arrow"
:color=" props.dayData.shifts.length > 0 ? 'primary' : 'transparent'"
size="24px"
style="transform: translateX(-5px);"
/>
horizontal
class="items-center justify-center q-mx-sm col"
>
<div
v-for="icon_data, index in [
{ transform: 'transform: translateX(5px);', color: 'accent' },
{ transform: 'transform: translateX(-5px);', color: 'primary' }]"
:key="index"
>
<q-icon
v-if="props.shift.type !== ''"
name="double_arrow"
:color="icon_data.color"
size="24px"
:style="icon_data.transform"
/>
</div>
</q-card-section>
<!-- punch-out timestamps -->
<q-card-section class="q-pa-none col">
<q-item-label
v-for="shift, index in props.dayData.shifts"
:key="index"
class="text-weight-bolder text-grey-8 bg-secondary q-pa-xs rounded-5"
style="font-size: 1.75em; line-height: 80% !important;"
class="text-weight-bolder text-white q-pa-xs rounded-5"
:class="'bg-' + getShiftColor(props.shift.type) + ' text-' + getTextColor(props.shift.type)"
style="font-size: 1.5em; line-height: 80% !important;"
>
{{ shift.end_time }}
</q-item-label>
<q-item-label v-if="props.dayData.shifts.length === 0"
class="text-weight-bolder text-grey-5"
style="font-size: 1.75em; line-height: 80% !important;"
>
---
{{ props.shift.end_time }}
</q-item-label>
</q-card-section>
<!-- shift type badge -->
<q-card-section class="col column q-pa-none items-center q-gutter-md">
<q-badge
v-for="shift, index in shifts_or_placeholder"
:key="index"
:color="shift.type ? getShiftColor(shift.type) : 'transparent'"
:label="shift.type || ''"
class="text-weight-medium justify-center col"
style="width: 80px; font-size: 0.8em;"
/>
</q-card-section>
<!-- comment button -->
<!-- comment and expenses buttons -->
<q-card-section
class="col-auto column q-pa-none items-end"
class="col q-pa-none text-right"
>
<q-space />
<!-- chat_bubble_outline or announcement -->
<q-btn
v-for="(shift, index) in shifts_or_placeholder"
:key="index"
v-if="props.shift.type !== ''"
flat
dense
:disable="shift.type === ''"
color='grey-8'
icon="chat_bubble_outline"
:color="shift.type === '' ? 'grey-5' : 'grey-8'"
class="q-pa-xs col"
class="q-pa-none"
/>
<!-- insert_drive_file or request_quote -->
<q-btn
v-if="props.shift.type !== ''"
flat
dense
color='grey-8'
icon="attach_money"
class="q-pa-none q-mx-xs"
/>
</q-card-section>
</q-card-section>

View File

@ -2,6 +2,7 @@
import TimesheetApprovalEmployeeDetailsShiftsRow from 'src/modules/timesheet-approval/components/timesheet-approval-employee-details-shifts-row.vue';
import TimesheetApprovalEmployeeDetailsShiftsRowHeader from 'src/modules/timesheet-approval/components/timesheet-approval-employee-details-shifts-row-header.vue';
import type { PayPeriod } from 'src/modules/shared/types/pay-period-interface';
import { default_shift, type Shift } from 'src/modules/timesheets/types/timesheet-shift-interface';
import type { PayPeriodEmployeeDetails } from 'src/modules/timesheet-approval/types/timesheet-approval-pay-period-employee-details-interface';
const props = defineProps<{
@ -9,6 +10,10 @@
currentPayPeriod: PayPeriod;
}>();
const shifts_or_placeholder = (shifts: Shift[]): Shift[] => {
return shifts.length > 0 ? shifts : [default_shift];
};
const getDate = (shift_date: string): Date => {
return new Date(props.currentPayPeriod.pay_year.toString() + '/' + shift_date);
};
@ -27,29 +32,39 @@
bordered
class="row items-center rounded-10 q-mb-xs"
>
<TimesheetApprovalEmployeeDetailsShiftsRowHeader class="absolute-top" />
<q-card-section class="col-auto q-pa-xs text-white">
<div
class="bg-primary rounded-10 q-pa-xs text-center"
style="width: 75px;"
:style="$q.screen.lt.md ? '' : 'width: 75px;'"
>
<q-item-label
style="font-size: 0.8em;"
style="font-size: 0.7em;"
class="text-uppercase"
>{{ $d(getDate(day.short_date), {weekday: 'long'}) }}</q-item-label>
>{{ $d(getDate(day.short_date), {weekday: $q.screen.lt.md ? 'short' : 'long'}) }}</q-item-label>
<q-item-label
class="text-weight-bolder"
style="font-size: 3em; line-height: 90% !important;"
style="font-size: 2.5em; line-height: 90% !important;"
>{{ day.short_date.split('/')[1] }}</q-item-label>
<q-item-label
style="font-size: 0.8em;"
style="font-size: 0.7em;"
class="text-uppercase"
>{{ $d(getDate(day.short_date), {month: 'long'}) }}</q-item-label>
>{{ $d(getDate(day.short_date), {month: $q.screen.lt.md ? 'short' : 'long'}) }}</q-item-label>
</div>
</q-card-section>
<q-card-section class="col q-pa-none">
<TimesheetApprovalEmployeeDetailsShiftsRowHeader />
<TimesheetApprovalEmployeeDetailsShiftsRow
:day-data="day"
v-for="shift, shift_index in shifts_or_placeholder(day.shifts)"
:key="shift_index"
:shift="shift"
/>
</q-card-section>
<q-card-section class="q-pr-xs col-auto">
<q-btn
push
color="primary"
icon="more_time"
class="q-pa-sm"
/>
</q-card-section>
</q-card>

View File

@ -9,6 +9,7 @@
import type { PayPeriodOverviewEmployee } from 'src/modules/timesheet-approval/types/timesheet-approval-pay-period-overview-employee-interface';
import type { PayPeriodEmployeeDetails } from '../types/timesheet-approval-pay-period-employee-details-interface';
import { PayPeriod } from 'src/modules/shared/types/pay-period-interface';
import { colors } from 'quasar';
const props = defineProps<{
isLoading: boolean;
@ -21,6 +22,53 @@ import { PayPeriod } from 'src/modules/shared/types/pay-period-interface';
const { t } = useI18n();
const is_showing_graph = ref<boolean>(true);
// case 'REGULAR': return 'green-5';
// case 'EVENING': return 'green-9';
// case 'EMERGENCY': return 'warning';
// case 'OVERTIME': return 'negative';
// case 'VACATION': return 'purple-10';
// case 'HOLIDAY': return 'purple-10';
// case 'SICK': return 'grey-9';
// default : return 'transparent';
type shiftColor = {
type: string;
color: string;
text_color?: string;
}
const shift_type_legend: shiftColor[] = [
{
type: t('timeSheet.shiftRegular'),
color: 'secondary',
text_color: 'grey-8',
},
{
type: t('timeSheet.shiftEvening'),
color: 'warning',
},
{
type: t('timeSheet.shiftEmergency'),
color: 'amber-10',
},
{
type: t('timeSheetValidations.hoursWorkedOvertime'),
color: 'negative',
},
{
type: t('timeSheet.shiftVacation'),
color: 'purple-10',
},
{
type: t('timeSheet.shiftHoliday'),
color: 'purple-8',
},
{
type: t('timeSheet.shiftSick'),
color: 'grey-8',
},
]
</script>
<template>
@ -51,7 +99,7 @@ import { PayPeriod } from 'src/modules/shared/types/pay-period-interface';
{{ props.employeeName }}
<q-separator class="q-mb-sm" color="accent" size="2px" />
<q-card-actions align="center">
<q-card-actions align="center" class="q-pa-none">
<q-card flat class="bg-secondary rounded-5 q-pa-xs">
<q-btn-toggle
color="white"
@ -70,13 +118,30 @@ import { PayPeriod } from 'src/modules/shared/types/pay-period-interface';
<!-- employee timesheet details edit -->
<q-card-section
v-if="!props.isLoading && !is_showing_graph"
:horizontal="$q.screen.gt.sm"
class="q-pa-none bg-secondary rounded-10"
class="q-pa-none"
>
<TimesheetApprovalEmployeeDetailsShifts
:raw-data="props.employeeDetails"
:current-pay-period="props.currentPayPeriod"
/>
<!-- shift type color legend -->
<q-card-section class="q-py-xs q-px-none text-center q-my-s">
<q-badge
v-for="shift_type in shift_type_legend"
:color="shift_type.color"
:label="shift_type.type"
:text-color="shift_type.text_color || 'white'"
class="q-px-md q-py-xs q-mx-xs q-my-none text-uppercase text-weight-bolder justify-center"
style="width: 120px; font-size: 0.8em;"
/>
</q-card-section>
<!-- list of shifts, broken down into weekly columns -->
<q-card-section
:horizontal="$q.screen.gt.sm"
class="q-pa-none bg-secondary rounded-10"
>
<TimesheetApprovalEmployeeDetailsShifts
:raw-data="props.employeeDetails"
:current-pay-period="props.currentPayPeriod"
/>
</q-card-section>
</q-card-section>
<!-- employee timesheet details, but look at these graphs -->
@ -84,32 +149,6 @@ import { PayPeriod } from 'src/modules/shared/types/pay-period-interface';
<q-card-section class="q-pa-none col no-wrap" style="min-height: 300px;">
<TimesheetApprovalEmployeeDetailsHoursWorkedChart
:raw-data="props.employeeDetails"
:options="({
indexAxis: $q.screen.lt.md? 'y' : 'x',
plugins: {
legend: {
labels: {
boxWidth: 15,
},
},
title: {
display: true,
text: t('timeSheetValidations.hoursWorkedChartTitle'),
color: '#616161'
}
},
scales: {
x: {
stacked: true,
},
y: {
stacked: true,
suggestedMin: 0,
suggestedMax: 10,
}
}
})"
/>
</q-card-section>
@ -122,15 +161,6 @@ import { PayPeriod } from 'src/modules/shared/types/pay-period-interface';
<q-card-section class="q-pa-none q-ma-none col-4">
<TimesheetApprovalEmployeeDetailsShiftTypesChart
:raw-data="props.employeeOverview"
:options="({
plugins:{
legend:{
labels:{
boxWidth: 15,
}
}
}
})"
/>
</q-card-section>
@ -138,33 +168,7 @@ import { PayPeriod } from 'src/modules/shared/types/pay-period-interface';
<q-card-section class="q-pa-none q-ma-none col" :class="$q.screen.lt.md ? 'full-width' : ''">
<TimesheetApprovalEmployeeExpensesChart
:style="$q.screen.lt.md ? 'min-height: 300px;': '' "
:raw-data="props.employeeDetails"
:options="({
indexAxis: $q.screen.lt.md? 'y' : 'x',
plugins: {
title: {
display: true,
text: t('timeSheetValidations.reportFilterExpenses'),
color: '#616161'
},
legend:{
labels:{
boxWidth: 15,
}
}
},
scales: {
x: {
stacked: true
},
y: {
suggestedMin: 0,
suggestedMax: 100,
stacked: true
}
}
})"
/>
</q-card-section>
</q-card-section>

View File

@ -8,8 +8,8 @@ export interface Shift {
export const default_shift: Shift = {
date: '',
start_time: '',
end_time: '',
start_time: '--:--',
end_time: '--:--',
type: '',
is_approved: false,
}