Also refactor mobile UI/UX for timesheet: reduced header bloat, made only shifts scrollable, added left or right swipe to travel between pay periods, showing default 'no data' message when beyond 6-month-back 1-month-forward timesheet scope.
407 lines
18 KiB
Vue
407 lines
18 KiB
Vue
<script
|
|
setup
|
|
lang="ts"
|
|
>
|
|
/* eslint-disable*/
|
|
import { onBeforeUnmount, onMounted, ref, useTemplateRef, watch, nextTick } from 'vue';
|
|
import { useI18n } from 'vue-i18n';
|
|
import { QSelect, QInput } from 'quasar';
|
|
import { Shift, type ShiftOption } from 'src/modules/timesheets/models/shift.models';
|
|
import { useUiStore } from 'src/stores/ui-store';
|
|
import { useShiftRules, SHIFT_OPTIONS } from 'src/modules/timesheets/utils/shift.util';
|
|
|
|
let timer: NodeJS.Timeout;
|
|
const { t } = useI18n();
|
|
const ui_store = useUiStore();
|
|
|
|
const COMMENT_LENGTH_MAX = 280;
|
|
|
|
const shift = defineModel<Shift>('shift', { required: true });
|
|
const shift_type_selected = ref(SHIFT_OPTIONS.find(option => option.value == shift.value.type));
|
|
const select_ref = useTemplateRef<QSelect>('select');
|
|
const start_time_ref = useTemplateRef<QInput>('start_time');
|
|
const end_time_ref = useTemplateRef<QInput>('end_time');
|
|
|
|
const { dayShifts = [], dense = false, hasShiftAfter = false, isTimesheetApproved = false } = defineProps<{
|
|
dayShifts: Shift[];
|
|
dense?: boolean;
|
|
hasShiftAfter?: boolean;
|
|
isTimesheetApproved?: boolean;
|
|
}>();
|
|
|
|
const shift_rules = useShiftRules(t('timesheet.errors.SHIFT_TIME_REQUIRED'), t('timesheet.errors.SHIFT_OVERLAP_SHORT'), dayShifts);
|
|
|
|
const emit = defineEmits<{
|
|
'saveComment': [comment: string, shift_id: number];
|
|
'requestDelete': [void];
|
|
'onTimeFieldBlur': [void];
|
|
}>();
|
|
|
|
const onBlurShiftTypeSelect = () => {
|
|
if (shift_type_selected.value === undefined) {
|
|
shift.value.type = 'REGULAR';
|
|
shift.value.id = 0;
|
|
emit('requestDelete');
|
|
}
|
|
};
|
|
|
|
const slideDeleteShift = async (reset: () => void) => {
|
|
timer = setTimeout(() => {
|
|
reset();
|
|
emit('requestDelete');
|
|
}, 200);
|
|
};
|
|
|
|
const getCommentCounterColor = (comment_length: number) => {
|
|
if (comment_length < 200) return 'primary';
|
|
if (comment_length < 250) return 'warning';
|
|
return 'negative';
|
|
};
|
|
|
|
onMounted(() => {
|
|
if (ui_store.focus_next_component) {
|
|
select_ref.value?.focus();
|
|
select_ref.value?.showPopup();
|
|
shift_type_selected.value = undefined;
|
|
ui_store.focus_next_component = false;
|
|
}
|
|
});
|
|
|
|
onBeforeUnmount(() => {
|
|
clearTimeout(timer);
|
|
});
|
|
|
|
watch(() => [start_time_ref.value?.hasError, end_time_ref.value?.hasError], ([start_error, end_error]) => {
|
|
shift.value.has_error = (start_error || end_error) ?? false;
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<q-slide-item
|
|
right-color="negative"
|
|
class="rounded-5 transparent"
|
|
:class="ui_store.is_mobile_mode ? 'q-my-md' : 'q-mr-xs'"
|
|
@right="details => slideDeleteShift(details.reset)"
|
|
>
|
|
<template
|
|
#right
|
|
v-if="ui_store.is_mobile_mode"
|
|
>
|
|
<q-icon name="delete" />
|
|
</template>
|
|
|
|
<div :class="ui_store.is_mobile_mode ? 'column' : 'row'">
|
|
<div
|
|
class="row items-center text-uppercase rounded-5"
|
|
:class="ui_store.is_mobile_mode ? 'col q-mb-xs q-px-xs' : 'col-4'"
|
|
>
|
|
<!-- mobile comment button -->
|
|
<q-btn
|
|
v-if="ui_store.is_mobile_mode && !dense"
|
|
:icon="shift.comment ? 'chat' : 'chat_bubble_outline'"
|
|
:text-color="shift.comment ? ((shift.is_approved && isTimesheetApproved) ? 'white' : 'accent') : 'grey-5'"
|
|
class="col-auto full-height q-mx-xs rounded-5 shadow-1"
|
|
>
|
|
<q-popup-edit
|
|
v-model="shift.comment"
|
|
:title="$t('timesheet.shift.fields.header_comment')"
|
|
auto-save
|
|
v-slot="scope"
|
|
class="bg-dark"
|
|
>
|
|
<q-input
|
|
color="white"
|
|
v-model="scope.value"
|
|
dense
|
|
:readonly="(shift.is_approved || isTimesheetApproved)"
|
|
autofocus
|
|
counter
|
|
bottom-slots
|
|
:maxlength="COMMENT_LENGTH_MAX"
|
|
class="q-pb-lg"
|
|
:class="(shift.is_approved || isTimesheetApproved) ? 'cursor-not-allowed' : ''"
|
|
@keyup.enter="scope.set"
|
|
>
|
|
<template #append>
|
|
<q-icon name="edit" />
|
|
</template>
|
|
|
|
<template #counter>
|
|
<div class="row flex-center">
|
|
<q-space />
|
|
<q-knob
|
|
:model-value="scope.value?.length"
|
|
readonly
|
|
:max="COMMENT_LENGTH_MAX"
|
|
size="1.6em"
|
|
:thickness="0.4"
|
|
:color="getCommentCounterColor(scope.value?.length ?? 0)"
|
|
track-color="grey-4"
|
|
class="col-auto q-mr-xs"
|
|
/>
|
|
<span
|
|
:class="'col-auto text-weight-bolder text-' + getCommentCounterColor(scope.value?.length ?? 0)"
|
|
>{{ 280 - (scope.value?.length ?? 0) }}</span>
|
|
</div>
|
|
</template>
|
|
</q-input>
|
|
</q-popup-edit>
|
|
</q-btn>
|
|
|
|
<!-- shift type -->
|
|
<q-select
|
|
ref="select"
|
|
v-model="shift_type_selected"
|
|
:standout="$q.dark.isActive ? 'bg-blue-grey-3' : 'bg-blue-grey-9'"
|
|
dense
|
|
:borderless="(shift.is_approved && isTimesheetApproved)"
|
|
:readonly="(shift.is_approved && isTimesheetApproved)"
|
|
:options-dense="!ui_store.is_mobile_mode"
|
|
hide-dropdown-icon
|
|
:menu-offset="[0, 10]"
|
|
menu-anchor="bottom middle"
|
|
menu-self="top middle"
|
|
:options="SHIFT_OPTIONS"
|
|
class="col rounded-5 q-mx-xs bg-dark"
|
|
:class="!shift.is_approved && !isTimesheetApproved ? '' : 'inset-shadow'"
|
|
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">
|
|
<div
|
|
class="row items-center text-weight-bold q-ma-none q-pa-none no-wrap ellipsis full-width"
|
|
:class="ui_store.is_mobile_mode ? 'full-height' : ''"
|
|
:tabindex="scope.tabindex"
|
|
>
|
|
<q-icon
|
|
:name="scope.opt.icon"
|
|
:color="scope.opt.icon_color"
|
|
size="sm"
|
|
class="col-auto"
|
|
:class="shift.is_approved ? 'q-mx-xs': 'q-mr-xs'"
|
|
/>
|
|
<span
|
|
style="line-height: 1.2em;"
|
|
class="col-auto ellipsis"
|
|
:class="!shift.is_approved ? '' : 'text-white'"
|
|
>
|
|
{{ $t(scope.opt.label) }}
|
|
</span>
|
|
</div>
|
|
</template>
|
|
|
|
<template #after>
|
|
<q-icon
|
|
v-if="shift.is_approved"
|
|
:name="shift.is_remote ? 'las la-laptop' : 'las la-building'"
|
|
size="1.2em"
|
|
color="white"
|
|
class="q-mr-sm"
|
|
>
|
|
<q-tooltip
|
|
anchor="top middle"
|
|
self="bottom middle"
|
|
:offset="[0, 10]"
|
|
class="text-uppercase text-weight-bold text-white bg-primary"
|
|
>
|
|
{{ shift.is_remote ? $t('timesheet.shift.types.REMOTE') :
|
|
$t('timesheet.shift.types.OFFICE') }}
|
|
</q-tooltip>
|
|
</q-icon>
|
|
|
|
<q-toggle
|
|
v-else
|
|
v-model="shift.is_remote"
|
|
:disable="shift.is_approved"
|
|
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>
|
|
|
|
<div class="col row items-start text-uppercase rounded-5 q-pa-xs">
|
|
<!-- punch in field -->
|
|
<q-input
|
|
ref="start_time"
|
|
v-model="shift.start_time"
|
|
dense
|
|
:borderless="(shift.is_approved && isTimesheetApproved)"
|
|
:readonly="(shift.is_approved && isTimesheetApproved)"
|
|
type="time"
|
|
:standout="$q.dark.isActive ? 'bg-blue-grey-3' : 'bg-blue-grey-9'"
|
|
label-slot
|
|
lazy-rules
|
|
no-error-icon
|
|
hide-bottom-space
|
|
:rules="[shift_rules.isTimeRequiredRule, shift_rules.isShiftOverlapRule]"
|
|
:label-color="!shift.is_approved ? 'accent' : 'white'"
|
|
class="col rounded-5 bg-dark"
|
|
:class="(shift.id === -2 ? 'bg-negative ' : ' ') + (ui_store.is_mobile_mode ? 'q-mr-xs ' : 'q-mx-xs ') + (!shift.is_approved && !isTimesheetApproved ? '' : 'cursor-not-allowed inset-shadow')"
|
|
:input-class="'text-weight-medium ' + (shift.id === -2 ? 'text-white ' : ' ') + (shift.is_approved ? 'text-white cursor-not-allowed q-px-sm' : '')"
|
|
input-style="font-size: 1.2em;"
|
|
:style="shift.is_approved ? 'background-color: #0a7d32 !important;' : ''"
|
|
@blur="emit('onTimeFieldBlur')"
|
|
>
|
|
<template #label>
|
|
<span
|
|
class="text-weight-bolder"
|
|
:class="shift.is_approved ? ' q-ml-md' : ''"
|
|
style="font-size: 0.95em;"
|
|
>{{ $t('shared.misc.in') }}</span>
|
|
</template>
|
|
</q-input>
|
|
|
|
<!-- punch out field -->
|
|
<q-input
|
|
ref="end_time"
|
|
v-model="shift.end_time"
|
|
:standout="$q.dark.isActive ? 'bg-blue-grey-3' : 'bg-blue-grey-9'"
|
|
dense
|
|
:borderless="(shift.is_approved && isTimesheetApproved)"
|
|
:readonly="(shift.is_approved && isTimesheetApproved)"
|
|
type="time"
|
|
label-slot
|
|
lazy-rules
|
|
no-error-icon
|
|
hide-bottom-space
|
|
:rules="[shift_rules.isTimeRequiredRule, shift_rules.isShiftOverlapRule]"
|
|
:label-color="!shift.is_approved ? 'accent' : 'white'"
|
|
:input-class="'text-weight-medium ' + (shift.id === -2 ? 'text-white ' : ' ') + (shift.is_approved ? 'text-white cursor-not-allowed q-px-sm' : '')"
|
|
input-style="font-size: 1.2em;"
|
|
class="col rounded-5 bg-dark"
|
|
:class="(shift.id === -2 ? 'bg-negative ' : ' ') + (ui_store.is_mobile_mode ? 'q-mr-xs ' : 'q-mx-xs ') + (shift.is_approved ? 'cursor-not-allowed q-px-xs transparent inset-shadow' : (isTimesheetApproved ? 'inset-shadow' : ''))"
|
|
:style="shift.is_approved ? 'background-color: #0a7d32 !important;' : ''"
|
|
@blur="emit('onTimeFieldBlur')"
|
|
>
|
|
<template #label>
|
|
<span
|
|
class="text-weight-bolder"
|
|
:class="shift.is_approved ? ' q-ml-md' : ''"
|
|
style="font-size: 0.95em;"
|
|
>{{ $t('shared.misc.out') }}</span>
|
|
</template>
|
|
</q-input>
|
|
|
|
<!-- comment and delete buttons -->
|
|
<div class="row full-height" :class="ui_store.is_mobile_mode ? 'col-12' : 'col-auto flex-center'">
|
|
<!-- desktop comment button -->
|
|
<q-btn
|
|
v-if="!ui_store.is_mobile_mode"
|
|
push
|
|
dense
|
|
:color="shift.is_approved ? 'white' : 'accent'"
|
|
:icon="shift.comment ? 'chat' : 'chat_bubble_outline'"
|
|
:text-color="shift.is_approved ? 'accent' : 'white'"
|
|
class="col"
|
|
:class="ui_store.is_mobile_mode ? 'q-mt-xs bg-dark' : ''"
|
|
>
|
|
<q-badge
|
|
v-if="shift.comment"
|
|
floating
|
|
rounded
|
|
color="negative"
|
|
/>
|
|
<q-popup-edit
|
|
v-model="shift.comment"
|
|
:title="$t('timesheet.shift.fields.header_comment')"
|
|
auto-save
|
|
v-slot="scope"
|
|
class="bg-dark"
|
|
>
|
|
<q-input
|
|
color="white"
|
|
v-model="scope.value"
|
|
dense
|
|
:readonly="shift.is_approved"
|
|
autofocus
|
|
counter
|
|
bottom-slots
|
|
:maxlength="COMMENT_LENGTH_MAX"
|
|
class="q-pb-lg"
|
|
:class="shift.is_approved ? 'cursor-not-allowed' : ''"
|
|
@keyup.enter="scope.set"
|
|
>
|
|
<template #append>
|
|
<q-icon name="edit" />
|
|
</template>
|
|
|
|
<template #counter>
|
|
<div class="row flex-center">
|
|
<q-space />
|
|
<q-knob
|
|
:model-value="scope.value?.length"
|
|
readonly
|
|
:max="COMMENT_LENGTH_MAX"
|
|
size="1.6em"
|
|
:thickness="0.4"
|
|
:color="getCommentCounterColor(scope.value?.length ?? 0)"
|
|
track-color="grey-4"
|
|
class="col-auto q-mr-xs"
|
|
/>
|
|
<span
|
|
:class="'col-auto text-weight-bolder text-' + getCommentCounterColor(scope.value?.length ?? 0)"
|
|
>{{ 280 - (scope.value?.length ?? 0) }}</span>
|
|
</div>
|
|
</template>
|
|
</q-input>
|
|
</q-popup-edit>
|
|
</q-btn>
|
|
|
|
<q-btn
|
|
v-if="!ui_store.is_mobile_mode && !shift.is_approved"
|
|
flat
|
|
dense
|
|
:disable="shift.is_approved"
|
|
tabindex="-1"
|
|
icon="las la-trash"
|
|
text-color="negative"
|
|
class="col"
|
|
size="1.2em"
|
|
:class="shift.is_approved ? 'invisible' : ''"
|
|
@click="$emit('requestDelete')"
|
|
>
|
|
</q-btn>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</q-slide-item>
|
|
|
|
<q-separator
|
|
v-if="hasShiftAfter && ui_store.is_mobile_mode"
|
|
spaced
|
|
color="accent"
|
|
class="q-mx-md"
|
|
/>
|
|
</template>
|
|
|
|
<style scoped>
|
|
:deep(.q-field--error) {
|
|
background-color: var(--q-negative) !important;
|
|
}
|
|
|
|
:deep(.q-field--error .q-field__bottom) {
|
|
color: white;
|
|
font-weight: 900;
|
|
border-radius: 0 0 5px 5px;
|
|
padding-top: 0;
|
|
align-items: center;
|
|
}
|
|
</style> |