feat(timesheet-approval): Add menu that contains shift options, approval-only feature

This commit is contained in:
Nic D 2026-03-18 10:45:02 -04:00
parent 83dd3a4de4
commit 57946dbadd
3 changed files with 157 additions and 44 deletions

View File

@ -12,6 +12,7 @@
noTopPadding?: boolean; noTopPadding?: boolean;
backgroundColor?: 'bg-secondary' | 'bg-dark'; backgroundColor?: 'bg-secondary' | 'bg-dark';
appendContent?: string | number; appendContent?: string | number;
autoFocus?: boolean;
}>(); }>();
defineOptions({ defineOptions({
@ -28,6 +29,7 @@
v-model="model" v-model="model"
v-bind="$attrs" v-bind="$attrs"
dense dense
:autofocus="autoFocus"
borderless borderless
color="accent" color="accent"
label-color="white" label-color="white"
@ -89,8 +91,8 @@
</template> </template>
<style scoped> <style scoped>
:deep(.q-field--dense.q-field--float .q-field__label) { :deep(.q-field--dense.q-field--float .q-field__label) {
transform: translate(-17px, -60%) scale(0.75); transform: translate(-17px, -60%) scale(0.75);
border-radius: 10px 10px 10px 0px; border-radius: 10px 10px 10px 0px;
} }
</style> </style>

View File

@ -0,0 +1,130 @@
<script
setup
lang="ts"
>
import TargoInput from 'src/modules/shared/components/targo-input.vue';
import { computed, ref } from 'vue';
import type { Shift } from 'src/modules/timesheets/models/shift.models';
const shift = defineModel<Shift>({ required: true });
defineEmits<{
'clickToggleApproval': [void];
'clickDelete': [void];
}>();
const isCommentDialogOpen = ref(false);
const approvalOptionState = computed<{ icon: string, label: string }>(() => shift.value.is_approved ?
{ icon: 'las la-unlock', label: 'shared.label.unlock' } :
{ icon: 'las la-lock', label: 'shared.label.lock' }
)
const hasComment = computed(() => shift.value.comment && shift.value.comment.length > 0)
const onClickViewComments = () => {
isCommentDialogOpen.value = true;
}
</script>
<template>
<div class="row full-height flex-center">
<q-dialog
v-model="isCommentDialogOpen"
full-width
backdrop-filter="blur(4px)"
>
<div class="row flex-center full-width">
<div class="col-xs-12 col-sm-10 col-md-8 col-lg-6">
<TargoInput
v-model="shift.comment"
auto-focus
:label="$t('timesheet.expense.employee_comment')"
/>
</div>
</div>
</q-dialog>
<q-btn
flat
dense
icon="more_vert"
color="accent"
class="col-auto q-px-md"
>
<q-badge
v-if="hasComment"
rounded
floating
color="negative"
style="transform:translate(-10px, 0px)"
/>
<q-menu
auto-close
transition-show="jump-down"
transition-hide="jump-up"
transition-duration="200"
>
<q-list dense>
<q-item
clickable
@click="$emit('clickToggleApproval')"
>
<q-item-section avatar>
<q-avatar :icon="approvalOptionState.icon" />
</q-item-section>
<q-item-section>
{{ $t(approvalOptionState.label) }}
</q-item-section>
</q-item>
<q-item
clickable
@click="onClickViewComments"
>
<q-item-section avatar>
<q-avatar icon="las la-power-off" />
</q-item-section>
<q-item-section>
<div class="row items-center">
<span class="col">{{ $t('timesheet.expense.employee_comment') }}</span>
<div class="col-auto q-pl-sm">
<q-badge
v-if="hasComment"
rounded
color="negative"
/>
</div>
</div>
</q-item-section>
</q-item>
<q-separator />
<q-item
clickable
@click="$emit('clickDelete')"
>
<q-item-section avatar>
<q-avatar
icon="las la-trash"
text-color="negative"
/>
</q-item-section>
<q-item-section>
{{ $t('shared.label.remove') }}
</q-item-section>
</q-item>
</q-list>
</q-menu>
</q-btn>
</div>
</template>

View File

@ -2,9 +2,11 @@
setup setup
lang="ts" lang="ts"
> >
import DetailsDialogShiftMenu from 'src/modules/timesheet-approval/components/details-dialog-shift-menu.vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { computed, inject, onMounted, ref } from 'vue'; import { computed, inject, onMounted, ref } from 'vue';
import { QSelect, QInput, useQuasar, type QSelectProps, QPopupProxy } from 'quasar'; import { QSelect, QInput, useQuasar, type QSelectProps } from 'quasar';
import { useUiStore } from 'src/stores/ui-store'; import { useUiStore } from 'src/stores/ui-store';
import { useAuthStore } from 'src/stores/auth-store'; import { useAuthStore } from 'src/stores/auth-store';
import { getCurrentDailyMinutesWorked, getShiftOptions, getTimeStringFromMinutes, SHIFT_OPTIONS } from 'src/modules/timesheets/utils/shift.util'; import { getCurrentDailyMinutesWorked, getShiftOptions, getTimeStringFromMinutes, SHIFT_OPTIONS } from 'src/modules/timesheets/utils/shift.util';
@ -51,7 +53,6 @@
const shiftErrorMessage = ref<string | undefined>(); const shiftErrorMessage = ref<string | undefined>();
const is_showing_delete_confirm = ref(false); const is_showing_delete_confirm = ref(false);
const isShowingPredefinedTime = ref(shift.value.type === 'HOLIDAY'); const isShowingPredefinedTime = ref(shift.value.type === 'HOLIDAY');
const popupProxyRef = ref<QPopupProxy | null>(null);
const predefinedHoursString = ref(''); const predefinedHoursString = ref('');
const predefinedHoursBgColor = ref(`bg-${shiftTypeSelected.value?.icon_color ?? ''}`); const predefinedHoursBgColor = ref(`bg-${shiftTypeSelected.value?.icon_color ?? ''}`);
@ -59,12 +60,6 @@
const hasPTO = computed(() => currentShifts.some(shift => SHIFT_TYPES_WITH_PREDEFINED_TIMES.includes(shift.type))); const hasPTO = computed(() => currentShifts.some(shift => SHIFT_TYPES_WITH_PREDEFINED_TIMES.includes(shift.type)));
const rightClickMenuIcon = computed(() => shift.value.is_approved ? 'lock_open' : 'lock');
const rightClickMenuLabel = computed(() => shift.value.is_approved ?
t('timesheet_approvals.tooltip.unapprove') :
t('timesheet_approvals.tooltip.approve'));
const timeInputProps = computed(() => ({ const timeInputProps = computed(() => ({
dense: true, dense: true,
borderless: shift.value.is_approved && isTimesheetApproved, borderless: shift.value.is_approved && isTimesheetApproved,
@ -136,12 +131,9 @@
is_showing_delete_confirm.value = state; is_showing_delete_confirm.value = state;
} }
const onRightClickApprove = () => { const onclickToogleApproval = () => {
if (authStore.user?.user_module_access.includes('timesheets_approval')) if (authStore.user?.user_module_access.includes('timesheets_approval'))
shift.value.is_approved = !shift.value.is_approved; shift.value.is_approved = !shift.value.is_approved;
if (popupProxyRef.value)
popupProxyRef.value.hide();
} }
const onShiftTypeChange = (option: ShiftOption) => { const onShiftTypeChange = (option: ShiftOption) => {
@ -179,31 +171,6 @@
<template> <template>
<div class="row"> <div class="row">
<!-- right-click to approve shift only (if in approval mode) -->
<q-popup-proxy
v-if="mode === 'approval'"
ref="popupProxyRef"
context-menu
class="rounded-5 q-px-md shadow-24 cursor-pointer"
style="border: 3px solid var(--q-primary);"
>
<q-banner
dense
class="cursor-pointer q-px-lg"
@click="onRightClickApprove"
>
<template v-slot:avatar>
<q-icon
:name="rightClickMenuIcon"
color="accent"
/>
</template>
<span class="text-weight-bold text-accent text-uppercase">
{{ rightClickMenuLabel }}
</span>
</q-banner>
</q-popup-proxy>
<!-- delete shift confirmation dialog --> <!-- delete shift confirmation dialog -->
<q-dialog <q-dialog
v-model="is_showing_delete_confirm" v-model="is_showing_delete_confirm"
@ -348,7 +315,10 @@
</div> </div>
<!-- Else show input fields for in-out timestamps --> <!-- Else show input fields for in-out timestamps -->
<div v-else class="col row items-start text-uppercase rounded-5 q-pa-xs"> <div
v-else
class="col row items-start text-uppercase rounded-5 q-pa-xs"
>
<q-input <q-input
ref="start_time" ref="start_time"
v-model="shift.start_time" v-model="shift.start_time"
@ -384,8 +354,8 @@
</div> </div>
<div <div
class="row full-height" v-if="mode === 'normal'"
:class="$q.platform.is.mobile ? 'col-12' : 'col-auto flex-center'" class="row col-auto flex-center full-height"
> >
<!-- comment button --> <!-- comment button -->
<q-btn <q-btn
@ -470,6 +440,17 @@
@click="toggleIsShowingDeleteConfirm(true)" @click="toggleIsShowingDeleteConfirm(true)"
/> />
</div> </div>
<div
v-else
class="col-auto"
>
<DetailsDialogShiftMenu
v-model="shift"
@click-toggle-approval="onclickToogleApproval"
@click-delete="toggleIsShowingDeleteConfirm(true)"
/>
</div>
</div> </div>
</div> </div>
</template> </template>