refactor(timesheet): add at least some updates to approved shift look, maybe more???

This commit is contained in:
Nicolas Drolet 2025-11-14 17:00:27 -05:00
parent da93753515
commit b307f33ab0
13 changed files with 482 additions and 412 deletions

View File

@ -105,7 +105,7 @@ export default defineConfig((ctx) => {
notify: { notify: {
color: 'primary', color: 'primary',
}, },
dark: false, dark: 'auto',
}, },
// iconSet: 'material-icons', // Quasar icon set // iconSet: 'material-icons', // Quasar icon set

View File

@ -37,7 +37,7 @@
label-color="accent" label-color="accent"
class="rounded-5 inset-shadow bg-blue-grey-1" class="rounded-5 inset-shadow bg-blue-grey-1"
label-slot label-slot
input-class="text-weight-medium text-h6" input-class="text-weight-medium text-h6 text-primary"
> >
<template #label> <template #label>
<span class="text-weight-bolder text-uppercase text-overline"> {{ $t('login.email') }} </span> <span class="text-weight-bolder text-uppercase text-overline"> {{ $t('login.email') }} </span>

View File

@ -20,7 +20,7 @@
dense dense
:stack-label="!isEditing" :stack-label="!isEditing"
autogrow autogrow
filled :standout="$q.dark.isActive ? 'bg-blue-grey-3' : 'bg-blue-grey-9'"
debounce="500" debounce="500"
label-color="accent" label-color="accent"
class="q-ma-xs text-uppercase" class="q-ma-xs text-uppercase"

View File

@ -16,7 +16,7 @@
v-model="model" v-model="model"
dense dense
:stack-label="!isEditing" :stack-label="!isEditing"
filled :standout="$q.dark.isActive ? 'bg-blue-grey-3' : 'bg-blue-grey-9'"
label-color="accent" label-color="accent"
class="q-ma-xs text-h6 text-uppercase" class="q-ma-xs text-h6 text-uppercase"
popup-content-class="text-weight-medium text-h6" popup-content-class="text-weight-medium text-h6"

View File

@ -2,7 +2,7 @@
setup setup
lang="ts" lang="ts"
> >
import { date } from 'quasar'; import { date } from 'quasar';
const { title, startDate = "", endDate = "" } = defineProps<{ const { title, startDate = "", endDate = "" } = defineProps<{
title: string; title: string;
@ -11,25 +11,33 @@
}>(); }>();
const date_format_options = { day: 'numeric', month: 'long', year: 'numeric', }; const date_format_options = { day: 'numeric', month: 'long', year: 'numeric', };
</script> </script>
<template> <template>
<div class="column q-mt-lg text-uppercase text-center text-weight-bolder text-h4"> <div class="column q-mt-lg text-uppercase text-center text-weight-bolder text-h4">
<span class="col">{{ $t(title) }}</span> <span class="col">{{ $t(title) }}</span>
<div <transition
v-if="startDate.length > 0" enter-active-class="animated fadeInDown"
class="col row flex-center full-width q-py-none q-my-none" leave-active-class="animated fadeOutDown"
mode="out-in"
> >
<div class="text-accent text-weight-bold text-h6"> <div
{{ $d(date.extractDate(startDate, 'YYYY-MM-DD'), date_format_options) }} :key="startDate"
v-if="startDate.length > 0"
class="col row flex-center full-width q-py-none q-my-none"
>
<div class="text-accent text-weight-bold text-h6">
{{ $d(date.extractDate(startDate, 'YYYY-MM-DD'), date_format_options) }}
</div>
<div class="text-body2 q-mx-md text-weight-medium">
{{ $t('shared.misc.to') }}
</div>
<div class="text-accent text-weight-bold text-h6">
{{ $d(date.extractDate(endDate, 'YYYY-MM-DD'), date_format_options) }}
</div>
</div> </div>
<div class="text-body2 q-mx-md text-weight-medium"> </transition>
{{ $t('shared.misc.to') }}
</div>
<div class="text-accent text-weight-bold text-h6">
{{ $d(date.extractDate(endDate, 'YYYY-MM-DD'), date_format_options) }}
</div>
</div>
</div> </div>
</template> </template>

View File

@ -2,15 +2,20 @@
setup setup
lang="ts" lang="ts"
> >
import { date } from 'quasar';
import { computed, inject, ref } from 'vue'; import { computed, inject, ref } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useUiStore } from 'src/stores/ui-store'; import { useUiStore } from 'src/stores/ui-store';
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 { useExpenseRules } from 'src/modules/timesheets/utils/expense.util'; import { convertToMonetaryAmount, getExpenseIcon, useExpenseRules } from 'src/modules/timesheets/utils/expense.util';
import { Expense, EXPENSE_TYPE, TYPES_WITH_AMOUNT_ONLY } from 'src/modules/timesheets/models/expense.models'; import { type ExpenseType, TYPES_WITH_AMOUNT_ONLY } from 'src/modules/timesheets/models/expense.models';
interface ExpenseOption {
label: string;
value: ExpenseType;
icon: string;
}
const { t } = useI18n(); const { t } = useI18n();
@ -28,25 +33,33 @@
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('-', '/') ?? '');
// const expense_options: { label: string, value: ExpenseType, icon: string }[] = EXPENSE_TYPE.map(expense_type => {
// return { label: t(`timesheet.expense.types.${expense_type}`), value: expense_type, icon: getExpenseIcon(expense_type) };
// });
const expense_options: ExpenseOption[] = [
{label: t('timesheet.expense.types.PER_DIEM'), value: 'PER_DIEM', icon: getExpenseIcon('PER_DIEM')},
{label: t('timesheet.expense.types.EXPENSES'), value: 'EXPENSES', icon: getExpenseIcon('EXPENSES')},
{label: t('timesheet.expense.types.MILEAGE'), value: 'MILEAGE', icon: getExpenseIcon('MILEAGE')},
{label: t('timesheet.expense.types.ON_CALL'), value: 'ON_CALL', icon: getExpenseIcon('ON_CALL')},
]
const expense_selected = ref(expense_options.find(expense => expense.value == expenses_store.current_expense.type));
const openDatePicker = () => { const openDatePicker = () => {
is_navigator_open.value = true; is_navigator_open.value = true;
if (timesheet_store.pay_period !== undefined) { if (timesheet_store.pay_period !== undefined) {
expenses_store.current_expense.date = timesheet_store.pay_period.period_start; expenses_store.current_expense.date = timesheet_store.pay_period.period_start;
} }
console.log('current pay period start date: ', period_start_date.value);
console.log('current pay period end date: ', period_end_date.value);
};
const cancelUpdateMode = () => {
expenses_store.current_expense = new Expense(date.formatDate(new Date(), 'YYYY-MM-DD'));
expenses_store.initial_expense = new Expense(date.formatDate(new Date(), 'YYYY-MM-DD'));
expenses_store.mode = 'create';
}; };
const requestExpenseCreationOrUpdate = async () => { const requestExpenseCreationOrUpdate = async () => {
if (expenses_store.mode === 'create') await expenses_api.createExpenseByEmployeeEmail(employee_email ?? '', expenses_store.current_expense?.date ?? ''); if (expenses_store.mode === 'create') await expenses_api.createExpenseByEmployeeEmail(employee_email ?? '', expenses_store.current_expense?.date ?? '');
else await expenses_api.updateExpenseByEmployeeEmail(employee_email ?? '', expenses_store.current_expense?.date ?? ''); else await expenses_api.updateExpenseByEmployeeEmail(employee_email ?? '', expenses_store.current_expense?.date ?? '');
}; };
defineEmits<{
'onClickUpdateCancel': [void];
}>();
</script> </script>
<template> <template>
@ -55,25 +68,30 @@
:key="expenses_store.current_expense.id" :key="expenses_store.current_expense.id"
flat flat
@submit.prevent="requestExpenseCreationOrUpdate" @submit.prevent="requestExpenseCreationOrUpdate"
class="full-width"
> >
<div <div
class="text-uppercase text-weight-medium q-pt-sm q-px-lg q-ma-sm" class="text-uppercase text-weight-medium q-pt-sm q-ma-sm"
:class="expenses_store.mode === 'create' ? '' : 'invisible'" :class="expenses_store.mode === 'create' ? 'q-px-lg' : 'invisible'"
> >
{{ $t('timesheet.expense.add_expense') }} {{ $t('timesheet.expense.add_expense') }}
</div> </div>
<div class="row justify-between items-start rounded-5 q-px-lg q-pb-sm"> <div
class="row justify-between items-start rounded-5 q-pb-sm"
:class="expenses_store.mode === 'create' ? 'q-px-lg' : ''"
>
<!-- date selection input --> <!-- date selection input -->
<q-input <q-input
v-model="expenses_store.current_expense.date" v-model="expenses_store.current_expense.date"
dense dense
type="date"
outlined outlined
readonly readonly
stack-label stack-label
color="primary" color="primary"
class="col q-px-xs" class="col-auto q-px-xs"
input-class="text-weight-medium" input-class="text-weight-medium"
input-style="font-size: 1.2em;" input-style="font-size: 1em;"
:label="$t('timesheet.expense.date')" :label="$t('timesheet.expense.date')"
> >
<template #prepend> <template #prepend>
@ -110,12 +128,12 @@
<!-- expenses type selection --> <!-- expenses type selection -->
<q-select <q-select
v-model="expenses_store.current_expense.type" v-model="expense_selected"
:options="EXPENSE_TYPE"
standout="bg-blue-grey-9" standout="bg-blue-grey-9"
dense dense
emit-value :options="expense_options"
hide-dropdown-icon hide-dropdown-icon
stack-label
label-slot label-slot
class="col q-px-xs" class="col q-px-xs"
color="primary" color="primary"
@ -126,7 +144,7 @@
popup-content-class="text-uppercase text-weight-bold text-center rounded-5" popup-content-class="text-uppercase text-weight-bold text-center rounded-5"
popup-content-style="border: 2px solid var(--q-accent)" popup-content-style="border: 2px solid var(--q-accent)"
:rules="[rules.typeRequired]" :rules="[rules.typeRequired]"
:option-label="label => $t(`timesheet.expense.types.${label}`)" @update:model-value="option => expenses_store.current_expense.type = option.value"
> >
<template #label> <template #label>
<span class="text-weight-bold text-accent text-uppercase text-caption"> <span class="text-weight-bold text-accent text-uppercase text-caption">
@ -136,13 +154,18 @@
<template #selected-item="scope"> <template #selected-item="scope">
<div <div
class="row flex-center text-weight-bold q-ma-none q-pa-none no-wrap ellipsis full-width" class="row items-center text-weight-bold q-ma-none q-pa-none no-wrap ellipsis full-width"
:class="ui_store.is_mobile_mode ? 'items-center full-height' : 'flex-center'" :class="ui_store.is_mobile_mode ? 'full-height' : ''"
:tabindex="scope.tabindex" :tabindex="scope.tabindex"
> >
<q-icon
:name="scope.opt.icon"
size="xs"
class="col-auto q-mx-xs"
/>
<span <span
style="line-height: 0.9em;" style="line-height: 1em;"
class="col-auto ellipsis" class="col-auto ellipsis text-uppercase"
>{{ scope.opt.label }}</span> >{{ scope.opt.label }}</span>
</div> </div>
</template> </template>
@ -153,16 +176,18 @@
<q-input <q-input
key="amount" key="amount"
v-model.number="expenses_store.current_expense.amount" v-model.number="expenses_store.current_expense.amount"
filled standout="bg-blue-grey-9"
input-class="text-right"
dense dense
stack-label
color="primary"
class="col q-px-xs"
label-slot label-slot
stack-label
suffix="$" suffix="$"
color="primary"
class="col-auto q-px-xs"
input-class="text-right text-weight-bold"
:input-style="'font-size: 1.2em;'"
lazy-rules="ondemand" lazy-rules="ondemand"
:rules="[rules.amountRequired]" :rules="[rules.amountRequired]"
@blur="expenses_store.current_expense.amount = convertToMonetaryAmount(expenses_store.current_expense.amount)"
> >
<template #label> <template #label>
<span class="text-weight-bold text-accent text-uppercase text-caption"> <span class="text-weight-bold text-accent text-uppercase text-caption">
@ -177,7 +202,7 @@
<q-input <q-input
key="mileage" key="mileage"
v-model.number="expenses_store.current_expense.mileage" v-model.number="expenses_store.current_expense.mileage"
filled standout="bg-blue-grey-9"
input-class="text-right" input-class="text-right"
dense dense
stack-label stack-label
@ -200,7 +225,7 @@
<!-- employee comment input --> <!-- employee comment input -->
<q-input <q-input
v-model="expenses_store.current_expense.comment" v-model="expenses_store.current_expense.comment"
filled standout="bg-blue-grey-9"
dense dense
stack-label stack-label
label-slot label-slot
@ -222,8 +247,8 @@
<!-- import attach file section --> <!-- import attach file section -->
<q-file <q-file
v-model="files" v-model="files"
standout="bg-blue-grey-9"
dense dense
filled
use-chips use-chips
multiple multiple
stack-label stack-label
@ -257,7 +282,7 @@
icon="clear" icon="clear"
color="negative" color="negative"
:label="$q.screen.gt.sm ? $t('shared.label.cancel') : ''" :label="$q.screen.gt.sm ? $t('shared.label.cancel') : ''"
@click="cancelUpdateMode" @click="$emit('onClickUpdateCancel')"
/> />
<q-btn <q-btn
@ -265,7 +290,8 @@
color="accent" color="accent"
:icon="expenses_store.mode === 'update' ? 'save' : 'upload'" :icon="expenses_store.mode === 'update' ? 'save' : 'upload'"
:label="$q.screen.gt.sm ? (expenses_store.mode === 'update' ? $t('shared.label.update') : $t('shared.label.add')) : ''" :label="$q.screen.gt.sm ? (expenses_store.mode === 'update' ? $t('shared.label.update') : $t('shared.label.add')) : ''"
class="q-px-sm q-mb-sm q-mx-lg" class="q-px-sm "
:class="expenses_store.mode === 'create' ? 'q-mr-lg q-mb-md' : 'q-mb-sm q-ml-lg'"
type="submit" type="submit"
/> />
</div> </div>

View File

@ -27,7 +27,6 @@
const refresh_key = ref(1); const refresh_key = ref(1);
const background_class = computed(() => deepEqual(expense, expenses_store.current_expense) ? '' : ''); const background_class = computed(() => deepEqual(expense, expenses_store.current_expense) ? '' : '');
const background_style = computed(() => deepEqual(expense, expenses_store.current_expense) ? 'border: 3px solid var(--q-accent);' : '');
const approved_class = computed(() => expense.is_approved ? ' bg-accent text-white' : '') const approved_class = computed(() => expense.is_approved ? ' bg-accent text-white' : '')
const is_authorized_to_approve = computed(() => CAN_APPROVE_PAY_PERIODS.includes(auth_store.user?.role ?? 'GUEST')) const is_authorized_to_approve = computed(() => CAN_APPROVE_PAY_PERIODS.includes(auth_store.user?.role ?? 'GUEST'))
const is_showing_update_form = ref(false); const is_showing_update_form = ref(false);
@ -64,7 +63,6 @@
:clickable="horizontal" :clickable="horizontal"
class="column col-4 items-center q-my-sm q-py-none shadow-3 rounded-5 bg-dark" class="column col-4 items-center q-my-sm q-py-none shadow-3 rounded-5 bg-dark"
:class="background_class + approved_class" :class="background_class + approved_class"
:style="background_style"
@click="onExpenseClicked" @click="onExpenseClicked"
> >
<div class="row full-width items-center"> <div class="row full-width items-center">
@ -72,21 +70,9 @@
<q-item-section avatar> <q-item-section avatar>
<q-icon <q-icon
:name="getExpenseIcon(expense.type)" :name="getExpenseIcon(expense.type)"
:color="expense.is_approved ? 'white' : ($q.dark.isActive ? 'blue-grey-2' : 'primary')" :color="expense.is_approved ? 'white' : ($q.dark.isActive ? 'white' : 'primary')"
size="lg" size="lg"
> />
<q-badge
v-if="expense.type === 'ON_CALL'"
floating
class="q-pa-none rounded-50 bg-white z-top"
>
<q-icon
name="shield"
size="xs"
:color="expense.is_approved ? 'white' : ($q.dark.isActive ? 'blue-grey-2' : 'primary')"
/>
</q-badge>
</q-icon>
</q-item-section> </q-item-section>
<!-- amount or mileage section --> <!-- amount or mileage section -->
@ -110,7 +96,7 @@
class="text-uppercase text-weight-light" class="text-uppercase text-weight-light"
:class="approved_class" :class="approved_class"
> >
{{ $d(new Date(expense.date), { month: 'short', day: 'numeric', weekday: 'long' }) }} {{ $d(date.extractDate(expense.date, 'YYYY-MM-DD'), { month: 'short', day: 'numeric', weekday: 'long' }) }}
</q-item-label> </q-item-label>
</q-item-section> </q-item-section>
@ -185,13 +171,20 @@
</q-item-section> </q-item-section>
<q-item-section :side="$q.screen.gt.sm"> <q-item-section :side="$q.screen.gt.sm">
<q-icon
v-if="expense.is_approved"
name="verified"
color="white"
size="lg"
/>
<q-btn <q-btn
v-else
flat flat
size="lg" size="lg"
:icon="expense.is_approved ? 'verified' : 'close'" icon="close"
:color="expense.is_approved ? 'white' : 'negative'" color="negative"
class="q-pa-none z-top" class="q-pa-none z-top"
:class="expense.is_approved ? 'no-pointer' : ''"
@click.stop="requestExpenseDeletion" @click.stop="requestExpenseDeletion"
/> />
</q-item-section> </q-item-section>
@ -201,7 +194,7 @@
@hide="expenses_store.is_hiding_create_form = false" @hide="expenses_store.is_hiding_create_form = false"
:duration="200" :duration="200"
> >
<ExpenseDialogForm v-if="is_showing_update_form && expenses_store.is_hiding_create_form" /> <ExpenseDialogForm v-if="is_showing_update_form && expenses_store.is_hiding_create_form" @on-click-update-cancel="onUpdateClicked"/>
</q-slide-transition> </q-slide-transition>
</q-item> </q-item>
</template> </template>

View File

@ -3,8 +3,7 @@
lang="ts" lang="ts"
> >
/* eslint-disable*/ /* eslint-disable*/
import { onBeforeUnmount, onMounted, ref, toRaw, useTemplateRef } from 'vue'; import { onBeforeUnmount, onMounted, ref, useTemplateRef } from 'vue';
import { unwrapAndClone } from 'src/utils/unwrap-and-clone';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { QSelect } from 'quasar'; import { QSelect } from 'quasar';
import { Shift, ShiftType } from 'src/modules/timesheets/models/shift.models'; import { Shift, ShiftType } from 'src/modules/timesheets/models/shift.models';
@ -22,27 +21,26 @@
const COMMENT_LENGTH_MAX = 280; const COMMENT_LENGTH_MAX = 280;
const SHIFT_OPTIONS: ShiftOption[] = [ const SHIFT_OPTIONS: ShiftOption[] = [
{ label: t('timesheet.shift.types.REGULAR'), value: 'REGULAR', icon: 'wb_sunny', icon_color: '' }, { label: t('timesheet.shift.types.REGULAR'), value: 'REGULAR', icon: 'wb_sunny', icon_color: 'blue-grey-3' },
{ label: t('timesheet.shift.types.EVENING'), value: 'EVENING', icon: 'bedtime', icon_color: 'indigo-8' }, { label: t('timesheet.shift.types.EVENING'), value: 'EVENING', icon: 'bedtime', icon_color: 'indigo-8' },
{ label: t('timesheet.shift.types.EMERGENCY'), value: 'EMERGENCY', icon: 'ring_volume', icon_color: 'red-8' }, { label: t('timesheet.shift.types.EMERGENCY'), value: 'EMERGENCY', icon: 'ring_volume', icon_color: 'red-8' },
{ label: t('timesheet.shift.types.VACATION'), value: 'VACATION', icon: 'beach_access', icon_color: 'yellow-8' }, { label: t('timesheet.shift.types.VACATION'), value: 'VACATION', icon: 'beach_access', icon_color: 'yellow-8' },
{ label: t('timesheet.shift.types.HOLIDAY'), value: 'HOLIDAY', icon: 'forest', icon_color: 'green-8' }, { label: t('timesheet.shift.types.HOLIDAY'), value: 'HOLIDAY', icon: 'forest', icon_color: 'green-8' },
{ label: t('timesheet.shift.types.SICK'), value: 'SICK', icon: 'medication_liquid', icon_color: 'cyan-8' }, { label: t('timesheet.shift.types.SICK'), value: 'SICK', icon: 'medication_liquid', icon_color: 'light-blue-6' },
]; ];
const shift = defineModel<Shift>('shift', { required: true }); const shift = defineModel<Shift>('shift', { required: true });
const { dense = false, outlined = false } = defineProps<{ const { dense = false, hasShiftAfter = false } = defineProps<{
dense?: boolean; dense?: boolean;
outlined?: boolean; hasShiftAfter?: boolean;
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
'saveComment': [comment: string, shift_id: number]; 'saveComment': [comment: string, shift_id: number];
'requestDelete': [void]; 'requestDelete': [void];
}>(); }>();
const is_showing_time_picker = ref(false);
const select_ref = useTemplateRef<QSelect>('select'); const select_ref = useTemplateRef<QSelect>('select');
const initial_shift = ref<Shift>(unwrapAndClone(toRaw(shift.value)))
let timer: NodeJS.Timeout; let timer: NodeJS.Timeout;
const shift_type_selected = ref(SHIFT_OPTIONS.find(option => option.value == shift.value.type)); const shift_type_selected = ref(SHIFT_OPTIONS.find(option => option.value == shift.value.type));
@ -95,226 +93,239 @@
> >
<q-icon name="delete" /> <q-icon name="delete" />
</template> </template>
<div :class="ui_store.is_mobile_mode ? 'column' : 'row'"> <div :class="ui_store.is_mobile_mode ? 'column' : 'row'" >
<div class="row items-center text-uppercase rounded-5 bg-transparent q-mb-xs" :class="ui_store.is_mobile_mode ? 'col' : 'col-4'"> <div
<!-- mobile comment button --> class="row items-center text-uppercase rounded-5 bg-transparent"
<q-btn :class="ui_store.is_mobile_mode ? 'col q-mb-xs' : 'col-4'"
v-if="ui_store.is_mobile_mode && !dense" >
:icon="shift.comment ? 'chat' : 'chat_bubble_outline'" <!-- mobile comment button -->
:text-color="shift.comment ? 'accent' : 'grey-5'" <q-btn
class="col-auto full-height q-mx-xs rounded-5 shadow-1" v-if="ui_store.is_mobile_mode && !dense"
> :icon="shift.comment ? 'chat' : 'chat_bubble_outline'"
<q-popup-edit :text-color="shift.comment ? 'accent' : 'grey-5'"
v-model="shift.comment" class="col-auto full-height q-mx-xs rounded-5 shadow-1"
:title="$t('timesheet.shift.fields.header_comment')" >
auto-save <q-popup-edit
v-slot="scope" v-model="shift.comment"
class="bg-dark" :title="$t('timesheet.shift.fields.header_comment')"
> auto-save
<q-input v-slot="scope"
color="white" class="bg-dark"
v-model="scope.value" >
dense <q-input
:readonly="shift.is_approved" color="white"
autofocus v-model="scope.value"
counter dense
bottom-slots :readonly="shift.is_approved"
:maxlength="COMMENT_LENGTH_MAX" autofocus
class="q-pb-lg" counter
:class="shift.is_approved ? 'cursor-not-allowed' : ''" bottom-slots
@keyup.enter="scope.set" :maxlength="COMMENT_LENGTH_MAX"
> class="q-pb-lg"
<template #append> :class="shift.is_approved ? 'cursor-not-allowed' : ''"
<q-icon name="edit" /> @keyup.enter="scope.set"
</template> >
<template #append>
<template #counter> <q-icon name="edit" />
<div class="row flex-center"> </template>
<q-space />
<q-knob <template #counter>
:model-value="scope.value?.length" <div class="row flex-center">
readonly <q-space />
:max="COMMENT_LENGTH_MAX" <q-knob
size="1.6em" :model-value="scope.value?.length"
:thickness="0.4" readonly
:color="getCommentCounterColor(scope.value?.length ?? 0)" :max="COMMENT_LENGTH_MAX"
track-color="grey-4" size="1.6em"
class="col-auto q-mr-xs" :thickness="0.4"
/> :color="getCommentCounterColor(scope.value?.length ?? 0)"
<span track-color="grey-4"
:class="'col-auto text-weight-bolder text-' + getCommentCounterColor(scope.value?.length ?? 0)" class="col-auto q-mr-xs"
>{{ 280 - (scope.value?.length ?? 0) }}</span> />
</div> <span
</template> :class="'col-auto text-weight-bolder text-' + getCommentCounterColor(scope.value?.length ?? 0)"
</q-input> >{{ 280 - (scope.value?.length ?? 0) }}</span>
</q-popup-edit> </div>
</q-btn> </template>
</q-input>
<!-- shift type --> </q-popup-edit>
<q-select </q-btn>
ref="select"
v-model="shift_type_selected" <!-- shift type -->
standout="bg-blue-grey-9" <q-select
dense ref="select"
:readonly="shift.is_approved" v-model="shift_type_selected"
:options-dense="!ui_store.is_mobile_mode" :standout="$q.dark.isActive ? 'bg-blue-grey-3' : 'bg-blue-grey-9'"
hide-dropdown-icon dense
:menu-offset="[0, 10]" :borderless="shift.is_approved"
menu-anchor="bottom middle" :readonly="shift.is_approved"
menu-self="top middle" :options-dense="!ui_store.is_mobile_mode"
:options="SHIFT_OPTIONS" hide-dropdown-icon
class="col rounded-5 q-mx-xs bg-dark" :menu-offset="[0, 10]"
popup-content-class="text-uppercase text-weight-bold text-center rounded-5" menu-anchor="bottom middle"
popup-content-style="border: 2px solid var(--q-accent)" menu-self="top middle"
@blur="onBlurShiftTypeSelect" :options="SHIFT_OPTIONS"
@update:model-value="option => shift.type = option.value" class="col rounded-5 q-mx-xs bg-dark"
> :class="shift.is_approved ? 'inset-shadow' : ''"
<template #selected-item="scope"> :style="shift.is_approved ? 'background-color: #0002 !important;' : ''"
<div popup-content-class="text-uppercase text-weight-bold text-center rounded-5"
class="row flex-center text-weight-bold q-ma-none q-pa-none no-wrap ellipsis full-width" popup-content-style="border: 2px solid var(--q-accent)"
:class="ui_store.is_mobile_mode ? 'items-center full-height' : 'flex-center'" @blur="onBlurShiftTypeSelect"
:tabindex="scope.tabindex" @update:model-value="option => shift.type = option.value"
> >
<q-icon <template #selected-item="scope">
:name="scope.opt.icon" <div
:color="scope.opt.icon_color" class="row flex-center text-weight-bold q-ma-none q-pa-none no-wrap ellipsis full-width"
size="sm" :class="ui_store.is_mobile_mode ? 'items-center full-height' : 'flex-center'"
class="col-auto q-mx-xs" :tabindex="scope.tabindex"
/> >
<span <q-icon
style="line-height: 0.9em;" :name="scope.opt.icon"
class="col-auto ellipsis" :color="scope.opt.icon_color"
>{{ scope.opt.label }}</span> size="sm"
</div> class="col-auto q-mx-xs"
</template> />
</q-select> <span
</div> style="line-height: 0.9em;"
class="col-auto ellipsis"
<div class="col row flex-center text-uppercase rounded-5 bg-transparent q-pa-xs"> :class="shift.is_approved ? 'text-white' : ''"
<!-- punch in field --> >{{ scope.opt.label }}</span>
<q-input </div>
v-model="shift.start_time" </template>
dense </q-select>
:readonly="shift.is_approved" </div>
type="time"
:standout="$q.dark.isActive ? 'bg-blue-grey-9' : 'bg-blue-grey-1 text-white'" <div class="col row flex-center text-uppercase rounded-5 bg-transparent q-pa-xs">
label-slot <!-- punch in field -->
label-color="accent" <q-input
:input-class="'text-weight-medium ' + (shift.id === -2 ? 'text-white ' : ' ') + (shift.is_approved ? 'cursor-not-allowed' : '')" v-model="shift.start_time"
input-style="font-size: 1.2em;" dense
class="col rounded-5 bg-dark" :borderless="shift.is_approved"
:class="(shift.id === -2 ? 'bg-negative ' : ' ') + (ui_store.is_mobile_mode ? 'q-mr-xs ' : 'q-mx-xs ') + (shift.is_approved ? 'cursor-not-allowed' : '')" :readonly="shift.is_approved"
> type="time"
<template #label> :standout="$q.dark.isActive ? 'bg-blue-grey-3' : 'bg-blue-grey-9'"
<span label-slot
class="text-weight-bolder" :label-color="shift.is_approved ? 'white' : 'accent'"
style="font-size: 0.95em;" :input-class="'text-weight-medium ' + (shift.id === -2 ? 'text-white ' : ' ') + (shift.is_approved ? 'cursor-not-allowed text-white' : '')"
>{{ $t('shared.misc.in') }}</span> input-style="font-size: 1.2em;"
</template> class="col rounded-5 bg-dark"
</q-input> :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' : '')"
:style="shift.is_approved ? 'background-color: #0002 !important;' : ''"
<!-- punch out field --> >
<q-input <template #label>
v-model="shift.end_time" <span
dense class="text-weight-bolder"
:readonly="shift.is_approved" style="font-size: 0.95em;"
type="time" >{{ $t('shared.misc.in') }}</span>
standout="bg-blue-grey-9" </template>
label-slot </q-input>
label-color="accent"
:input-class="'text-weight-medium ' + (shift.id === -2 ? 'text-white ' : ' ') + (shift.is_approved ? 'cursor-not-allowed' : '')" <!-- punch out field -->
input-style="font-size: 1.2em;" <q-input
class="col rounded-5 bg-dark" v-model="shift.end_time"
:class="(shift.id === -2 ? 'bg-negative ' : ' ') + (ui_store.is_mobile_mode ? 'q-ml-xs ' : 'q-mx-xs ') + (shift.is_approved ? 'cursor-not-allowed' : '')" :standout="$q.dark.isActive ? 'bg-blue-grey-3' : 'bg-blue-grey-9'"
> dense
<template #label> :borderless="shift.is_approved"
<span :readonly="shift.is_approved"
class="text-weight-bolder" type="time"
style="font-size: 0.95em;" label-slot
>{{ $t('shared.misc.out') }}</span> :label-color="shift.is_approved ? 'white' : 'accent'"
</template> :input-class="'text-weight-medium ' + (shift.id === -2 ? 'text-white ' : ' ') + (shift.is_approved ? 'cursor-not-allowed text-white' : '')"
</q-input> input-style="font-size: 1.2em;"
class="col rounded-5 bg-dark"
<!-- comment and delete buttons --> :class="(shift.id === -2 ? 'bg-negative ' : ' ') + (ui_store.is_mobile_mode ? 'q-ml-xs ' : 'q-mx-xs ') + (shift.is_approved ? 'cursor-not-allowed q-px-xs transparent inset-shadow' : '')"
<div :class="ui_store.is_mobile_mode ? 'col-12 row' : 'col-auto'"> :style="shift.is_approved ? 'background-color: #0002 !important;' : ''"
<q-icon >
v-if="shift.type && dense" <template #label>
:name="shift.comment ? 'comment' : ''" <span
color="primary" class="text-weight-bolder"
:size="dense ? 'xs' : 'sm'" style="font-size: 0.95em;"
class="col" >{{ $t('shared.misc.out') }}</span>
/> </template>
</q-input>
<!-- desktop comment button -->
<q-btn <!-- comment and delete buttons -->
v-else-if="!ui_store.is_mobile_mode" <div :class="ui_store.is_mobile_mode ? 'col-12 row' : 'col-auto'">
flat <q-icon
dense v-if="shift.type && dense"
:icon="shift.comment ? 'chat' : 'chat_bubble_outline'" :name="shift.comment ? 'comment' : ''"
:text-color="shift.comment ? 'accent' : 'grey-5'" color="primary"
class="col" :size="dense ? 'xs' : 'sm'"
:class="ui_store.is_mobile_mode ? 'q-mt-xs bg-dark' : ''" class="col"
> />
<q-popup-edit
v-model="shift.comment" <!-- desktop comment button -->
:title="$t('timesheet.shift.fields.header_comment')" <q-btn
auto-save v-else-if="!ui_store.is_mobile_mode"
v-slot="scope" flat
class="bg-dark" dense
> :icon="shift.comment ? 'chat' : 'chat_bubble_outline'"
<q-input :text-color="shift.comment ? 'accent' : 'grey-5'"
color="white" class="col"
v-model="scope.value" :class="ui_store.is_mobile_mode ? 'q-mt-xs bg-dark' : ''"
dense >
:readonly="shift.is_approved" <q-popup-edit
autofocus v-model="shift.comment"
counter :title="$t('timesheet.shift.fields.header_comment')"
bottom-slots auto-save
:maxlength="COMMENT_LENGTH_MAX" v-slot="scope"
class="q-pb-lg" class="bg-dark"
:class="shift.is_approved ? 'cursor-not-allowed' : ''" >
@keyup.enter="scope.set" <q-input
> color="white"
<template #append> v-model="scope.value"
<q-icon name="edit" /> dense
</template> :readonly="shift.is_approved"
autofocus
<template #counter> counter
<div class="row flex-center"> bottom-slots
<q-space /> :maxlength="COMMENT_LENGTH_MAX"
<q-knob class="q-pb-lg"
:model-value="scope.value?.length" :class="shift.is_approved ? 'cursor-not-allowed' : ''"
readonly @keyup.enter="scope.set"
:max="COMMENT_LENGTH_MAX" >
size="1.6em" <template #append>
:thickness="0.4" <q-icon name="edit" />
:color="getCommentCounterColor(scope.value?.length ?? 0)" </template>
track-color="grey-4"
class="col-auto q-mr-xs" <template #counter>
/> <div class="row flex-center">
<span <q-space />
:class="'col-auto text-weight-bolder text-' + getCommentCounterColor(scope.value?.length ?? 0)" <q-knob
>{{ 280 - (scope.value?.length ?? 0) }}</span> :model-value="scope.value?.length"
</div> readonly
</template> :max="COMMENT_LENGTH_MAX"
</q-input> size="1.6em"
</q-popup-edit> :thickness="0.4"
</q-btn> :color="getCommentCounterColor(scope.value?.length ?? 0)"
track-color="grey-4"
<q-btn class="col-auto q-mr-xs"
v-if="!ui_store.is_mobile_mode" />
flat <span
dense :class="'col-auto text-weight-bolder text-' + getCommentCounterColor(scope.value?.length ?? 0)"
:disable="shift.is_approved" >{{ 280 - (scope.value?.length ?? 0) }}</span>
tabindex="-1" </div>
icon="cancel" </template>
text-color="negative" </q-input>
class="col" </q-popup-edit>
:class="shift.is_approved ? 'invisible' : ''" </q-btn>
@click="$emit('requestDelete')"
/> <q-btn
</div> v-if="!ui_store.is_mobile_mode"
</div> flat
</div> dense
:disable="shift.is_approved"
tabindex="-1"
icon="cancel"
text-color="negative"
class="col"
:class="shift.is_approved ? 'invisible' : ''"
@click="$emit('requestDelete')"
/>
</div>
</div>
</div>
</q-slide-item> </q-slide-item>
<q-separator v-if="hasShiftAfter && ui_store.is_mobile_mode" spaced color="accent" class="q-mx-md"/>
</template> </template>

View File

@ -32,13 +32,14 @@
<template> <template>
<div class="column justify-center q-py-xs" :class="approved ? '' : ''"> <div class="column justify-center q-py-xs" :class="approved ? '' : ''">
<ShiftListDayRow <ShiftListDayRow
v-for="shift, shift_index in day.shifts" v-for="shift, shift_index in day.shifts"
:key="shift_index" :key="shift_index"
v-model:shift="day.shifts[shift_index]!" v-model:shift="day.shifts[shift_index]!"
:outlined="outlined" :outlined="outlined"
:dense="dense" :dense="dense"
@request-delete="deleteCurrentShift(shift)" :has-shift-after="shift_index < day.shifts.length - 1"
/> @request-delete="deleteCurrentShift(shift)"
/>
</div> </div>
</template> </template>

View File

@ -2,6 +2,7 @@
setup setup
lang="ts" lang="ts"
> >
import { computed } from 'vue';
import { date } from 'quasar'; import { date } from 'quasar';
import { useUiStore } from 'src/stores/ui-store'; import { useUiStore } from 'src/stores/ui-store';
import { useTimesheetStore } from 'src/stores/timesheet-store'; import { useTimesheetStore } from 'src/stores/timesheet-store';
@ -15,6 +16,8 @@
const ui_store = useUiStore(); const ui_store = useUiStore();
const timesheet_store = useTimesheetStore(); const timesheet_store = useTimesheetStore();
const animation_style = computed(() => ui_store.is_mobile_mode ? 'fadeInLeft' : 'fadeInDown' );
const addNewShift = (day_shifts: Shift[], date: string, timesheet_id: number) => { const addNewShift = (day_shifts: Shift[], date: string, timesheet_id: number) => {
ui_store.focus_next_component = true; ui_store.focus_next_component = true;
const new_shift = new Shift; const new_shift = new Shift;
@ -48,118 +51,128 @@
:key="timesheet.timesheet_id" :key="timesheet.timesheet_id"
class="col column" class="col column"
> >
<div <transition-group
v-for="day, day_index in timesheet.days" appear
:key="day.date" :enter-active-class="`animated ${animation_style}`"
class="col-auto row rounded-10 q-ma-sm shadow-10"
> >
<div <div
v-if="ui_store.is_mobile_mode" v-for="day, day_index in timesheet.days"
class="col column full-width" :key="day.date"
> class="col-auto row rounded-10 q-ma-sm shadow-10"
<q-card :style="`animation-delay: ${day_index / 15}s;`"
class="rounded-10 bg-dark"
:style="ui_store.is_mobile_mode ? (getDayApproval(day) ? 'border: 3px solid var(--q-accent)' : 'border: 1px solid var(--q-accent);') : ''"
>
<q-card-section
class="text-weight-bolder text-uppercase text-h6 q-py-xs"
:class="getDayApproval(day) ? 'bg-dark text-accent' : 'bg-primary text-white'"
style="line-height: 1em;"
>
<span> {{ $d(extractDate(day.date, 'YYYY-MM-DD'), {
weekday: 'long', day: 'numeric', month:
'long'
}) }}</span>
</q-card-section>
<q-card-section
v-if="day.shifts.filter(shift => shift.id !== 0).length > 0"
class="q-pa-none transparent"
>
<ShiftListDay
outlined
:approved="getDayApproval(day)"
:day="day"
@delete-unsaved-shift="deleteUnsavedShift(timesheet_index, day_index)"
/>
</q-card-section>
<q-card-actions class="q-pa-none">
<q-btn
v-if="!getDayApproval(day)"
square
color="accent"
icon="more_time"
class="full-width"
style="border-radius: 0 0 5px 5px;"
@click="addNewShift(day.shifts, day.date, timesheet.timesheet_id)"
/>
</q-card-actions>
<q-badge
v-if="getDayApproval(day)"
floating
class="transparent q-pa-none rounded-50"
style="transform: translate(15px, -5px);"
>
<q-icon
name="verified"
size="5em"
color="accent"
/>
</q-badge>
</q-card>
</div>
<div
v-else
class="col row full-width"
:class="getDayApproval(day) ? 'rounded-10 bg-accent' : ''"
> >
<div <div
class="col row bg-dark" v-if="ui_store.is_mobile_mode"
:class="getDayApproval(day) ? 'bg-transparent' : ''" class="col column full-width"
style="border-radius: 10px 0 0 10px;"
> >
<!-- Date block --> <q-card
<ShiftListDateWidget class="rounded-10 bg-dark"
:display-date="day.date" :style="ui_store.is_mobile_mode ? (getDayApproval(day) ? 'border: 6px inset var(--q-accent)' : 'border: 1px solid var(--q-accent);') : ''"
:approved="getDayApproval(day)" >
class="col-auto"
/>
<!-- List of shifts --> <q-card-section
<ShiftListDay class="text-weight-bolder text-uppercase text-h6 q-py-xs"
:day="day" :class="getDayApproval(day) ? 'bg-dark text-accent' : 'bg-primary text-white'"
class="col" style="line-height: 1em;"
@delete-unsaved-shift="deleteUnsavedShift(timesheet_index, day_index)" >
/> <span> {{ $d(extractDate(day.date, 'YYYY-MM-DD'), {
weekday: 'long', day: 'numeric', month:
'long'
}) }}</span>
</q-card-section>
<q-card-section
v-if="day.shifts.filter(shift => shift.id !== 0).length > 0"
class="q-pa-none transparent"
>
<ShiftListDay
outlined
:animation-delay-multiplier="day_index"
:approved="getDayApproval(day)"
:day="day"
@delete-unsaved-shift="deleteUnsavedShift(timesheet_index, day_index)"
/>
</q-card-section>
<q-card-actions class="q-pa-none">
<q-btn
v-if="!getDayApproval(day)"
square
color="accent"
icon="more_time"
class="full-width"
style="border-radius: 0 0 5px 5px;"
@click="addNewShift(day.shifts, day.date, timesheet.timesheet_id)"
/>
</q-card-actions>
<q-badge
v-if="getDayApproval(day)"
floating
class="transparent q-pa-none rounded-50"
style="transform: translate(15px, -5px);"
>
<q-icon
name="verified"
size="5em"
color="accent"
/>
</q-badge>
</q-card>
</div> </div>
<div class="col-auto self-stretch"> <div
<q-icon v-else
v-if="getDayApproval(day)" class="col row full-width"
name="verified" :class="getDayApproval(day) ? 'rounded-10 bg-accent' : ''"
color="white" >
size="xl" <!-- List of shifts -->
class="full-height"
/> <div
<q-btn class="col row bg-dark"
v-else :class="getDayApproval(day) ? 'bg-transparent' : ''"
:dense="!ui_store.is_mobile_mode" style="border-radius: 10px 0 0 10px;"
icon="more_time" >
size="lg" <!-- Date block -->
color="accent" <ShiftListDateWidget
text-color="white" :display-date="day.date"
class="full-height" :approved="getDayApproval(day)"
:class="$q.screen.lt.md ? 'q-px-xs ' : ' '" class="col-auto"
style="border-radius: 0 10px 10px 0;" />
@click="addNewShift(day.shifts, day.date, timesheet.timesheet_id)"
/>
<ShiftListDay
:day="day"
class="col"
@delete-unsaved-shift="deleteUnsavedShift(timesheet_index, day_index)"
/>
</div>
<div class="col-auto self-stretch">
<q-icon
v-if="getDayApproval(day)"
name="verified"
color="white"
size="xl"
class="full-height"
/>
<q-btn
v-else
:dense="!ui_store.is_mobile_mode"
icon="more_time"
size="lg"
color="accent"
text-color="white"
class="full-height"
:class="$q.screen.lt.md ? 'q-px-xs ' : ' '"
style="border-radius: 0 10px 10px 0;"
@click="addNewShift(day.shifts, day.date, timesheet.timesheet_id)"
/>
</div>
</div> </div>
</div> </div>
</div> </transition-group>
</div> </div>
</div> </div>
</template> </template>

View File

@ -36,7 +36,7 @@
> >
<q-card class="q-pa-xl rounded-200 bg-white frosted-glass"> <q-card class="q-pa-xl rounded-200 bg-white frosted-glass">
<q-spinner-radio <q-spinner-radio
color="primary" color="accent"
size="20vh" size="20vh"
/> />
</q-card> </q-card>

View File

@ -23,4 +23,21 @@ export const useExpenseRules = (t: (_key: string) => string) => {
mileageRequired, mileageRequired,
commentRequired, commentRequired,
}; };
};
export const convertToMonetaryAmount = (amount: number | string): number => {
if (typeof amount === 'number') return Number(amount.toFixed(2));
if (typeof amount === 'string') {
try {
const single_decimal_amount = amount.replace(/\.(?=.*\.)/g, '');
const numbers_only_decimal = single_decimal_amount.replace(/[^0-9.]/g, '');
return Number(numbers_only_decimal);
} catch(error) {
console.error(error);
}
}
return 0;
}; };

View File

@ -25,6 +25,7 @@ export const useExpensesStore = defineStore('expenses', () => {
const close = () => { const close = () => {
is_open.value = false; is_open.value = false;
is_hiding_create_form.value = false;
}; };
const upsertExpensesById = async (expense_id: number, expense: Expense): Promise<void> => { const upsertExpensesById = async (expense_id: number, expense: Expense): Promise<void> => {