Merge pull request 'fix(approvals): add more functionality and ui fixes to list view, add weekly breakdown hours, ui adjustments to card view' (#43) from dev/nicolas/staging-prep into main

Reviewed-on: Targo/targo_frontend#43
This commit is contained in:
Nicolas 2026-01-09 07:38:26 -05:00
commit 78d70f8fe4
21 changed files with 513 additions and 264 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 618 KiB

After

Width:  |  Height:  |  Size: 90 KiB

View File

@ -353,8 +353,20 @@ export default {
verified: "approved",
unverified: "pending",
inactive: "inactive",
regular: "regular",
evening: "evening",
emergency: "emergency",
overtime: "overtime",
holiday: "holiday",
vacation: "vacation",
sick: "sick",
remote: "remote work",
weekly_hours_1: "1st week hours",
weekly_hours_2: "2nd week hours",
total_hours: "total hours",
filter_active: "show only active employees",
filter_team: "show my team only",
filter_columns: "Information displayed",
},
tooltip: {
button_detailed_view: "detailed view",

View File

@ -354,8 +354,20 @@ export default {
verified: "approuvé",
unverified: "à vérifier",
inactive: "inactif",
regular: "régulier",
evening: "soir",
emergency: "urgence",
overtime: "supplémentaire",
holiday: "férié",
vacation: "vacances",
sick: "maladie",
remote: "télétravail",
weekly_hours_1: "heures semaine 1",
weekly_hours_2: "heures semaine 2",
total_hours: "heures totales",
filter_active: "montrer les employés inactifs",
filter_team: "montrer mon équipe seulement",
filter_columns: "informations affichés",
},
tooltip: {
button_detailed_view: "vue détaillée",

View File

@ -1,7 +1,20 @@
<script
setup
lang="ts"
>
const CREATE_YEAR = 2025;
const today = new Date();
</script>
<template>
<q-footer elevated class="bg-primary text-white">
<q-toolbar>
<q-toolbar-title>© 2025 Targo Communications inc.</q-toolbar-title>
</q-toolbar>
</q-footer>
<q-footer
elevated
class="bg-primary text-white"
>
<div class="q-px-md q-py-xs full-width text-right">
<span class="text-weight-light text-caption text-uppercase">
© {{ CREATE_YEAR }} - {{ today.getFullYear() }} Targo Communications inc.
</span>
</div>
</q-footer>
</template>

View File

@ -76,7 +76,7 @@
rounded
disabled
type="submit"
color="accent"
color="grey-5"
:label="$t('login.button.connect')"
class="full-width q-mt-lg"
/>
@ -108,7 +108,7 @@
rounded
push
disabled
color="fb-blue"
color="blue-grey-7"
icon="img:src/assets/Facebook-f_Logo-White-Logo.wine.svg"
:label="$t('login.button.facebook')"
class="full-width row q-mb-sm"

View File

@ -2,8 +2,9 @@
setup
lang="ts"
>
/* eslint-disable */
import { date, useQuasar } from 'quasar';
import { computed } from 'vue';
import { computed, onMounted, onUpdated, ref } from 'vue';
const { title, startDate = "", endDate = "" } = defineProps<{
title: string;
@ -13,17 +14,36 @@
const q = useQuasar();
const emit = defineEmits<{ 'onGetComponentHeight': [value: number] }>();
const selfRef = ref<HTMLElement | null>(null);
const date_format_options = computed(() => q.platform.is.mobile ? { day: 'numeric', month: 'short', year: 'numeric' } : { day: 'numeric', month: 'long', year: 'numeric', });
onUpdated(() => {
if (selfRef.value) {
emit('onGetComponentHeight', selfRef.value.offsetHeight);
}
});
onMounted(() => {
if (selfRef.value) {
emit('onGetComponentHeight', selfRef.value.offsetHeight);
}
})
</script>
<template>
<div class="column text-uppercase text-center text-weight-bolder text-h4">
<span
<div
ref="selfRef"
class="column text-uppercase text-center text-weight-bolder text-h4 q-pt-md"
>
<!-- <span
v-if="!$q.platform.is.mobile"
class="col q-pt-lg"
>
{{ $t(title) }}
</span>
</span> -->
<transition
enter-active-class="animated fadeInDown"

View File

@ -5,17 +5,17 @@
<template>
<q-input
v-model="search_model"
outlined
:dark="false"
dense
outlined
rounded
debounce="300"
:label="$t('shared.label.search')"
color="accent"
bg-color="white"
label-color="accent"
class="text-primary"
>
<template #prepend>
<template #append>
<q-icon
name="search"
color="accent"

View File

@ -2,34 +2,62 @@
setup
lang="ts"
>
import type { PayPeriodOverviewFilters } from 'src/modules/timesheet-approval/models/timesheet-overview.models';
import { overview_column_names, type OverviewColumns, type PayPeriodOverviewFilters } from 'src/modules/timesheet-approval/models/timesheet-overview.models';
import { onMounted, ref } from 'vue';
const filters = defineModel<PayPeriodOverviewFilters>('filters', { required: true })
const filters = defineModel<PayPeriodOverviewFilters>('filters', { required: true });
const visible_columns = defineModel<OverviewColumns[]>('visibleColumns', {required: true});
const column_options = ref<{ label: string, value: OverviewColumns }[]>([]);
const EXCLUDED_COLUMNS: OverviewColumns[] = ['employee_last_name', 'employee_first_name', 'is_active']
onMounted(() => {
Object.values(overview_column_names).map(column => column_options.value.push({ label: `timesheet_approvals.table.${column}`, value: column as OverviewColumns }))
column_options.value = column_options.value.filter(column => !EXCLUDED_COLUMNS.includes(column.value));
console.log('filter column values: ', column_options.value )
})
</script>
<template>
<div class="column bg-primary text-uppercase">
<div class="column bg-primary text-uppercase q-px-sm text-white">
<div class="col row">
<q-checkbox
v-model="filters.is_showing_inactive"
keep-color
size="lg"
color="accent"
:label="$t('timesheet_approvals.table.filter_active')"
class="col"
class="col-auto"
:class="filters.is_showing_inactive ? 'text-accent text-weight-bolder' : 'text-white text-weight-medium'"
/>
<q-checkbox
v-model="filters.is_showing_team_only"
keep-color
size="lg"
val="team"
color="accent"
:label="$t('timesheet_approvals.table.filter_team')"
class="col"
class="col-auto q-px-sm"
:class="filters.is_showing_team_only ? 'text-accent text-weight-bolder' : 'text-white text-weight-medium'"
/>
</div>
<span class="col-auto q-px-md q-pt-sm text-h6 text-bold">
{{ $t('timesheet_approvals.table.filter_columns') }}
</span>
<div class="col row">
<q-option-group
v-model="visible_columns"
:options="column_options"
inline
keep-color
color="accent"
type="checkbox"
>
<template #label="scope">
<span class="text-caption text-uppercase text-weight-light">{{ $t(scope.label.toLowerCase()) }}</span>
</template>
</q-option-group>
</div>
</div>
</template>

View File

@ -3,7 +3,7 @@
lang="ts"
>
import type { TimesheetApprovalOverview } from 'src/modules/timesheet-approval/models/timesheet-overview.models';
import { getHoursMinutesStringFromHoursFloat, getMinutes } from 'src/utils/date-and-time-utils';
import { getHoursMinutesStringFromHoursFloat } from 'src/utils/date-and-time-utils';
const modelApproval = defineModel<boolean>();
@ -82,7 +82,7 @@
> {{
$t('shared.shift_type.regular') }} </span>
<span
class="text-weight-bolder text-h3 q-py-none"
class="text-weight-bolder text-h4 q-py-none"
:class="row.regular_hours > 80 || !row.is_active ? 'text-negative' : ''"
> {{ getHoursMinutesStringFromHoursFloat(row.regular_hours) }} </span>
<q-separator class="q-mr-sm" />
@ -94,7 +94,7 @@
v-for="hour_type, index in row.other_hours"
:key="index"
class="col-4 column ellipsis"
:class="hour_type === 0 ? 'invisible' : ''"
:class="hour_type === 0 ? 'invisible order-last' : 'order-first'"
>
<span
class="text-weight-bold text-accent text-uppercase q-pa-none q-my-none"
@ -102,10 +102,13 @@
>
{{ $t(`shared.shift_type.${index.replace('_hours', '')}`) }}
</span>
<span
class="text-weight-bolder q-pa-none q-mb-xs"
style="font-size: 1.2em; line-height: 1em;"
> {{ getHoursMinutesStringFromHoursFloat(hour_type) }} </span>
>
{{ getHoursMinutesStringFromHoursFloat(hour_type) }}
</span>
</div>
</div>
</div>
@ -144,20 +147,47 @@
<!-- Validate Pay Period section -->
<q-card-section
horizontal
class="justify-between items-center text-weight-bold q-pa-none"
class="justify-between items-center q-pa-none"
:class="row.is_active ? (row.is_approved ? 'text-white bg-accent' : 'bg-dark') : 'bg-transparent'"
>
<!-- weekly totals -->
<q-separator />
<div
v-if="row.is_active"
class="col row full-width justify-between"
>
<div
v-for="weekly_hours, index in row.weekly_hours"
:key="index"
class="col q-px-sm text-uppercase"
>
<span class="text-weight-bold">
{{ $t('timesheet.week') }} {{ index + 1 }}
</span>
<span
class="q-ml-sm"
:class="row.total_hours > 80 ? 'bg-negative q-px-sm rounded-5 text-white' : ''"
>
{{ getHoursMinutesStringFromHoursFloat(weekly_hours) }}
</span>
</div>
</div>
<q-separator />
<!-- overall totals and approve all button -->
<div
v-if="row.is_active"
class="col row full-width"
>
<div class="col text-uppercase">
<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>
<span class="text-h6 q-ml-sm text-weight-bolder">Total</span>
<span
class="q-ml-sm text-h6"
:class="row.total_hours > 80 ? 'bg-negative q-px-sm rounded-5 text-white' : 'text-weight-light'"
>
{{ getHoursMinutesStringFromHoursFloat(row.total_hours) }}
</span>
</div>
<div class="col-auto q-py-xs q-px-md">

View File

@ -13,15 +13,19 @@
import { useAuthStore } from 'src/stores/auth-store';
import { useTimesheetStore } from 'src/stores/timesheet-store';
import { useTimesheetApprovalApi } from 'src/modules/timesheet-approval/composables/use-timesheet-approval-api';
import { overview_column_names, OverviewColumns, pay_period_overview_columns, PayPeriodOverviewFilters, type TimesheetApprovalOverview } from 'src/modules/timesheet-approval/models/timesheet-overview.models';
import { type OverviewColumns, pay_period_overview_columns, PayPeriodOverviewFilters, type TimesheetApprovalOverview } from 'src/modules/timesheet-approval/models/timesheet-overview.models';
import { getHoursMinutesStringFromHoursFloat } from 'src/utils/date-and-time-utils';
import { useUiStore } from 'src/stores/ui-store';
const WARNING_COLUMNS: OverviewColumns[] = ['EMERGENCY', 'EVENING', 'HOLIDAY', 'VACATION', 'SICK']
const NEGATIVE_COLUMNS: OverviewColumns[] = ['OVERTIME',]
const ui_store = useUiStore();
const auth_store = useAuthStore();
const timesheet_store = useTimesheetStore();
const timesheet_approval_api = useTimesheetApprovalApi();
const TIME_COLUMNS: OverviewColumns[] = ['REGULAR', 'EVENING', 'EMERGENCY', 'OVERTIME', 'HOLIDAY', 'VACATION'];
const TIME_COLUMNS: OverviewColumns[] = ['REGULAR', 'EVENING', 'EMERGENCY', 'OVERTIME', 'HOLIDAY', 'VACATION', 'SICK'];
const VISIBLE_COLUMNS = ref<OverviewColumns[]>([
'employee_first_name',
'REGULAR',
@ -32,6 +36,9 @@
'VACATION',
'expenses',
'mileage',
'weekly_hours_1',
'weekly_hours_2',
'total_hours',
'is_approved',
]);
@ -74,229 +81,276 @@
if (terms.name_search_string.length > 0) {
const search_words = terms.name_search_string.trim().split(' ');
search_words.map(word => result = result.filter(row =>
row.employee_first_name.includes(word ?? '') || row.employee_last_name.includes(word ?? '')
row.employee_first_name.toLowerCase().includes(word.toLowerCase() ?? '') || row.employee_last_name.toLowerCase().includes(word ?? '')
));
}
return result;
};
const getListViewTimeClass = (column_name: OverviewColumns, value: number) => {
if(WARNING_COLUMNS.includes(column_name) && value > 0)
return 'bg-warning text-white rounded-5';
if(NEGATIVE_COLUMNS.includes(column_name) && value > 0)
return 'bg-negative text-white text-bold rounded-5';
}
</script>
<template>
<div class="full-width">
<LoadingOverlay v-model="timesheet_store.is_loading" />
<q-table
dense
row-key="email"
color="accent"
hide-pagination
:rows="overview_rows"
:columns="pay_period_overview_columns"
:visible-columns="VISIBLE_COLUMNS"
:grid="timesheet_store.is_approval_grid_mode"
:pagination="{ sortBy: 'is_active' }"
:filter="overview_filters"
:filter-method="filterEmployeeRows"
:rows-per-page-options="[0]"
class="bg-transparent"
:class="timesheet_store.is_approval_grid_mode ? '' : 'sticky-header-table no-shadow'"
card-container-class="justify-center"
table-class="q-pa-none q-mx-md rounded-10 bg-dark shadow-15 hide-scrollbar"
:no-data-label="$t('shared.error.no_data_found')"
:no-results-label="$t('shared.error.no_search_results')"
:loading-label="$t('shared.label.loading')"
:style="overview_rows.length > 0 ? `max-height: ${maxHeight - (timesheet_store.is_approval_grid_mode ? 0 : 20)}px;` : ''"
@row-click="(_evt, row: TimesheetApprovalOverview) => onClickedDetails(row)"
>
<template #top>
<div class="column full-width">
<q-table
dense
row-key="email"
color="accent"
hide-pagination
:rows="overview_rows"
:columns="pay_period_overview_columns"
:table-colspan="pay_period_overview_columns.length"
:visible-columns="VISIBLE_COLUMNS"
:grid="ui_store.user_preferences.is_timesheet_approval_grid"
:pagination="{ sortBy: 'is_active' }"
:filter="overview_filters"
:filter-method="filterEmployeeRows"
:rows-per-page-options="[0]"
class="bg-transparent"
:class="ui_store.user_preferences.is_timesheet_approval_grid ? '' : 'sticky-header-table no-shadow'"
card-container-class="justify-center"
table-class="q-pa-none q-mx-md rounded-10 bg-dark shadow-15 hide-scrollbar"
:no-data-label="$t('shared.error.no_data_found')"
:no-results-label="$t('shared.error.no_search_results')"
:loading-label="$t('shared.label.loading')"
table-header-style="min-width: 80xp; max-width: 80px;"
:style="overview_rows.length > 0 ? `max-height: ${maxHeight - (ui_store.user_preferences.is_timesheet_approval_grid ? 0 : 20)}px;` : ''"
:table-style="{ tableLayout: 'fixed'}"
@row-click="(_evt, row: TimesheetApprovalOverview) => onClickedDetails(row)"
>
<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'"
>
<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 items-start full-width q-px-lg"
:class="$q.platform.is.mobile ? 'column flex-center' : 'row q-mt-md'"
class="col-auto row no-wrap items-start"
:class="$q.platform.is.mobile ? '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' : ''"
<q-btn-toggle
v-model="ui_store.user_preferences.is_timesheet_approval_grid"
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-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="overview_filters.name_search_string"
class="col-auto q-mb-sm"
/>
<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-primary"
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-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"
/>
</q-slide-transition>
<q-separator
color="primary"
size="5px"
class="q-mx-lg q-my-none q-pa-none"
/>
<QTableFilters
v-model:search="overview_filters.name_search_string"
class="col-auto q-mb-sm"
/>
<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-primary"
style="border-radius: 5px 5px 0 0;"
@click="is_showing_filters = !is_showing_filters"
/>
</div>
</div>
</template>
<template #header="props">
<q-tr
:props="props"
class="bg-primary"
>
<q-th
v-for="col in props.cols"
:key="col.name"
:props="props"
>
<span class="text-uppercase text-weight-bolder text-white">
{{ $t(col.label) }}
</span>
</q-th>
</q-tr>
</template>
<q-slide-transition>
<OverviewListFilters
v-if="is_showing_filters"
v-model:filters="overview_filters"
v-model:visible-columns="VISIBLE_COLUMNS"
class="q-mx-lg col-auto"
/>
</q-slide-transition>
<template #body-cell="props">
<q-td
<q-separator
color="primary"
size="5px"
class="q-mx-lg q-my-none q-pa-none"
/>
</div>
</template>
<template #header="props">
<q-tr
:props="props"
class="bg-primary"
>
<q-th
v-for="col in props.cols"
:key="col.name"
:props="props"
class="text-weight-medium"
>
<transition
appear
enter-active-class="animated fadeInUp slow"
leave-active-class="animated fadeOutDown"
mode="out-in"
<span class="text-uppercase text-weight-bolder text-white">
{{ $t(col.label) }}
</span>
<span
v-if="col.name.includes('weekly_hours')"
class="q-ml-sm text-uppercase text-weight-bolder text-white"
>
<div
:key="props.rowIndex + (timesheet_store.pay_period?.pay_period_no ?? 0)"
class="rounded-5"
style="font-size: 1.2em;"
:style="`animation-delay: ${props.rowIndex / 15}s; opacity: ${props.row.is_active ? '1' : '0.5'};`"
{{ col.name.slice(-1,) }}
</span>
</q-th>
</q-tr>
</template>
<template #body-cell="props">
<q-td
:props="props"
:class="props.rowIndex % 2 === 0 ? ($q.dark.isActive ? 'bg-primary' : 'bg-secondary') : ''"
>
<transition
appear
enter-active-class="animated fadeInUp"
leave-active-class="animated fadeOutDown"
mode="out-in"
>
<div
:key="props.rowIndex + (timesheet_store.pay_period?.pay_period_no ?? 0)"
class="rounded-5"
:style="`animation-delay: ${props.rowIndex / 15}s; opacity: ${props.row.is_active ? '1' : '0.5'};`"
>
<!-- button to toggle approval status -->
<transition
v-if="props.col.name === 'is_approved'"
enter-active-class="animated swing"
mode="out-in"
>
<transition
v-if="props.col.name === 'is_approved'"
enter-active-class="animated swing"
mode="out-in"
>
<q-btn
:key="props.row.is_approved"
flat
dense
:icon="props.value ? 'lock' : 'lock_open'"
:color="props.value ? 'white' : 'grey-5'"
class="rounded-5 "
:class="props.value ? (props.row.is_active ? 'bg-accent' : 'bg-negative') : ''"
@click.stop="onClickApproveAll(props.row.email, !props.row.is_approved)"
/>
</transition>
<div v-else-if="props.col.name === 'employee_first_name'">
<span
class="text-h5 text-uppercase q-mr-xs"
:class="props.row.is_active ? 'text-accent' : 'text-negative'"
>
{{ props.value }}
</span>
<span class="text-uppercase text-weight-light">
{{ props.row.employee_last_name }}
</span>
</div>
<q-btn
:key="props.row.is_approved"
flat
dense
:icon="props.value ? 'lock' : 'lock_open'"
:color="props.value ? 'white' : 'grey-5'"
class="rounded-5 "
:class="props.value ? (props.row.is_active ? 'bg-accent' : 'bg-negative') : ''"
@click.stop="onClickApproveAll(props.row.email, !props.row.is_approved)"
/>
</transition>
<!-- display full employee name with large first name and smaller last name -->
<div v-else-if="props.col.name === 'employee_first_name'">
<span
v-else
:class="props.col.name === overview_column_names.REGULAR && props.row.overtime > 0 ? 'text-negative text-weight-bolder' : 'text-weight-regular'"
class="text-h5 text-uppercase q-mr-xs"
:class="props.row.is_active ? 'text-accent' : 'text-negative'"
>
{{ TIME_COLUMNS.includes(props.col.name) ?
getHoursMinutesStringFromHoursFloat(props.value) : props.value }}
{{ props.value }}
</span>
<span class="text-uppercase text-weight-light">
{{ props.row.employee_last_name }}
</span>
</div>
</transition>
</q-td>
</template>
<!-- Template for individual employee cards -->
<template #item="props: { row: TimesheetApprovalOverview, rowIndex: number }">
<OverviewListItem
v-model="props.row.is_approved"
:key="props.row.email + timesheet_store.pay_period?.pay_period_no"
:index="props.rowIndex"
:row="props.row"
@click-details="onClickedDetails"
@click-approval-all="is_approved => onClickApproveAll(props.row.email, is_approved)"
<!-- display weekly total hours (regular, vacation, holiday, emergency, evening) -->
<div
v-else-if="props.col.name.includes('weekly_hours')"
class="q-px-xs"
:class="props.value[Number(props.col.name.slice(-1,)) - 1] > 40 ? 'bg-negative text-white rounded-5' : ''"
>
<span>
{{
getHoursMinutesStringFromHoursFloat((props.value[Number(props.col.name.slice(-1,)) -
1]) ?? 0) }}
</span>
</div>
<!-- display total worked hours (regular, vacation, holiday, emergency, evening) -->
<div
v-else-if="props.col.name === 'total_hours'"
class="q-px-xs"
:class="props.value > 80 ? 'bg-negative rounded-5 text-white' : ''"
>
<span>{{ getHoursMinutesStringFromHoursFloat(props.value) }}</span>
</div>
<!-- any other fields, though time fields will have their own conditional class to highlight abnormalities -->
<div
v-else
class="q-px-xs"
:class="getListViewTimeClass(props.col.name, props.value)"
>
{{ TIME_COLUMNS.includes(props.col.name) ?
getHoursMinutesStringFromHoursFloat(props.value) : props.value }}
</div>
</div>
</transition>
</q-td>
</template>
<!-- Template for individual employee cards -->
<template #item="props: { row: TimesheetApprovalOverview, rowIndex: number }">
<OverviewListItem
v-model="props.row.is_approved"
:key="props.row.email + timesheet_store.pay_period?.pay_period_no"
:index="props.rowIndex"
:row="props.row"
@click-details="onClickedDetails"
@click-approval-all="is_approved => onClickApproveAll(props.row.email, is_approved)"
/>
</template>
<!-- Template for custome failed-to-load state -->
<template #no-data="{ message, filter }">
<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'"
/>
</template>
<!-- Template for custome failed-to-load state -->
<template #no-data="{ message, filter }">
<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'"
/>
<span class="text-h6">
{{ message }}
</span>
</div>
</template>
</q-table>
<span class="text-h6">
{{ message }}
</span>
</div>
</template>
</q-table>
</div>
</template>
<style lang="sass">
<style lang="sass" scoped>
.sticky-header-table
thead tr:first-child th
background-color: var(--q-primary)
background-color: var(--q-accent)
margin-top: none
thead tr th
@ -313,4 +367,7 @@
.q-table__grid-content
overflow: auto
td
min-width: 80px
</style>

View File

@ -19,6 +19,7 @@ export class TimesheetApprovalOverview {
holiday_hours: number;
vacation_hours: number;
};
weekly_hours: number[];
total_hours: number;
expenses: number;
mileage: number;
@ -39,6 +40,7 @@ export class TimesheetApprovalOverview {
holiday_hours: 0,
vacation_hours: 0,
}
this.weekly_hours = [0];
this.total_hours = 0;
this.expenses = 0;
this.mileage = 0;
@ -63,7 +65,7 @@ export interface PayPeriodOverviewFilters {
name_search_string: string;
}
export type OverviewColumns = 'employee_first_name' | 'employee_last_name' | 'email' | 'REGULAR' | 'EVENING' | 'EMERGENCY' | 'SICK' | 'HOLIDAY' | 'VACATION' | 'OVERTIME' | 'expenses' | 'mileage' | 'is_approved' | 'is_active'
export type OverviewColumns = 'employee_first_name' | 'employee_last_name' | 'email' | 'REGULAR' | 'EVENING' | 'EMERGENCY' | 'SICK' | 'HOLIDAY' | 'VACATION' | 'OVERTIME' | 'expenses' | 'mileage' | 'total_hours' | 'weekly_hours_1' | 'weekly_hours_2' | 'is_approved' | 'is_active'
export const overview_column_names = {
FIRST_NAME: 'employee_first_name',
@ -78,6 +80,9 @@ export const overview_column_names = {
OVERTIME: 'OVERTIME',
EXPENSES: 'expenses',
MILEAGE: 'mileage',
WEEKLY_HOURS_1: 'weekly_hours_1',
WEEKLY_HOURS_2: 'weekly_hours_2',
TOTAL_HOURS: 'total_hours',
IS_APPROVED: 'is_approved',
IS_ACTIVE: 'is_active',
}
@ -90,12 +95,13 @@ export const pay_period_overview_columns: QTableColumn[] = [
field: overview_column_names.FIRST_NAME,
sortable: true,
required: true,
style: 'width: 10vw;'
},
{
name: overview_column_names.LAST_NAME,
label: 'timesheet_approvals.table.full_name',
align: 'left',
field: 'employee_last_name',
field: '',
sortable: true,
},
{
@ -104,6 +110,7 @@ export const pay_period_overview_columns: QTableColumn[] = [
align: 'left',
field: overview_column_names.EMAIL,
sortable: true,
style: 'width: 10vw;'
},
{
name: overview_column_names.REGULAR,
@ -168,6 +175,27 @@ export const pay_period_overview_columns: QTableColumn[] = [
field: 'mileage',
sortable: true,
},
{
name: overview_column_names.WEEKLY_HOURS_1,
label: 'timesheet.week',
align: 'left',
field: 'weekly_hours',
sortable: true,
},
{
name: overview_column_names.WEEKLY_HOURS_2,
label: 'timesheet.week',
align: 'left',
field: 'weekly_hours',
sortable: true,
},
{
name: overview_column_names.TOTAL_HOURS,
label: 'timesheet_approvals.table.total_hours',
align: 'left',
field: 'total_hours',
sortable: true,
},
{
name: overview_column_names.IS_APPROVED,
label: 'timesheet_approvals.table.is_approved',

View File

@ -4,7 +4,7 @@
>
import { date } from 'quasar';
import { useI18n } from 'vue-i18n';
import { computed, ref } from 'vue';
import { computed, onMounted, ref } from 'vue';
import { useUiStore } from 'src/stores/ui-store';
import { useExpensesStore } from 'src/stores/expense-store';
import { useTimesheetStore } from 'src/stores/timesheet-store';
@ -40,7 +40,7 @@
{ label: t('timesheet.expense.types.ON_CALL'), value: 'ON_CALL', icon: getExpenseIcon('ON_CALL') },
]
const expense_selected = ref(expense_options.find(expense => expense.value == expenses_store.current_expense.type));
const expense_selected = ref<ExpenseOption | undefined>();
const openDatePicker = () => {
is_navigator_open.value = true;
@ -61,6 +61,10 @@
expenses_store.mode = 'create';
expenses_store.current_expense = new Expense(date.formatDate(new Date(), 'YYYY-MM-DD'));
};
onMounted(() => {
expense_selected.value = expense_options.find(expense => expense.value === expenses_store.current_expense.type);
})
</script>
<template>

View File

@ -73,10 +73,19 @@
<div class="col-auto">
<q-icon
:name="getExpenseIcon(expense.type)"
:color="expense.is_approved ? 'white' : ($q.dark.isActive ? 'white' : 'primary')"
:color="expense.is_approved ? 'white' : ($q.dark.isActive ? 'white' : 'blue-grey-8')"
size="lg"
class="q-pr-md"
/>
<q-tooltip
anchor="top middle"
self="center middle"
:offset="[0, 20]"
class="bg-accent text-uppercase text-weight-bold"
>
{{ $t(`timesheet.expense.types.${expense.type}`) }}
</q-tooltip>
</div>
<!-- amount or mileage section -->
@ -118,18 +127,27 @@
</div>
<!-- comment section -->
<div class="col column">
<div class="col column no-wrap">
<span class="col-auto text-weight-bold text-accent text-uppercase text-caption">
{{ $t('timesheet.expense.employee_comment') }}
</span>
<span
class="col"
class="col ellipsis"
:class="expense.is_approved ? ' bg-accent text-white' : ''"
style="font-size: 1.3em;"
style="font-size: 1em;"
>
{{ expense.comment }}
</span>
<q-tooltip
anchor="top middle"
self="center middle"
:offset="[0, 20]"
class="bg-accent text-uppercase text-weight-bold"
>
{{ expense.comment }}
</q-tooltip>
</div>
<!-- supervisor comment section -->
@ -167,7 +185,8 @@
:offset="[0, 20]"
class="bg-accent text-uppercase text-weight-bold"
>
{{ expense.is_approved ? $t('timesheet_approvals.tooltip.unapprove') : $t('timesheet_approvals.tooltip.approve') }}
{{ expense.is_approved ? $t('timesheet_approvals.tooltip.unapprove') :
$t('timesheet_approvals.tooltip.approve') }}
</q-tooltip>
</q-btn>

View File

@ -39,6 +39,14 @@
}
}
const onBlurShiftTypeSelect = () => {
if (shift_type_selected.value === undefined) {
shift.value.type = 'REGULAR';
shift.value.id = 0;
emit('requestDelete');
}
};
const getCommentCounterColor = (comment_length: number) => {
if (comment_length < 200) return 'primary';
if (comment_length < 250) return 'warning';
@ -66,7 +74,7 @@
>
<!-- shift type -->
<q-select
ref="select"
ref="select_ref"
v-model="shift_type_selected"
:standout="$q.dark.isActive ? 'bg-blue-grey-3' : 'bg-blue-grey-9'"
dense
@ -83,6 +91,7 @@
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">

View File

@ -4,14 +4,11 @@
>
import ShiftList from 'src/modules/timesheets/components/shift-list.vue';
import { useQuasar } from 'quasar';
import { computed, ref, watch } from 'vue';
import { computed, ref } from 'vue';
import { useTimesheetStore } from 'src/stores/timesheet-store';
import { useTimesheetApi } from 'src/modules/timesheets/composables/use-timesheet-api';
import type { QScrollArea, TouchSwipeValue } from 'quasar';
const q = useQuasar();
const timesheet_store = useTimesheetStore();
const timesheet_api = useTimesheetApi();
@ -22,8 +19,6 @@
const mobile_animation_direction = ref('fadeInLeft');
const timesheet_page = ref<QScrollArea | null>(null);
const currentDayComponent = ref<HTMLElement[] | null>(null);
const currentDayComponentWatcher = ref(currentDayComponent);
const scroll_y = computed(() => timesheet_page.value?.getScrollPosition().top ?? 0);
@ -34,12 +29,10 @@
}
};
watch(currentDayComponentWatcher, () => {
if (currentDayComponent.value && timesheet_page.value && q.platform.is.mobile) {
timesheet_page.value.setScrollPosition('vertical', currentDayComponent.value[0]!.offsetTop, 800);
return;
}
})
const onTodayComponentFound = (today_component: HTMLElement | undefined) => {
if (timesheet_page.value && today_component)
timesheet_page.value.setScrollPosition('vertical', today_component.offsetTop, 800);
}
</script>
<template>
@ -73,7 +66,7 @@
</div>
<!-- Else show timesheets if found -->
<ShiftList />
<ShiftList @on-current-day-component-found="onTodayComponentFound" />
</q-scroll-area>
<q-page-sticky

View File

@ -5,27 +5,34 @@
import ShiftListDay from 'src/modules/timesheets/components/shift-list-day.vue';
import ShiftListDateWidget from 'src/modules/timesheets/components/shift-list-date-widget.vue';
import { ref, computed } from 'vue';
import { date, useQuasar } from 'quasar';
import { ref, computed, watch } from 'vue';
import { useUiStore } from 'src/stores/ui-store';
import { useTimesheetStore } from 'src/stores/timesheet-store';
import { Shift } from 'src/modules/timesheets/models/shift.models';
import { useTimesheetApi } from 'src/modules/timesheets/composables/use-timesheet-api';
import type { TimesheetDay } from 'src/modules/timesheets/models/timesheet.models';
import { date } from 'quasar';
const CURRENT_DATE_STRING = new Date().toISOString().slice(0, 10);
const { extractDate } = date;
const q = useQuasar();
const ui_store = useUiStore();
const timesheet_store = useTimesheetStore();
const timesheet_api = useTimesheetApi();
const mobile_animation_direction = ref('fadeInLeft');
const currentDayComponent = ref<HTMLElement[] | null>(null);
const currentDayComponentWatcher = ref(currentDayComponent);
const animation_style = computed(() => ui_store.is_mobile_mode ? mobile_animation_direction.value : 'fadeInDown');
const emit = defineEmits<{
'onCurrentDayComponentFound': [component: HTMLElement | undefined];
}>();
const addNewShift = (day_shifts: Shift[], date: string, timesheet_id: number) => {
ui_store.focus_next_component = true;
const new_shift = new Shift;
@ -50,6 +57,12 @@
const getMobileDayRef = (iso_date_string: string): string => {
return iso_date_string === CURRENT_DATE_STRING ? 'currentDayComponent' : '';
};
watch(currentDayComponentWatcher, () => {
if (currentDayComponent.value && q.platform.is.mobile) {
emit('onCurrentDayComponentFound', currentDayComponent.value[0])
}
})
</script>
<template>

View File

@ -3,11 +3,27 @@
lang="ts"
>
import { useShiftStore } from 'src/stores/shift-store';
import { computed } from 'vue';
import { computed, ref } from 'vue';
const shift_store = useShiftStore();
const is_counting_error_display_time = ref(false);
const is_showing_errors = computed(() => shift_store.shift_errors.length > 0);
const is_showing_errors = computed(() => {
if (shift_store.shift_errors.length > 0 && !is_counting_error_display_time.value) {
onShowingErrorMessage();
}
return shift_store.shift_errors.length > 0;
});
const onShowingErrorMessage = () => {
is_counting_error_display_time.value = true;
setTimeout(() => {
is_counting_error_display_time.value = false
shift_store.shift_errors = [];
}, 5000);
};
</script>
<template>

View File

@ -12,7 +12,7 @@ import { useRouter } from 'vue-router';
<div class=" column justify-center" :class="$q.platform.is.mobile ? 'col-11' : 'col-8'">
<div class="column rounded-20 q-pa-xs bg-accent" :class="$q.platform.is.mobile ? 'col-5' : 'col-4'">
<div class="col">
<q-img src="src/assets/line-truck-1.jpg" fit="cover" class="relative-position fit border-radius-inherit">
<q-img src="src/assets/line-truck-1.jpg" fit="cover" class="relative-position fit" style="border-radius: 18px 18px 0 0;">
<div class="absolute-bottom text-center column flex-center">
<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">{{
@ -22,7 +22,7 @@ import { useRouter } from 'vue-router';
</q-img>
</div>
<div class="col-auto text-center text-h6 text-weight-light bg-dark q-pa-md">
<div class="col-auto text-center text-h6 text-weight-light bg-dark q-pa-md" style="border-radius: 0 0 18px 18px;">
<div>{{ $t('error.not_found_description') }}</div>
</div>
</div>

View File

@ -17,12 +17,8 @@
const timesheet_store = useTimesheetStore();
const page_height = ref(0);
const headerComponent = ref<HTMLElement | null>(null);
const table_max_height = computed(() => {
const height = page_height.value - Math.min(headerComponent.value?.clientHeight ?? 0, headerComponent.value?.offsetHeight ?? 0);
return height;
});
const header_height = ref(0);
const table_max_height = computed(() => page_height.value - header_height.value);
const tableStyleFunction = (offset: number, height: number) => {
page_height.value = height - offset;
@ -60,6 +56,7 @@
title="timesheet_approvals.page_title"
:start-date="timesheet_store.pay_period?.period_start ?? ''"
:end-date="timesheet_store.pay_period?.period_end ?? ''"
@on-get-component-height="value => header_height = value"
/>
</div>

View File

@ -24,7 +24,6 @@ export const useTimesheetStore = defineStore('timesheet', () => {
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();
const getNextOrPreviousPayPeriod = (direction: number) => {
@ -169,7 +168,6 @@ export const useTimesheetStore = defineStore('timesheet', () => {
return {
is_loading,
is_report_dialog_open,
is_approval_grid_mode,
is_details_dialog_open,
pay_period,
pay_period_overviews,

View File

@ -23,5 +23,5 @@ export const getMinutes = (hours: number) => {
export const getHoursMinutesStringFromHoursFloat = (hours: number): string => {
const flat_hours = Math.floor(hours);
const minutes = Math.round((hours - flat_hours) * 60);
return `${flat_hours}h ${minutes > 1 ? minutes : ''}`
return `${flat_hours}h${minutes > 1 ? ' ' + minutes : ''}`
}