fix(timesheet, approvals): overhaul of UI elements, standardized inputs and selects

This commit is contained in:
Nic D 2026-03-23 14:35:51 -04:00
parent dcd3a5c188
commit 6576177652
26 changed files with 819 additions and 774 deletions

BIN
src/assets/circle.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@ -42,6 +42,14 @@ body.body--dark {
backdrop-filter: blur(5px); backdrop-filter: blur(5px);
} }
.text-accent2 {
color: #95f0a1B0;
}
.bg-accent2 {
background-color: #95f0a1B0;
}
.q-btn--push::before { .q-btn--push::before {
border-bottom: 4px solid rgba(0,0,0, 0.25); border-bottom: 4px solid rgba(0,0,0, 0.25);
} }
@ -73,4 +81,14 @@ input[type=number] {
.q-field--dark .q-field__control:hover::before, .q-field--outlined .q-field__control:hover::before { .q-field--dark .q-field__control:hover::before, .q-field--outlined .q-field__control:hover::before {
border-color: var(--q-accent2); border-color: var(--q-accent2);
border-width: 2px; border-width: 2px;
}
.text-border-white {
text-shadow: 2px 0 #fff, -2px 0 #fff, 0 2px #fff, 0 -2px #fff,
1px 1px #fff, -1px -1px #fff, 1px -1px #fff, -1px 1px #fff;
}
.text-border-dark {
text-shadow: 2px 0 var(--q-primary), -2px 0 var(--q-primary), 0 2px var(--q-primary), 0 -2px var(--q-primary),
1px 1px var(--q-primary), -1px -1px var(--q-primary), 1px -1px var(--q-primary), -1px 1px var(--q-primary);
} }

View File

@ -13,7 +13,7 @@
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';
setCssVar('accent2', '#36c45a44'); setCssVar('accent2', '#95f0a1B0');
const ui_store = useUiStore(); const ui_store = useUiStore();
const auth_store = useAuthStore(); const auth_store = useAuthStore();
const userPreferences = ref(ui_store.userPreferences); const userPreferences = ref(ui_store.userPreferences);
@ -47,14 +47,4 @@
<FooterBar v-if="!$q.platform.is.mobile" /> <FooterBar v-if="!$q.platform.is.mobile" />
</q-layout> </q-layout>
</template> </template>
<style lang="css">
.text-accent2 {
color: '#36c45a44' !important;
}
.bg-accent2 {
background-color: '#36c45a44' !important;
}
</style>

View File

@ -15,7 +15,7 @@
<template> <template>
<div <div
class="full-width cursor-pointer rounded-50 bg-accent text-white link-btn" class="full-width cursor-pointer rounded-50 link-btn shadow-4"
@click="onClickExternalShortcut" @click="onClickExternalShortcut"
> >
<div class="row items-center q-px-lg q-py-xs rounded-50"> <div class="row items-center q-px-lg q-py-xs rounded-50">
@ -25,7 +25,7 @@
<q-icon <q-icon
round round
color="dark" color="accent"
size="md" size="md"
:name="iconImageSource" :name="iconImageSource"
class="col-auto" class="col-auto"
@ -39,16 +39,11 @@
lang="css" lang="css"
> >
.link-btn { .link-btn {
box-shadow: 0 6px rgb(4, 77, 4); background-color: var(--q-dark);
transform: translateY(-6px); border: 2px solid var(--q-accent);
} }
.link-btn:hover { .link-btn:hover {
background-color: var(--q-accent2) !important; background-color: var(--q-accent2);
}
.link-btn:active {
box-shadow: 0 2px rgb(4, 77, 4);
transform: translateY(2px);
} }
</style> </style>

View File

@ -2,22 +2,43 @@
setup setup
lang="ts" lang="ts"
> >
import { colors, getCssVar } from 'quasar';
import { computed } from 'vue';
const model = defineModel<string | number | null | undefined>({ required: true }); const model = defineModel<string | number | null | undefined>({ required: true });
const is_date_picker_open = defineModel<boolean>('isDatePickerOpen', { default: false });
defineProps<{ defineProps<{
label?: string | undefined; label?: string | undefined;
requiresDatePicker?: boolean | undefined; requiresDatePicker?: boolean | undefined;
maxLength?: number; maxLength?: number;
noTopPadding?: boolean; noTopPadding?: boolean;
backgroundColor?: 'bg-secondary' | 'bg-dark'; backgroundColor?: 'secondary' | 'dark' | 'white' | undefined;
inputTextColor?: string | undefined;
appendContent?: string | number; appendContent?: string | number;
autoFocus?: boolean; autoFocus?: boolean;
error?: boolean;
dense?: boolean;
readonly?: boolean;
textAlign?: 'left' | 'right';
}>(); }>();
defineOptions({ defineOptions({
inheritAttrs: false inheritAttrs: false
}) })
defineEmits<{
'focus': [void];
'blur': [void];
}>();
const isDatePickerOpen = defineModel<boolean>('isDatePickerOpen', { default: false });
const bgLightGrey = computed(() => {
const secondary = getCssVar('secondary');
if (secondary === null) return;
return colors.lighten(secondary, 50);
});
</script> </script>
<template> <template>
@ -25,68 +46,81 @@
class="col q-px-sm" class="col q-px-sm"
:class="noTopPadding ? '' : 'q-pt-md'" :class="noTopPadding ? '' : 'q-pt-md'"
> >
<q-input <transition
v-model="model" enter-active-class="animated shakeX"
v-bind="$attrs" :duration="{ enter: 200, leave: 0 }"
dense mode="out-in"
:autofocus="autoFocus"
borderless
color="accent"
label-color="white"
stack-label
label-slot
no-error-icon
hide-bottom-space
:maxlength="maxLength"
class="q-px-md rounded-5 inset-shadow"
:class="$q.dark.isActive ? 'bg-primary' : (backgroundColor ?? 'bg-secondary')"
:style="`border: 1px solid var(${$q.dark.isActive ? '--q-secondary' : '--q-primary'});`"
> >
<template #label> <q-input
<span v-model="model"
class="text-weight-bold text-uppercase q-px-md" v-bind="$attrs"
:class="$q.dark.isActive ? 'bg-secondary' : 'bg-primary'" :key="error ? 1 : 2"
> :dense="dense"
{{ label }} :autofocus="autoFocus"
</span> :readonly="readonly"
</template> borderless
color="accent"
<template label-color="white"
#append stack-label
v-if="requiresDatePicker || !!appendContent" label-slot
no-error-icon
hide-bottom-space
:error="error"
:maxlength="maxLength"
class="q-px-md rounded-5 inset-shadow"
:class="backgroundColor ? `bg-${backgroundColor ?? 'secondary'}` : ($q.dark.isActive ? 'bg-primary' : '')"
:style="`border: 1px solid ${error ? getCssVar('negative') : colors.getPaletteColor($q.dark.isActive ? 'black' : 'blue-grey-4')}; ${(backgroundColor || $q.dark.isActive) ? '' : `background-color: ${bgLightGrey}`}`"
:input-class="`text-${inputTextColor} text-${textAlign}`"
input-style="font-size: 1.3em; font-weight: 500;"
@focus="$emit('focus')"
@blur="$emit('blur')"
> >
<div v-if="requiresDatePicker"> <template #label>
<q-btn <span
flat class="text-weight-bold text-uppercase q-px-md"
dense :class="$q.dark.isActive ? 'bg-secondary' : 'bg-blue-grey-7'"
size="lg"
icon="calendar_month"
color="accent"
@click="is_date_picker_open = true"
> >
<q-dialog {{ label }}
v-model="is_date_picker_open" </span>
backdrop-filter="none" </template>
>
<q-date
v-model="model"
mask="YYYY-MM-DD"
color="accent"
@update:model-value="is_date_picker_open = false"
/>
</q-dialog>
</q-btn>
</div>
<div <template
v-if="!!appendContent" #append
class="self-end text-uppercase text-bold text-accent" v-if="requiresDatePicker || !!appendContent"
style="font-size: 0.8em;"
> >
{{ appendContent }} <div v-if="requiresDatePicker">
</div> <q-btn
</template> flat
</q-input> dense
size="lg"
icon="calendar_month"
color="accent"
@click="isDatePickerOpen = true"
>
<q-dialog
v-model="isDatePickerOpen"
backdrop-filter="none"
>
<q-date
v-model="model"
mask="YYYY-MM-DD"
color="accent"
@update:model-value="isDatePickerOpen = false"
/>
</q-dialog>
</q-btn>
</div>
<div
v-if="!!appendContent"
class="self-end text-uppercase text-weight-medium text-accent"
style="font-size: 0.75em;"
>
{{ appendContent }}
</div>
</template>
</q-input>
</transition>
</div> </div>
</template> </template>

View File

@ -54,6 +54,26 @@
color="accent" color="accent"
class="col-auto q-px-md" class="col-auto q-px-md"
> >
<q-tooltip
v-if="shift.comment && shift.comment.length > 0"
anchor="top middle"
self="center middle"
:offset="[0, 20]"
class="bg-dark shadow-24"
:class="$q.dark.isActive ? 'text-white' : 'text-primary'"
style="border: 1px solid var(--q-accent)"
>
<div class="row">
<span
class="text-uppercase text-bold text-accent q-pr-xs"
style="font-size: 1.2em;"
>
{{ $t('timesheet.expense.employee_comment') }}:
</span>
<span style="font-size: 1.2em;">{{ shift.comment }}</span>
</div>
</q-tooltip>
<q-badge <q-badge
v-if="hasComment" v-if="hasComment"
rounded rounded
@ -87,14 +107,34 @@
clickable clickable
@click="onClickViewComments" @click="onClickViewComments"
> >
<q-tooltip
v-if="shift.comment && shift.comment.length > 0"
anchor="top middle"
self="center middle"
:offset="[0, 20]"
class="bg-dark shadow-24"
:class="$q.dark.isActive ? 'text-white' : 'text-primary'"
style="border: 1px solid var(--q-accent)"
>
<div class="row">
<span
class="text-uppercase text-bold text-accent q-pr-xs"
style="font-size: 1.2em;"
>
{{ $t('timesheet.expense.employee_comment') }}:
</span>
<span style="font-size: 1.2em;">{{ shift.comment }}</span>
</div>
</q-tooltip>
<q-item-section avatar> <q-item-section avatar>
<q-avatar icon="las la-power-off" /> <q-avatar icon="las la-comment" />
</q-item-section> </q-item-section>
<q-item-section> <q-item-section>
<div class="row items-center"> <div class="row items-center">
<span class="col">{{ $t('timesheet.expense.employee_comment') }}</span> <span class="col">{{ $t('timesheet.expense.employee_comment') }}</span>
<div class="col-auto q-pl-sm"> <div class="col-auto q-pl-sm">
<q-badge <q-badge
v-if="hasComment" v-if="hasComment"

View File

@ -20,8 +20,8 @@
// ========== constants ======================================== // ========== constants ========================================
const WARNING_COLUMNS: OverviewColumns[] = ['EVENING', 'HOLIDAY', 'VACATION', 'SICK'] const WARNING_COLUMNS: OverviewColumns[] = ['EVENING']
const NEGATIVE_COLUMNS: OverviewColumns[] = ['OVERTIME', 'EMERGENCY'] const NEGATIVE_COLUMNS: OverviewColumns[] = ['EMERGENCY', 'OVERTIME']
const TIME_COLUMNS: OverviewColumns[] = ['REGULAR', 'EVENING', 'EMERGENCY', 'OVERTIME', 'HOLIDAY', 'VACATION', 'SICK']; const TIME_COLUMNS: OverviewColumns[] = ['REGULAR', 'EVENING', 'EMERGENCY', 'OVERTIME', 'HOLIDAY', 'VACATION', 'SICK'];
const VISIBLE_COLUMNS = ref<OverviewColumns[]>([ const VISIBLE_COLUMNS = ref<OverviewColumns[]>([
'employee_first_name', 'employee_first_name',
@ -105,10 +105,14 @@
const getListViewTimeCss = (column_name: OverviewColumns, value: number): { classes: string, style: string } => { const getListViewTimeCss = (column_name: OverviewColumns, value: number): { classes: string, style: string } => {
if (WARNING_COLUMNS.includes(column_name) && value > 0) if (WARNING_COLUMNS.includes(column_name) && value > 0)
return { classes: 'bg-warning text-white text-bold rounded-5', style: '' }; return { classes: 'bg-warning text-bold text-primary rounded-5', style: '' };
if (NEGATIVE_COLUMNS.includes(column_name) && value > 0) {
if ((column_name === 'OVERTIME') && value < 4)
return { classes: 'bg-warning text-bold text-primary rounded-5', style: '' };
if (NEGATIVE_COLUMNS.includes(column_name) && value > 0)
return { classes: 'bg-negative text-white text-bold rounded-5', style: '' }; return { classes: 'bg-negative text-white text-bold rounded-5', style: '' };
}
return { classes: '', style: '' } return { classes: '', style: '' }
} }
@ -117,7 +121,7 @@
<template> <template>
<div class="full-width"> <div class="full-width">
<LoadingOverlay v-model="timesheetStore.is_loading" /> <LoadingOverlay v-model="timesheetStore.is_loading" />
<q-table <q-table
dense dense
row-key="email" row-key="email"
@ -253,11 +257,15 @@
<!-- any other fields, though time fields will have their own conditional class to highlight abnormalities --> <!-- any other fields, though time fields will have their own conditional class to highlight abnormalities -->
<div <div
v-else v-else
class="q-px-xs" class="q-px-xs row"
:class="getListViewTimeCss(props.col.name, props.value).classes"
> >
{{ TIME_COLUMNS.includes(props.col.name) ? <div
getHoursMinutesStringFromHoursFloat(props.value) : props.value }} class="col-auto q-px-sm"
:class="getListViewTimeCss(props.col.name, props.value)?.classes"
>
{{ TIME_COLUMNS.includes(props.col.name) ?
getHoursMinutesStringFromHoursFloat(props.value) : props.value }}
</div>
</div> </div>
</div> </div>
</transition> </transition>

View File

@ -4,23 +4,25 @@
> >
import TargoInput from 'src/modules/shared/components/targo-input.vue'; import TargoInput from 'src/modules/shared/components/targo-input.vue';
import { colors } from 'quasar';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { computed, onMounted, ref } from 'vue'; import { computed, onMounted, ref } from 'vue';
import { useExpensesStore } from 'src/stores/expense-store'; import { useExpensesStore } from 'src/stores/expense-store';
import { useTimesheetStore } from 'src/stores/timesheet-store'; import { useTimesheetStore } from 'src/stores/timesheet-store';
import { useExpensesApi } from 'src/modules/timesheets/composables/use-expense-api'; import { useExpensesApi } from 'src/modules/timesheets/composables/use-expense-api';
import { getExpenseIcon, useExpenseRules } from 'src/modules/timesheets/utils/expense.util'; import { getExpenseIcon, useExpenseRules } from 'src/modules/timesheets/utils/expense.util';
import { Expense, type ExpenseOption, TYPES_WITH_AMOUNT_ONLY } from 'src/modules/timesheets/models/expense.models'; import { type ExpenseOption, type ExpenseType, TYPES_WITH_AMOUNT_ONLY } from 'src/modules/timesheets/models/expense.models';
// ================= state ====================== // ================= state ======================
const COMMENT_MAX_LENGTH = 280; const COMMENT_MAX_LENGTH = 280;
const expense = defineModel<Expense>({ default: new Expense(new Date().toISOString().slice(0, 10)) })
const file = defineModel<File>('file'); const file = defineModel<File>('file');
const { email } = defineProps<{ const { expenseType, email } = defineProps<{
expenseType?: ExpenseType;
email?: string | undefined; email?: string | undefined;
mode?: 'normal' | 'approval'; mode?: 'normal' | 'approval';
refreshKey?: number;
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
'clickSave': [void]; 'clickSave': [void];
@ -30,8 +32,9 @@
const timesheetStore = useTimesheetStore(); const timesheetStore = useTimesheetStore();
const expenseStore = useExpensesStore(); const expenseStore = useExpensesStore();
const expensesApi = useExpensesApi(); const expensesApi = useExpensesApi();
const rules = useExpenseRules();
const isNavigatorOpen = ref(false); const isNavigatorOpen = ref(false);
const rules = useExpenseRules(t); const isHoveringDisabledSave = ref(false);
const expenseOptions: ExpenseOption[] = [ const expenseOptions: ExpenseOption[] = [
@ -47,8 +50,11 @@
const period_start_date = computed(() => timesheetStore.pay_period?.period_start.replaceAll('-', '/') ?? ''); const period_start_date = computed(() => timesheetStore.pay_period?.period_start.replaceAll('-', '/') ?? '');
const period_end_date = computed(() => timesheetStore.pay_period?.period_end.replaceAll('-', '/') ?? ''); const period_end_date = computed(() => timesheetStore.pay_period?.period_end.replaceAll('-', '/') ?? '');
const isSaveDisabled = computed(() => const isSaveDisabled = computed(() =>
JSON.stringify(expenseStore.current_expense) === JSON.stringify(expenseStore.initial_expense) (JSON.stringify(expenseStore.current_expense) === JSON.stringify(expenseStore.initial_expense)) ||
(!expenseStore.current_expense.amount && !expenseStore.current_expense.mileage) ||
expenseStore.current_expense.comment.length < 1
); );
const isTypeError = computed(() => isHoveringDisabledSave.value && !rules.typeRequired(expenseStore.current_expense.type))
// ==================== method ======================= // ==================== method =======================
@ -73,11 +79,23 @@
} }
}; };
onMounted(() => { const resetAmounts = (resetAmount?: number) => {
if (expense.value) expenseStore.current_expense.amount = resetAmount ?? null;
expenseSelected.value = expenseOptions.find(expense_option => expense_option.value === expense.value.type); expenseStore.current_expense.mileage = resetAmount ?? null;
}
const onAmountBlur = () => {
if (expenseStore.current_expense.type === 'MILEAGE' && expenseStore.current_expense.mileage)
expenseStore.current_expense.amount = null;
else if (expenseStore.current_expense.type !== 'MILEAGE' && expenseStore.current_expense.amount)
expenseStore.current_expense.mileage = null;
else else
expenseSelected.value = expenseOptions[0]; resetAmounts(0);
}
onMounted(() => {
if (expenseType)
expenseSelected.value = expenseOptions.find(opt => opt.value === expenseType);
}) })
</script> </script>
@ -121,65 +139,77 @@
<TargoInput <TargoInput
v-model="expenseStore.current_expense.date" v-model="expenseStore.current_expense.date"
no-top-padding no-top-padding
dense
readonly
:label="$t('timesheet.expense.date')" :label="$t('timesheet.expense.date')"
background-color="bg-dark" background-color="dark"
class="col" class="col"
/> />
</div> </div>
<!-- expenses type selection --> <!-- expenses type selection -->
<div class="col"> <div
<q-select class="col"
v-model="expenseSelected" :key="refreshKey ?? 0"
dense >
borderless <transition
color="accent" enter-active-class="animated shakeX"
label-color="white" :duration="{ enter: 200, leave: 0 }"
stack-label mode="out-in"
label-slot
:options="expenseOptions"
hide-dropdown-icon
lazy-rules
no-error-icon
hide-bottom-space
options-selected-class="text-white text-bold bg-accent"
class="q-px-md rounded-5 inset-shadow"
:class="$q.dark.isActive ? 'bg-primary' : 'bg-dark'"
popup-content-class="text-uppercase text-weight-medium rounded-5 shadow-12 z-top"
popup-content-style="border: 1px solid var(--q-primary);"
menu-anchor="bottom middle"
menu-self="top middle"
:menu-offset="[0, 5]"
:style="`border: 1px solid var(${$q.dark.isActive ? '--q-secondary' : '--q-primary'});`"
:rules="[rules.typeRequired]"
@update:model-value="option => expenseStore.current_expense.type = option.value"
> >
<template #label> <q-select
<span v-model="expenseSelected"
class="text-weight-medium text-uppercase q-px-sm no-pointer-events" :key="isTypeError ? 1 : 2"
:class="$q.dark.isActive ? 'bg-secondary' : 'bg-primary'" dense
> borderless
{{ $t('timesheet.expense.type') }} color="accent"
</span> label-color="white"
</template> stack-label
label-slot
<template #selected-item="scope"> :options="expenseOptions"
<div hide-dropdown-icon
class="row items-center text-weight-bold q-ma-none q-pa-none no-wrap ellipsis full-width" lazy-rules
:tabindex="scope.tabindex" no-error-icon
> hide-bottom-space
<q-icon options-selected-class="text-white text-bold bg-accent"
:name="scope.opt.icon" class="q-px-md rounded-5 inset-shadow"
size="xs" :class="$q.dark.isActive ? 'bg-primary' : 'bg-dark'"
class="col-auto q-mx-xs" popup-content-class="text-uppercase text-weight-medium rounded-5 shadow-12 z-top"
/> popup-content-style="border: 1px solid var(--q-primary);"
menu-anchor="bottom middle"
menu-self="top middle"
:menu-offset="[0, 5]"
:style="`border: 1px solid ${isTypeError ? 'var(--q-negative)' : ($q.dark.isActive ? 'var(--q-secondary)' : colors.getPaletteColor('blue-grey-4'))};`"
:error="isTypeError"
@update:model-value="option => expenseStore.current_expense.type = option.value"
>
<template #label>
<span <span
style="line-height: 1em;" class="text-weight-medium text-uppercase q-px-sm no-pointer-events"
class="col-auto ellipsis text-uppercase" :class="$q.dark.isActive ? 'bg-secondary' : 'bg-blue-grey-7'"
>{{ scope.opt.label }}</span> >
</div> {{ $t('timesheet.expense.type') }}
</template> </span>
</q-select> </template>
<template #selected-item="scope">
<div
class="row items-center text-weight-bold q-ma-none q-pa-none no-wrap ellipsis full-width"
:tabindex="scope.tabindex"
>
<q-icon
:name="scope.opt.icon"
size="xs"
class="col-auto q-mx-xs"
/>
<span
style="line-height: 1em;"
class="col-auto ellipsis text-uppercase"
>{{ scope.opt.label }}</span>
</div>
</template>
</q-select>
</transition>
</div> </div>
<!-- amount input --> <!-- amount input -->
@ -188,22 +218,32 @@
v-if="TYPES_WITH_AMOUNT_ONLY.includes(expenseStore.current_expense?.type ?? 'EXPENSES')" v-if="TYPES_WITH_AMOUNT_ONLY.includes(expenseStore.current_expense?.type ?? 'EXPENSES')"
v-model.number="expenseStore.current_expense.amount" v-model.number="expenseStore.current_expense.amount"
no-top-padding no-top-padding
background-color="bg-dark" dense
background-color="dark"
type="number" type="number"
input-class="text-right" input-class="text-right"
append-content=" $" append-content=" $"
:label="$t('timesheet.expense.amount')" :label="$t('timesheet.expense.amount')"
text-align="right"
:error="isHoveringDisabledSave && !rules.amountRequired(expenseStore.current_expense.amount)"
@focus="resetAmounts()"
@blur="onAmountBlur()"
/> />
<TargoInput <TargoInput
v-else v-else
v-model.number="expenseStore.current_expense.mileage" v-model.number="expenseStore.current_expense.mileage"
no-top-padding no-top-padding
background-color="bg-dark" dense
background-color="dark"
type="number" type="number"
input-class="text-right" input-class="text-right"
append-content=" km" append-content=" km"
:label="$t('timesheet.expense.mileage')" :label="$t('timesheet.expense.mileage')"
text-align="right"
:error="isHoveringDisabledSave && !rules.mileageRequired(expenseStore.current_expense.mileage)"
@focus="resetAmounts()"
@blur="onAmountBlur()"
/> />
</div> </div>
</div> </div>
@ -213,9 +253,11 @@
<div class="col"> <div class="col">
<TargoInput <TargoInput
v-model="expenseStore.current_expense.comment" v-model="expenseStore.current_expense.comment"
dense
no-top-padding no-top-padding
background-color="bg-dark" background-color="dark"
:max-length="COMMENT_MAX_LENGTH" :max-length="COMMENT_MAX_LENGTH"
:error="isHoveringDisabledSave && !rules.commentRequired(expenseStore.current_expense.comment)"
:label="$t('timesheet.expense.employee_comment')" :label="$t('timesheet.expense.employee_comment')"
/> />
</div> </div>
@ -226,8 +268,9 @@
> >
<TargoInput <TargoInput
v-model="expenseStore.current_expense.supervisor_comment" v-model="expenseStore.current_expense.supervisor_comment"
dense
no-top-padding no-top-padding
background-color="bg-dark" background-color="dark"
:max-length="COMMENT_MAX_LENGTH" :max-length="COMMENT_MAX_LENGTH"
:label="$t('timesheet.expense.supervisor_comment')" :label="$t('timesheet.expense.supervisor_comment')"
/> />
@ -247,7 +290,7 @@
accept="image/*" accept="image/*"
class="q-px-md rounded-5 inset-shadow" class="q-px-md rounded-5 inset-shadow"
:class="$q.dark.isActive ? 'bg-primary' : 'bg-dark'" :class="$q.dark.isActive ? 'bg-primary' : 'bg-dark'"
:style="`border: 1px solid var(${$q.dark.isActive ? '--q-secondary' : '--q-primary'});`" :style="`border: 1px solid ${$q.dark.isActive ? 'var(--q-secondary)' : colors.getPaletteColor('blue-grey-4')};`"
> >
<template #append> <template #append>
<q-icon <q-icon
@ -260,7 +303,7 @@
<template #label> <template #label>
<span <span
class="text-weight-medium text-uppercase q-px-sm no-pointer-events" class="text-weight-medium text-uppercase q-px-sm no-pointer-events"
:class="$q.dark.isActive ? 'bg-secondary' : 'bg-primary'" :class="$q.dark.isActive ? 'bg-secondary' : 'bg-blue-grey-7'"
> >
{{ $t('timesheet.expense.hints.attach_file') }} {{ $t('timesheet.expense.hints.attach_file') }}
</span> </span>
@ -273,16 +316,24 @@
<div class="col row full-width items-center"> <div class="col row full-width items-center">
<q-space /> <q-space />
<q-btn <transition
push enter-active-class="animated rubberBand fast"
:disable="isSaveDisabled" mode="out-in"
:color="isSaveDisabled ? 'grey-5' : 'accent'" >
:icon="expenseStore.mode === 'update' ? 'save' : 'upload'" <q-btn
:label="expenseStore.mode === 'update' ? $t('shared.label.update') : $t('shared.label.add')" :key="isSaveDisabled ? 1 : 0"
class="q-px-sm " push
:class="expenseStore.mode === 'create' ? 'q-mr-lg q-mb-md' : 'q-mb-sm q-ml-lg'" :disable="isSaveDisabled"
type="submit" :color="isSaveDisabled ? 'grey-5' : 'accent'"
/> :icon="expenseStore.mode === 'update' ? 'save' : 'upload'"
:label="expenseStore.mode === 'update' ? $t('shared.label.update') : $t('shared.label.add')"
class="q-px-xl"
:class="expenseStore.mode === 'create' ? 'q-mr-lg q-my-md' : 'q-mb-sm q-ml-lg'"
type="submit"
@mouseenter="isHoveringDisabledSave = isSaveDisabled"
@mouseleave="isHoveringDisabledSave = false"
/>
</transition>
</div> </div>
</q-form> </q-form>
</div> </div>
@ -292,8 +343,8 @@
scoped scoped
lang="css" lang="css"
> >
: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) !important; transform: translate(-17px, -60%) scale(0.75) !important;
border-radius: 10px 10px 10px 0px; border-radius: 10px 10px 10px 0px;
} }
</style> </style>

View File

@ -133,8 +133,12 @@
}) }} }) }}
</span> </span>
</div> </div>
<q-separator vertical spaced class="q-my-xs"/> <q-separator
vertical
spaced
class="q-my-xs"
/>
<!-- comments section --> <!-- comments section -->
<div class="col column"> <div class="col column">
@ -154,7 +158,7 @@
</span> </span>
</div> </div>
<q-separator class="q-mr-md"/> <q-separator class="q-mr-md" />
<div class="col row items-center"> <div class="col row items-center">
<span <span
@ -241,7 +245,8 @@
</template> </template>
<ExpenseDialogForm <ExpenseDialogForm
v-model="expense" :key="isShowingUpdateForm ? 1 : 2"
:expense-type="expense.type"
:email="getEmployeeEmail()" :email="getEmployeeEmail()"
:mode="mode" :mode="mode"
@click-save="hideUpdateForm" @click-save="hideUpdateForm"

View File

@ -11,7 +11,7 @@
import { date } from 'quasar'; import { date } from 'quasar';
import { useExpensesStore } from 'src/stores/expense-store'; import { useExpensesStore } from 'src/stores/expense-store';
import { Expense } from 'src/modules/timesheets/models/expense.models'; import { Expense } from 'src/modules/timesheets/models/expense.models';
import { ref } from 'vue'; import { ref } from 'vue';
const expense_store = useExpensesStore(); const expense_store = useExpensesStore();
const refreshKey = ref(0); const refreshKey = ref(0);
@ -23,6 +23,7 @@ import { ref } from 'vue';
const onClickExpenseCreate = () => { const onClickExpenseCreate = () => {
expense_store.mode = 'create'; expense_store.mode = 'create';
expense_store.current_expense = new Expense(date.formatDate(new Date(), 'YYYY-MM-DD')); expense_store.current_expense = new Expense(date.formatDate(new Date(), 'YYYY-MM-DD'));
refreshKey.value += 1
} }
</script> </script>
@ -49,7 +50,7 @@ import { ref } from 'vue';
<q-card-section class="q-pa-none"> <q-card-section class="q-pa-none">
<ExpenseDialogHeader /> <ExpenseDialogHeader />
<ExpenseDialogList :key="refreshKey + 1" /> <ExpenseDialogList />
<q-expansion-item <q-expansion-item
v-if="!isApproved" v-if="!isApproved"
@ -57,8 +58,7 @@ import { ref } from 'vue';
hide-expand-icon hide-expand-icon
:dense="!$q.platform.is.mobile" :dense="!$q.platform.is.mobile"
group="expenses" group="expenses"
@show="onClickExpenseCreate()" @before-show="onClickExpenseCreate()"
@after-hide="refreshKey += 1"
header-class="bg-accent text-white" header-class="bg-accent text-white"
> >
<template #header> <template #header>
@ -77,10 +77,8 @@ import { ref } from 'vue';
</template> </template>
<ExpenseDialogFormMobile v-if="$q.platform.is.mobile" /> <ExpenseDialogFormMobile v-if="$q.platform.is.mobile" />
<ExpenseDialogForm
v-else <ExpenseDialogForm v-else :key="refreshKey" :refresh-key="refreshKey" />
:key="refreshKey"
/>
</q-expansion-item> </q-expansion-item>
</q-card-section> </q-card-section>
</q-card> </q-card>

View File

@ -32,7 +32,7 @@
const is_navigator_open = ref(false); const is_navigator_open = ref(false);
const is_showing_comment_dialog_mobile = ref(false); const is_showing_comment_dialog_mobile = ref(false);
const rules = useExpenseRules(t); const rules = useExpenseRules();
const period_start_date = computed(() => timesheet_store.pay_period?.period_start.replaceAll('-', '/') ?? ''); const period_start_date = computed(() => timesheet_store.pay_period?.period_start.replaceAll('-', '/') ?? '');
const period_end_date = computed(() => timesheet_store.pay_period?.period_end.replaceAll('-', '/') ?? ''); const period_end_date = computed(() => timesheet_store.pay_period?.period_end.replaceAll('-', '/') ?? '');

View File

@ -102,23 +102,18 @@
<div class="column col q-px-sm q-py-xs"> <div class="column col q-px-sm q-py-xs">
<!-- date label and delete button --> <!-- date label and delete button -->
<div class="col-auto row items-center q-pl-xs"> <div class="col-auto row items-center q-pl-xs">
<q-icon
name="calendar_month"
size="sm"
class="col-auto"
/>
<span <span
class="col text-uppercase text-weight-light full-width q-pl-sm text-h6" class="col text-uppercase text-bold text-h5 full-width q-pl-sm"
:class="approved_class" :class="approved_class"
> >
{{ $d( {{ $d(
date.extractDate(expense.date, 'YYYY-MM-DD'), date.extractDate(expense.date, 'YYYY-MM-DD'),
{ month: 'long', day: 'numeric' } { month: 'long', day: 'numeric', year: 'numeric' }
) }} ) }}
</span> </span>
<q-btn <q-btn
v-if="!expense.is_approved"
flat flat
dense dense
icon="las la-trash" icon="las la-trash"
@ -130,7 +125,7 @@
/> />
</div> </div>
<div class="col row full-width items-center q-px-xs"> <div class="col row full-width items-center q-px-xs no-wrap">
<!-- avatar type icon section --> <!-- avatar type icon section -->
<q-icon <q-icon
:name="getExpenseIcon(expense.type)" :name="getExpenseIcon(expense.type)"
@ -139,26 +134,28 @@
/> />
<!-- amount or mileage section --> <!-- amount or mileage section -->
<div class="col text-weight-bold text-h6"> <div class="col text-h6">
<q-item-label v-if="expense.type === 'MILEAGE'"> <q-item-label v-if="expense.type === 'MILEAGE'">
{{ expense.mileage?.toFixed(1) }} km {{ expense.mileage?.toFixed(1) }} km
</q-item-label> </q-item-label>
<q-item-label v-else> <q-item-label v-else>
$ {{ expense.amount.toFixed(2) }} $ {{ expense.amount?.toFixed(2) }}
</q-item-label> </q-item-label>
</div> </div>
</div> <!-- attachment file -->
<div class="col-6 q-pa-xs">
<!-- attachment file --> <q-btn
<div class="col-auto q-pa-xs full-width"> push
<q-btn :disable="!expense.attachment_key"
:color="expense.is_approved ? 'white' : 'accent'" :color="expense.is_approved ? 'white' : 'accent'"
:text-color="expense.is_approved ? 'accent' : 'white'" :text-color="expense.is_approved ? 'accent' : 'white'"
icon="las la-paperclip" icon="las la-paperclip"
:label="expense.attachment_name ?? `( ${$t('shared.label.empty')} )`" :label="expense.attachment_name ?? $t('timesheet.expense.no_attachment')"
class="full-width text-lowercase q-mx-sm q-px-sm q-pb-sm inset-shadow" class="full-width text-lowercase q-mx-sm q-px-sm q-pb-sm text-caption"
/> :style="expense.attachment_key ? '' : 'filter: grayscale(1); font-style: italic;'"
/>
</div>
</div> </div>
</div> </div>

View File

@ -0,0 +1,123 @@
<script
setup
lang="ts"
>
import ShiftListDayRowMobile from 'src/modules/timesheets/components/mobile/shift-list-day-row-mobile.vue';
import { useI18n } from 'vue-i18n';
import { date } from 'quasar';
import { computed } 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 type { TimesheetDay } from 'src/modules/timesheets/models/timesheet.models';
// import { useTimesheetApi } from 'src/modules/timesheets/composables/use-timesheet-api';
const { locale } = useI18n();
const uiStore = useUiStore();
const timesheetStore = useTimesheetStore();
const day = defineModel<TimesheetDay>({ required: true });
const { isTimesheetApproved = false } = defineProps<{
timesheetId: number;
isTimesheetApproved?: boolean;
}>();
const isDayApproved = computed(() => day.value.shifts.every(shift => shift.is_approved) && day.value.shifts.length > 1);
const addNewShift = (day_shifts: Shift[], date: string, timesheet_id: number) => {
uiStore.focusNextComponent = true;
const newShift = new Shift;
newShift.date = date;
newShift.timesheet_id = timesheet_id;
day_shifts.push(newShift);
};
const getHolidayName = (date: string) => {
const holiday = timesheetStore.federal_holidays.find(holiday => holiday.date === date);
if (!holiday) return;
if (locale.value === 'fr-FR')
return holiday.nameFr;
else if (locale.value === 'en-CA')
return holiday.nameEn;
};
</script>
<template>
<div class="row q-pa-sm full-width relative-position">
<!-- optional label indicating which holiday if today is a holiday -->
<span
v-if="timesheetStore.federal_holidays.some(holiday => holiday.date === day.date)"
class="absolute-top-left text-uppercase text-weight-bolder text-purple-5"
style="transform: translate(25px, -7px);"
>
{{ getHolidayName(day.date) }}
</span>
<!-- mobile version in portrait mode -->
<div
v-if="$q.platform.is.mobile && ($q.screen.width < $q.screen.height)"
class="col-auto full-width q-px-md q-py-sm"
>
<div
class="shadow-12 rounded-10"
:class="(isDayApproved || isTimesheetApproved) ? 'bg-accent' : 'bg-dark'"
>
<div
class="text-weight-bolder text-uppercase text-h6 q-py-sm text-center relative-position"
:class="(isDayApproved || isTimesheetApproved) ? 'bg-accent rounded-10' : 'bg-primary'"
style="line-height: 1em; border-radius: 10px 10px 0 0;"
>
<span class="text-white">
{{ $d(date.extractDate(day.date, 'YYYY-MM-DD'), {
weekday: 'long', day: 'numeric', month: 'long'
}) }}
</span>
<q-icon
v-if="(isDayApproved || isTimesheetApproved)"
name="verified"
size="3em"
color="white"
class="absolute-top-left z-top"
style="top: -0.2em; left: 0px;"
/>
</div>
<div
v-if="day.shifts.filter(shift => shift.id !== 0).length > 0"
class="q-pa-none transparent"
>
<div
v-for="_shift, shiftIndex in day.shifts"
:key="shiftIndex"
>
<ShiftListDayRowMobile
v-model:shift="day.shifts[shiftIndex]!"
:current-shifts="day.shifts"
:has-shift-after="shiftIndex < day.shifts.length - 1"
/>
</div>
</div>
<div class="q-pa-none">
<q-btn
v-if="!(isDayApproved || isTimesheetApproved)"
square
dense
size="xl"
color="accent"
icon="more_time"
class="full-width"
style="border-radius: 0 0 10px 10px;"
@click="addNewShift(day.shifts, day.date, timesheetId)"
/>
</div>
</div>
</div>
</div>
</template>

View File

@ -2,34 +2,35 @@
setup setup
lang="ts" lang="ts"
> >
import TargoInput from 'src/modules/shared/components/targo-input.vue';
import { computed, onMounted, ref } from 'vue'; import { computed, onMounted, ref } from 'vue';
import { QSelect, QInput } from 'quasar'; import { colors, getCssVar, QSelect } from 'quasar';
import { useUiStore } from 'src/stores/ui-store'; import { useUiStore } from 'src/stores/ui-store';
import { getCurrentDailyMinutesWorked, getTimeStringFromMinutes, SHIFT_OPTIONS } from 'src/modules/timesheets/utils/shift.util'; import { getCurrentDailyMinutesWorked, getShiftOptions, getTimeStringFromMinutes, SHIFT_OPTIONS } from 'src/modules/timesheets/utils/shift.util';
import type { Shift, ShiftOption, ShiftType } from 'src/modules/timesheets/models/shift.models'; import type { Shift, ShiftOption, ShiftType } from 'src/modules/timesheets/models/shift.models';
import { getHoursMinutesStringFromHoursFloat } from 'src/utils/date-and-time-utils'; import { getHoursMinutesStringFromHoursFloat } from 'src/utils/date-and-time-utils';
// ========== state ======================================== // ========== state ========================================
const SHIFT_TYPES_WITH_PREDEFINED_TIMES: ShiftType[] = ['HOLIDAY', 'SICK', 'VACATION']; const SHIFT_TYPES_WITH_PREDEFINED_TIMES: ShiftType[] = ['HOLIDAY', 'SICK', 'VACATION'];
const COMMENT_LENGTH_MAX = 280; // const COMMENT_LENGTH_MAX = 280;
const shift = defineModel<Shift>('shift', { required: true }); const shift = defineModel<Shift>('shift', { required: true });
const { const {
dense = false, currentShifts,
hasShiftAfter = false, hasShiftAfter = false,
isTimesheetApproved = false, isTimesheetApproved = false,
errorMessage = undefined, errorMessage = undefined,
expectedDailyHours = 8, expectedDailyHours = 8,
currentShifts,
} = defineProps<{ } = defineProps<{
dense?: boolean; currentShifts: Shift[];
hasShiftAfter?: boolean; hasShiftAfter?: boolean;
isTimesheetApproved?: boolean; isTimesheetApproved?: boolean;
errorMessage?: string | undefined; errorMessage?: string | undefined;
expectedDailyHours?: number; expectedDailyHours?: number;
currentShifts: Shift[]; isHoliday?: boolean;
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
@ -40,7 +41,7 @@
const uiStore = useUiStore(); const uiStore = useUiStore();
const shiftTypeSelected = ref(SHIFT_OPTIONS.find(option => option.value == shift.value.type)); const shiftTypeSelected = ref(SHIFT_OPTIONS.find(option => option.value == shift.value.type));
const selectRef = ref<QSelect | null>(null); const selectRef = ref<QSelect | null>(null);
const isShowingCommentPopup = ref(false); // const isShowingCommentPopup = ref(false);
const errorMessageRow = ref(''); const errorMessageRow = ref('');
const isShowingPredefinedTime = ref(shift.value.type === 'HOLIDAY'); const isShowingPredefinedTime = ref(shift.value.type === 'HOLIDAY');
const predefinedHoursString = ref(''); const predefinedHoursString = ref('');
@ -48,7 +49,9 @@
// ========== computed ======================================== // ========== computed ========================================
const comment_length = computed(() => shift.value.comment?.length ?? 0); const commentLength = computed(() => shift.value.comment?.length ?? 0);
const isApproved = computed(() => isTimesheetApproved || shift.value.is_approved);
const hasPTO = computed(() => currentShifts.some(shift => SHIFT_TYPES_WITH_PREDEFINED_TIMES.includes(shift.type)));
// ========== methods ========================================= // ========== methods =========================================
@ -71,12 +74,6 @@
} }
} }
const getCommentCounterColor = (comment_length: number) => {
if (comment_length < 200) return 'primary';
if (comment_length < 250) return 'warning';
return 'negative';
};
const onShiftTypeChange = (option: ShiftOption) => { const onShiftTypeChange = (option: ShiftOption) => {
shift.value.type = option.value; shift.value.type = option.value;
@ -114,266 +111,216 @@
</script> </script>
<template> <template>
<div class="row q-px-xs"> <div class="column">
<div class="col column"> <div class="row q-pa-sm">
<div class="col row items-center text-uppercase q-px-xs rounded-5"> <div class="col column">
<!-- comment button --> <div class="row justify-center q-pb-xs q-px-sm full-width">
<q-btn <!-- shift type -->
v-if="!dense" <q-select
:icon="shift.comment ? 'chat' : 'chat_bubble_outline'" ref="selectRef"
:text-color="shift.comment ? ((shift.is_approved && isTimesheetApproved) ? 'white' : 'accent') : 'grey-5'" v-model="shiftTypeSelected"
class="col-auto full-height q-mx-xs rounded-5 shadow-1"
@click="isShowingCommentPopup = true"
>
<q-dialog v-model="isShowingCommentPopup">
<q-input
color="white"
v-model="shift.comment"
dense
:readonly="(shift.is_approved || isTimesheetApproved)"
autofocus
counter
bottom-slots
stack-label
:label="$t('timesheet.shift.fields.header_comment')"
:maxlength="COMMENT_LENGTH_MAX"
:class="(shift.is_approved || isTimesheetApproved) ? 'cursor-not-allowed' : ''"
>
<template #append>
<q-icon name="edit" />
</template>
<template #counter>
<div class="row flex-center">
<q-space />
<q-knob
v-model="comment_length"
readonly
:max="COMMENT_LENGTH_MAX"
size="1.6em"
:thickness="0.4"
:color="getCommentCounterColor(comment_length)"
track-color="grey-4"
class="col-auto q-mr-xs"
/>
<span
:class="'col-auto text-weight-bolder text-' + getCommentCounterColor(comment_length)"
>{{ 280 - comment_length }}</span>
</div>
</template>
</q-input>
</q-dialog>
</q-btn>
<!-- shift type -->
<q-select
ref="select"
v-model="shiftTypeSelected"
: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
hide-dropdown-icon
:menu-offset="[0, 10]"
menu-anchor="bottom middle"
menu-self="top middle"
:options="SHIFT_OPTIONS"
class="col rounded-5 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="onShiftTypeChange"
>
<template #selected-item="scope">
<div
class="row items-center text-weight-bold q-ma-none q-pa-none no-wrap ellipsis fit"
: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]"
:hide-delay="1000"
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]"
:hide-delay="1000"
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>
<template #option="scope">
<q-item
clickable
v-bind="scope.itemProps"
>
<q-item-section avatar>
<q-icon :name="scope.opt.icon" />
</q-item-section>
<q-item-section class="text-left">
{{ $t(scope.label) }}
</q-item-section>
</q-item>
</template>
</q-select>
</div>
<div
v-if="isShowingPredefinedTime"
class="col row items-start text-uppercase rounded-5 q-pa-xs relative-position"
>
<div
class="absolute-full rounded-5 q-mx-sm q-my-xs"
:class="predefinedHoursBgColor"
style="opacity: 0.3;"
></div>
<span class="col text-center text-uppercase text-h6 text-bold q-py-xs">
{{ getHoursMinutesStringFromHoursFloat(expectedDailyHours) }}
</span>
</div>
<div
v-else
class="col row items-start text-uppercase rounded-5 q-pa-xs"
>
<!-- punch in field -->
<div class="col q-pr-xs">
<q-input
v-model="shift.start_time"
dense dense
:borderless="(shift.is_approved && isTimesheetApproved)" borderless
:readonly="(shift.is_approved && isTimesheetApproved)" color="accent"
type="time" label-color="white"
:standout="$q.dark.isActive ? 'bg-blue-grey-3' : 'bg-blue-grey-9'" stack-label
label-slot label-slot
hide-dropdown-icon
:readonly="isApproved"
:options="getShiftOptions(hasPTO, currentShifts.length > 1)"
lazy-rules lazy-rules
no-error-icon no-error-icon
hide-bottom-space hide-bottom-space
:error="shift.has_error" options-selected-class="text-white text-bold bg-accent"
:error-message="errorMessage || errorMessageRow !== '' ? $t(errorMessage ?? errorMessageRow) : ''" class="col q-px-md rounded-5 inset-shadow text-uppercase"
:label-color="!shift.is_approved ? 'accent' : 'white'" :class="isApproved ? 'bg-white' : ($q.dark.isActive ? 'bg-primary' : 'bg-secondary')"
class="rounded-5 bg-dark" popup-content-class="text-uppercase text-weight-medium rounded-5 shadow-12 z-top"
:class="(shift.id === -2 ? 'bg-negative ' : ' ') + (!shift.is_approved && !isTimesheetApproved ? '' : 'cursor-not-allowed inset-shadow')" popup-content-style="border: 1px solid var(--q-primary)"
:input-class="'text-weight-medium ' + (shift.id === -2 ? 'text-white ' : ' ') + (shift.is_approved ? 'text-white cursor-not-allowed q-px-sm' : '')" menu-anchor="bottom middle"
input-style="font-size: 1.2em;" menu-self="top middle"
:style="shift.is_approved ? 'background-color: #0a7d32 !important;' : ''" :menu-offset="[0, 5]"
@blur="onTimeFieldBlur(shift.start_time)" :style="`border: 1px solid ${$q.dark.isActive ? 'var(--q-secondary)' : colors.getPaletteColor('blue-grey-4')}; background-color: ${colors.lighten(getCssVar('secondary')!, 50)}`"
@blur="onBlurShiftTypeSelect"
@update:model-value="onShiftTypeChange"
> >
<template #selected-item="scope">
<div
class="row items-center text-weight-bold q-pt-sm no-wrap ellipsis"
:tabindex="scope.tabindex"
>
<q-icon
:name="scope.opt.icon"
:color="shift.is_approved ? 'accent' : scope.opt.icon_color"
size="sm"
class="col-auto"
:class="shift.is_approved ? 'q-mx-xs' : 'q-mr-xs'"
/>
<span
style="font-size: 1.3em;"
class="col ellipsis"
:class="shift.is_approved ? 'text-accent' : ''"
>
{{ $t(scope.opt.label) }}
</span>
</div>
</template>
<template #option="scope">
<q-item
clickable
v-bind="scope.itemProps"
>
<q-item-section avatar>
<q-icon :name="scope.opt.icon" />
</q-item-section>
<q-item-section class="text-left">
{{ $t(scope.label) }}
</q-item-section>
</q-item>
</template>
<!-- work-from-home toggle -->
<template #after>
<q-icon
v-if="shift.is_approved"
:name="shift.is_remote ? 'las la-laptop' : 'las la-building'"
size="1.2em"
color="accent"
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="isHoliday ? 'purple-5' : '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>
<template #label> <template #label>
<span <span
class="text-weight-bolder" class="text-weight-medium text-uppercase q-px-sm no-pointer-events"
:class="shift.is_approved ? ' q-ml-md' : ''" :class="$q.dark.isActive ? 'bg-secondary' : 'bg-blue-grey-7'"
style="font-size: 0.95em;" >
>{{ $t('shared.misc.in') }}</span> {{ $t('timesheet.shift.types.label') }}
</span>
</template> </template>
</q-input> </q-select>
</div> </div>
<!-- punch out field --> <div
<div class="col"> v-if="isShowingPredefinedTime"
<q-input class="col row items-start text-uppercase rounded-5 q-pa-xs relative-position"
v-model="shift.end_time" >
standout <div
class="absolute-full rounded-5 q-mx-sm q-my-xs"
:class="predefinedHoursBgColor"
style="opacity: 0.3;"
></div>
<span class="col text-center text-uppercase text-h6 text-bold q-py-xs">
{{ getHoursMinutesStringFromHoursFloat(expectedDailyHours) }}
</span>
</div>
<div
v-else
class="col row items-start text-uppercase rounded-5 q-pt-sm"
>
<!-- punch in field -->
<div class="col">
<TargoInput
v-model="shift.start_time"
no-top-padding
dense
type="time"
:readonly="isApproved"
:background-color="isApproved ? 'white' : undefined"
:input-text-color="isApproved ? 'accent' : ''"
:label="$t('shared.misc.in')"
:error="shift.has_error"
@blur="onTimeFieldBlur(shift.start_time)"
/>
</div>
<!-- punch out field -->
<div class="col">
<TargoInput
v-model="shift.end_time"
no-top-padding
dense
type="time"
:readonly="isApproved"
:background-color="isApproved ? 'white' : undefined"
:input-text-color="isApproved ? 'accent' : ''"
:label="$t('shared.misc.out')"
:error="shift.has_error"
@blur="onTimeFieldBlur(shift.end_time)"
/>
</div>
</div>
<div class="col-auto q-pt-md">
<TargoInput
v-model="shift.comment"
no-top-padding
dense dense
:borderless="(shift.is_approved && isTimesheetApproved)" :readonly="isApproved"
:readonly="(shift.is_approved && isTimesheetApproved)" :background-color="isApproved ? 'white' : undefined"
type="time" :input-text-color="isApproved ? 'accent' : ''"
label-slot :label="$t('timesheet.expense.employee_comment')"
no-error-icon :append-content="isApproved ? '' : `${commentLength ?? 0}/280`"
hide-bottom-space />
:error="shift.has_error"
:error-message="errorMessage || errorMessageRow !== '' ? $t(errorMessage ?? errorMessageRow) : ''"
: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="rounded-5 bg-dark"
:class="(shift.id === -2 ? 'bg-negative ' : ' ') + (shift.is_approved ? 'cursor-not-allowed q-px-xs transparent inset-shadow' : (isTimesheetApproved ? 'inset-shadow' : ''))"
:style="shift.is_approved ? 'background-color: #0a7d32 !important;' : ''"
@blur="onTimeFieldBlur(shift.end_time)"
>
<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>
</div> </div>
</div> </div>
</div>
<div class="col-auto"> <div class="col-auto">
<q-btn <q-btn
v-if="!shift.is_approved" v-if="!shift.is_approved"
flat outline
dense dense
color="negative" color="negative"
icon="las la-trash" icon="las la-trash"
size="lg" size="lg"
class="full-height" class="full-height rounded-5"
@click="$emit('requestDelete')" @click="$emit('requestDelete')"
/> />
</div>
</div> </div>
<q-separator <q-separator
v-if="hasShiftAfter" v-if="hasShiftAfter"
spaced spaced
class="q-mx-md col-12" size="2px"
:color="isApproved ? 'accent2' : 'accent'"
class="q-mx-lg"
/> />
</div> </div>
</template> </template>
@ -390,4 +337,13 @@
padding-top: 0; padding-top: 0;
align-items: center; align-items: center;
} }
:deep(.q-field--float .q-field__label) {
transform: translate(-17px, -60%) scale(0.75) !important;
border-radius: 10px 10px 10px 0px;
}
:deep(.q-field--auto-height.q-field--labeled .q-field__control-container) {
padding: 0;
}
</style> </style>

View File

@ -2,17 +2,12 @@
setup setup
lang="ts" lang="ts"
> >
import ShiftListDay from 'src/modules/timesheets/components/shift-list-day.vue'; import ShiftListDayMobile from 'src/modules/timesheets/components/mobile/shift-list-day-mobile.vue';
import ShiftListDateWidget from 'src/modules/timesheets/components/shift-list-date-widget.vue';
import { date, useQuasar } from 'quasar';
import { ref, computed, watch, onMounted, inject } from 'vue'; import { useQuasar } from 'quasar';
import { useUiStore } from 'src/stores/ui-store'; import { ref, computed, watch, onMounted } from 'vue';
import { useTimesheetStore } from 'src/stores/timesheet-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 { useI18n } from 'vue-i18n';
// ========== constants ======================================== // ========== constants ========================================
@ -25,16 +20,11 @@
}>(); }>();
const q = useQuasar(); const q = useQuasar();
const { extractDate } = date;
const { locale } = useI18n();
const uiStore = useUiStore();
const timesheetApi = useTimesheetApi();
const timesheetStore = useTimesheetStore(); const timesheetStore = useTimesheetStore();
const mobileAnimationDirection = ref('fadeInLeft'); const mobileAnimationDirection = ref('fadeInLeft');
const currentDayComponent = ref<HTMLElement[] | null>(null); const currentDayComponent = ref<HTMLElement[] | null>(null);
const currentDayComponentWatcher = ref(currentDayComponent); const currentDayComponentWatcher = ref(currentDayComponent);
const employeeEmail = inject<string>('employeeEmail');
// ========== computed ======================================== // ========== computed ========================================
@ -42,38 +32,10 @@
// ========== methods ======================================== // ========== methods ========================================
const addNewShift = (day_shifts: Shift[], date: string, timesheet_id: number) => {
uiStore.focusNextComponent = true;
const newShift = new Shift;
newShift.date = date;
newShift.timesheet_id = timesheet_id;
day_shifts.push(newShift);
};
const getDayApproval = (day: TimesheetDay) => {
if (day.shifts.length < 1) return false;
return day.shifts.every(shift => shift.is_approved === true);
};
const getMobileDayRef = (iso_date_string: string): string => { const getMobileDayRef = (iso_date_string: string): string => {
return iso_date_string === CURRENT_DATE_STRING ? 'currentDayComponent' : ''; return iso_date_string === CURRENT_DATE_STRING ? 'currentDayComponent' : '';
}; };
const getHolidayName = (date: string) => {
const holiday = timesheetStore.federal_holidays.find(holiday => holiday.date === date);
if (!holiday) return;
if (locale.value === 'fr-FR')
return holiday.nameFr;
else if (locale.value === 'en-CA')
return holiday.nameEn;
};
const onClickApplyWeeklyPreset = async (timesheet_id: number) => {
await timesheetApi.applyPreset(timesheet_id, undefined, undefined, employeeEmail);
}
onMounted(async () => { onMounted(async () => {
await timesheetStore.getCurrentFederalHolidays(); await timesheetStore.getCurrentFederalHolidays();
}); });
@ -86,166 +48,28 @@
</script> </script>
<template> <template>
<div <div class="fit column no-wrap q-pb-lg">
class="fit"
:class="$q.platform.is.mobile ? 'column no-wrap q-pb-lg' : 'row'"
>
<div <div
v-for="timesheet of timesheetStore.timesheets" v-for="timesheet of timesheetStore.timesheets"
:key="timesheet.timesheet_id" :key="timesheet.timesheet_id"
class="no-wrap" class="col-auto column no-wrap"
:class="$q.platform.is.mobile ? 'col-auto column' : 'col column fit items-center'"
> >
<transition
appear
enter-active-class="animated fadeInDown"
leave-active-class="animated fadeOutUp"
>
<q-btn
v-if="!$q.platform.is.mobile && timesheet.days.every(day => day.shifts.length < 1) && timesheetStore.has_timesheet_preset"
:disable="!timesheet.days.every(day => day.shifts.length < 1)"
flat
dense
:label="$t('timesheet.apply_preset_week')"
class="col-auto text-uppercase text-weight-bold text-accent q-mx-lg q-py-none rounded-5"
@click="onClickApplyWeeklyPreset(timesheet.timesheet_id)"
>
<q-icon
name="las la-calendar-week"
color="accent"
size="md"
/>
</q-btn>
</transition>
<transition-group <transition-group
appear appear
:enter-active-class="`animated ${animationStyle}`" :enter-active-class="`animated ${animationStyle}`"
> >
<div <div
v-for="day, day_index in timesheet.days" v-for="day, dayIndex in timesheet.days"
:key="day.date" :key="day.date"
:ref="getMobileDayRef(day.date)" :ref="getMobileDayRef(day.date)"
class="col-auto row q-pa-sm full-width relative-position" class="col-auto row q-pa-sm full-width relative-position"
:style="`animation-delay: ${day_index / 15}s;`" :style="`animation-delay: ${dayIndex / 15}s;`"
> >
<!-- optional label indicating which holiday if today is a holiday --> <ShiftListDayMobile
<span v-model="timesheet.days[dayIndex]!"
v-if="timesheetStore.federal_holidays.some(holiday => holiday.date === day.date)" :timesheet-id="timesheet.timesheet_id"
class="absolute-top-left text-uppercase text-weight-bolder text-purple-5" :is-timesheet-approved="timesheet.is_approved"
style="transform: translate(25px, -7px);" />
>
{{ getHolidayName(day.date) }}
</span>
<!-- mobile version in portrait mode -->
<div
v-if="$q.platform.is.mobile && ($q.screen.width < $q.screen.height)"
class="col-auto full-width q-px-md q-py-sm"
>
<q-card
class="shadow-12"
:class="(getDayApproval(day) || timesheet.is_approved) ? 'bg-accent rounded-10' : 'bg-dark mobile-rounded-10'"
>
<q-card-section
class="text-weight-bolder text-uppercase text-h6 q-py-sm text-center relative-position"
:class="(getDayApproval(day) || timesheet.is_approved) ? 'bg-accent text-white' : 'bg-primary text-white'"
style="line-height: 1em;"
>
<span> {{ $d(extractDate(day.date, 'YYYY-MM-DD'), {
weekday: 'long', day: 'numeric', month:
'long'
}) }}</span>
<q-icon
v-if="(getDayApproval(day) || timesheet.is_approved)"
name="verified"
size="3em"
color="white"
class="absolute-top-left z-top"
style="top: -0.2em; left: 0px;"
/>
</q-card-section>
<q-card-section
v-if="day.shifts.filter(shift => shift.id !== 0).length > 0"
class="q-pa-none transparent"
>
<ShiftListDay
v-model="timesheet.days[day_index]!"
:timesheet-id="timesheet.timesheet_id"
:week-day-index="day_index"
:timesheet-approved="timesheet.is_approved"
/>
</q-card-section>
<q-card-section class="q-pa-none">
<q-btn
v-if="!(getDayApproval(day) || timesheet.is_approved)"
square
dense
size="xl"
color="accent"
icon="more_time"
class="full-width"
style="border-radius: 0 0 10px 10px;"
@click="addNewShift(day.shifts, day.date, timesheet.timesheet_id)"
/>
</q-card-section>
</q-card>
</div>
<!-- desktop version -->
<div
v-else
class="col row full-width rounded-10 ellipsis shadow-10"
:style="timesheetStore.federal_holidays.some(holiday => holiday.date === day.date) ? 'border: 2px solid #ab47bc' : ''"
>
<div
class="col row"
:class="(getDayApproval(day) || timesheet.is_approved) ? (timesheetStore.federal_holidays.some(holiday => holiday.date === day.date) ? 'bg-purple-5' : 'bg-accent') : 'bg-dark'"
>
<!-- Date block -->
<ShiftListDateWidget
:display-date="day.date"
:approved="(getDayApproval(day) || timesheet.is_approved)"
class="col-auto"
/>
<ShiftListDay
v-model="timesheet.days[day_index]!"
:timesheet-id="timesheet.timesheet_id"
:week-day-index="day_index"
:approved="timesheet.is_approved"
class="col"
/>
</div>
<div class="col-auto self-stretch">
<q-icon
v-if="(getDayApproval(day) || timesheet.is_approved)"
name="verified"
color="white"
size="xl"
class="full-height"
:class="(getDayApproval(day) || timesheet.is_approved) ? (timesheetStore.federal_holidays.some(holiday => holiday.date === day.date) ? 'bg-purple-5' : 'bg-accent') : ''"
/>
<q-btn
v-else
:dense="!$q.platform.is.mobile"
square
icon="more_time"
size="lg"
:color="timesheetStore.federal_holidays.some(holiday => holiday.date === day.date) ? 'purple-5' : 'accent'"
text-color="white"
class="full-height"
:class="$q.platform.is.mobile ? 'q-px-xs' : ''"
@click="addNewShift(day.shifts, day.date, timesheet.timesheet_id)"
/>
</div>
</div>
</div> </div>
</transition-group> </transition-group>
</div> </div>

View File

@ -24,16 +24,17 @@
<template> <template>
<div <div
class="column flex-center rounded-10 text-center self-center bg-transparent" class="column flex-center rounded-10 text-center self-center bg-transparent relative-position"
:style="date_box_size" :style="date_box_size"
> >
<span <div
v-if="today" v-if="today"
class="absolute-top-left text-uppercase text-weight-bolder q-pt-xs bordered-text" class="absolute fit q-px-sm q-py-md"
style="transform: translate(20px, -8px);"
> >
{{ $t('shared.label.today') }} <div class="fit" style="background-image: url('src/assets/circle.png'); background-size: 100% 100%; background-repeat: no-repeat; opacity: 0.65;">
</span>
</div>
</div>
<span <span
v-if="!dense" v-if="!dense"
@ -46,7 +47,7 @@
<span <span
class="col-auto text-weight-bolder" class="col-auto text-weight-bolder"
:class="today ? 'text-info' : (approved ? 'text-white' : '')" :class="today ? 'text-accent' : (approved ? 'text-white' : '')"
:style="'font-size: ' + date_font_size + '; line-height: 90% !important;'" :style="'font-size: ' + date_font_size + '; line-height: 90% !important;'"
> >
{{ display_date.getDate() }} {{ display_date.getDate() }}
@ -67,7 +68,8 @@
lang="css" lang="css"
> >
.bordered-text { .bordered-text {
text-shadow: 2px 0 var(--q-dark), -2px 0 var(--q-dark), 0 2px var(--q-dark), 0 -2px var(--q-dark), text-shadow: 2px 0 white, -2px 0 white, 0 2px white, 0 -2px white,
1px 1px var(--q-dark), -1px -1px var(--q-dark), 1px -1px var(--q-dark), -1px 1px var(--q-dark); 1px 1px white, -1px -1px white, 1px -1px white, -1px 1px white;
font-size: 0.9em;
} }
</style> </style>

View File

@ -3,10 +3,10 @@
lang="ts" lang="ts"
> >
import DetailsDialogShiftMenu from 'src/modules/timesheet-approval/components/details-dialog-shift-menu.vue'; import DetailsDialogShiftMenu from 'src/modules/timesheet-approval/components/details-dialog-shift-menu.vue';
import TargoInput from 'src/modules/shared/components/targo-input.vue';
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 } from 'quasar'; import { QSelect, useQuasar, colors, getCssVar } 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';
@ -23,7 +23,6 @@
const shift = defineModel<Shift>('shift', { required: true }); const shift = defineModel<Shift>('shift', { required: true });
const { const {
errorMessage = undefined,
isTimesheetApproved = false, isTimesheetApproved = false,
currentShifts, currentShifts,
holiday = false, holiday = false,
@ -32,7 +31,7 @@
currentShifts: Shift[]; currentShifts: Shift[];
expectedDailyHours?: number; expectedDailyHours?: number;
isTimesheetApproved?: boolean; isTimesheetApproved?: boolean;
errorMessage?: string | undefined; errorTimesheet?: boolean | undefined;
holiday?: boolean | undefined; holiday?: boolean | undefined;
}>(); }>();
@ -42,7 +41,6 @@
}>(); }>();
const q = useQuasar(); const q = useQuasar();
const { t } = useI18n();
const uiStore = useUiStore(); const uiStore = useUiStore();
const authStore = useAuthStore(); const authStore = useAuthStore();
@ -59,41 +57,7 @@
// ================== Computed ================== // ================== Computed ==================
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 isApproved = computed(() => isTimesheetApproved || shift.value.is_approved);
const timeInputProps = computed(() => ({
dense: true,
borderless: shift.value.is_approved && isTimesheetApproved,
readonly: shift.value.is_approved && isTimesheetApproved,
standout: q.dark.isActive ? 'bg-blue-grey-3' : 'bg-blue-grey-9',
labelSlot: true,
lazyRules: true,
noErrorIcon: true,
hideBottomSpace: true,
error: shift.value.has_error,
errorMessage: errorMessage ? t(errorMessage) : (shiftErrorMessage.value ? t(shiftErrorMessage.value) : undefined),
labelColor: shift.value.is_approved ? 'white' : (holiday ? 'purple-5' : 'accent'),
class: `col rounded-5 bg-dark q-mx-xs ${shift.value.id === -2 ? 'bg-negative' : ''} ${shift.value.is_approved || isTimesheetApproved ? 'cursor-not-allowed inset-shadow' : ''}`,
inputClass: `text-weight-medium ${shift.value.id === -2 ? 'text-white ' : ' '} ${shift.value.is_approved ? 'text-white cursor-not-allowed q-px-sm' : ''}`,
style: shift.value.is_approved ? (holiday ? 'background-color: #7b1fa2 !important' : 'background-color: #0a7d32 !important;') : '',
inputStyle: "font-size: 1.2em;"
}));
const shiftTypeSelectProps = computed<Partial<QSelectProps>>(() => ({
standout: q.dark.isActive ? 'bg-blue-grey-3' : 'bg-blue-grey-9',
dense: true,
borderless: shift.value.is_approved && isTimesheetApproved,
readonly: shift.value.is_approved && isTimesheetApproved,
optionsDense: !q.platform.is.mobile,
hideDropdownIcon: true,
menuOffset: [0, 10],
menuAnchor: "bottom middle",
menuSelf: "top middle",
options: getShiftOptions(hasPTO.value, currentShifts.length > 1),
class: `col rounded-5 q-mx-xs bg-dark ${!shift.value.is_approved && !isTimesheetApproved ? '' : 'inset-shadow'}`,
popupContentClass: "text-uppercase text-weight-bold text-center rounded-5",
style: shift.value.is_approved ? (holiday ? 'background-color: #7b1fa2 !important' : 'background-color: #0a7d32 !important;') : '',
popupContentStyle: "border: 2px solid var(--q-accent)",
}));
// ================== Methods ================== // ================== Methods ==================
@ -170,7 +134,7 @@
</script> </script>
<template> <template>
<div class="row"> <div class="row full-width q-py-xs">
<!-- delete shift confirmation dialog --> <!-- delete shift confirmation dialog -->
<q-dialog <q-dialog
v-model="is_showing_delete_confirm" v-model="is_showing_delete_confirm"
@ -203,34 +167,53 @@
</q-dialog> </q-dialog>
<div <div
class="row items-center text-uppercase rounded-5" class="row items-center text-uppercase rounded-5 no-wrap"
:class="$q.platform.is.mobile ? 'col q-mb-xs q-px-xs' : 'col-4'" :class="$q.platform.is.mobile ? 'col q-mb-xs q-px-xs' : 'col-4'"
> >
<!-- shift type --> <!-- shift type -->
<q-select <q-select
ref="selectRef" ref="selectRef"
v-model="shiftTypeSelected" v-model="shiftTypeSelected"
v-bind="shiftTypeSelectProps" dense
borderless
color="accent"
label-color="white"
stack-label
label-slot
hide-dropdown-icon
:readonly="isApproved"
:options="getShiftOptions(hasPTO, currentShifts.length > 1)"
lazy-rules
no-error-icon
hide-bottom-space
options-selected-class="text-white text-bold bg-accent"
class="col q-pl-md rounded-5 inset-shadow text-uppercase"
:class="isApproved ? 'bg-white' : ($q.dark.isActive ? 'bg-primary' : '')"
popup-content-class="text-uppercase text-weight-medium rounded-5 shadow-12 z-top"
popup-content-style="border: 1px solid var(--q-primary)"
menu-anchor="bottom middle"
menu-self="top middle"
:menu-offset="[0, 5]"
:style="`border: 1px solid ${q.dark.isActive ? 'var(--q-secondary)' : colors.getPaletteColor('blue-grey-4')}; background-color: ${colors.lighten(getCssVar('secondary')!, 50)}`"
@blur="onBlurShiftTypeSelect" @blur="onBlurShiftTypeSelect"
@update:model-value="onShiftTypeChange" @update:model-value="onShiftTypeChange"
> >
<template #selected-item="scope"> <template #selected-item="scope">
<div <div
class="row items-center text-weight-bold q-ma-none q-pa-none no-wrap ellipsis full-width" class="row items-center text-weight-bold no-wrap ellipsis"
:class="$q.platform.is.mobile ? 'full-height' : ''"
:tabindex="scope.tabindex" :tabindex="scope.tabindex"
> >
<q-icon <q-icon
:name="scope.opt.icon" :name="scope.opt.icon"
:color="shift.is_approved ? 'white' : scope.opt.icon_color" :color="scope.opt.icon_color"
size="sm" size="sm"
class="col-auto" class="col-auto"
:class="shift.is_approved ? 'q-mx-xs' : 'q-mr-xs'" :class="shift.is_approved ? 'q-mx-xs' : 'q-mr-xs'"
/> />
<span <span
style="line-height: 1.2em;"
class="col-auto ellipsis" class="col-auto ellipsis"
:class="!shift.is_approved ? '' : 'text-white'" :class="shift.is_approved ? 'text-accent' : ''"
> >
{{ $t(scope.opt.label) }} {{ $t(scope.opt.label) }}
</span> </span>
@ -278,7 +261,7 @@
:disable="shift.is_approved" :disable="shift.is_approved"
dense dense
keep-color keep-color
size="3em" size="2.75em"
:color="holiday ? 'purple-5' : 'accent'" :color="holiday ? 'purple-5' : 'accent'"
icon="las la-building" icon="las la-building"
checked-icon="las la-laptop" checked-icon="las la-laptop"
@ -294,6 +277,15 @@
</q-tooltip> </q-tooltip>
</q-toggle> </q-toggle>
</template> </template>
<template #label>
<span
class="text-weight-medium text-uppercase q-px-sm no-pointer-events"
:class="$q.dark.isActive ? 'bg-secondary' : 'bg-blue-grey-7'"
>
{{ $t('timesheet.shift.types.label') }}
</span>
</template>
</q-select> </q-select>
</div> </div>
@ -319,38 +311,32 @@
v-else v-else
class="col row items-start text-uppercase rounded-5 q-pa-xs" class="col row items-start text-uppercase rounded-5 q-pa-xs"
> >
<q-input <TargoInput
ref="start_time"
v-model="shift.start_time" v-model="shift.start_time"
v-bind="timeInputProps" no-top-padding
dense
type="time" type="time"
:readonly="isApproved"
:background-color="isApproved ? 'white' : undefined"
:input-text-color="isApproved ? 'accent' : ''"
:label="$t('shared.misc.in')"
:error="shift.has_error"
@blur="onTimeFieldBlur(shift.start_time)" @blur="onTimeFieldBlur(shift.start_time)"
> />
<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 --> <!-- punch out field -->
<q-input <TargoInput
ref="end_time"
v-model="shift.end_time" v-model="shift.end_time"
v-bind="timeInputProps" no-top-padding
dense
type="time" type="time"
:readonly="isApproved"
:background-color="isApproved ? 'white' : undefined"
:input-text-color="isApproved ? 'accent' : ''"
:label="$t('shared.misc.out')"
:error="shift.has_error"
@blur="onTimeFieldBlur(shift.end_time)" @blur="onTimeFieldBlur(shift.end_time)"
> />
<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>
</div> </div>
<div <div
@ -387,7 +373,7 @@
style="border: 3px solid var(--q-accent);" style="border: 3px solid var(--q-accent);"
> >
<q-input <q-input
color="white" color="accent"
v-model="scope.value" v-model="scope.value"
dense dense
:readonly="shift.is_approved" :readonly="shift.is_approved"
@ -469,4 +455,9 @@ drops down, rather than the standard floating red text only -->
padding-top: 0; padding-top: 0;
align-items: center; align-items: center;
} }
:deep(.q-field--dense.q-field--float .q-field__label) {
transform: translate(-17px, -60%) scale(0.75) !important;
border-radius: 10px 10px 10px 0px;
}
</style> </style>

View File

@ -95,24 +95,25 @@
</script> </script>
<template> <template>
<div class="row fit rounded-10 ellipsis no-wrap"> <div class="row fit rounded-10 ellipsis no-wrap shadow-6">
<!-- optional label indicating which holiday if today is a holiday --> <!-- optional label indicating which holiday if today is a holiday -->
<span <span
v-if="isHoliday" v-if="isHoliday"
class="absolute-top-left text-uppercase text-weight-bolder text-purple-5" class="absolute-top-left text-uppercase text-bold holiday-border text-white"
style="transform: translate(25px, -7px);" style="transform: translate(25px, -2px);"
> >
{{ getHolidayName(day.date) }} {{ getHolidayName(day.date) }}
</span> </span>
<div <div
class="col" class="col rounded-10"
:class="isToday ? 'z-top' : 'shadow-12'" :class="isToday ? 'bg-accent' : ''"
:style="isHoliday ? 'border: 2px solid #ab47bc' : ''" :style="isToday ? 'padding: 3px; ' : ''"
> >
<div <div
class="col row fit" class="col row fit rounded-10"
:class="(isDayApproved || timesheetApproved) ? (isHoliday ? 'bg-purple-5' : 'bg-accent') : 'bg-dark'" :class="(isDayApproved || timesheetApproved) ? (isHoliday ? 'bg-purple-5' : 'bg-accent') : 'bg-dark'"
:style="isHoliday ? 'border: 3px solid #ab47bc' : ''"
> >
<!-- Date block --> <!-- Date block -->
<ShiftListDateWidget <ShiftListDateWidget
@ -123,7 +124,7 @@
/> />
<div <div
class="col column justify-center q-py-xs full-width" class="col column justify-center q-py-xs full-width q-my-xs"
@mouseenter="presetMouseover = true" @mouseenter="presetMouseover = true"
@mouseleave="presetMouseover = false" @mouseleave="presetMouseover = false"
> >
@ -134,7 +135,7 @@
leave-active-class="animated zoomOut fast" leave-active-class="animated zoomOut fast"
> >
<q-btn <q-btn
v-if="!$q.platform.is.mobile && day.shifts.length < 1 && presetMouseover && timesheetStore.has_timesheet_preset" v-if="day.shifts.length < 1 && presetMouseover && timesheetStore.has_timesheet_preset"
:disable="day.shifts.length > 0" :disable="day.shifts.length > 0"
flat flat
dense dense
@ -155,7 +156,7 @@
<div <div
v-for="shift, shiftIndex in day.shifts" v-for="shift, shiftIndex in day.shifts"
:key="shiftIndex" :key="shiftIndex"
class="col-auto" class="col-auto row"
> >
<ShiftListDayRow <ShiftListDayRow
v-model:shift="day.shifts[shiftIndex]!" v-model:shift="day.shifts[shiftIndex]!"
@ -188,10 +189,21 @@
:color="isHoliday ? 'purple-5' : 'accent'" :color="isHoliday ? 'purple-5' : 'accent'"
text-color="white" text-color="white"
class="full-height" class="full-height"
:style="isHoliday ? 'border-radius: 0 6px 6px 0;' : ''"
@click="addNewShift" @click="addNewShift"
/> />
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</template> </template>
<style
scoped
lang="scss"
>
.holiday-border {
text-shadow: 2px 0 $purple-5, -2px 0 $purple-5, 0 2px $purple-5, 0 -2px $purple-5,
1px 1px $purple-5, -1px -1px $purple-5, 1px -1px $purple-5, -1px 1px $purple-5;
}
</style>

View File

@ -10,10 +10,6 @@
import { useTimesheetApi } from 'src/modules/timesheets/composables/use-timesheet-api'; import { useTimesheetApi } from 'src/modules/timesheets/composables/use-timesheet-api';
import { TimesheetDayDisplay } from 'src/modules/timesheets/models/timesheet.models'; import { TimesheetDayDisplay } from 'src/modules/timesheets/models/timesheet.models';
// ========== constants ========================================
const CURRENT_DATE_STRING = new Date().toISOString().slice(0, 10);
// ========== state ======================================== // ========== state ========================================
const emit = defineEmits<{ const emit = defineEmits<{
@ -96,15 +92,14 @@
> >
<div <div
v-for="row, rowIndex of timesheetRows" v-for="row, rowIndex of timesheetRows"
:key="JSON.stringify(row)" :key="rowIndex"
class="col-auto row items-stretch" class="col-auto row items-stretch"
:style="`animation-delay: ${rowIndex / 10}s;`" :style="`animation-delay: ${rowIndex / 10}s;`"
> >
<div <div
v-for="day, dayIndex in row" v-for="day, dayIndex in row"
:key="day.day.date" :key="day.day.date"
class="col row items-stretch q-pa-sm relative-position" class="col row items-stretch q-pa-xs relative-position"
:class="day.day.date === CURRENT_DATE_STRING ? 'bg-info rounded-15 shadow-6' : ''"
> >
<ShiftListDay <ShiftListDay
v-model="row[dayIndex]!.day" v-model="row[dayIndex]!.day"

View File

@ -20,7 +20,10 @@
import { useTimesheetApi } from 'src/modules/timesheets/composables/use-timesheet-api'; import { useTimesheetApi } from 'src/modules/timesheets/composables/use-timesheet-api';
import { useExpensesStore } from 'src/stores/expense-store'; import { useExpensesStore } from 'src/stores/expense-store';
import { useTimesheetStore } from 'src/stores/timesheet-store'; import { useTimesheetStore } from 'src/stores/timesheet-store';
import { RouteNames } from 'src/router/router-constants'; import { RouteNames } from 'src/router/router-constants';
import { getHoursMinutesBetweenTwoHHmm } from 'src/utils/date-and-time-utils';
import type { Shift } from 'src/modules/timesheets/models/shift.models';
import type { TimesheetDay } from 'src/modules/timesheets/models/timesheet.models';
// ================= state ==================== // ================= state ====================
@ -39,18 +42,22 @@ import { RouteNames } from 'src/router/router-constants';
// ================== computed ==================== // ================== computed ====================
const hasShiftErrors = computed(() => timesheetStore.all_current_shifts.filter(shift => shift.has_error === true).length > 0); const hasShiftErrors = computed(() => timesheetStore.all_current_shifts.filter(shift => shift.has_error === true).length > 0);
const isTimesheetsApproved = computed(() => timesheetStore.timesheets.every(timesheet => timesheet.is_approved)); const isTimesheetsApproved = computed(() => timesheetStore.timesheets.every(timesheet => timesheet.is_approved));
const weeklyHours = computed(() => timesheetStore.timesheets.map(timesheet => const weeklyHours = computed(() => timesheetStore.timesheets.map(timesheet =>
Object.values(timesheet.weekly_hours).reduce((sum, hoursPerType) => sum += hoursPerType, 0) - timesheet.weekly_hours.sick timesheet.days.reduce((daySum: number, day: TimesheetDay) => {
return daySum + day.shifts.reduce((shiftSum: number, shift: Shift) => {
if (!shift.end_time || !shift.start_time || shift.type === 'SICK') return shiftSum;
const time = getHoursMinutesBetweenTwoHHmm(shift.start_time, shift.end_time);
return shiftSum + time.hours + Number(time.minutes / 60);
}, 0)
}, 0)
)); ));
const totalHours = computed(() => timesheetStore.timesheets.reduce((sum, timesheet) =>
sum += timesheet.weekly_hours.regular const totalHours = computed(() => weeklyHours.value.reduce((sum, week) =>
+ timesheet.weekly_hours.evening sum += week,
+ timesheet.weekly_hours.emergency
+ timesheet.weekly_hours.vacation
+ timesheet.weekly_hours.holiday
+ timesheet.weekly_hours.overtime,
0 //initial value 0 //initial value
)); ));
@ -82,7 +89,7 @@ import { RouteNames } from 'src/router/router-constants';
timesheetStore.isShowingUnsavedWarning = false; timesheetStore.isShowingUnsavedWarning = false;
timesheetStore.timesheets = []; timesheetStore.timesheets = [];
timesheetStore.initial_timesheets = []; timesheetStore.initial_timesheets = [];
await router.push({ name: timesheetStore.nextPageNameAfterUnsaveWarning ?? RouteNames.DASHBOARD}); await router.push({ name: timesheetStore.nextPageNameAfterUnsaveWarning ?? RouteNames.DASHBOARD });
} }
const onClickSaveBeforeLeaving = async () => { const onClickSaveBeforeLeaving = async () => {
@ -232,7 +239,7 @@ import { RouteNames } from 'src/router/router-constants';
style="min-height: 20vh;" style="min-height: 20vh;"
> >
<span class="text-uppercase text-weight-bolder text-center">{{ $t('shared.error.no_data_found') <span class="text-uppercase text-weight-bolder text-center">{{ $t('shared.error.no_data_found')
}}</span> }}</span>
<q-icon <q-icon
name="las la-calendar" name="las la-calendar"
color="accent" color="accent"

View File

@ -23,7 +23,6 @@ export const useExpensesApi = () => {
const success = await expenses_store.upsertExpense(expense, employee_email); const success = await expenses_store.upsertExpense(expense, employee_email);
if (success) { if (success) {
expenses_store.current_expense = new Expense(date.formatDate(new Date(), 'YYYY-MM-DD'));
await timesheet_store.getTimesheetsByOptionalEmployeeEmail(employee_email); await timesheet_store.getTimesheetsByOptionalEmployeeEmail(employee_email);
return true; return true;

View File

@ -1,4 +1,4 @@
export type ExpenseType = 'PER_DIEM' | 'MILEAGE' | 'EXPENSES' | 'ON_CALL'; export type ExpenseType = 'PER_DIEM' | 'MILEAGE' | 'EXPENSES' | 'ON_CALL' | undefined;
export const EXPENSE_TYPE: ExpenseType[] = ['PER_DIEM', 'MILEAGE', 'EXPENSES', 'ON_CALL',]; export const EXPENSE_TYPE: ExpenseType[] = ['PER_DIEM', 'MILEAGE', 'EXPENSES', 'ON_CALL',];
export const TYPES_WITH_MILEAGE_ONLY: Readonly<ExpenseType[]> = ['MILEAGE']; export const TYPES_WITH_MILEAGE_ONLY: Readonly<ExpenseType[]> = ['MILEAGE'];
@ -9,8 +9,8 @@ export class Expense {
timesheet_id: number; timesheet_id: number;
date: string; //YYYY-MM-DD date: string; //YYYY-MM-DD
type: ExpenseType; type: ExpenseType;
amount: number; amount?: number | null;
mileage?: number; mileage?: number | null;
attachment_name?: string; attachment_name?: string;
attachment_key?: string; attachment_key?: string;
comment: string; comment: string;
@ -21,7 +21,7 @@ export class Expense {
this.id = -1; this.id = -1;
this.timesheet_id = -1; this.timesheet_id = -1;
this.date = date; this.date = date;
this.type = 'EXPENSES'; this.type = undefined;
this.amount = 0; this.amount = 0;
this.comment = ''; this.comment = '';
this.is_approved = false; this.is_approved = false;

View File

@ -10,12 +10,12 @@ export const getExpenseIcon = (type: ExpenseType) => {
} }
}; };
export const useExpenseRules = (t: (_key: string) => string) => { export const useExpenseRules = () => {
const isPresent = (val: unknown) => val !== undefined && val !== null && val !== ''; const isPresent = (val: unknown) => val !== undefined && val !== null && val !== '';
const typeRequired = (val: unknown) => (!!val) || t('timesheet.expense.errors.type_required'); const typeRequired = (val: unknown) => isPresent(val);
const amountRequired = (val: unknown) => (isPresent(val)) || t('timesheet.expense.errors.amount_required_for_type'); const amountRequired = (val: number | null | undefined) => isPresent(val) && !!val && val > 0;
const mileageRequired = (val: unknown) => (isPresent(val)) || t('timesheet.expense.errors.mileage_required_for_type'); const mileageRequired = (val: number | null | undefined) => isPresent(val) && !!val && val > 0;
const commentRequired = (val: unknown) => (typeof val === 'string' ? val.trim().length > 0 : false) || t('timesheet.expense.hints.comment_required'); const commentRequired = (val: string | null | undefined) => typeof val === 'string' ? val.trim().length > 0 : false;
return { return {
typeRequired, typeRequired,