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: {
color: 'primary',
},
dark: false,
dark: 'auto',
},
// iconSet: 'material-icons', // Quasar icon set

View File

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

View File

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

View File

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

View File

@ -11,13 +11,20 @@
}>();
const date_format_options = { day: 'numeric', month: 'long', year: 'numeric', };
</script>
<template>
<div class="column q-mt-lg text-uppercase text-center text-weight-bolder text-h4">
<span class="col">{{ $t(title) }}</span>
<transition
enter-active-class="animated fadeInDown"
leave-active-class="animated fadeOutDown"
mode="out-in"
>
<div
:key="startDate"
v-if="startDate.length > 0"
class="col row flex-center full-width q-py-none q-my-none"
>
@ -31,5 +38,6 @@
{{ $d(date.extractDate(endDate, 'YYYY-MM-DD'), date_format_options) }}
</div>
</div>
</transition>
</div>
</template>

View File

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

View File

@ -27,7 +27,6 @@
const refresh_key = ref(1);
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 is_authorized_to_approve = computed(() => CAN_APPROVE_PAY_PERIODS.includes(auth_store.user?.role ?? 'GUEST'))
const is_showing_update_form = ref(false);
@ -64,7 +63,6 @@
:clickable="horizontal"
class="column col-4 items-center q-my-sm q-py-none shadow-3 rounded-5 bg-dark"
:class="background_class + approved_class"
:style="background_style"
@click="onExpenseClicked"
>
<div class="row full-width items-center">
@ -72,21 +70,9 @@
<q-item-section avatar>
<q-icon
: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"
>
<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>
<!-- amount or mileage section -->
@ -110,7 +96,7 @@
class="text-uppercase text-weight-light"
: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-section>
@ -185,13 +171,20 @@
</q-item-section>
<q-item-section :side="$q.screen.gt.sm">
<q-icon
v-if="expense.is_approved"
name="verified"
color="white"
size="lg"
/>
<q-btn
v-else
flat
size="lg"
:icon="expense.is_approved ? 'verified' : 'close'"
:color="expense.is_approved ? 'white' : 'negative'"
icon="close"
color="negative"
class="q-pa-none z-top"
:class="expense.is_approved ? 'no-pointer' : ''"
@click.stop="requestExpenseDeletion"
/>
</q-item-section>
@ -201,7 +194,7 @@
@hide="expenses_store.is_hiding_create_form = false"
: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-item>
</template>

View File

@ -3,8 +3,7 @@
lang="ts"
>
/* eslint-disable*/
import { onBeforeUnmount, onMounted, ref, toRaw, useTemplateRef } from 'vue';
import { unwrapAndClone } from 'src/utils/unwrap-and-clone';
import { onBeforeUnmount, onMounted, ref, useTemplateRef } from 'vue';
import { useI18n } from 'vue-i18n';
import { QSelect } from 'quasar';
import { Shift, ShiftType } from 'src/modules/timesheets/models/shift.models';
@ -22,27 +21,26 @@
const COMMENT_LENGTH_MAX = 280;
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.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.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 { dense = false, outlined = false } = defineProps<{
const { dense = false, hasShiftAfter = false } = defineProps<{
dense?: boolean;
outlined?: boolean;
hasShiftAfter?: boolean;
}>();
const emit = defineEmits<{
'saveComment': [comment: string, shift_id: number];
'requestDelete': [void];
}>();
const is_showing_time_picker = ref(false);
const select_ref = useTemplateRef<QSelect>('select');
const initial_shift = ref<Shift>(unwrapAndClone(toRaw(shift.value)))
let timer: NodeJS.Timeout;
const shift_type_selected = ref(SHIFT_OPTIONS.find(option => option.value == shift.value.type));
@ -96,7 +94,10 @@
<q-icon name="delete" />
</template>
<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
class="row items-center text-uppercase rounded-5 bg-transparent"
:class="ui_store.is_mobile_mode ? 'col q-mb-xs' : 'col-4'"
>
<!-- mobile comment button -->
<q-btn
v-if="ui_store.is_mobile_mode && !dense"
@ -154,8 +155,9 @@
<q-select
ref="select"
v-model="shift_type_selected"
standout="bg-blue-grey-9"
:standout="$q.dark.isActive ? 'bg-blue-grey-3' : 'bg-blue-grey-9'"
dense
:borderless="shift.is_approved"
:readonly="shift.is_approved"
:options-dense="!ui_store.is_mobile_mode"
hide-dropdown-icon
@ -164,6 +166,8 @@
menu-self="top middle"
:options="SHIFT_OPTIONS"
class="col rounded-5 q-mx-xs bg-dark"
:class="shift.is_approved ? 'inset-shadow' : ''"
:style="shift.is_approved ? 'background-color: #0002 !important;' : ''"
popup-content-class="text-uppercase text-weight-bold text-center rounded-5"
popup-content-style="border: 2px solid var(--q-accent)"
@blur="onBlurShiftTypeSelect"
@ -184,6 +188,7 @@
<span
style="line-height: 0.9em;"
class="col-auto ellipsis"
:class="shift.is_approved ? 'text-white' : ''"
>{{ scope.opt.label }}</span>
</div>
</template>
@ -195,15 +200,17 @@
<q-input
v-model="shift.start_time"
dense
:borderless="shift.is_approved"
:readonly="shift.is_approved"
type="time"
:standout="$q.dark.isActive ? 'bg-blue-grey-9' : 'bg-blue-grey-1 text-white'"
:standout="$q.dark.isActive ? 'bg-blue-grey-3' : 'bg-blue-grey-9'"
label-slot
label-color="accent"
:input-class="'text-weight-medium ' + (shift.id === -2 ? 'text-white ' : ' ') + (shift.is_approved ? 'cursor-not-allowed' : '')"
:label-color="shift.is_approved ? 'white' : 'accent'"
:input-class="'text-weight-medium ' + (shift.id === -2 ? 'text-white ' : ' ') + (shift.is_approved ? 'cursor-not-allowed text-white' : '')"
input-style="font-size: 1.2em;"
class="col rounded-5 bg-dark"
:class="(shift.id === -2 ? 'bg-negative ' : ' ') + (ui_store.is_mobile_mode ? 'q-mr-xs ' : 'q-mx-xs ') + (shift.is_approved ? 'cursor-not-allowed' : '')"
: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;' : ''"
>
<template #label>
<span
@ -216,16 +223,18 @@
<!-- punch out field -->
<q-input
v-model="shift.end_time"
:standout="$q.dark.isActive ? 'bg-blue-grey-3' : 'bg-blue-grey-9'"
dense
:borderless="shift.is_approved"
:readonly="shift.is_approved"
type="time"
standout="bg-blue-grey-9"
label-slot
label-color="accent"
:input-class="'text-weight-medium ' + (shift.id === -2 ? 'text-white ' : ' ') + (shift.is_approved ? 'cursor-not-allowed' : '')"
:label-color="shift.is_approved ? 'white' : 'accent'"
:input-class="'text-weight-medium ' + (shift.id === -2 ? 'text-white ' : ' ') + (shift.is_approved ? 'cursor-not-allowed text-white' : '')"
input-style="font-size: 1.2em;"
class="col rounded-5 bg-dark"
:class="(shift.id === -2 ? 'bg-negative ' : ' ') + (ui_store.is_mobile_mode ? 'q-ml-xs ' : 'q-mx-xs ') + (shift.is_approved ? 'cursor-not-allowed' : '')"
: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' : '')"
:style="shift.is_approved ? 'background-color: #0002 !important;' : ''"
>
<template #label>
<span
@ -317,4 +326,6 @@
</div>
</div>
</q-slide-item>
<q-separator v-if="hasShiftAfter && ui_store.is_mobile_mode" spaced color="accent" class="q-mx-md"/>
</template>

View File

@ -38,6 +38,7 @@
v-model:shift="day.shifts[shift_index]!"
:outlined="outlined"
:dense="dense"
:has-shift-after="shift_index < day.shifts.length - 1"
@request-delete="deleteCurrentShift(shift)"
/>
</div>

View File

@ -2,6 +2,7 @@
setup
lang="ts"
>
import { computed } from 'vue';
import { date } from 'quasar';
import { useUiStore } from 'src/stores/ui-store';
import { useTimesheetStore } from 'src/stores/timesheet-store';
@ -15,6 +16,8 @@
const ui_store = useUiStore();
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) => {
ui_store.focus_next_component = true;
const new_shift = new Shift;
@ -47,11 +50,16 @@
v-for="timesheet, timesheet_index in timesheet_store.timesheets"
:key="timesheet.timesheet_id"
class="col column"
>
<transition-group
appear
:enter-active-class="`animated ${animation_style}`"
>
<div
v-for="day, day_index in timesheet.days"
:key="day.date"
class="col-auto row rounded-10 q-ma-sm shadow-10"
:style="`animation-delay: ${day_index / 15}s;`"
>
<div
v-if="ui_store.is_mobile_mode"
@ -59,7 +67,7 @@
>
<q-card
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);') : ''"
:style="ui_store.is_mobile_mode ? (getDayApproval(day) ? 'border: 6px inset var(--q-accent)' : 'border: 1px solid var(--q-accent);') : ''"
>
<q-card-section
@ -79,6 +87,7 @@
>
<ShiftListDay
outlined
:animation-delay-multiplier="day_index"
:approved="getDayApproval(day)"
:day="day"
@delete-unsaved-shift="deleteUnsavedShift(timesheet_index, day_index)"
@ -117,6 +126,8 @@
class="col row full-width"
:class="getDayApproval(day) ? 'rounded-10 bg-accent' : ''"
>
<!-- List of shifts -->
<div
class="col row bg-dark"
:class="getDayApproval(day) ? 'bg-transparent' : ''"
@ -129,7 +140,7 @@
class="col-auto"
/>
<!-- List of shifts -->
<ShiftListDay
:day="day"
class="col"
@ -137,6 +148,7 @@
/>
</div>
<div class="col-auto self-stretch">
<q-icon
v-if="getDayApproval(day)"
@ -160,6 +172,7 @@
</div>
</div>
</div>
</transition-group>
</div>
</div>
</template>

View File

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

View File

@ -24,3 +24,20 @@ export const useExpenseRules = (t: (_key: string) => string) => {
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 = () => {
is_open.value = false;
is_hiding_create_form.value = false;
};
const upsertExpensesById = async (expense_id: number, expense: Expense): Promise<void> => {